Code along video
STOP: Each chapter of Programming for Lovers comprises two parts. First, the “core text” presents critical concepts at a high level, avoiding language-specific details. The core text is followed by “code alongs,” where you will apply what you have learned while learning the specifics of the language syntax. We strongly suggest starting with this chapter’s core text (click the button below) to grasp the fundamentals before moving onto code alongs to see the application of these fundamentals. Once you have done so, you are ready to proceed with this code along!
Learning objectives
In this code along, we return to the shapes that we used to introduce object-oriented programming. We will give our shapes their own functions, called methods, so that every shape can compute its own area and move itself around the plane.
Code along summary
Setup
Create a new directory python/src/shapes_methods and add a new main.py file there. We will write our classes ourselves in this lesson, so the starter code is just an empty main().
def main():
print("Methods and returning to shapes.")
if __name__ == "__main__":
main()
Reviewing dataclasses and parameter checks
We will declare our two shape classes as dataclasses. Recall that to do so, we need to import dataclass from the dataclasses module. We will also import field, which we will need in a later lesson, and math, which will help us when we work with circles. Add the following lines to the top of main.py.
from dataclasses import dataclass, field import math
Next, we declare a Rectangle class. A rectangle has a width, a height, the coordinates (x1, y1) of one of its corners, and a rotation. Each attribute gets a default value of 0.0.
When we first worked with shapes, we wrote our own __init__ function, which allowed us to check that the user did not try to create a rectangle with a negative width or height. A dataclass writes __init__ for us, so where do these checks go? The answer is a special function called __post_init__, which Python calls automatically right after the generated __init__ has set all of the attributes. Note that this function is indented inside the class, alongside the attributes, and that it takes a parameter called self, which refers to the rectangle that was just created.
@dataclass
class Rectangle:
"""
Represents a 2D rectangle in the plane.
Attributes:
width: the rectangle's width (float)
height: the rectangle's height (float)
x1: the x-coordinate of the rectangle's corner (float)
y1: the y-coordinate of the rectangle's corner (float)
rotation: the rectangle's rotation angle in degrees (float)
"""
width: float = 0.0
height: float = 0.0
x1: float = 0.0
y1: float = 0.0
rotation: float = 0.0
# let's perform some parameter checks
def __post_init__(self) -> None:
"""Processes parameter checks and results in an error if invalid parameters are given for attributes."""
if self.width < 0.0 or self.height < 0.0:
raise ValueError("Width and height must be nonnegative.")
We declare a Circle class in the same way. A circle has the coordinates (x1, y1) of its center and a radius, and its __post_init__ checks that the radius is not negative.
@dataclass
class Circle:
"""
Represents a circle in the 2-D plane.
Attributes:
x1: the x-coordinate of the circle's center (float)
y1: the y-coordinate of the circle's center (float)
radius: the radius of the circle (float)
"""
x1: float = 0.0
y1: float = 0.0
radius: float = 0.0
def __post_init__(self) -> None:
"""Processes parameter checks and results in an error if invalid parameters are given for attributes."""
if self.radius < 0.0:
raise ValueError("Radius must be nonnegative.")
Our first methods: computing area
When we first worked with shapes, we wanted a function computing the area of a rectangle and another computing the area of a circle. Python would not let us give these two functions the same name (the definition that came last would win), so we were forced to call them something like area_rectangle() and area_circle(). If we had twelve kinds of shapes, we would need twelve differently named area functions.
Instead, we would like each shape to possess its own area, in the same way that it possesses a width or a radius. We could try storing the area as an attribute, but that would be redundant, since the area can be inferred from the other attributes; every time we changed the width, we would need to remember to change the area as well. Remember our principle of minimalism: we keep as few attributes as we need.
The solution is to write a method, which is a function that is defined inside a class. Just like __post_init__, a method takes self as its first parameter, which gives the method access to the attributes of the object on which it is called. Add the following area() method to Rectangle, at the same level of indentation as __post_init__.
def area(self) -> float:
"""Return the area of the rectangle."""
return self.width * self.height
Then add an area() method to Circle as well. We can use math.pi for an accurate value of π, and ** to raise the radius to the power of two.
def area(self) -> float:
"""Returns the area of the circle."""
return math.pi * (self.radius ** 2)
Now let’s call these methods in main(). We first declare a rectangle and a circle. We can specify attribute values in any order as long as we name them; these are called keyword arguments, and they make declarations very readable. Any attribute we do not specify gets its default value, so r below has x1 and y1 equal to 0.0.
To call a method, we place the object before the method name with a dot. In other words, where we used to write area_rectangle(r), we now write r.area().
def main():
print("Methods and returning to shapes.")
r = Rectangle(width=3.0, height=4.0)
c = Circle(x1=0.0, y1=0.0, radius=2.0)
print(r)
print(c)
print("Rectangle area:", r.area())
print("Circle area:", c.area())
Open a terminal, navigate into python/src/shapes_methods, and run python3 main.py (or python main.py on Windows). Dataclasses provide a __repr__ behind the scenes, so the shapes print nicely, and we see that the rectangle’s area is 12 and the circle’s area is 4π.
Methods and returning to shapes. Rectangle(width=3.0, height=4.0, x1=0.0, y1=0.0, rotation=0.0) Circle(x1=0.0, y1=0.0, radius=2.0) Rectangle area: 12.0 Circle area: 12.566370614359172
Note: If you see the errorTypeError: Circle() takes no arguments, check that you placed@dataclassdirectly aboveclass Circle:. Without the decorator, Python treatsCircleas an ordinary class with no__init__that accepts arguments. (This exact bug appears in the video!)
We have been using methods all along
Even though we are only now writing our own methods, we have been calling methods for a long time. Whenever we write lst.append(foo), append() is a method belonging to Python’s list class. And when we drew with pygame, a call like surface.fill(dark_gray) used the fill() method of a Surface object.
One way to think about classes is in terms of parts of speech. Objects are like nouns, and their attributes are like adjectives that describe them. Methods are then like verbs, actions that an object can take, such as a surface filling itself with a color. A method can also compute a property of an object that we can infer from its attributes, which is what area() does.
Methods will be especially powerful when our data are recursive, like an evolutionary tree, in which a node is connected to two other nodes, each of which is connected to two other nodes, and so on. We will see soon that a very short method will be able to answer questions about trees that would otherwise be quite complicated.
Methods with parameters: translating shapes
Like surface.fill(), which takes a color, a method can take parameters in addition to self. Let’s write a translate() method for each shape that moves it a units in the x-direction and b units in the y-direction. Add the following method to Rectangle.
def translate(self, a: float, b: float) -> None:
"""
Translate a rectangle by shifting the coordinates of the point that we store (x1, y1) by (a, b).
"""
self.x1 += a
self.y1 += b
Then add the analogous method to Circle.
def translate(self, a: float, b: float) -> None:
"""
Translate a circle by shifting the coordinates of the center (x1, y1) by (a, b).
"""
self.x1 += a
self.y1 += b
Note that translate() does not return anything. Let’s add a few lines to the end of main() to move our shapes and print them again.
# let's move the shapes
r.translate(10.0, -5)
c.translate(2.5, 3.5)
print("After translation...")
print(r)
print(c)
STOP: Before running this code, predict whether the shapes will move. That is, will the printed values ofx1andy1change?
When we run our code, we see that the shapes do move.
After translation... Rectangle(width=3.0, height=4.0, x1=10.0, y1=-5.0, rotation=0.0) Circle(x1=2.5, y1=3.5, radius=2.0)
The one-word explanation is mutability. Instances of user-defined classes are mutable, like lists and dictionaries, and so when an object is passed into a function, the function can change it. A method is no exception: you can think of self as a parameter that is placed in front of the method name instead of inside the parentheses. Calling r.translate(10.0, -5) changes r itself, which is exactly what we want.
Scaling shapes
Exercise: Write a methodscale()for each ofRectangleandCirclethat takes a scaling factorfas a parameter and returns nothing. Its signature should bedef scale(self, f: float) -> None:.
Be careful about what scaling means. If the scaling factor is 3, then each dimension of the shape should become three times as large, which means that the area grows by a factor of 9; if the factor is 0.5, then the area shrinks to a quarter of its original size. As a hint for the circle, the center (x1, y1) does not move; ask yourself which attribute does need to change.
Looking ahead
We now know how to write methods, and we have seen that calling a method can change the object on which it is called because user-defined objects are mutable. In the next code along, we will dig into what is really going on behind the scenes when we pass something into a function, and we will learn that in Python, everything is an object.