Objects#

In Python, objects are used to represent information. Every variable you use in a Python program is a reference to an object. The values you have been using so far – numbers, strings, dicts, lists, etc – are objects. They are among the built-in classes of Python, i.e., kinds of value that are already defined when you start the Python interpreter.

You are not limited to those built-in classes. You can use them as a foundation to build your own.

Example: Points#

What if we wanted to define a new kind of value? For example, if we wanted to write a program to draw a graph, we might want to work with cartesian coordinates, representing each point as an (x,y) pair. We might represent the point as a tuple like (5,7), or we could represent it as the list [5, 7], or we could represent it as a dict {"x": 5, "y": 7}, and that might be satisfactory. If we wanted to represent moving a point (x,y) by some distance (dx, dy), we could define a a function like

def move(p, d):
    x,y = p
    dx, dy = d
    return (x+dx, y+dy)
    
pt_1 = (5,8)
pt_2 = move(pt_1, (3,7))
print(pt_2)
(8, 15)

But if we are making a graphics program, we’ll need to move functions for other graphical objects like rectangles and ovals, so instead of naming it move we’ll need a more descriptive name like move_point. Also we should give the type contract for the function, which we can do with Python type hints. With these changes, we get something like this

from typing import Tuple
from numbers import Number

def move_point(p: Tuple[Number, Number],
               d: Tuple[Number, Number]) \
        -> Tuple[Number, Number]:
    x, y = p
    dx, dy = d
    return (x+dx, y+dy)
    
move_point((3,4),(5,6))
(8, 10)

Can we do better?#

We aren’t really satisfied with using tuples to represent points. What we’d really like is to express the concept of adding two points more concisely, as (3,4) + (5,6). What would happen if we tried this?

(3,4) + (5,6)
(3, 4, 5, 6)

That’s not what we wanted! Would it be better if we represented points as lists?

[3,4] + [5,6]
[3, 4, 5, 6]

No better. Maybe as dicts?

{"x": 3, "y": 4} + {"x": 5, "y": 6}
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[5], line 1
----> 1 {"x": 3, "y": 4} + {"x": 5, "y": 6}

TypeError: unsupported operand type(s) for +: 'dict' and 'dict'

That is not much of an improvement, although an error message is usually better than silently producing a bad result. What we really want is not to use one of the existing representations like lists or tuples or dicts, but to define a new representation for points.

A new representation#

Each data type in Python, including list, tuple, and dict, is defined as a class from which objects can be constructed. We can also define our own classes, to construct new kinds of objects.
For example, we can make a new class Point to represent points.

class Point:
    """An (x,y) coordinate pair"""

Inside the class we can define methods, which are like functions that are specialized for the new representation. The first method we should define is a constructor with the name __init__. The constructor describes how to create a new Point object:

class Point:
    """An (x,y) coordinate pair"""
    def __init__(self, x: Number, y: Number):
        self.x = x
        self.y = y
        
p = Point(5,3)

print(f"p has x coordinate {p.x} and y coordinate {p.y}")
p has x coordinate 5 and y coordinate 3

Instance variables#

Notice that the first argument to the constructor method is self, and within the method we refer to self.x and self.y. In a method that operates on some object o, the first argument to the method will always be self, which refers to the whole object o. Within the self object we can store instance variables, like self.x and self.y for the x and y coordinates of a point. When we use the Point object p from outside the class, we refer to those elements as p.x and p.y, as in the print statement above.

Methods#

What about defining an operation for moving a point? Instead of adding _point to the name of a move function, we can just put the function (now called a method) inside the Point class:

class Point:
    """An (x,y) coordinate pair"""
    def __init__(self, x: Number, y: Number):
        self.x = x
        self.y = y

    def move(self, d: "Point") -> "Point":
        """(x,y).move(dx,dy) = (x+dx, y+dy)"""
        x = self.x + d.x
        y = self.y + d.y
        return Point(x,y)

Notice that the instance variables self.x and self.y we created in the constructor can be used in the move method. They are part of the object, and can be used by any method in the class. The instance variables of the other Point object d are also available in the move method. Let’s look at how these objects are passed to the move method.

