Skip to content

Object Oriented Programming

Tiago Duarte da Silva edited this page Feb 26, 2021 · 4 revisions

Introduction

In Python OOP is very simple, and follows the same basic principles of other languages, such as Java or C++:

  • Inheritance: A process of using details from a new class without modifying existing class
  • Abstraction/Encapsulation: Hiding the private details of a class from other objects
  • Polymorphism: A concept of using common operation in different ways for different data input

Building a Class

This chapter will rely on many comparisons to Java and C++, but if you have no knowledge of OOP in these languages, this workshop will should still be able to guide you through it.

Methods & Self

Methods are just functions associated to objects of a class. You've already used plenty of methods before, such as Str.split() or List.insert().

You can create an empty class, a stub, by using the keyword pass.

class EmptyClass:
    pass

In Python all methods need to have at least one argument, the self argument (the implicit parameter this in other languages). This keyword is a reference to the specific object the method was called from, effectively making it like this:

class Person:
    def printName(self):
        print("Eduardo")

p = Person()
p.printName()  # Eduardo
Person.printName(p)  # Eduardo

This works because these calls are equivalent, with Classname working as a namespace:

object = Classname()
object.method(param1, param2)
Classname.method(object, param1, param2)

Constructor & Destructor

Just like other languages, you can initialize various parts of a class by using a constructor, in this case by overloading the __init__() method.

Whenever the object is garbage collected, the method __del__ is called, so you can use it to do something when an object is deleted.
Warning: This does not mean the method will be called when using del object, it is only called when it's garbage collected, this means its Reference Count reached 0

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

    def printName(self):
        print(self.name)

    def __del__(self):
        print("Goodbye!")

p = Person("Eduardo")
p.printName()  # Eduardo
# Goodbye!

Attributes

In the previous example's constructor we initialized an attribute for the Person class, name. In Python all attributes created inside a method are specific to that object. If you wish to have static attributes, this is, attributes common to all instances of a class, they must be declared inside the class itself.

class Person:

    people_created = 0  # Common to all instances
    
    def __init__(self, name):
        self.people_created += 1
        self.name = name

    def printName(self):
        print(self.name)
        self.aNumber = 0

p1 = Person("Eduardo")
p2 = Person("Daniel")
print()
print(Person.people_created) # 2

We also just accessed the class attributes directly, specifically name and people_created. By default, all attribute are "public", meaning they can be accessed from outside the class.
This violates the principle of Encapsulation. To prevent discourage an attribute/method from being accessed from outside the class, it must start with either _ or __. The meaning between using one or two underscores is different:

  • One underscore is usually used within a module
  • Two underscores is usually used within Python itself

In Python there is no privacy model, so the underscores are used merely as a suggestion (that you should follow whenever possible). They are mainly used to prevent names from different classes clashing together.

The example below creates an attribute called __name. In truth, this variable can still be accessed through p._Person__name.
Note: This is quite more complex than what we explained here, so please do investigate on your own if you wish to know more information (and more accurate). Search name mangling.

class Person:
    def __init__(self, name):
        self.__name = name
    
p = Person("Eduardo")
# print(p.__name)  # Results in an error!

Note: When using the global keyword some odd behaviour may arise

Static Methods vs Class Methods

If your class has a method that does not depend on an instance of itself, it can be considered good policy to indicate it. Decorators are used for this distinction:

class Date():

    self.epoch = 1970

    def __init__(self, day, month, year):
        pass

    @staticmethod
    def fromString(s):
        # Logic to extract info from string
        return Date(day, month, year) 
    
    @classmethod
    def yearsFromEpoch(cls, year):
        return year - cls.epoch

So what's the difference between static method and class method?
A static method doesn't need to pass any argument, be it cls or self. As such it does not rely on anything defined in the class itself (trying to access the epoch attribute would return an error). A very common use case is to implement methods that construct an instance of the class from other sources (imagine a Color class that would have a fromRBG, fromHSV or fromHex methods).
A class method requires the first argument to be cls, which is a reference to the class itself. It can be used, for example, to retrive class constants or to later on create modules (just like Math or datetime, for example).

Unitialized Attributes

class Person:
    def __init__(self, name):
        self.name = name
    
    def aux(self):
        self.a = 1

p = Person("Eduardo")
# print(p.a)  # Results in an error!
p.aux()
print(p.a)  # 1

An attribute is only available when it is actually initialized (obviously), as such trying to access p.a before initializing it results in an error. This is a very common and easy mistake to do.

Properties

Sometimes you need to hide some logic, or need to refactor a class but need to keep old behaviour. For this, Decorators come to the rescue again! You can interact with properties just as if you would an attribute, but they can work miracles behind the scenes. On the following example we use a property to get the user's email and full name as an attribute and do extra logic on them as well! We also provide an way to set fullname, which will extract the first and last name automatically. We also support the del statement to set both names to None.

class Person():

    def __init__(self, firstName, lastName, emailDomain):
        self.firstName = firstName
        self.lastName = lastName
        self.emailDomain = emailDomain
    
    @property
    def email(self):
        if (self.firstName != None and self.lastName != None):
            return f"{self.firstName}.{self.lastName}@{self.emailDomain}"
        else
            return None
    
    @property
    def fullname(self):
        if (self.firstName != None and self.lastName != None):
            return f"{self.firstName} {self.lastName}"
        else
            return None

    @fullname.setter
    def fullname(self, name):
        self.firstName, self.lastName = name.split(' ')
    
    @fullname.deleter
    def fullname(self):
        print("Deleted name!")
        self.firstName = None
        self.lastName = None

