The four principles of Object-Oriented Programming in Python

Object-oriented programming (OOP) is a programming design based on the idea of objects in real-life. Objects have properties (known as attributes) and functions (known as methods). This paradigm allows programmers to think about codes as working with real-life objects. The main idea of OOP includes four critical principles, which will be explained in the following post using Python.

This post requires the basic knowledge of writing OOP in Python. Please first read about the fundamentals of OOP in Python if you have not learned about classes and objects yet.

OOP

We will be using the following code as the basis for all the examples below.

class Human:
    def __init__(self, age, job=None):
        self.age = age
        self.job = job

class Cat:
    def __init__(self, age):
        self.age = age

h = Human(25, "Software Developer")
print("Age:", h.age)

c = Cat(7)
print("Age:", c.age)
Age: 25
Age: 7

Inheritance

Inheritance is when one class inherits the attributes and methods of another class, the two classes are also known as child class (or derived class) and parent class (or base class) respectively.

In Java, we would use the extends keyword, but in Python, we would simply put the parent class in parentheses when declaring the child class.

Here we observe that the Human class and the Cat class both share similar attributes and methods. We can therefore create an Animal parent class (yes, humans are animals) that defines the age variable. We would then add the parent class when defining the child class by putting the parent class in parentheses, like class Human(Animal). To make sure when instantiating a child object, the parent class __init__() is run, we need to call super().__init__(). The keyword super() indicates the parent class, whereas .__init__() calls the __init__() method of the parent class.

# Define parent class
class Animal:
    def __init__(self, age):
        self.age = age

# Inheriting parent class
class Human(Animal):
    def __init__(self, age, job=None):
        # Calling __init__() of parent class, passing age as argument
        super().__init__(age)
        self.job = job

# Inheriting parent class
class Cat(Animal):
    def __init__(self, age):
        # Calling __init__() of parent class, passing age as argument
        super().__init__(age)

h = Human(25, "Software Developer")
print("Age:", h.age)

c = Cat(7)
print("Age:", c.age)
Age: 25
Age: 7

Polymorphism

Polymorphism simply means “many forms”. In OOP, it is used to describe that one method name can have multiple implementations.

Here we are implementing a speak() method for both the Human and Cat classes. We see that they both share the same method name, but they act differently depending on whether we are calling h.speak() or c.speak().

class Animal:
    def __init__(self, age):
        self.age = age

class Human(Animal):
    def __init__(self, age, job=None):
        super().__init__(age)
        self.job = job

    # Defining a speak() method for Human
    def speak(self):
        print("Hi")

class Cat(Animal):
    def __init__(self, age):
        super().__init__(age)

    # Defining a speak() method for Cat
    def speak(self):
        print("Meow")

h = Human(25, "Software Developer")
# Calling the speak() method
h.speak()

c = Cat(7)
# Calling the speak() method
c.speak()
Hi
Meow

This is only the most basic example of polymorphism. Let us go through two other common usages of polymorphism, Method Overloading and Method Overriding.

Method Overloading

Method overloading is a type of polymorphism where methods have the same name but different parameters. By passing a specific amount and/or data type of arguments, we can invoke the method with the correct configuration.

However, Python does not support method overloading. There is a way around it though, but it is not the same as other languages such as Java or C++. We shall first look at how method overloading is done in Java to understand its concept, then we will look at how we can partially get around it in Python.

Here, we see that we have three methods all named add, but they all have different arguments. The first method takes in two integers as arguments, the second method takes in three integers as arguments, and the third method takes in two doubles as arguments. When calling the add method, depending on the arguments passed, it will call a specific method matching the argument specifications.

public class overloading {

    static int add(int a, int b) { return a + b; }
    static int add(int a, int b, int c) { return a + b + c; }
    static double add(double a, double b) { return a + b; }

    public static void main(String[] args) {
        System.out.println(add(1, 2));
        System.out.println(add(1, 2, 3));
        System.out.println(add(1.1, 2.2));
    }
}
3
6
3.3

But as we mentioned, Python does not support method overloading. As of Python 3.4, the way around it is to import singledispatch from the functools module. If you are working with class methods (which is what this post is about, but it gets a little confusing so we will explain in terms of normal functions), as of Python 3.8, you should import singledispatchmethod instead. Note that this way of overloading only validates the FIRST argument (or the argument right after self or cls if using singledispatchmethod).

