10  Object-Oriented Programming


Goals


Classes & Objects Review

  • Object - An encapsulation of data & related operations. In Python implemented using classes.
  • Class - A blueprint for an object, provides methods that will act on associated data.
  • Instance - An individual object created from a class blueprint. The value assigned to a variable.
  • Method - A function associated with a specific class.
  • Attribute - Data that is associated with a specific instance.
  • Constructor - A special method that creates & populates an instance of a class.
# class definition - like a function definition, nothing happens until
#                    an *instance* is created by calling the constructor
class Blueprint:

    # constructor
    def __init__(
        # the self parameter is required on all methods
        # and refers to the instance of the class
        self,   
        # additional arguments are allowed
        arg1,
        arg2,
    ):
        # assign attributes as variables on 'self'
        self.args = [arg1, arg2]
        self.other_attribute = 123

    # methods are functions which can act on instances of 
    # the data by accessing attributes on self
    def print_args(self):
        for arg in self.args:
            print(arg)


# individual instances each have their own internal state
b1 = Blueprint(1, 2)
b2 = Blueprint(3, 4)

# so for example
b1.args != b2.args

Inheritance

Classes in Python can inherit behavior from parent classes.

This allows us to have similar types that have differences in behavior without reimplmenting the entire class.

We’ve seen this with exceptions:

class CustomError(Exception):
  pass

This syntax creates a CustomError that is a subclass or child class of Exception. In this context we would call Exception the superclass or parent class.

When we use the exception class, all methods that are defined on Exception are available on our CustomError.

# the Exception class defines a constructor that takes a message
# we use it here since CustomError did not define an __init__
raise CustomError("this is a message")

Let’s look at a larger example:

from datetime import date

class Person:
    def __init__(self, name, birth_date):
        self.name = name
        self.birth_date = birth_date

    def age(self):
        today = date.today()
        # calculate age
        age = today.year - self.birth_date.year - ((today.month, today.day) < (self.birth_date.month, self.birth_date.day))
        return int(age)

    def __str__(self):
        return f"{self.name} is {self.age()}"

# this class inherits from Person
#   a Student is-a Person
class Student(Person):
    def __init__(self, name, birth_date):
        # this call to super() calls Person.__init__
        super().__init__(name, birth_date)
        self.grades = {}

    def add_grade(self, course, grade):
        self.grades[course] = grade


# this class inherits from Student
#   an Alum is-a Student, which is in turn a Person
class Alum(Student):
    def __init__(self, name, birth_date, graduation_year):
        # this call to super() calls Student.__init__
        super().__init__(name, birth_date)
        self.graduation_year = graduation_year

    def add_grade(self, course, grade):
        # this method is overriden and will not call Student.add_grade
        raise Exception("can't add grades to alumni")

    def __str__(self):
        return f"{self.name} ({self.graduation_year}) is {self.age()}"
p = Person("Paul", birth_date=date(1960, 10, 10))
s = Student("Sarah", birth_date=date(2000, 4, 1))
a = Alum("Akbar", birth_date=date(1990, 8, 12), graduation_year=2023)

print(p, type(p))
print(s, type(s))
print(a, type(a))
Paul is 64 <class '__main__.Person'>
Sarah is 24 <class '__main__.Student'>
Akbar (2023) is 34 <class '__main__.Alum'>

We see different output for Akbar, since the Alum class has a custom __str__.

When to Use Inheritance

As a general rule, inheritance should be thought of as implementing an “is-a” relationship between child and parent.

If it makes sense to say “Y is an X”, then we might create a relationship where Y is a child class of X.

This is because a child class shares all methods and attributes of the parents.

  • Student is a (specialized type of) Person
  • Alum is a (specialized type of) Student

This can apply to real-world entities like these, or abstract concepts like data structures.

For example, we could define a SortedList type that ensures its elements are always sorted. This is a specialization of list, it would be correct to say SortedList(list) and base our implementation on the built in list.

Method Resolution Order

When a method is called on an instance of a class, Python looks at the method resolution order or MRO of the class.

If a class implements a method, the search stops. If it doesn’t, Python will check the next class in the MRO. If no classes in the hierarchy implement the method, an error is raised.

help(Alum)
Help on class Alum in module __main__:

