Skip to content

Abstract classes.

Object oriented programming

Object-oriented programming is an approach to modeling specific things in the real world, such as cars, as well as relationships between companies and employees, students and teachers, etc. Each object has attributes (which describe its characteristics) and methods (which describe its behavior).

The basic concepts of object-oriented programming are:

  • abstraction - limiting the features of an object from the real world to the essential features, critical from the programmer's point of view,
  • encapsulation - hiding access to private details of an object,
  • polymorphism - the ability to use the same interface for objects of different types,
  • inheritance - class extension.

Let's take a look at the picture that shows the concept of object oriented programming.

Object oriented programming

At the top, we have the Animal class, the objects of which will have attributes specific to this class: _ mass_ and age and methods: see and breathe. This class is expanded by the following three classes: Fish, Mammal and Bird. Apart from the methods specific to these classes (for the Fish - swim, for the Mammal - run and for the Bird - fly) their objects will also have all the attributes and methods of the Animal class. The House_Dog class extends the Mammal class, so all its instances will have the attributes: weight, age, breed, coat_color and methods: see, breathe, run, bark and retrive.

Inheritance

Although objects behave the same on a certain level of abstraction, they may also differ on a different level, e.g. each person has a name and age, but depending on what we do, we may have different properties, e.g. for a working person a characteristic feature will be the hourly rate and the number of hours worked per month, and for the student - the amount of the scholarship.

We can model it using inheritance, expanding the base class.

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

        def __str__(self):
            return f"{self.name} ma {self.age} years old"


    class Employee(Person):
        def __init__(self, name, age, rate, working_hours):
            super().__init__(name, age)
            self.rate = rate
            self.working_hours = working_hours

        def show_finance(self):
            return self.rate * self.working_hours


    class Student(Person):
        def __init__(self, name, age, scholarship):
            super().__init__(name, age)
            self.scholarship = scholarship

        def show_finance(self):
            return self.scholarship


    os1 = Person("Henry", 54)
    os2 = Employee("Jack", 36, 20, 160)
    os3 = Student("Agatha", 22, 1000)
    print(os1)
    print(os2)
    print(os3)

We have a Person class with name and age attributes and two classes that extend them, Employee with rate and working_hours attributes specific to this class, and a Student class with scholarship. In addition, both classes implement the show_finance method.

The result of executing the above code will be:

    Henry is 54 years old
    Jack is 36 years old
    Agatha is 22 years old

Multi-inheritance

Sometimes there is a need to describe classes that combine the features of several superior classes. For example, imagine there might be a working student who has a scholarship and is paid for the hours worked.

    class Employee(Person):
        def __init__(self, name, age, rate, working_hours):
            Person.__init__(self, name, age)
            self.rate = rate
            self.working_hours = working_hours

        def show_finance(self):
            return self.rate * self.working_hours


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

        def show_finance(self):
            return self.scholarship


    class WorkingStudent(Employee, Student):
        def __init__(self, name, age, rate, working_hours, scholarship):
            Employee.__init__(self, name, age, rate, working_hours)
            Student.__init__(self, name, age, scholarship)

        def show_finance(self):
            return self.rate * self.working_hours + self.scholarship

The above code shows the correct way to implement multi-inheritance. As can be seen, due to the MRO, it was necessary to modify the two previous classes.

What is MRO? This is Method Resolution Order, i.e. the order in which the program looks for references. Let's look at the figure below:

MRO

We have four classes here: the parent class A, which is extended by B and C, and the D class, which inherits from both of the previous ones. In this case, the MRO will look like this: D -> B -> C -> A, which means that e.g. in the B class we cannot refer to the parent class's initialization method with super () _ because the above sequence points to lookup references for the _B class in the C class. This means that we have to explicitly specify to the initialization method which class we want to refer to. This is why in Student we have replaced the call to super().__init__ (name, age) with Person.__init__ (self, name, age).

The call looks like this:

    per1 = Person("Henry", 54)
    per2 = Employee("Jack", 36, 20, 160)
    per3 = Student("Agatha", 22, 1000)
    per4 = WorkingStudent("Monica", 24, 9.5, 70, 550)
    print(per1)
    print(per2)
    print(per3)
    print(per4)
    Henry is 54 years old
    Jack is 36 years old
    Agatha is 22 years old
    Monica is 24 years old

Polymorphism

Polymorphism means using a common interface for different types of data.

    def check_finance(obj):
        print(obj.show_finance())


    check_finance(os2)  # an instance of the Employee class
    check_finance(os3)  # an instance of the Student class

