Chapter 16. Classes and Objects

At this point we have defined classes and created objects that represent the time of day and the day of the year. And we’ve defined methods that create, modify, and perform computations with these objects.

In this chapter we’ll continue our tour of object-oriented programming (OOP) by defining classes that represent geometric objects, including points, lines, rectangles, and circles. We’ll write methods that create and modify these objects, and we’ll use the jupyturtle module to draw them.

I’ll use these classes to demonstrate OOP topics including object identity and equivalence, shallow and deep copying, and polymorphism.

Creating a Point

In computer graphics, a location on the screen is often represented using a pair of coordinates in an x-y plane. By convention, the point (0, 0) usually represents the upper-left corner of the screen, and (x, y) represents the point x units to the right and y units down from the origin. Compared to the Cartesian coordinate system you might have seen in a math class, the y-axis is upside down.

There are several ways we might represent a point in Python:

  • We can store the coordinates separately in two variables, x and y.

  • We can store the coordinates as elements in a list or tuple.

  • We can create a new type to represent points as objects.

In object-oriented programming, it would be most idiomatic to create a new type. To do that, we’ll start with a class definition for Point:

class Point:
    """Represents a point in 2-D space."""
           
    def __init__(self, x, y):
        self.x = x
        self.y = y
               
    def __str__(self):
        return f'Point({self.x}, {self.y})'
       

The __init__ method takes the coordinates as parameters and assigns them to attributes x and y. The __str__ method returns a string representation of the Point.

Now we can instantiate and display a Point object like this:

start = Point(0, 0)
print(start)
       
Point(0, 0)
       

The following diagram shows the state of the new object:

As usual, a programmer-defined type is represented by a box with the name of the type outside and the attributes inside.

In general, programmer-defined types are mutable, so we can write a method like translate that takes two numbers, dx and dy, and adds them to the attributes x and y:

%%add_method_to Point

    def translate(self, dx, dy):
        self.x += dx
        self.y += dy
       

This function translates the Point from one location in the plane to another.

If we don’t want to modify an existing Point, we can use copy to copy the original object and then modify the copy:

from copy import copy

end1 = copy(start)
end1.translate(300, 0)
print(end1)
       
Point(300, 0)
       

We can encapsulate those steps in another method called translated:

%%add_method_to Point

    def translated(self, dx=0, dy=0):
        point = copy(self)
        point.translate(dx, dy)
        return point
       

In the same way that the built-in function sort modifies a list, and the sorted function creates a new list, now we have a translate method that modifies a Point, and a translated method that creates a new one.

Here’s an example:

end2 = start.translated(0, 150)
print(end2)
       
Point(0, 150)
       

In the next section, we’ll use these points to define and draw a line.

Creating a Line

Now let’s define a class that represents the line segment between two points. As usual, we’ll start with an __init__ method and a __str__ method:

class Line:
    def __init__(self, p1, p2):
        self.p1 = p1
        self.p2 = p2
               
    def __str__(self):
        return f'Line({self.p1}, {self.p2})'
       

With those two methods, we can instantiate and display a Line object we’ll use to represent the x-axis:

line1 = Line(start, end1)
print(line1)
       
Line(Point(0, 0), Point(300, 0))
       

When we call print and pass line as a parameter, print invokes __str__ on line. The __str__ method uses an f-string to create a string representation of the line.

The f-string contains two expressions in curly braces, self.p1 and self.p2. When those expressions are evaluated, the results are Point objects. Then, when they are converted to strings, the __str__ method from the Point class gets invoked.

That’s why, when we display a Line, the result contains the string representations of the Point objects.

The following object diagram shows the state of this Line object:

String representations and object diagrams are useful for debugging, but the point of this example is to generate graphics, not text! So we’ll use the jupyturtle module to draw lines on the screen.

As we did in “The jupyturtle Module”, we’ll use make_turtle to create a Turtle object and a small canvas where it can draw. To draw lines, we’ll use two new functions from the jupyturtle module:

jumpto

Takes two coordinates and moves the Turtle to the given location without drawing a line

moveto

Moves the Turtle from its current location to the given location, and draws a line segment between them

Here’s how we import them:

from jupyturtle import make_turtle, jumpto, moveto
       

And here’s a method that draws a Line:

%%add_method_to Line

    def draw(self):
        jumpto(self.p1.x, self.p1.y)
        moveto(self.p2.x, self.p2.y)
        

To show how it’s used, I’ll create a second line that represents the y-axis:

line2 = Line(start, end2)
print(line2)
        
Line(Point(0, 0), Point(0, 150))
        

And then draw the axes:

make_turtle()
line1.draw()
line2.draw()
        

As we define and draw more objects, we’ll use these lines again. But first let’s talk about object equivalence and identity.

Creating a Rectangle