class Alum(Student)
 |  Alum(name, birth_date, graduation_year)
 |  
 |  Method resolution order:
 |      Alum
 |      Student
 |      Person
 |      builtins.object
 |  
 |  Methods defined here:
 |  
 |  __init__(self, name, birth_date, graduation_year)
 |      Initialize self.  See help(type(self)) for accurate signature.
 |  
 |  __str__(self)
 |      Return str(self).
 |  
 |  add_grade(self, course, grade)
 |  
 |  ----------------------------------------------------------------------
 |  Methods inherited from Person:
 |  
 |  age(self)
 |  
 |  ----------------------------------------------------------------------
 |  Data descriptors inherited from Person:
 |  
 |  __dict__
 |      dictionary for instance variables (if defined)
 |  
 |  __weakref__
 |      list of weak references to the object (if defined)
Alum.__mro__
(__main__.Alum, __main__.Student, __main__.Person, object)

super

Sometimes a child class wants to augment the behavior of a parent class, in this case it can define its own implementation, and also access the parent implementation.

This is done using super():

class Parent:
    def method(self, a, b):
        return a + b

class Child(Parent):
    def method(self, a, b, c):
        super().method(a, b) + c

Changes to the parent are then automatically reflected in the child, which is preferable to having duplicated code between the two.

isinstance

To determine if a class is of a type you may have used the type function:

x = [1, 2, 3]
if type(x) == list:
    print("x is a list!")

But often we do not care about the actual type, but whether or not a type can be used in a certain way. It’d likely be OK to use our SortedList subclass in lieu of a list since they’d have the same behavior for most purposes.

We can use isinstance to ask if a class is a type or any of its subclasses:

p = Person("Paul", birth_date=date(1960, 10, 10))
s = Student("Sarah", birth_date=date(2000, 4, 1))
a = Alum("Akbar", birth_date=date(1990, 8, 12), graduation_year=2023)

types = [Person, Student, Alum]
for t in types:
     if isinstance(p, t):
        print(p.name, "is a", t.__name__)
     if isinstance(s, t):
        print(s.name, "is a", t.__name__)
     if isinstance(a, t):
        print(a.name, "is a", t.__name__)
Paul is a Person
Sarah is a Person
Akbar is a Person
Sarah is a Student
Akbar is a Student
Akbar is a Alum

Python Data Model

We’ve seen before that Python defines dunder methods, special methods which are called in particular situations to allow your types to behave like Python’s built-in types.

When a type is used in a context where Python expects a str, the __str__ method is called:

x = 3
print(x)

# same as
print(x.__str__())  # we don't write this though!
3
3

Most syntax in Python has an associated dunder method, for example:

a = "abc"
b = "def"
c = a + b
d = a.__add__(b)

print(c, "==", d)
abcdef == abcdef
p = 100
q = 90
r = p > q
s = p.__gt__(q)
print(r, "==", s)
True == True

Emulating Collections & Sequences

Collections

  • Have a length: len(obj)
  • Can be iterated over: for item in obj: ...
  • Can query membership: item in obj

Sequences

  • Everything a collection can do
  • Can be indexed: obj[0]
You Write… Python calls…
len(obj) obj.__len__()
x in obj obj.__contains__(x)
obj[i] obj.__getitem__(i)
obj[i] = x obj.__setitem__(i,x)
del obj[i] obj.__delitem__(i)

Rich Comparison

You Write… Python calls…
x == y x.__eq__(y)
x != y x.__ne__(y)
x < y x.__lt__(y)
x > y x.__gt__(y)
x <= y x.__le__(y)
x >= y x.__ge__(y)

Emulating numeric operators

You Write… Python calls…
x + y x.__add__(y)
x - y x.__sub__(y)
x * y x.__mul__(y)
x / y x.__truediv__(y)
x // y x.__floordiv__(y)
x ** y x.__pow__(y)
x @ y x.__matmul__(y)

Reverse/Reflected/Right operators

In all of the above examples, the dunder method called on the left side of the operator.

For example:

x = "abc"
y = 3
x * y    # calls x (str) __mul__
y * x    # calls y (int) __mul__

If you were to write a custom numeric type you might want it to be able to appear on either side of the operator.

But since you cannot update a built-in type’s methods this wouldn’t be possible:

x = CustomType(...)

x * 3    # could work if x.__mul__ accepted an integer
3 * x    # would call int.__mul__ which doesn't know about CustomType

For this reason, Python allows you to define reflected or right-hand versions of these functions:

You Write… Python calls…
x + y y.__radd__(x)
x - y y.__rsub__(x)
x * y y.__rmul__(x)
x / y y.__rtruediv__(x)
x // y y.__rfloordiv__(x)
x ** y y.__rpow__(x)
x @ y y.__rmatmul__(x)