This means that we can call the check_finance function by passing it as an argument to any of these objects, even though per2 is of type Employee and per3 is an instance of Student.

    3200
    1000

Abstract class

An abstract class is one that is merely a generalization of other classes, but does not exist itself (you cannot create objects of this type). For example, the classes Circle, Square, and Triangle correspond to real mathematical objects and each has specific properties. However, we can generalize all of them to the Figure class, which is an unreal concept - so it will be an abstract class with methods common to all these figures.

To create abstract classes we use the abc package. Abstract methods have no body and must be implemented by inheriting methods.

    from abc import ABC, abstractmethod
    from math import pi


    class Figure(ABC):
        @abstractmethod
        def circuit(self):
            pass

        @abstractmethod
        def area(self):
            pass

Classes extending the class Figure.

    class Rectangle(Figure):
        def __init__(self, a, b):
            self.a = a
            self.b = b

        def circuit(self):
            return 2 * (self.a + self.b)

        def area(self):
            return self.a * self.b


    class Circle(Figure):
        def __init__(self, r):
            self.r = r

        def circuit(self):
            return 2 * self.r * pi

        def area(self):
            return pi * self.r ** 2

Call

    Rectangle = Rectangle(3, 5)
    Circle = Circle(12)
    print(Rectangle.circuit())
    print(Circle.circuit())

return:

    16
    75.39822368615503

Class and static methods

A class method is one that is called for the whole class, not for an instance of it, and takes the class as the first argument - we usually call it cls, and wrap the method with the decorator @classmethod.

A Static_method is one that does not operate on a specific class instance. It has no self parameter, but has the @staticmethod decorator.

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

        def show_finance(self):
            return self.scholarship

        @classmethod
        def create_from_string(cls, inscription):
            name, age, scholarship = inscription.split()
            age, scholarship = int(age), float(scholarship)
            if cls.is_name_correct(name):
                return cls(name, age, scholarship)

        @staticmethod
        def is_name_correct(name):
            if name[0].isupper() and len(name) > 3:
                return True
            return False


    stud1 = Student("Margaret", 32, 0)
    stud2 = Student.create_from_string("Mark 21 600")
    print(stud1)
    print(stud2)
    print(Student.is_name_correct("alice"))

As we can see, in order to call a class or static method, we do not need an instance of this class. Therefore, they do not operate on instance attributes.

The result of executing the above code will be:

    Margaret is 32 years old
    Mark is 21 years old
    False

dataclass

Since Python 3.7 we can use dataclass to create classes. It is enough for the developer to define what parameters are necessary and methods __init__, __repr__, __eq__, etc. will be automatically created.

    from dataclasses import dataclass

    @dataclass
    class Rectangle(Figure):
        a: int
        b: int

        def circuit(self):
            return 2 * (self.a + self.b)

        def area(self):
            return self.a * self.b


    p1 = Rectangle(3, 4)
    p2 = Rectangle(3, 4)
    print(p1)
    print(p1 == p2)

Calling the above code will display the following information in the console:

    Rectangle(a=3, b=4)
    True

In order to achieve the same effect in the traditional way, we would have to implement the class like this:

    class Rectangle(Figure):
        def __init__(self, a: int, b: int):
            self.a = a
            self.b = b

        def __repr__(self) -> str:
            return f"Rectangle(a={self.a}, b={self.b})"

        def __eq__(self, other) -> bool:
            return isinstance(other, Rectangle) and (self.a, self.b) == (other.a, other.b)

        def circuit(self) -> float:
            return 2 * (self.a + self.b)

        def area(self) -> float:
            return self.a * self.b

Deep copy

There are two copy options in Python: shallow and deep. Shallow copies the object but not the elements: this means that the copied object shares the individual elements (eg objects). Deep Copy, on the other hand, recursively copies the object and makes deep copies of the content.

    from copy import deepcopy

    p1 = Rectangle(3, 4)
    lst = [1, p1, 3]
    shallow_copy_lst = list(lst)
    deep_copy_lst = deepcopy(lst)

    lst[0] = 5 # we change the first value of the list to 5
    lst[1].a = 5  # we change the value of the side of the rectangle
    print(lst, shallow_copy_lst, deep_copy_lst)
    [5, Rectangle(a=5, b=4), 3] [1, Rectangle(a=5, b=4), 3] [1, Rectangle(a=3, b=4), 3]

As we can see, the shallow copy is resistant to changing simple values, but changing an attribute of an object in the list also changes that attribute in the object in the list resulting from the shallow copy - as a result, both lists share the same Rectangle object. If we want to make sure that the modification of an object does not affect the copy, we must use a deep copy.