Returning to Shapes: An Introduction to Methods and Deep Copy

Setup

Return to main.py in your python/src/shapes directory from a previous code along. We will be extending the Rectangle and Circle classes that we defined there.

Area methods

In the previous code along, we wanted to compute the areas of our shapes, but since Python does not allow two functions in the same file to share the same name, we were forced to give our area functions different names.

def area_rectangle(r: Rectangle) -> float:
    """
    Compute the area of a given rectangle.
    """
    return r.width * r.height

def area_circle(c: Circle) -> float:
    """
    Compute the area of a circle.
    """
    return 3.0 * (c.radius ** 2)

This works, but it is inelegant. The fact that both shapes have an area is not captured anywhere in our code, and the function names grow unwieldy as we add more shape types. Instead, we will use methods — functions that belong to a class and are defined inside the class body. When we include a function inside a class definition, any instance of that class can call it directly using dot notation (e.g., r.area() rather than area_rectangle(r)). Every method takes self as its first parameter, where self refers to the object on which the method is being called.

class Rectangle:
    """
    Represents a 2D rectangle with width, height, position, and rotation
    Attributes:
        width: (float)
        height: (float)
        x1: the x-coordinate of the rectangle's origin (float)
        y1: the y-coordinate of the rectangle's origin (float)
        rotation: rotation of shape in degrees (float)
    Class Attributes:
        description: describes some characteristic of the object (string)
    """
    description: str = "boxy"
    def __init__(self, width: float=0.0, height: float=0.0, x1: float=0, y1: float=0, rotation: float=0):
        # let's protect the program from a bad user
        if width < 0.0 or height < 0.0:
            raise ValueError("width and height must be nonnegative.")
        self.width = width
        self.height = height
        self.x1 = x1
        self.y1 = y1
        self.rotation = rotation
    def __repr__(self) -> str:
        return f"Rectangle(width={self.width}, height={self.height}, x1={self.x1}, y1={self.y1}, rotation={self.rotation})"
    def area(self) -> float:
        """Return the area of the rectangle."""
        return self.width * self.height

Let’s do this for circles also.

class Circle:
    """
    Represents a 2D circle via its center and radius.
    Attributes:
        x1: the x-coordinate of the center (float)
        y1: the y-coordinate of the center (float)
        radius: the circle's radius (float)
    Class Attributes:
        description: describes some characteristic of the object (string)
    """
    description: str = "round"
    def __init__(self, x1: float=0, y1: float=0, radius: float=0):
        if radius < 0.0:
            raise ValueError("radius must be nonnegative.")
        self.x1 = x1
        self.y1 = y1
        self.radius = radius
    def __repr__(self) -> str:
        return f"Circle(x1={self.x1}, y1={self.y1}, radius={self.radius})"
    def area(self) -> float:
        """Return the area of the circle."""
        return 3.0 * (self.radius ** 2)

Now return to def main(). Notice that when creating instances, we can specify each argument by name — these are called keyword arguments. Keyword arguments are convenient because we do not need to remember the order of the parameters; as long as we provide the names, Python will match them correctly. Also note how we call the area() method: we write r.area() rather than passing r as an argument to a standalone function.

def main():
    # Create one rectangle and one circle
    r = Rectangle(width=3.0, height=4.0)
    c = Circle(x1=0.0, y1=0.0, radius=2.0)

    # Print the shapes
    print(r)
    print(c)

    # Compute and print their areas
    print("Rectangle area:", r.area())
    print("Circle area:", c.area())

In fact, we have been calling methods throughout this course without necessarily thinking of them as such. When we draw something using pygame, for example, we write:

surface.fill(dark_gray)

pygame.draw.circle(surface, white, (int(x), int(y)), int(radius))

Here, surface.fill() is a method call: fill() is a function defined inside pygame’s Surface class. The second call, pygame.draw.circle(), follows a slightly different pattern: it means “go into the pygame module, then into its draw submodule, and call the circle() function there.” We also have seen this when calling append(), which is a built-in method of Python’s list class.

With methods in hand, we can now state the three key concepts of object-oriented programming.

Object: Collection of variables seen as a whole (noun) 

Field: A property of the object (adjective)

Method: An essential action of the object (verb).

More methods