These are called if an attempt to call the left hand equivalent returns the special value NotImplemented.

So if CustomType defined both __mul__ and __rmul__:

x = CustomType(...)

x * 3    # calls CustomType.__mul__
3 * x    # calls int.__mul__ first, which returns NotImplemented
         # then calls CustomType.__rmul__

Interfaces

The collection of dunder methods for various types define interfaces. We saw that we can say that a type is a sequence if it implements a specific set of dunder methods.

An interface is the “exposed” means of interacting with a system.

When you get into a car, there’s an interface for starting the engine, controlling the direction, speed, etc. There are of course, other ways to start and stop a car– but we generally prefer to use the pedals.

The same is true of our code. When we write functions and classes meant to be used by other developers (or other parts of our own code) we are creating interfaces. We often think of interfaces as a contract between the author and the user of the interface.

Benefits of Interfaces

Using interfaces helps with many common challenges in larger programs:

  1. Allow multiple team members to work on related code without requiring one portion to be 100% complete before the other starts. As seen in the next example
  2. Clear separation of interface and implementation allows for safe refactoring of code, you can know when your change is likely to break something elsewhere in the program.
  3. The interface is typically the right level of abstraction for testing, so a good interface can help define tests.
  4. A well-written interface can make it possible to change implementations (e.g. switch which database is being used) without disruptive changes throughout the entire codebase.

Example: Student Interface

Two developers, José and Sally, are working together on a project.

José is responsible for defining the class definitions of the type of people. For example:

from datetime import date
class Student:
    def __init__(self, first_name, last_name, birth_date):
        self.first_name = first_name
        self.last_name = last_name
        self.birth_date = birth_date

    def age(self):
        today = date.today()
        # calculate age
        age = today.year - self.birth_date.year - ((today.month, today.day) < (self.birth_date.month, self.birth_date.day))
        return str(age)
    
students = [Student("Ada", "Lovelace", date(2000, 12, 10)), 
            Student("Charles", "Babbage", date(1991, 12, 26))]

Sarah’s job is to define a function that displays the full names and ages of people. She starts with a function like:

def display_people(people):
    for person in people:
        print(f"{person.first_name} {person.last_name} is {person.age()} years old.")

display_people(students)
Ada Lovelace is 24 years old.
Charles Babbage is 33 years old.

José then reads Falsehoods Programmers Believe About Names and decides that he wants to change the implementation to store the name as a tuple of first and last names.

class Student:
    def __init__(self, first_name, last_name, birth_date):
        # for now we'll leave the constructor the same and just combine the two
        self.names = (first_name, last_name)
        self.birth_date = birth_date

    def age(self):
        today = date.today()
        # calculate age
        age = today.year - self.birth_date.year - ((today.month, today.day) < (self.birth_date.month, self.birth_date.day))
        return str(age)
try:
  students = [Student("Ada", "Lovelace", date(2000, 12, 10)), 
              Student("Charles", "Babbage", date(1991, 12, 26))]
  display_people(students)
except Exception as e:
  print(e)
'Student' object has no attribute 'first_name'

Sarah’s code breaks because she’s accessing the first_name and last_name attributes directly. She could fix it by accessing the names attribute and then indexing into it, but that’s not very readable, and it’s not very future-proof.

Instead, she asks José to define an interface for accessing the name. He does so by defining a full_name method:

class Student:
    def __init__(self, first_name, last_name, birth_date):
        # for now we'll leave the constructor the same and just combine the two
        self.names = (first_name, last_name)
        self.birth_date = birth_date

    def full_name(self):
        return f"{self.names[0]} {self.names[1]}"
    
    def age(self):
        today = date.today()
        # calculate age
        age = today.year - self.birth_date.year - ((today.month, today.day) < (self.birth_date.month, self.birth_date.day))
        return str(age)

and updates her function to use it:

def display_people(people):
    for person in people:
        print(f"{person.full_name()} is {person.age()} years old.")
students = [Student("Ada", "Lovelace", date(2000, 12, 10)), 
            Student("Charles", "Babbage", date(1991, 12, 26))]
display_people(students)
Ada Lovelace is 24 years old.
Charles Babbage is 33 years old.

Now, if José decides to change the implementation of the name, Sarah’s code won’t break since they’ve agreed on an interface.

A new team member, Pat, is tasked with writing an Employee class.

class Employee:
    def __init__(self, first_name, last_name, age, employee_id):
        self.names = (first_name, last_name)
        self.age = age
        self.employee_id = employee_id

    def name(self):
        return f"{self.names[0]} {self.names[1]}"
