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