p = Person("Tiago", "Silva", "fe.up.pt")
print(p.email)  # Tiago.Silva@fe.up.pt
p.fullname = "Eduardo Correia"
print(p.email)  # Eduardo.Correia@fe.up.pt
del p.fullname  # Deleted name!
print(p.email)  # None

The syntax for a getter property is @property
The syntax for a setter property is @propertyName.setter
The syntax for a deleter property is @propertyName.deleter

Inheritance & Overriding

In Python if you were to extend the Person class you only have to do class Student(Person).

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

    def printName(self):
        print(f"Person: {self.name}")

class Student(Person):
    def __init__(self, name, grade):
        super().__init__(name)
        self.grade = grade
    
    def printName(self):
        print(f"Student: {self.name}")

    def printStudent(self):
        print(f"Grade: {self.grade}")

s = Student("Eduardo", 18)
s.printName()  # Student: Eduardo
s.printStudent()  # Grade: 18

When looking for a method/attribute, Python starts looking from the subclasses to the superclasses.

By default, all Python classes extend the Object class. That's why you've been overriding __init__() and __del__() so far.

Multiple Inheritance

Sometimes you need to inherit multiple classes at once, for example if you needed Student to extend Person and a either a new Worker class or the methods of a LibraryPass class (think Java Interfaces) as well.
For the following example the we will use a simple LibraryPass class which defines a method to borrow a book.

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

    def printName(self):
        print(f"Person: {self.name}")

class LibraryPass():
    def __init__(self, borrower_id):
        self.borrower_id = borrower_id

    def borrow(self, book_id):
        print(f"Borrower {self.borrower_id} has taken book {book_id}")

class Student(Person, LibraryPass):
    def __init__(self, identifier, name, grade):
        # super().__init__(identifier, name)
        # super().__init__(identifier)
        Person.__init__(self, identifier, name)
        LibraryPass.__init__(self, identifier)
        self.grade = grade
    
    def printName(self):
        print(f"Student: {self.name}")


s = Student(12345678, "Eduardo", 18)
s.printName()  # Student: Eduardo
s.borrow(42)  # Borrower 12345678 has taken book 42

In the case of multiple inheritance trying to call both __init__ methods from super() (the commented lines in Student.__init__()) would spit out the following error:

Traceback (most recent call last):
  File "c:\PythonWorkshop\test.py", line 266, in <module>
    s = Student(12345678, "Eduardo", 18)
  File "c:\PythonWorkshop\test.py", line 257, in __init__
    super().__init__(identifier)
TypeError: __init__() missing 1 required positional argument: 'name'

As Python tries to find __init__() methods in the superclasses, it starts looking in the order of the __bases__ attribute (Built-in Class Attributes). To find the exact order through every superclass (including itself), all the way to object, you can call Student.mro(). In this case it would return the following:

[<class '__main__.Student'>, <class '__main__.Person'>, <class '__main__.LibraryPass'>, <class 'object'>]

So, as you can see from that list, Python found a valid __init()__ method in Person, it tried to use it. Unfortunately the number of arguments didn't match, so it gave us that error.

To specify which superclass to use, you must specify it, such as Person.__init__(self, identifier, name) and LibraryPass.__init__(self, identifier).

Polymorphism

The word polymorphism means having many forms.

Polymorphism is a very important concept in programming. It refers to the use of a single type entity (method, operator or object) to represent different types in different scenarios.

In this example, the same method greet() is being used differently depending on the class in question.

class American():
    def greet(self):
        print("Hello World!")
        
class Spanish():
    def greet(self):
        print("Hola Mundo!")

class Portuguese():
    def greet(self):
        print("Olá Mundo!")
            
people = (American(), Spanish(), Portuguese())

for person in people:
    person.greet()

Operator Overloading

You might have noticed that the same built-in operator or function shows different behavior for objects of different classes, this is called operator overloading.

Operator overloading means giving extended meaning beyond their predefined operational meaning.

For example, the operator + is used to add two integers as well as join two strings or merge two lists. It is achievable because it is overloaded by int and str classes.

class Integer():
    def __init__(self, i):
        self.i = i
 
    # Adding two objects 
    def __add__(self, o):
        return self.i + o.i
        
num1 = Integer(2)
num2 = Integer(4)
 
print(num1 + num2) # 6

Commonly overridden operators:

Built-in Method Operator Meaning
__add__() + add
__sub__() - minus
__mul__() * multiply
__truediv__() / regular division
__floordiv__() // integer division
__mod__() % remainder/modulo
__pow__() ** power
__lt__() < less than
__gt__() > greater than
__le__() <= lesser than or equal to
__ge__() >= greater than or equal to
__eq__() == equality check
__ne__() != inequality check

Note: For more operators check the official documentation

Overloading str

There are two different methods that convert an object into a string, whose use case are usually opposite:

  • __str__()
    Used whenever you call print(myClassInstance), and is expected to be able to be seen by a user. As such try to make this method show only what the end user of your class should see.
  • __repr__()
    This method is supposed to give an accurate representation of your class object, so do try to give as many details as possible, as this can be used as an important debug tool.
    A common technique is to give a constructor-like string that could replicate the object.

Built-in Class Attributes

Every Python class keeps following built-in attributes and they can be accessed using dot operator like any other attribute.

  • __dict__ : Dictionary containing the class's namespace.
  • __doc__: Class documentation string or None, if undefined.
  • __name__: Class name.
  • __module__ : Module name in which the class is defined. This attribute is __main__ in interactive mode (REPL).
  • __bases__ : A possibly empty tuple containing the base classes, in the order of their occurrence in the base class list.

Sections

Previous: Decorators
Next: Modules