from datetime import date
class Person:
def __init__(self, name, birth_date):
self.name = name
self.birth_date = birth_date
def age(self):
= date.today()
today # calculate age
= today.year - self.birth_date.year - ((today.month, today.day) < (self.birth_date.month, self.birth_date.day))
age 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()}"
10 Object-Oriented Programming
Goals
- Look at Python’s data model, and how we can implement our own types that implmeent Python’s internal interfaces.
- Introduce inheritance and it’s implementation in Python.
- Explore the idea of the interface.
- See how abstract base classes allow us to separate interface from implementation.
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
= Blueprint(1, 2)
b1 = Blueprint(3, 4)
b2
# so for example
!= b2.args b1.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:
= Person("Paul", birth_date=date(1960, 10, 10))
p = Student("Sarah", birth_date=date(2000, 4, 1))
s = Alum("Akbar", birth_date=date(1990, 8, 12), graduation_year=2023)
a
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:
= [1, 2, 3]
x 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:
= Person("Paul", birth_date=date(1960, 10, 10))
p = Student("Sarah", birth_date=date(2000, 4, 1))
s = Alum("Akbar", birth_date=date(1990, 8, 12), graduation_year=2023)
a
= [Person, Student, Alum]
types 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:
= 3
x 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:
= "abc"
a = "def"
b = a + b
c = a.__add__(b)
d
print(c, "==", d)
abcdef == abcdef
= 100
p = 90
q = p > q
r = p.__gt__(q)
s 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:
= "abc"
x = 3
y * y # calls x (str) __mul__
x * x # calls y (int) __mul__ y
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:
= CustomType(...)
x
* 3 # could work if x.__mul__ accepted an integer
x 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__
:
= CustomType(...)
x
* 3 # calls CustomType.__mul__
x 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:
- 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
- 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.
- The interface is typically the right level of abstraction for testing, so a good interface can help define tests.
- 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):
= date.today()
today # calculate age
= today.year - self.birth_date.year - ((today.month, today.day) < (self.birth_date.month, self.birth_date.day))
age return str(age)
= [Student("Ada", "Lovelace", date(2000, 12, 10)),
students "Charles", "Babbage", date(1991, 12, 26))] Student(
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):
= date.today()
today # calculate age
= today.year - self.birth_date.year - ((today.month, today.day) < (self.birth_date.month, self.birth_date.day))
age return str(age)
try:
= [Student("Ada", "Lovelace", date(2000, 12, 10)),
students "Charles", "Babbage", date(1991, 12, 26))]
Student(
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):
= date.today()
today # calculate age
= today.year - self.birth_date.year - ((today.month, today.day) < (self.birth_date.month, self.birth_date.day))
age 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.")
= [Student("Ada", "Lovelace", date(2000, 12, 10)),
students "Charles", "Babbage", date(1991, 12, 26))]
Student( 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:
= [Employee("Fred", "Flintstone", 44, 1),
employees "George", "Jetson", 40, 7777)]
Employee(
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:
= Person()
p 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):
= date.today()
today # calculate age
= today.year - self.birth_date.year - ((today.month, today.day) < (self.birth_date.month, self.birth_date.day))
age 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.
= [Student("Ada", "Lovelace", date(2000, 12, 10)),
students "Charles", "Babbage", date(1991, 12, 26))]
Student(= [Employee("Fred", "Flintstone", 44, 1),
employees "George", "Jetson", 40, 77777)]
Employee(= students + employees
people 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):
= date.today()
today # calculate age
= today.year - self.birth_date.year - ((today.month, today.day) < (self.birth_date.month, self.birth_date.day))
age 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.