Let’s add a translate() method to both classes. Translating a shape means shifting its position by a given offset in the x- and y-directions. Since the position of each shape is stored in its x1 and y1 attributes, we simply update those.

class Rectangle:
    description: str = "boxy"
    def __init__(self, width: float=0.0, height: float=0.0, x1: float=0.0, y1: float=0.0, rotation: float=0.0):
        if width < 0.0 or height < 0.0:
            raise ValueError("width and height must be nonnegative.")
        self.width = width
        self.height = height
        self.x1 = x1
        self.y1 = y1
        self.rotation = rotation
    def __repr__(self) -> str:
        return f"Rectangle(width={self.width}, height={self.height}, x1={self.x1}, y1={self.y1}, rotation={self.rotation})"
    def area(self) -> float:
        """Return the area of the rectangle."""
        return self.width * self.height
    def translate(self, a: float, b: float) -> None:
        """Move the rectangle by (a, b)."""
        self.x1 += a
        self.y1 += b

Next, circles.

class Circle:
    description: str = "round"
    def __init__(self, x1: float=0.0, y1: float=0.0, radius: float=0.0):
        if radius < 0.0:
            raise ValueError("radius must be nonnegative.")
        self.x1 = x1
        self.y1 = y1
        self.radius = radius
    def __repr__(self) -> str:
        return f"Circle(x1={self.x1}, y1={self.y1}, radius={self.radius})"
    def area(self) -> float:
        """Return the area of the circle."""
        import math
        return math.pi * (self.radius ** 2)
    def translate(self, a: float, b: float) -> None:
        """Move the circle by (a, b)."""
        self.x1 += a
        self.y1 += b

Now return to def main() and call our new methods. Will the shapes actually move?

def main():
    # Create one rectangle and one circle
    r = Rectangle(width=3.0, height=4.0, x1=0.0, y1=0.0)
    c = Circle(x1=0.0, y1=0.0, radius=2.0)

    # Print the shapes
    print("Original shapes:")
    print(r)
    print(c)

    # Compute and print their areas
    print("Rectangle area:", r.area())
    print("Circle area:", c.area())

    # Translate both shapes
    r.translate(10.0, -5.0)
    c.translate(2.5, 3.5)

    print("\nAfter translation:")
    print(r)
    print(c)
Exercise: Write Scale() methods for rectangle and circle objects.

Solution

class Rectangle:
    # old stuff
    def scale(self, factor: float) -> None:
        """Scale the rectangle's width and height by a given factor."""
        if factor < 0:
            raise ValueError("scale factor must be nonnegative.")
        self.width *= factor
        self.height *= factor
class Circle:
    # old stuff
    def scale(self, factor: float) -> None:
        """Scale the circle's radius by a given factor."""
        if factor < 0:
            raise ValueError("scale factor must be nonnegative.")
        self.radius *= factor

Add the following to def main() to test scaling.

    # Scale both shapes
    r.scale(2.0)
    c.scale(1.5)

    print("\nAfter scaling:")
    print(r)
    print(c)
    print("Scaled rectangle area:", r.area())
    print("Scaled circle area:", c.area())

Lists

Before we discuss mutability in depth, let's revisit lists. Recall what happens when we pass a list into a function and modify it.

def change_first(lst: list[int]) -> None:
    lst[0] = 1

def main():
    lst = [0] * 10       # make([]int, 10)
    change_first(lst)
    print(lst[0])        # prints 1

The first element prints as 1, because lists are mutable: their elements can be modified in place, and the function receives a reference to the same list object rather than a copy. There is one exception to this behavior, however.

When we take a slice of a list in Python, we get a fresh, independent copy of those elements — not a view into the original list. Add the following to def main().

    # make an array of length 10
    a = [0] * 10  # capacity concept doesn't exist in Python

    # fill it with -1, -2, ..., -10
    for i in range(10):
        a[i] = -i - 1

    # q is a slice — but in Python this makes a *copy*
    q = a[8:10]

    print(a)  # [-1, -2, -3, -4, -5, -6, -7, -8, -9, -10]
    print(q)  # [-9, -10] (only two elements, copy)

Now change an element of a and see whether q is affected.

    # previous code block
    # Change an element of a
    a[9] = 999

    print("\nAfter changing a[9] = 999:")
    print("a:", a)
    print("q:", q)  # q does NOT change!