# Sarah is asked to ensure display_people will work for Employee as well
try:
  employees = [Employee("Fred", "Flintstone", 44, 1), 
              Employee("George", "Jetson", 40, 7777)]
  display_people(employees)
except Exception as e:
  print(e)
'Employee' object has no attribute 'full_name'

Sarah’s display_people function does not work with Employee objects because it’s expecting a full_name method but Employee has a name method. Additionally, age is a property of Employee but a method on Student.

A naive solution to this might be to add more code to display_people to check what type it gets. Why is this not a good idea?

This problem stems from the fact that the code the three are writing is already tightly coupled. This means that the code is dependent on the implementation details of other parts of the code. In this case, the display_people function is dependent on the full_name method and the age method.

To loosely couple the code, we need to define an interface that the display_people function can depend on, rather than the implementation details of the Student and Employee classes.

Abstract Classes

One solution to this problem is to define an interface using an abstract class, that defines the methods that must be implemented by any class that implements the interface.

In Python, we can use the abc module to define abstract classes. A class that inherits from ABC is an abstract class, and any methods decorated with @abstractmethod must be implemented by any class that inherits from it.

For example:

from abc import ABC, abstractmethod

class Person(ABC):
    @abstractmethod
    def full_name(self):
        pass

    @abstractmethod
    def age(self):
        pass

try:
  p = Person()
except Exception as e:
  print(e)
Can't instantiate abstract class Person with abstract methods age, full_name

Person is an abstract base class (ABC), and any class that inherits from it must implement the full_name and age methods. Trying to instantiate Person directly, or any incomplete subclass, will raise an error.

To make a class that implements the Person interface, we can do:

class Student(Person):
    def __init__(self, first_name, last_name, birth_date):
        self.names = (first_name, last_name)
        self.birth_date = birth_date

    def full_name(self):
        return f"{self.names[0]} {self.names[1]}"

    def age(self):
        today = date.today()
        # calculate age
        age = today.year - self.birth_date.year - ((today.month, today.day) < (self.birth_date.month, self.birth_date.day))
        return str(age) 


class Employee(Person):
    def __init__(self, first_name, last_name, age, employee_id):
        self._names = (first_name, last_name)
        self._age = age
        self.employee_id = employee_id

    def full_name(self):
        return f"{self._names[0]} {self._names[1]}"

    def age(self):
        return str(self._age)

Sarah’s implementation of display_people will work with any Person subclass, since they are guaranteed to have the required methods.

students = [Student("Ada", "Lovelace", date(2000, 12, 10)), 
            Student("Charles", "Babbage", date(1991, 12, 26))]
employees = [Employee("Fred", "Flintstone", 44, 1), 
             Employee("George", "Jetson", 40, 77777)]
people = students + employees
display_people(people)
Ada Lovelace is 24 years old.
Charles Babbage is 33 years old.
Fred Flintstone is 44 years old.
George Jetson is 40 years old.

It is also possible to provide default implementations in classes. These can be overridden by subclasses.

For example:

from abc import ABC, abstractmethod


class Person(ABC):
    def __init__(self, first_name, last_name, birth_date):
        self.names = (first_name, last_name)
        self.birth_date = birth_date

    # these methods will be inherited by subclasses, but can be overridden
    def full_name(self):
        return f"{self.names[0]} {self.names[1]}"

    def age(self):
        today = date.today()
        # calculate age
        age = today.year - self.birth_date.year - ((today.month, today.day) < (self.birth_date.month, self.birth_date.day))
        return str(age) 

    @abstractmethod
    def include_in_payroll(self):
        pass


class Student(Person):
    def __init__(self, first_name, last_name, birth_date):
        # if you need to call a parent classes implementation, you can use super()
        super().__init__(first_name, last_name, birth_date)
        
    def include_in_payroll(self):
        return False


class Employee(Person):
    def __init__(self, first_name, last_name, birth_date, employee_id):
        super().__init__(first_name, last_name, birth_date)
        self.employee_id = employee_id
    
    def include_in_payroll(self):
        return True

Further Exploration

  • The Python Data Model Documentation describes all of the protocols & interfaces implemented with dunder methods.
  • abc documentation
  • PEP 3119 - This is an advanced reading perhaps of interest, but definitely not needed for this course. PEPs are documents proposing changes to Python. This one is the justification for adding the abc module to the standard library. It explains benefits of using abstract base classes, and also provides some insight into what governance looks like in an Open Source project.