Method calls#

Next we’ll create two Point objects and call the move method to create a third Point object with the sums of their x and y coordinates:

p = Point(3,4)
v = Point(5,6)
m = p.move(v)

print(f"m has x coordinate {m.x} and y coordinate {m.y}")
m has x coordinate 8 and y coordinate 10

At first it may seem confusing that we defined the move method with two arguments, self and d, but it looks like we passed it only one argument, v. In fact we passed it both points: p.move(v) passes p as the self argument and v as the d argument. We use the variable before the ., like p in this case, in two different ways: To find the right method (function) to call, by looking inside the class to which p belongs, and to pass as the self argument to the method.

The move method above returns a new Point object at the computed coordinates. A method can also change the values of instance variables. For example, suppose we add a move_to method to Point:

class Point:
    """An (x,y) coordinate pair"""
    def __init__(self, x: Number, y: Number):
        self.x = x
        self.y = y

    def move(self, d: "Point") -> "Point":
        """(x,y).move(dx,dy) = (x+dx, y+dy)"""
        x = self.x + d.x
        y = self.y + d.y
        return Point(x,y)
        
    def move_to(self, new_x, new_y):
        """Change the coordinates of this Point"""
        self.x = new_x
        self.y = new_y

m = Point(3,4)
m.move_to(19,23)
print(f"({m.x}, {m.y})")
(19, 23)

Note that the move_to method does not return the moved point. This is a common mistake!

w = m.move_to(19, 23)  # Oops! 
print(w)

# Attempting to access w.x or w.y will fail: 
print(f"w has x coordinate {w.x} and y coordinate {w.y}")
None
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
Cell In[10], line 5
      2 print(w)
      4 # Attempting to access w.x or w.y will fail: 
----> 5 print(f"w has x coordinate {w.x} and y coordinate {w.y}")

AttributeError: 'NoneType' object has no attribute 'x'

What is self?#

Many people are confused by the self variable. The name self is merely a convention in Python. Conventions are important for readability and avoiding errors, so you should never write code like the following, but it may help you to see that there is really nothing special about self aside from convention.

class BadExample():
    """An example in which we use other names instead of "self".
    DON'T DO THIS ... but understand it. 
    """
    
    def __init__(elephant, x: int): 
        elephant.v = x    # Might as well be consistently inconsistent
        
    def increase(zebra, y: int): 
        zebra.v += y
        
wacky = BadExample(17)
wacky.increase(13)
print(wacky.v)
30

As you can see, when we make a method call like wacky.increase(13), the first argument is the object wacky. We ordinarily call that argument self, not because it matters to Python, but because it matters to other programmers who need to read and understand our code.

Check your understanding#

Consider class Pet and object my_pet.
What are the instance variables of my_pet? What are the values of those instance variables after executing the code below?

class Pet: 
    def __init__(self, kind: str, name: str):
        self.species = kind
        self.called = name
    
    def rename(self, new_name):
        self.called = new_name

my_pet = Pet("canis familiaris", "fido")

A little magic#

We said above that what we really wanted was to express movement of points very compactly, as addition. We saw that addition of tuples or lists did not act as we wanted; instead of (3,4) + (5,6) giving us (8,10), it gave us (3,4,5,6). We can almost get what we want by describing how we want + to act on Point objects. We do this by defining a special method __add__:

class Point:
    """An (x,y) coordinate pair"""
    def __init__(self, x: Number, y: Number):
        self.x = x
        self.y = y
    
    def move(self, d: "Point") -> "Point":
        """(x,y).move(dx,dy) = (x+dx, y+dy)"""
        x = self.x + d.x
        y = self.y + d.y
        return Point(x,y)
        
    def move_to(self, new_x, new_y):
        """Change the coordinates of this Point"""
        self.x = new_x
        self.y = new_y
        
    def __add__(self, other: "Point"):
        """(x,y) + (dx, dy) = (x+dx, y+dy)"""
        return Point(self.x + other.x, self.y + other.y)