All of Python is pass by object reference

The question is, why are some things pass by reference and others pass by value? The answer is, mutability. User-defined classes are mutable, as are lists and dictionaries, while built-in types like integers and floats and strings are immutable, as are tuples.

  • Mutable objects are those whose value can be modified in place after creation.Examples include:
    • Lists (list)
    • Dictionaries (dict)
    • user defined classes
  • Immutable objects are those whose value cannot be changed after creation. Any operation that appears to modify an immutable object actually creates a new object with the new value. Examples include:
    • Integers (int)
    • Floats (float)
    • Strings (str)
    • Tuples (tuple)

There is a single explanation for all of this: in Python, everything is an object, including immutable things. This is part of why Python is so slow.

Python provides a built-in function id() that returns a unique integer identifier for any object, guaranteed to be constant for that object during its lifetime (and tied to its memory address in CPython). Strangely, even when we pass an integer into a function, the identity inside the function is the same as outside — confirming that Python truly passes everything by object reference.

def change_value(x):
    print("Inside before:", x, id(x))

def main():
    n = 5
    print("Outside before:", n, id(n))
    change_value(n)
    print("Outside after:", n, id(n))

But as soon as we modify x inside the function, the id changes — because when we write x += 10, a new integer object (with value 15) is created, and x is rebound to it. The original object that n refers to is left untouched.

def change_value(x):
    print("Inside before:", x, id(x))
    x += 10                # creates a NEW int object
    print("Inside after:", x, id(x))

def main():
    n = 5
    print("Outside before:", n, id(n))
    change_value(n)
    print("Outside after:", n, id(n))

Assignment and shallow and deep copies

Next, we think about what happens when we assign objects.

    r1 = Rectangle(3.0, 4.0, x1=0.0, y1=0.0)
    print("Original r1:", r1)

    # Assignment — both names point to the same object
    r2 = r1
    r2.translate(10, 5)
    print("\nAfter translating r2 (assigned reference):")
    print("r1:", r1)   # r1 also changed!
    print("r2:", r2)

When we write r2 = r1, we do not create a new rectangle — we simply give a second name to the same object in memory. Any change made through r2 is visible through r1.

One way to make a genuine independent copy is to construct a new object and manually copy each attribute over.

    # Manual field-by-field copy (independent object)
    r3 = Rectangle(
        width=r1.width,
        height=r1.height,
        x1=r1.x1,
        y1=r1.y1,
        rotation=r1.rotation
    )

    # Modify r3
    r3.translate(-5, -5)
    print("\nAfter translating r3 (manual copy):")
    print("r1:", r1)
    print("r3:", r3)

This works here because every attribute of Rectangle is an immutable float, so copying them creates truly independent values. The problem arises when an object has mutable attributes. Consider the following pair of classes.

class Node:
    def __init__(self, name="", age=0.0):
        self.name = name
        self.age = age

class Tree:
    def __init__(self, nodes=None, label=""):
        self.nodes = nodes if nodes is not None else []
        self.label = label

Now consider what happens when we try to manually copy a Tree by assigning its fields one by one.

def main():
    # original tree
    t = Tree(nodes=[Node("A", 1), Node("B", 2)], label="This is t.")
    print("Original t:", t)
    # create an empty tree and manually copy fields
    s = Tree()
    s.label = t.label
    s.nodes = t.nodes     # <-- manual copy causes shared reference!
    # mutate s
    s.label = "This is s."
    s.nodes[0].name = "Fred"
    print("\nAfter modifying s:")
    print("s:", s)
    print("t:", t)  # t changed too!

Assigning s.nodes = t.nodes does not copy the list — it makes s.nodes and t.nodes point to the exact same list object in memory. This is called a shallow copy: we copied the top-level object but not the mutable objects it contains. Modifying a node through s therefore also modifies it through t.

We can fix this with copy.deepcopy(), a built-in Python function that recursively copies an object and everything it contains, producing a fully independent clone. Add import copy at the top of your file, then replace the manual copy. Recall that in an earlier code along we wrote a copy_universe() function that manually duplicated every field of every body — copy.deepcopy() makes that unnecessary.