The @singledispatch decorator is placed on the first method which defines what happens if the first argument data type does not match any of the methods. All subsequent methods require a methodName.register(type) decorator and the method name can simply be an underscore _. This defines what happens when the method name is called with the first argument being the specified type.

from functools import singledispatch

# Case if the method is called with none of the data types specified
@singledispatch
def add(a, b):
    raise NotImplementedError(f"Type {type(a)} + Type {type(b)} is not implemented")

# Case if the method is called with the first argument being an integer
@add.register(int)
def _(a, b):
    print("Running int method: ", end="")
    return a + b

# Case if the method is called with the first argument being a float
@add.register(float)
def _(a, b):
    print("Running float method: ", end="")
    return a + b

print(add(1, 2))  # int, int
print(add(1.1, 2.2))  # float, float
print(add([1, 2], [3, 4]))  # list, list
Running int method: 3
Running float method: 3.3
NotImplementedError: Type <class 'list'> + Type <class 'list'> is not implemented

Not only does this way of method overloading only validates the first argument, but it also does not work with a varying number of arguments. If you really need to work with a varying number of arguments, the best option is likely to just use *args.

For more information about using @singledispatch, you may take a look at its documentation.

Method Overriding

Method overriding is another type of polymorphism where a child class can override a method of the parent class with the same name.

Going back to our OOP example, a lot of animals can speak, so let us add the speak() method to the parent class. Let’s also add another class for Sloth, which makes no sound. The speak() method in the parent class will be the default behavior when the animal cannot speak. The Human and Cat classes will have their own speak() method that overrides the parent method.

class Animal:
    def __init__(self, age):
        self.age = age

    # Defining a speak() method at the parent class
    def speak(self):
        print("This animal can't speak")

class Human(Animal):
    def __init__(self, age, job=None):
        super().__init__(age)
        self.job = job

    # Overriding the method in the parent class
    def speak(self):
        print("Hi")

class Cat(Animal):
    def __init__(self, age):
        super().__init__(age)

    # Overriding the method in the parent class
    def speak(self):
        print("Meow")

# Defining an animal that cannot speak
class Sloth(Animal):
    def __init__(self, age):
        super().__init__(age)

h = Human(25, "Software Developer")
h.speak()

c = Cat(7)
c.speak()

s = Sloth(13)
s.speak()
Hi
Meow
This animal can't speak

Encapsulation

Encapsulation is a way to restrict access to some aspects of a class from the outside. To put it simply, properties of a class may only be accessed by the class itself and sometimes its child classes, but not in the main program.

There are three different types of access: public, protected, and private.

In Java and C++, specifying the access scope of a class property is straightforward. You simply use the public, protected, or private keywords when declaring a variable or method. In Python, however, you would use underscores _ in front to indicate the access scope of a class property. One underscore for protected (e.g., _age) and two underscores for private (e.g., __dna).

Protected Member

A protected member is declared with one underscore in front of the variable name (e.g., _age). It can only be accessed within its class and all of its sub-classes (child classes), but not outside the class. However, it is not enforced, that is to say, you will not get an error if you try accessing the protected variable outside.

class Animal:
    def __init__(self, age):
        # Declare the age attribute to be protected
        self._age = age
        # Able to access within the class
        print("Inside the class:", self._age)

class Human(Animal):
    def __init__(self, age, job=None):
        super().__init__(age)
        self.job = job
        # Able to access within the sub-class
        print("Inside the sub-class:", self._age)

h = Human(25, "Software Developer")
# Able to (but not supposed to) access outside
print("Outside (not supposed to be accessed, but not enforced):", h._age)
Inside the class: 25
Inside the sub-class: 25
Outside (not supposed to be accessed, but not enforced): 25

Private Member

A private member is declared with two underscores in front of the variable name (e.g., __dna). It can only be accessed within its class, but not any sub-classes or outside the class. Python does enforce this by throwing an AttributeError when trying to access a private member outside of its class.

class Animal:
    def __init__(self, age, dna):
        self._age = age
        # Declare the dna attribute to be private
        self.__dna = dna
        # Able to access within the class
        print("Inside the class:", self.__dna)