Special methods are more commonly known as magic methods. They allow us to define how arithmetic operations like + and - work for each class of object, as well as comparisons like < and ==, and some other operations.
If p is a Point object, then p + q is interpreted as p.__add__(q). So finally we get a very compact and readable notation:

p = Point(3,4)
v = Point(5,6)
m = p + v

print(f"({m.x}, {m.y})")
(8, 10)

More: Appendix on magic methods

Magic for printing#

Suppose we wanted to print a Point object. We could do it with an f-string, as we have above:

print(f"p is ({p.x}, {p.y})")
p is (3, 4)

That would give us a reasonable printed representation, like “p is (3, 4)”, but it is tedious, verbose, and easy to get wrong. What if we just wrote

print(f"p is {p}")
p is <__main__.Point object at 0x103f2eba0>

That isn’t a very useful way to print an object!

str()d, not shaken#

If we want to print Point objects as simply as we print strings and numbers, but we want the printed representation to be readable, we will need to write additional methods to describe how a Point object should be converted to a string.

In fact, in Python we normally write two magic methods for this: __str__ describes how it should be represented by the str() function, which is the representation used in print or in an f-string like f"it is {p}". We might decide that we want the object created by Point(3,2) to print as “(3, 2)”. We would then write a __str__ method in the Point class like this:

class Point:
    """An (x,y) coordinate pair"""
    def __init__(self, x: Number, y: Number):
        self.x = x
        self.y = y
    
    def move(self, d: "Point") -> "Point":
        """(x,y).move(dx,dy) = (x+dx, y+dy)"""
        x = self.x + d.x
        y = self.y + d.y
        return Point(x,y)
        
    def move_to(self, new_x, new_y):
        """Change the coordinates of this Point"""
        self.x = new_x
        self.y = new_y
        
    def __add__(self, other: "Point"):
        """(x,y) + (dx, dy) = (x+dx, y+dy)"""
        return Point(self.x + other.x, self.y + other.y)

    # Two new methods, used by the "str" and "repr"
    # functions. 
  
    def __str__(self) -> str:
        """Printed representation.
        str(p) is an implicit call to p.__str__()
        """
        return f"({self.x}, {self.y})"
      
    def __repr__(self) -> str:
        """Debugging representation.  This is what
        we see if we type a point name at the console.
        """
        return f"Point({self.x}, {self.y})"

Now if we again execute

p = Point(17,13)
print(f"p is {p}")
p is (17, 13)

we get a more useful result.

A repr() for debugging#

Usually we will also want to provide a different string representation that is useful in debugging and at the Python command line interface. The string representation above may be fine for end users, but for the software developer it does not differentiate between a tuple (3, 4) and a Point object (3, 4). We can define a __repr__ method to give a string representation more useful in debugging. The function repr(x) is actually a call on the __repr__ method of x, i.e., x.__repr__().

Although Python will permit us to write whatever __repr__ method we choose, by accepted convention is to make it look like a call on the constructor, i.e., like Python code to create an identical object. Thus, for the Point class we might write:

    def __repr__(self) -> str:
        return f"Point({self.x}, {self.y})"

Now we can write

print(f"repr(p) is {repr(p)}")
repr(p) is Point(17, 13)

More often, the repr function is called implicitly when we just enter an expression at the console.

p + m
Point(25, 23)

The print function automatically applies the str function to its arguments, so defining a good __str__ method will ensure it is printed as you like in most cases. Oddly, though, the __str__ method for list applies the __repr__ method to each of its arguments, which we can see by writing

p = Point(22, 17)
v = Point(18, 13)
print(p)
print(v)
print([p, v])
(22, 17)
(18, 13)
[Point(22, 17), Point(18, 13)]

Check your understanding#

Which of the following are legal, and what values do they return?

  • str(5)

  • (5).str()

  • (5).__str__()

  • __str__(5)

  • repr([1, 2, 3])

  • [1, 2, 3].repr()

  • [1, 2, 3].__repr__()

What does the following little program print?

class Wrap: 
    def __init__(self, val: str):
        self.value = val

    def __str__(self) -> str:
        return self.value

    def __repr__(self) -> str:
        return f"Wrap({self.value})"

a = Wrap("alpha")
b = Wrap("beta")
print([a, b])

