Day 42: Object-Orientation in Python - Summarized
Tags: beginners, programming, python
Published: 2023-05-29 16:13:40

Today, I'll write about the fundamentals of Python Object-Oriented Programming (OOP).

Simply put, Object-Oriented Programming (OOP) is a programming paradigm that uses 'objects' - instances of classes, which are like blueprints for objects. OOP focuses on utilizing these objects to design and implement software.

It allows for a clear modular structure of programs which therefore makes it good for defining abstract data types in large programs. It is also good for code reusability and to reduce complexity.

There are four basic concepts in OOP: Encapsulation, Inheritance, Polymorphism, and Abstraction. And I sometimes have a hard time explaining them in simple terms so I'm writing it down so I can come back here every time I need to look it up. But first, we need to talk about objects and classes.

In Python, everything is an object. Classes define the structure and behavior of objects. Objects are just instances of classes.

In Python, we use the 'class' keyword to define classes and to create an object, we simply call the class name like a function. For example: 

```
class Employee:
  pass

emp = Employee()
```

Then there things called Attributes and Methods. Attributes are the variables that belong to a class while Methods are functions that also belong to class.

```
class Employee:
    name = "John"

    def __repr__(self):
        print(f"Name: {self.name}")

emp = Employee()
print(emp)
```

Note: __repr__ is a "magic method" that makes the display of the object instance look nicer. So the output in this case is:

`Name: John`

If you're curious, try remove the entire method for __repr__ to check out the difference.

There is a way to have more control over the initialization of an object up creation and that is through the use of the __init__ method. In programming lingo it's called a constructor method.

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

    def __repr__(self):
        return f"{self.name}, {self.age} years old."

emp = Employee("John", 22)
print(emp)

When talking about Object Orientation, the word "Inheritance" get thrown around a lot. It simply means being able to define a class that inherits all the methods and properties from another class.

In the example that follows we will create a Manager class that inherits from the Employee class:

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

    def __repr__(self):
        # return f"Employee: {self.name}"
        return f"{self.name}, {self.age} years old."


class Manager(Employee):
    def __init__(self, name, age, employees=None):
        super().__init__(name, age)
        if employees is None:
            self.employees = []
        else:
            self.employees = employees

    def __repr__(self):
        return f"Manager: {self.name}, {self.age} years old. Employees: {self.employees}"


john = Employee("John", 22)
print(john)
mary = Employee("Mary", 21)
print(mary)

mgr = Manager("Bob", 20, [john, mary])
print(mgr)
```

The built-in function super() has also been used here. It is used to call that parent class method (Employee class __init__ constructor). It's commonly used in __init__ methods to ensure that the child class properly initializes the parent class.

Another concept in Object-Oriented programming is Polymorphism. This refers to the ability to take multiple forms. Polymorphism allows us to define methods in the child class with the same name as in the parent class.

For example:

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

    def __repr__(self):
        # return f"Employee: {self.name}"
        return f"{self.name}, {self.age} years old."

    def do_work(self):
        print(f"{self.name} is working employee stuff.")


class Manager(Employee):
    def __init__(self, name, age, employees=None):
        super().__init__(name, age)
        if employees is None:
            self.employees = []
        else:
            self.employees = employees

    def __repr__(self):
        return f"Manager: {self.name}, {self.age} years old. Employees: {self.employees}"

    def do_work(self):
        print(f"{self.name} is working manager stuff.")


john = Employee("John", 22)
print(john)
print(john.do_work())
mary = Employee("Mary", 21)
print(mary)
print(mary.do_work())

mgr = Manager("Bob", 20, [john, mary])
print(mgr)
print(mgr.do_work())
```


The next important concept to know is Encapsulation and it is merely the idea that an Instance of an object can wrap data and methods within it and that Python can put restrictions on accessing variables and methods directly.

We will modify the Employee class to demonstrate Encapsulation.

```
class Employee:
    def __init__(self, name, age):
        self.__name = name
        self.__age = age

    def __repr__(self):
        # return f"Employee: {self.name}"
        return f"{self.__name}, {self.__age} years old."

    def do_work(self):
        print(f"{self.__name} is working employee stuff.")

    def get_name(self):
        return self.__name

    def get_age(self):
        return self.__age

    def set_name(self, name):
        self.__name = name

    def set_age(self, age):
        self.__age = age


class Manager(Employee):
    def __init__(self, name, age, employees=None):
        super().__init__(name, age)
        if employees is None:
            self.__employees = []
        else:
            self.__employees = employees

    def __repr__(self):
        return f"Manager: {self.get_name()}, {self.get_age()} years old. Employees: {self.__employees}"

    def do_work(self):
        print(f"{self.get_name()} is working manager stuff.")


john = Employee("John", 22)
print(john)
print(john.do_work())
mary = Employee("Mary", 21)
print(mary)
print(mary.do_work())

mgr = Manager("Bob", 20, [john, mary])
print(mgr)
print(mgr.do_work())
```


And finally, we have the concept of Abstraction which means making sure to provide only the necessary details and hiding the underlying implementation and this is achieved through the use of abstract classes and methods.

An abstract class is a class that has one or more abstract methods. An abstract method is a method that has a declaration but doesn't have an implementation.

Below, we introduce an abstract class called Worker that defins an abstract method called calculate_bonus. It is annotated by @abstractmethod. It can be noticed that the Employee class inherits from Worker and Manager indirectly inherits Worker indirectly through Employee and that they both provide an implementation of the the calculate_bonus method.

```
from abc import ABC, abstractmethod


class Worker:
    @abstractmethod
    def calculate_bonus(self):
        pass


class Employee(Worker):
    def __init__(self, name, age, salary):
        self.__name = name
        self.__age = age
        self.__salary = salary

    def __repr__(self):
        # return f"Employee: {self.name}"
        return f"{self.__name}, Bonus: {self.calculate_bonus()}, {self.__age} years old."

    def do_work(self):
        print(f"{self.__name} is working employee stuff.")

    def get_name(self):
        return self.__name

    def get_age(self):
        return self.__age

    def get_salary(self):
        return self.__salary

    def set_name(self, name):
        self.__name = name

    def set_age(self, age):
        self.__age = age

    def set_salary(self, salary):
        self.__salary = salary

    def calculate_bonus(self):
        return self.__salary * 0.1


class Manager(Employee):
    def __init__(self, name, age, salary, employees=None):
        super().__init__(name, age, salary)
        if employees is None:
            self.__employees = []
        else:
            self.__employees = employees

    def __repr__(self):
        return f"Manager: {self.get_name()}, Bonus: {self.calculate_bonus()}, {self.get_age()} years old. Employees: {self.__employees}"

    def do_work(self):
        print(f"{self.get_name()} is working manager stuff.")

    def calculate_bonus(self):
        return self.get_salary() * 0.2


john = Employee("John", 22, 1000)
print(john)
print(john.do_work())
mary = Employee("Mary", 21, 1000)
print(mary)
print(mary.do_work())

mgr = Manager("Bob", 20, 2000, [john, mary])
print(mgr)
print(mgr.do_work())
```

OOP offers several benefits including modularity, code reusability which results in a clear structure for long and complex programs.
But although it offers many benefits, there may be situations where it may not be suitable and if it is not designed properly, 
it cause programs to become over-complicated.