class Human(Animal):
    def __init__(self, age, dna, job=None):
        super().__init__(age, dna)
        self.job = job
        # Error when accessing within the sub-class
        print("Inside the sub-class:", self.__dna)

h = Human(25, "ATCGGTACTA", "Software Developer")
# Error when accessing outside
print("Outside:", h.__dna)
Inside the class: ATCGGTACTA
AttributeError: 'Human' object has no attribute '_Human__dna'
AttributeError: 'Human' object has no attribute '__dna'

Note that when the program experienced an error inside the sub-class, it did not keep running the script, but the above output is simply to illustrate what would happen if it kept running. And as you can see, the private attribute is not able to be accessed in a sub-class or on the outside.

The proper way to access private (or protected) attributes outside their restricted scope would be to create a public getter method that returns the value of the attributes, and a public setter method if you wish to change the values of these restricted attributes. For example:

# ...Skipping the Animal class declaration
    def get_dna(self):
        return self.__dna

print("Outside:" h.get_dna())
Outside: ATCGGTACTA

Another way to access private attributes outside of their restricted scope, which I don’t necessarily recommend as it defeats the purpose of encapsulation, is to use name mangling. You can access private attributes directly by calling _className__attributeName, one underscore in front of the class name, followed by two underscores and the attribute name.

print("Outside:", h._Animal__dna)
Outside: ATCGGTACTA

Abstraction

Abstraction is probably the OOP principle that is the most difficult to understand, as its concept is quite abstract (pun intended). Abstraction is when we declare the functionality of a class, but we do not define and implement it. An analogy would be the functionality of a washing machine. We know that its function is to wash your garment clean, but how it accomplishes that, the intricacies of different washing procedures, are unknown to the user. It is up to the manufacturer to define how it accomplishes the function of “washing garments”, but abstractly we all understand its purpose.

In Java and C++, we have a keyword for declaring abstract methods. We would use abstract in Java and we would use virtual in C++. However, Python does not have native capabilities for doing abstraction, as such, we will have to import the ABC and abstractmethod from the abc module. The abc stands for Abstract Base Classes.

With our abstract WashingMachine class, we need to inherit the ABC class to declare that we will be using the attributes and methods from the ABC class. Before the abstract method wash, we need to put an @abstractmethod decorator to indicate that this method is an abstract method. Then, we define two child classes of WashingMachine that have their own unique implementation of the wash method. It is important to note that when we define a child class of an abstract parent class, all abstract methods must be defined and implemented in the child class, or a TypeError will be thrown. This will be illustrated in the example below with a third child class.

Let us implement the washing machine example.

from abc import ABC, abstractmethod

# Define the abstract class
class WashingMachine(ABC):
    # Declare the abstract method
    @abstractmethod
    def wash(self):
        pass

# Inherit the abstract parent class
class Samsung(WashingMachine):
    # Define the abstract method
    def wash(self):
        print("1. Flush with 100 degrees F water\n"
              "2. Add detergents with 160 degrees F water\n"
              "3. Bleach\n"
              "4. Rinse\n")

# Inherit the abstract parent class
class LG(WashingMachine):
    # Define the abstract method
    def wash(self):
        print("1. Add detergents with 140 degrees F water\n"
              "2. Soak for 15 minutes with 160 degrees F water\n"
              "3. Rinse\n"
              "4. Soak for 10 minutes with 100 degrees F water\n"
              "5. Rinse\n")

# Inherit the abstract parent class
class Whirlpool(WashingMachine):
    # Not defining the abstract method
    # This will lead to an error when instantiated
    pass

w1 = Samsung()
w1.wash()

w2 = LG()
w2.wash()

w3 = Whirlpool()  # Error when instantiated
1. Flush with 100 degrees F water
2. Add detergents with 160 degrees F water
3. Bleach
4. Rinse

1. Add detergents with 140 degrees F water
2. Soak for 15 minutes with 160 degrees F water
3. Rinse
4. Soak for 10 minutes with 100 degrees F water
5. Rinse

TypeError: Can't instantiate abstract class Whirlpool with abstract method wash

To summarize abstraction, an abstract method is a method that is declared but not defined. When a class has an abstract method, it is considered an abstract class. When you define a child class that inherits an abstract parent class, you must define all the abstract methods or a TypeError will be thrown.