Variables refer to objects#

Before reading on, try to predict what the following little program will print.

x = [1, 2, 3]
y = x
y.append(4)
print(x)
print(y)

Now execute that program. Did you get the result you expected? If it surprised you, try visualizing it in PythonTutor (http://pythontutor.com/). You should get a diagram that looks like this:

x and y are distinct variables, but they are both references to the same list. When we change y by appending 4, we are changing the same object that x refers to. We say that x and y are aliases, two names for the same object.

Note this is very different from the following:

x = [1, 2, 3]
y = [1, 2, 3]
y.append(4)
print(x)
print(y)

Each time we create a list like [1, 2, 3], we are creating a distinct list. In this seocond version of the program, x and y are not aliases.

It is essential to remember that variables hold references to objects, and there may be more than one reference to the same object. We can observe the same phenomenon with classes we add to Python. Consider this program:

p1 = Point(3,5)
p2 = p1
p1.move_to(7, 9)
print(p2)
(7, 9)

Once again we have created two variables that are aliases, i.e., they refer to the same object. PythonTutor illustrates:

Note that Point is a reference to the class, while p1 and p2 are references to the Point object we created from the Point class. When we call p1.move, the move method of class Point makes a change to the object that is referenced by both p1 and p2. We often say that a method like move mutates an object.

There is another way we could have written a method like move.
Instead of mutating the object (changing the values of its fields x and y), we could have created a new Point object at the modified coordinates:

class Point:
    """An (x,y) coordinate pair"""
    def __init__(self, x: int, y: int):
        self.x = x
        self.y = y

    def moved(self, dx: int, dy: int) -> "Point":
        return Point(self.x + dx, self.y + dy)

p1 = Point(3,5)
p2 = p1
p1 = p1.moved(4,4)
print(p1.x, p1.y)
print(p2.x, p2.y)
7 9
3 5

Notice that method moved, unlike method move in the prior example, return a new Point object that is distinct from the Point object that was aliased. Initially p1 and p2 may be aliases, after p2 = p1:

But after p1 = p1.moved(4,4), p1 refers to a new, distinct object:

As we saw with the list example, aiasing applies to objects from the built-in Python classes as well as to objects from the classes that you will write. It just hasn’t been apparent until now, because many of the built-in classes are immutable: They do not have any methods that change the values stored in an object. For example, when we write 3 + 5, we are actually calling (3).__add__(5); The __add__ method does not change the value of 3 (that would be confusing!) but instead returns a new object 8.

We will write both immutable and mutable classes.
Aliasing of mutable objects is often a mistake, but not always. Later we will intentionally created aliases to access mutable objects. The important thing is to be aware of it.

Combining Objects: Composing#

The instance variables defined in a class and stored in the objects of that class can themselves be objects. We can make lists of objects, tuples of objects, etc.

Often we will want to create a new class with instance variables that are objects created from classes that we have previously created. For example, if we create a new class Rect to represent rectangles, we might want to use Point objects to represent two corners of the rectangle:

Hide code cell content
class Point:
    """An (x,y) coordinate pair"""
    def __init__(self, x: Number, y: Number):
        self.x = x
        self.y = y
    
    def move(self, d: "Point") -> "Point":
        """(x,y).move(dx,dy) = (x+dx, y+dy)"""
        x = self.x + d.x
        y = self.y + d.y
        return Point(x,y)
        
    def move_to(self, new_x, new_y):
        """Change the coordinates of this Point"""
        self.x = new_x
        self.y = new_y
        
    def __add__(self, other: "Point"):
        """(x,y) + (dx, dy) = (x+dx, y+dy)"""
        return Point(self.x + other.x, self.y + other.y)

    def __str__(self) -> str:
        """Printed representation.
        str(p) is an implicit call to p.__str__()
        """
        return f"({self.x}, {self.y})"
      
    def __repr__(self) -> str:
        """Debugging representation.  This is what
        we see if we type a point name at the console.
        """
        return f"Point({self.x}, {self.y})"
class Rect:
    """A rectangle is represented by a pair of points
    (x_min, y_min), (x_max, y_max) at opposite corners.
    Whether (x_min, y_min) is lower left or upper left
    depends on the coordinate system.
    """
    def __init__(self, xy_min: Point, xy_max: Point):
        self.min_pt = xy_min
        self.max_pt = xy_max
    
    def area(self) -> Number:
        """Area is height * width"""
        height = self.max_pt.x - self.min_pt.x
        width = self.max_pt.y - self.min_pt.y
        return height * width

    def translate(self, delta: Point) -> "Rect":
        """New rectangle offset from this one by delta as movement vector"""
        return Rect(self.min_pt + delta, self.max_pt + delta)

    def __repr__(self) -> str:
        return f"Rect({repr(self.min_pt)}, {repr(self.max_pt)}"

    def __str__(self) -> str:
        return f"Rect({str(self.min_pt)}, {str(self.max_pt)})"

p1 = Point(3,5)
p2 = Point(8,7)
r1 = Rect(p1, p2)
mvmt = Point(4, 5)
r2 = r1.translate(mvmt)  # Treat Point(4,5) as (dx, dy)
print(f"{r1} + {mvmt} => {r2}")
print(f"Area of {r1} is {r1.area()}")
Rect((3, 5), (8, 7)) + (4, 5) => Rect((7, 10), (12, 12))
Area of Rect((3, 5), (8, 7)) is 10

Note that the height and width are local variables that exist only while method area is executing. min_pt and max_pt, on the other hand, are instance variables that are stored within the Rect object.

Check your understanding#

Suppose we ran the above code in PythonTutor. (PythonTutor cannot import Number, but for the examples we could replace it with int.) What picture would it draw of r1? Would height and width in method area be included as instance variables? Why or why not?

Wrapping and delegation#

Sometimes we want a class of objects that is almost like an existing class, but with a little extra information or a few new methods. One way to do this is to build a new class that wraps an existing class, often a built-in class like list or dict. (In the next chapter we will see another approach.)

Suppose we wanted objects that provide some of the same functionality as list objects, and also some new functionality or some restrictions. For example, we might want a method area that returns the sum of the areas of all the Rect objects in the RectList:

class RectList:
    """A collection of Rects."""

    def __init__(self):
        self.elements = [ ]

    def area(self) -> Number:
        total = 0
        for el in self.elements:
            total += el.area()
        return total

That seems reasonable, but how do we add Rect objects to the Rectlist?

We do not want to do it this way:

li = RectList()
# DON'T DO THIS
li.elements.append(Rect(Point(3,3), Point(5,7)))
li.elements.append(Rect(Point(2,2), Point(3,3)))

As a general rule, we should be cautious about accessing the instance variables of an object outside of methods of the object’s class, and we should especially avoid modifying instance variables anywhere except in methods. Code that “breaks the abstraction”, like the example above calling the append method of the elements instance variable, is difficult to read and maintain. So we want instead to give RectList it’s own append method, so that we can write

li = RectList()
li.append(Rect(Point(3,3), Point(5,7)))
li.append(Rect(Point(2,2), Point(3,3)))
print(f"Combined area is {li.area()}")

The append method can be very simple!

    def append(self, item: Rect):
        """Delegate to elements"""
        self.elements.append(item)
Hide code cell content
class RectList:
    """A collection of Rects."""

    def __init__(self):
        self.elements = [ ]
        
    def append(self, item: Rect):
        """Delegate to elements"""
        self.elements.append(item)

    def area(self) -> Number:
        total = 0
        for el in self.elements:
            total += el.area()
        return total
li = RectList()
li.append(Rect(Point(3,3), Point(5,7)))
li.append(Rect(Point(2,2), Point(3,3)))
print(f"Combined area is {li.area()}")
Combined area is 9

We call this delegation because append method of RectList method
just hands off the work to the append method of class list. When we write a wrapper class, we typically write several such trivial delegation methods.

Wrapping and delegation work well when we want the wrapper class (like RectList in this example) to have a few of the same methods as the wrapped class (list). When we want the new collection class to have all or nearly all the methods of an existing collection, the inheritance approach introduced in the next chapter is more appropriate.