Returning to Shapes: An Introduction to Methods and Deep Copy

Setup

[Return to main.py in shapes directory.]

Area methods

[Last time, we had an issue that we couldn’t name two functions the same thing. So we had to do the following, which was dumb.]

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)

Instead, we will use methods. When we define a class, the method is included in the function.

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)

[Return to main. Note how we define parameters, which we have not done before. This is nice because we can mess up the order of the parameters and still get it right. Nice Python feature. Note also how we call them.]

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())

[We have been using this for some time. For example, when we draw something using pygame, we have function calls like the following. The latter is because it comes from pygame.draw submodule.]

surface.fill(dark_gray)

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

We also have seen this when calling append, which is a built-in method of lists.

[Bird’s eye view 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 translate shapes. Remember that we just need to update x1, y1

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

Return to main. Will the shapes 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 to main

    # 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

Recall: what happens?

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

Answer is that lists are mutable. There is one exception to this

Sublists. add the following to 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)

then

    # 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, which is that all things in Python are objects, including immutable things. This is part of why Python is so slow.

Consider the following code. Introduce id(), which is an integer that is guaranteed to be unique and constant for a specific object during its lifetime. Strangely, the following code shows that the identity of even an integer is the same when it is passed into a function.

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 change the input, the id() changes because when we set x += 10, a new integer object (15) is created, and x is rebound to it.

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)

[We could just manually copy all of the fields 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)

[Problem happens when we try to manually copy over mutable attributes. Consider following]

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

[Then consider def main as follows]

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!

We can fix this with deepcopy. Great Python built-in function that makes everything OK. Recall that we wrote copyuniverse(), which is 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!

[Twitter v. 1.0 quiz if we have time.]

Starting trees: idea 1 for representing data

{This needs to be replaced by @dataclass}

Here is a starting point for representing trees

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

Then we have def main

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 what we shouldn’t do

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

This isn’t a good design though for a subtle reason. see def main below

    # 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. This is why we set child1 and child2 to None as well, since they are attributes that are user defined classes.

Here is the better version

class Tree:
    def __init__(self, nodes=None):    # good: safe idiom
        self.nodes = nodes

# 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

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!