Now let’s define a class that represents and draws rectangles. To keep things simple, we’ll assume that the rectangles are either vertical or horizontal, not at an angle. What attributes do you think we should use to specify the location and size of a rectangle?

There are at least two possibilities:

  • You could specify the width and height of the rectangle and the location of one corner.

  • You could specify two opposing corners.

At this point it’s hard to say whether one is better than the other, so let’s implement the first one. Here is the class definition:

class Rectangle:
    """Represents a rectangle. 

    attributes: width, height, corner.
    """
    def __init__(self, width, height, corner):
        self.width = width
        self.height = height
        self.corner = corner
                
        def __str__(self):
            return f'Rectangle({self.width}, {self.height}, {self.corner})'
        

As usual, the __init__ method assigns the parameters to attributes and the __str__ returns a string representation of the object. Now we can instantiate a Rectangle object, using a Point as the location of the upper-left corner:

corner = Point(30, 20)
box1 = Rectangle(100, 50, corner)
print(box1)
        
Rectangle(100, 50, Point(30, 20))
        

The following diagram shows the state of this object:

To draw a rectangle, we’ll use the following method to make four Point objects to represent the corners:

%%add_method_to Rectangle

    def make_points(self):
        p1 = self.corner
        p2 = p1.translated(self.width, 0)
        p3 = p2.translated(0, self.height)
        p4 = p3.translated(-self.width, 0)
        return p1, p2, p3, p4
        

Then we’ll make four Line objects to represent the sides:

%%add_method_to Rectangle

    def make_lines(self):
        p1, p2, p3, p4 = self.make_points()
        return Line(p1, p2), Line(p2, p3), Line(p3, p4), Line(p4, p1)
        

Then we’ll draw the sides:

%%add_method_to Rectangle

    def draw(self):
        lines = self.make_lines()
        for line in lines:
            line.draw()
        

Here’s an example:

make_turtle()
line1.draw()
line2.draw()
box1.draw()
        

The figure includes two lines to represent the axes.

Deep Copy

When we use copy to duplicate box1, it copies the Rectangle object but not the Point object it contains. So box1 and box2 are different objects, as intended:

box1 is box2
        
False
        

But their corner attributes refer to the same object:

box1.corner is box2.corner
        
True
        

The following diagram shows the state of these objects:

What copy does is create a shallow copy because it copies the object but not the objects it contains. As a result, changing the width or height of one Rectangle does not affect the other, but changing the attributes of the shared Point affects both! This behavior is confusing and error prone.

Fortunately, the copy module provides another function, called deepcopy, that copies not only the object but also the objects it refers to, and the objects they refer to, and so on. This operation is called a deep copy.

To demonstrate, let’s start with a new Rectangle that contains a new Point:

corner = Point(20, 20)
box3 = Rectangle(100, 50, corner)
print(box3)
        
Rectangle(100, 50, Point(20, 20))
        

And we’ll make a deep copy:

from copy import deepcopy

box4 = deepcopy(box3)
        

We can confirm that the two Rectangle objects refer to different Point objects:

box3.corner is box4.corner
        
False
        

Because box3 and box4 are completely separate objects, we can modify one without affecting the other. To demonstrate, we’ll move box3 and grow box4:

box3.translate(50, 30)
box4.grow(100, 60)
        

And we can confirm that the effect is as expected:

make_turtle()
line1.draw()
line2.draw()
box3.draw()
box4.draw()
        

Glossary

identical: Being the same object (which implies equivalence).

equivalent: Having the same value.

shallow copy: A copy operation that does not copy nested objects.

deep copy: A copy operation that also copies nested objects.

polymorphism: The ability of a method or operator to work with multiple types of objects.

Exercises

Ask a Virtual Assistant

For all of the following exercises, consider asking a virtual assistant for help. If you do, you’ll want include as part of the prompt the class definitions for Point, Line, and Rectangle—otherwise the virtual assistant will make a guess about their attributes and functions, and the code it generates won’t work.

Exercise

Write an __eq__ method for the Line class that returns True if the Line objects refer to Point objects that are equivalent, in either order.

Exercise

Write a Line method called midpoint that computes the midpoint of a line segment and returns the result as a Point object.

Exercise

Write a Rectangle method called midpoint that finds the point in the center of a rectangle and returns the result as a Point object.

Exercise

Write a Rectangle method called make_cross that does the following:

  1. Uses make_lines to get a list of Line objects that represent the four sides of the rectangle.

  2. Computes the midpoints of the four lines.

  3. Makes and returns a list of two Line objects that represent lines connecting opposite midpoints, forming a cross through the middle of the rectangle.

Exercise

Write a definition for a class named Circle with attributes center and radius, where center is a Point object and radius is a number. Include special methods __init__ and a __str__, and a method called draw that uses jupyturtle functions to draw the circle.