def main():
    # original tree
    t = Tree(nodes=[Node("A", 1), Node("B", 2)], label="This is t.")
    print("Original t:", t)

    # deepcopy automatically copies all nested mutable fields
    s = copy.deepcopy(t)
    s.label = "This is s."
    s.nodes[0].name = "Fred"

    print("\nAfter modifying s:")
    print("s:", s)
    print("t:", t)  # t is unaffected!

Starting trees: idea 1 for representing data

We will be building an algorithm for constructing evolutionary trees in the coming lessons. As a first step, let's think about how to represent a tree using classes. One natural approach is to define separate classes for nodes, edges, and the tree itself.

class Node:
    def __init__(self, label, age=0):
        self.label = label
        self.age = age

class Edge:
    def __init__(self, parent=None, child=None):
        self.parent = parent
        self.child = child

class Tree:
    def __init__(self, nodes=None, edges=None):
        self.nodes = nodes
        self.edges = edges

Let's construct a small tree in def main() and see how the data structure behaves.

def main():
    # create a tree with two nodes and one edge
    n1 = Node("A", age=5)
    n2 = Node("B", age=10)
    e = Edge(n1, n2)
    t = Tree(nodes=[n1, n2], edges=[e])

    print("Original tree:")
    print(t)

    # Replace a node in t.nodes (a common operation)
    t.nodes[0] = Node("A'", age=0)  # new Node object!

    # The edge still points to the OLD node (n1), not the new one
    print("\nAfter replacing t.nodes[0]:")
    print("t.nodes:", t.nodes)
    print("t.edges:", t.edges)

    # Now updates are inconsistent
    t.nodes[0].age = 99
    t.edges[0].parent.age = 123456

    print("\nAfter changing ages separately:")
    print("t.nodes[0].age:", t.nodes[0].age)
    print("t.edges[0].parent.age:", t.edges[0].parent.age)
  • When we did t.nodes[0] = Node("A'", age=0), we replaced the node in the node list with a new Node object.
  • But the Edge still held a reference to the old node (Node('A', age=5)).
  • Now we effectively have two different "A" nodes — the one in Tree.nodes and the one in the Edge.
  • Updates diverge, and the data structure is inconsistent.

Idea 2

Here is another approach where each node stores direct references to its children. However, it contains a subtle pitfall that we need to address.

class Tree:
    def __init__(self, nodes=[]):   # bad: shared default list
        self.nodes = nodes

class Node:
    def __init__(self, age=0.0, label="", child1=None, child2=None):
        self.age = age
        self.label = label
        self.child1 = child1  # type: Node | None
        self.child2 = child2  # type: Node | None

Note that we set the defaults of child1 and child2 to None. Since Node is a user-defined class (and therefore mutable), using a Node instance as a default value would cause every Node to share the same child object — clearly wrong. Using None avoids this.

However, the Tree class has a different problem for a subtle reason. Add the following to def main().

    # Create two trees
    t1 = Tree()
    t2 = Tree()

    t1.nodes.append("A")
    print("t1.nodes:", t1.nodes)
    print("t2.nodes:", t2.nodes)   # 😱 same list as t1!
  • The default value [] was created once, when the class was defined.
  • Every instance that doesn't pass a nodes argument shares that same list object.
  • So modifying one instance modifies the others.

Rule of thumb: For mutable types, set them to None as the default and initialize to a fresh object inside __init__. This is why we set child1 and child2 to None as well, since they are attributes whose type is a user-defined class.

Here is the better version.

class Tree:
    def __init__(self, nodes=None):    # good: safe idiom
        self.nodes = nodes if nodes is not None else []

# Create two trees
t1 = Tree()
t2 = Tree()

t1.nodes.append("A")
print("t1.nodes:", t1.nodes)
print("t2.nodes:", t2.nodes)   # 🎉 independent lists

Looking ahead

In this lesson, we added methods to our shape classes, explored Python's pass-by-object-reference model, learned to make safe deep copies with copy.deepcopy(), and took a first look at representing tree data structures. Along the way, we noticed that writing __init__() and __repr__() by hand for every class is repetitive. In the next lesson, we will introduce Python's @dataclass decorator, which generates both automatically, and use it to define the data structures we need to implement the UPGMA phylogenetic tree-building algorithm.

Scroll to Top
Programming for Lovers banner no background
programming for lovers logo cropped

Join our community!

programming for lovers logo cropped
Programming for Lovers banner no background

Join our community!