Object-Oriented Programming
Object-Oriented vs. Procedural Programming¶
Suppose we want to write a program for a university library that manages students and available textbooks.
First, let us consider how this might look in a procedural programming style.
Properties of students could be stored in a list:
# Student: Name, Gender, Age, Major, Semester, Grades
mary = ["Mary", "w", 23, "Mathematics", 8, [1,3,2,2]]
john = ["John", "m", 25, "Mechanical Engineering", 6, [3,3,4]]One could now define free functions that work with these properties to implement a logical program flow:
def celebrate_birthday(student):
student[2] += 1
celebrate_birthday(mary)
print(mary)['Mary', 'w', 24, 'Mathematics', 8, [1, 3, 2, 2]]
However, this programming style has some disadvantages. If we want to change a student’s major later in the program, will we remember that it is at index 4 of the list? Moreover, the free function celebrate_birthday could also be applied to any other list with at least three elements and a number in the third position. Our library program could, for example, store textbooks as follows:
# Textbook: Author, Title, Shelf Location, Loan Status
textbook1 = ["Harro Heuser", "Lehrbuch der Analysis 1", 4, False]Of course, it makes no sense to celebrate the birthday of a textbook, yet our program would allow this. In that case, however, the book would be moved to the next shelf:
print("Our book is on shelf", textbook1[2])
celebrate_birthday(textbook1)
print("Our book is now on shelf", textbook1[2])Our book is on shelf 4
Our book is now on shelf 5
Here, the age of the student is correctly incremented, but the same function could also change the shelf number of a book—which is logically nonsensical. It would make sense to couple the function celebrate_birthday directly to students. In the following section, we will look at an object-oriented implementation of our program.
Classes, Methods, and Attributes¶
Creating Your Own Classes¶
Object-oriented programming (OOP) is a programming style in which specific properties and applicable operations are bundled into individual objects.
Such an object could be a student, equipped with individual properties like name, gender, age, major, semester, and grades. A student can perform certain actions, for example, aging, breathing, learning, taking an exam, etc.
The idea of OOP is to associate the classification “student” with a dedicated data type, called a class. This class should provide the predefined properties and operations. We may recall previous chapters, where we already worked with a class for complex numbers.
import cmath
c = 1+3j
# Access an attribute
print("The real part of c is", c.real)
# Call a method
print("The complex conjugate is", c.conjugate())The real part of c is 1.0
The complex conjugate is (1-3j)
Here, real is an attribute and conjugate() is a method of the object c.
Attributes and methods are the main components of a class. We now want to learn how to implement our own classes.
The general syntax looks like this:
class <class_name>:
def __init__(self, <param1>, <param2>, ...):
self.<attribute_1> = ...
self.<attribute_2> = ...
def <method_1>(self, [parameter_list]):
[do something]
return <result>
def <method_2>(self, [parameter_list]):
[do something]
return <result> The class definition begins with the keyword class, followed by the name of our class. Inside the class, we define functions called methods, which are always tied to an instance (object) of the class.
A special method we want to highlight first is the init function, also called the constructor. It is always named __init__ and receives the object self as a parameter, along with any additional parameters. Its task is to initialize the attributes of the class—either with default values or based on the input parameters.
We can then create instances of our class in the main program with:
<instance> = <class_name>(<param1>, <param2>, ...)We can access the attributes of the class inside the class (in a method) using
self.<attribute>and from outside the class with
<instance>.<attribute>for both reading and writing.
Similarly, for methods, inside the class we use
self.<method>(<param1>, <param2>, ...)and outside the class
<instance>.<method>(<param1>, <param2>, ...)Example: For managing students, an object-oriented solution could first define the following class:
class student:
# Constructor
def __init__(self, name, sex, age, course="not enrolled", semester=1):
print(name, "enrolled")
self.name = name
self.sex = sex
self.age = age
self.course = course
self.semester = semester
self.marks = []
# Increases and returns age of a student
def celebrate_birthday(self):
print(self.name, "celebrates birthday")
self.age += 1
return self.ageThis code requires a more detailed explanation:
We have defined a class named
student.The class has attributes
name,sex,age,course,semester,marks, which are initialized in the init function. The init function initializes the attributes:name,sex,ageare set based on the input parameters passed when calling the init function.course,semesterreceive default values (course="not enrolled"andsemester=1in the init function parameter list). These values are used if the parameter is omitted during the call.marksis initialized as an empty list, independent of the input parameters.
The class has a method:
celebrate_birthday.
Note: For all methods of a class, the first parameter is always self, i.e., a pointer to the instance itself. This object is not explicitly passed when calling the function.
Let’s now see how we can work with our class. First, we create 2 instances of this class:
# Create instances of the student class (calls init function)
mary = student("Mary", "w", 23, "Mathematics", 8)
john = student("John", "m", 28, "Mechanical Engineering")Mary enrolled
John enrolled
From the console output, we can see that the init function was indeed called. Thus, the attributes are properly initialized.
For example, we can query Mary’s age, i.e., access an attribute:
# Access an attribute
print("Mary is", mary.age, "years old")Mary is 23 years old
To call a method on Mary, we use
# Calling a method
new_age = mary.celebrate_birthday()
print("Mary is now", new_age, "years old")Mary celebrates birthday
Mary is now 24 years old
These were already the most important concepts regarding classes. At this point, we should understand attributes, methods, and the purpose of the init function.
Class Attributes¶
Some additional terms must be introduced. What we have so far called an attribute should actually be called an instance attribute, because it is tied to a specific instance of our class. Another type of attribute is the class attribute (or class variable). A class attribute is a variable shared by all instances of a class. A class attribute is defined in the body of the class:
class <class_name>:
<class_attribute> = <value>and can be read or written both inside and outside the class using:
<value> = <class_name>.<class_attribute>
<class_name>.<class_attribute> = <value>For example, we could extend our student class with a class attribute called count, which we can later use to track the number of enrolled students. The counter should be incremented in the init function and decremented in the counterpart, the delete function, or the destructor (which is called when an object is destroyed). We could edit the cell in the Jupyter Notebook where the student class was defined, or extend the class through a recursive class definition class student(student) to add or replace a method:
class student(student):
# Define class attribute
count = 0
# Init function
def __init__(self, name, sex, age, course="not enrolled", semester=1):
print(name, "enrolled")
self.name = name
self.sex = sex
self.age = age
self.course = course
self.semester = semester
self.marks = []
# Update class attribute
student.count += 1
def __del__(self):
print(self.name, "unenrolled")
# Update class attribute
student.count -= 1We create 2 instances of the student class again, read the class attribute count, delete a student, and then read the class attribute again:
mary = student("Mary", "f", 23, "Mathematics", 8)
john = student("John", "m", 28, "Mechanical Engineering")
print("Number of students:", student.count)
del john # This calls the delete function
print("Number of students:", student.count)Mary enrolled
John enrolled
Number of students: 2
John unenrolled
Number of students: 1
Let us first summarize the concepts we have learned:
| Term | Example |
|---|---|
| Class | student |
| Instance | mary, john |
| Method | student.celebrate_birthday() |
| Instance attribute | self.name, self.age, ... |
| Class attribute | student.count |
Instance variables can be accessed using the following syntax:
<instance>.<local_variable>Methods are called using:
<instance>.<function>(<param1>, <param2>, ...)We have already encountered special methods that are not explicitly called but are executed automatically when certain events occur:
| Name | Description | Remark |
|---|---|---|
__init__(self, <param_list>) | Constructor | Called when an instance is created |
__del__(self) | Destructor | Called when an instance is deleted using del <instance> |
As an exercise, let us now add some additional functions to our class:
class student(student):
# Add exam result
def add_exam(self, mark):
self.marks.append(mark)
# Compute average grade
def get_average_mark(self):
nr_marks = len(self.marks)
if nr_marks > 0:
return sum(self.marks) / len(self.marks)
else:
return 0Since we have modified the student class, we need to recreate the instance mary. For this instance, we can now add exam grades and compute the average grade:
mary = student("Mary", "f", 23, "Mathematics", 8)
mary.add_exam(2.0)
mary.add_exam(1.3)
avg = mary.get_average_mark()
print(mary.name, "has an average grade of", avg)Mary enrolled
Mary unenrolled
Mary has an average grade of 1.65
If a name like mary already refers to an existing object, a new object is created when it is reassigned. The previous object is no longer referenced, and Python automatically calls the destructor __del__. For example, the unenrollment is displayed when the old object is deleted.
Special methods¶
Let us take another look at the constructor __init__. This method is not called explicitly but is executed automatically when a class instance is created via student(...). There are other such special methods which, if implemented in a class, make working with the class more convenient.
Console output of an instance:
If we simply print the instance mary to the console, we only get a memory address and the class information by default:
print("This is Mary:", mary)This is Mary: <__main__.student object at 0x78f8b0655400>
mary<__main__.student at 0x78f8b0655400>A nicely formatted output with name, age, gender, etc. would be desirable.
We extend the class with methods that control the output:
class student(student):
# Internal method for string representation
def __to_string__(self):
return self.name + ", " \
+ ("male" if self.sex == "m" else "female") + ", " \
+ str(self.age) + " years, " \
+ self.course + " (" + str(self.semester) + "th semester)"
# Called when using print(<instance>)
def __str__(self):
return self.__to_string__()
# Called for direct console output <instance>
def __repr__(self):
return self.__to_string__()__str__is called when usingprint(<instance>).__repr__is called when directly entering the instance in the console.
The actual string conversion is outsourced to __to_string__ to avoid code duplication.
After this modification, we obtain the desired console output:
mary = student("Mary", "f", 23, "Mathematics", 8)
print("This is Mary:", mary)Mary enrolled
This is Mary: Mary, female, 23 years, Mathematics (8th semester)
maryMary, female, 23 years, Mathematics (8th semester)Methods with double underscores (__to_string__) are considered private, i.e., they are intended to be used only within the class. Although it is possible to call mary.__to_string__() from outside, this is considered bad practice.
Comparison operations:
Comparison operations are also important. We may want to:
sort people by name (
<)detect duplicate instances (
==)
To make such operations work, the corresponding methods must be defined in the class. For comparison operators, one implements:
__lt__(self, other)– “less than” (<)__le__(self, other)– “less or equal” (<=)__eq__(self, other)– “equal” (==)
All of these functions must return boolean values, i.e., True if the comparison holds and False otherwise.
For our student class, an implementation could look like this:
class student(student):
# Operator <
def __lt__(self, other):
return self.name < other.name
# Operator <=
def __le__(self, other):
return self.name <= other.name
# Operator ==
def __eq__(self, other):
return self.name == other.namemary = student("Mary", "f", 23, "Mathematics", 8)
john = student("John","m", 25, "Mechanical Engineering", 6)
mary2 = student("Mary", "f", 21, "Psychology", 2)
print("Mary less than John : ", mary < john)
print("Mary less or equal John : ", mary <= john)
print("Mary equal Mary2 : ", mary == mary2)
print("Mary greater than John : ", mary > john)
print("Mary greater or equal John: ", mary >= john)Mary enrolled
John enrolled
Mary enrolled
Mary less than John : False
Mary less or equal John : False
Mary equal Mary2 : True
Mary greater than John : True
Mary greater or equal John: True
Since we have defined __lt__ and __le__, we can automatically also use > and >=.
As soon as a comparison operation is implemented, lists of instances can be sorted by name:
student_list = [student("Mary", "f", 23, "Mathematics", 8),
student("John", "m", 25, "Mechanical Engineering", 6),
student("Tom", "m", 32, "Psychology", 22),
student("Anna", "f", 19, "Chemistry", 2)]
student_list.sort()
student_listMary enrolled
John enrolled
Tom enrolled
Anna enrolled
[Anna, female, 19 years, Chemistry (2th semester),
John, male, 25 years, Mechanical Engineering (6th semester),
Mary, female, 23 years, Mathematics (8th semester),
Tom, male, 32 years, Psychology (22th semester)]Thanks to the previously implemented __str__ and __repr__ methods, the list is displayed in a human-readable format when printed.
Operator overloading¶
Common arithmetic operations can also be defined for custom classes. As already seen in the Linear Algebra with NumPy chapter, addition, subtraction, multiplication, and division are implemented for objects of type numpy.ndarray.
For custom classes, these operations can be realized by special methods. For example, to define addition (+), one implements the method
__add__(self, other)Since adding two students does not make much sense, we switch the example here and implement a custom class for complex numbers.
class my_complex:
# Constructor
def __init__(self, real, imag=0.):
self.real = real
self.imag = imag
# Console output
def __to_string__(self):
return str(self.real) + ("+" if self.imag >= 0 else "") + str(self.imag) + "i"
def __str__(self):
return self.__to_string__()
def __repr__(self):
return self.__to_string__()
# Addition
def __add__(self, other):
return my_complex(self.real + other.real, self.imag + other.imag)In the following example, two complex numbers are created, added, and the result is printed to the console:
x = my_complex(1., 3.)
y = my_complex(1., -2.)
z = x + y # calls __add__
print(x, "+", y, "=", z)1.0+3.0i + 1.0-2.0i = 2.0+1.0i
The following table summarizes additional predefined methods for operator overloading:
| Operator | Method |
|---|---|
+ | __add__(self,other) |
- | __sub__(self,other) |
* | __mul__(self,other) |
/ | __div__(self,other) |
** | __pow__(self,other) |
== | __eq__(self, other) |
>= | __ge__(self, other) |
> | __gt__(self, other) |
<= | __le__(self, other) |
< | __lt__(self, other) |
Inheritance¶
In inheritance, a class takes over the properties and methods of another class and may extend them with additional attributes and methods. This is especially useful when multiple classes share many common properties.
The syntax for deriving a class is:
class <sub_class>(<base_class>):
[...]As an example, we consider
class animal:
# Class attributes
description = "unknown animal"
region = "somewhere"
# Console output for all animals
def __str__(self):
return "I am a " + self.description + " and my habitat is " + self.region + ".";
class fish(animal):
# Class attributes
description = "fish"
region = "water";
# Constructor for fish
def __init__(self, color):
# Instance attribute
self.color = color
class mammal(animal):
# Class attributes
description = "mammal"
region = "land"
def __init__(self, nr_legs):
# Instance attribute
self.nr_legs = nr_legs
# Main program starts here
carp = fish("blue")
print(carp)
monkey = mammal(2)
print(monkey)I am a fish and my habitat is water.
I am a mammal and my habitat is land.
The class attributes
descriptionandregionexist both in the base classanimaland in the derived classesfishandmammal.For objects of the derived classes, the class attributes always take the values of the derived class.
The method
__str__only needs to be defined once in the base class to be valid for all objects.
Overriding methods:
If we implement the method __str__ again in the derived class, this overrides the implementation of the base class for objects of the derived class. However, we can still explicitly access the base class implementation using animal.__str__:
class mammal(animal):
description = "mammal"
region = "land"
def __init__(self, nr_legs):
self.nr_legs = nr_legs
def __str__(self):
return animal.__str__(self) + " I have " + str(self.nr_legs) + " legs."
human = mammal(2)
print(human)I am a mammal and my habitat is land. I have 2 legs.
Python also allows multiple levels of inheritance.
We could derive further classes from our mammal class, e.g., for rodents, ungulates, etc.
In addition, multiple inheritance is possible. If we want to derive a class from two or more base classes, we use the syntax:
class <sub_class>(<base_class_1>, <base_class_2>[, ...]):
[...]The new class then inherits all properties and methods from <base_class_1> and <base_class_2>. It becomes critical only if both base classes provide methods with the same name but different implementations:
class horse:
def __init__(self):
print("Horse constructor called")
def output(self):
print("Neigh")
class human:
def __init__(self):
print("Human constructor called")
def output(self):
print("Ughhhh")
# Class for a hybrid being
class centaur(horse, human):
pass
a = centaur()
a.output()Horse constructor called
Neigh
centaurinherits fromhorseandhuman.When calling
a.output(), the method of the first base class (horse) is used.Python uses the so-called Method Resolution Order (MRO), i.e. the order in which base classes are searched to find a method.
This becomes problematic if constructors (
__init__) contain complex logic; in this case, it is often necessary to explicitly call the base class constructors, e.g.horse.__init__(self)orsuper().__init__().
Iterators¶
We have already seen several data types that we can iterate over in a for loop, such as list, tuple, and string. We can also make custom classes iterable. For this, two methods must be implemented:
__iter__(self): Initializes the iterator and returns the object itself.__next__(self): Returns the next element of the iteration or raises the exceptionStopIteration.
Example: Lottery numbers 6 out of 49
import random
class LotteryNumbers:
def __iter__(self):
self.n = 0 # Initialize counter
return self # Return the current object
def __next__(self):
self.n += 1 # Increment counter
if self.n >= 7:
raise StopIteration # Stop after 6 numbers
else:
return random.randint(1,49) # Generate random numberThese two functions are sufficient to use the object in a for loop:
for i in LotteryNumbers():
print("The number", i, "was drawn.")The number 14 was drawn.
The number 40 was drawn.
The number 44 was drawn.
The number 25 was drawn.
The number 46 was drawn.
The number 29 was drawn.
An iterable object can also be traversed manually as follows:
numbers = iter(LotteryNumbers()) # Create iterator
try:
print("Lottery number", next(numbers))
print("Lottery number", next(numbers))
print("Lottery number", next(numbers))
print("Lottery number", next(numbers))
print("Lottery number", next(numbers))
print("Lottery number", next(numbers))
print("Lottery number", next(numbers))
print("Lottery number", next(numbers))
except StopIteration:
print("All numbers have been drawn")Lottery number 19
Lottery number 2
Lottery number 18
Lottery number 44
Lottery number 21
Lottery number 23
All numbers have been drawn
The construct consisting of try and except is used for exception handling. Python attempts to execute the block under try. As soon as an exception occurs (here StopIteration in LotteryNumbers.__next__), the except block is executed.
Code documentation¶
To make it easier for potential users of our custom classes, proper code documentation is very helpful.
We can document the class itself as well as all methods using docstrings, i.e., comments of the form:
"""
[Comment]
...
[Comment]
"""This text will then appear in the help documentation in Jupyter Lab when we request the documentation of a class or method using ?.
Example: Documented class
class Car:
"""
A car object, equipped with a model name and color.
"""
def __init__(self, model, color="gray"):
"""
Constructor
Initializes a new car object with a default name. The engine is off.
Parameters
----------
model: string
Model of the car (e.g. VW, Mercedes, ...)
color: string, optional
Color of the car (e.g. red, blue, ...). Default value: gray
"""
self.model = model
self.color = color
self.engine_on = False
def start_engine(self):
"""
start_engine(self)
Method that allows to start the engine
Example
-------
>>> mercedes = Car()
>>> mercedes.start_engine()
"""
self.engine_on = True
def engine_status(self):
"""
engine_status()
Returns the engine status.
Returns
-------
out: bool
Returns True when the engine is on or False when it is off
See Also
--------
start_engine : Method used to start the engine
"""
return engine_onWe can now display our class documentation using
Car?and the documentation of the engine_status method using
Car.engine_status?