Object-Oriented Programming (OOP) is a programming paradigm based on the concept of "objects," which are instances of classes. OOP in Python helps in organizing and structuring the code, making it easier to manage and extend. This guide will explore the key principles of OOP with Python examples: Inheritance, Polymorphism, Encapsulation, Composition and Abstraction.


Classes and Objects

In Python, classes serve as blueprints for creating objects. An object is an instance of a class.

class Car:

    def __init__(self, make, model, year):
        self.make = make
        self.model = model
        self.year = year

    def display_info(self):
        print(f"{self.year} {self.make} {self.model}")

my_car = Car('Mitsubishi', 'Lancer', 2008)
my_car.display_info() # 2008 Mitsubishi Lancer
  • The __init__() method is the constructor, which initializes the object's attributes;
  • The display_info() method prints information about the car;

Inheritance

Inheritance allows a class (called a subclass) to inherit attributes and methods from another class (called a superclass). It promotes code reuse and can extend functionality.

class Animal:

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

    def speak(self):
        print(f"{self.name} makes a sound")

class Dog(Animal):

    def speak(self):
        print(f"{self.name} barks")

class Cat(Animal):

    def speak(self):
        print(f"{self.name} meows")

# Creating objects
dog = Dog("Buddy")
dog.speak()  # Buddy barks

cat = Cat("Whiskers")
cat.speak()  # Whiskers meows
  • Dog and Cat inherit the speak method from Animal, but they override it with their own implementations.

- Key Concepts in Inheritance:

  • Overriding: Subclasses can provide their own version of methods;
  • super() function: It allows the subclass to call methods from the superclass;
class Animal:

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

    def speak(self):
        print(f"{self.name} makes a sound")

class Dog(Animal):

    def __init__(self, name, breed):
        super().__init__(name)  # Call the constructor of Animal
        self.breed = breed

    def speak(self):
        print(f"{self.name} ({self.breed}) barks")

dog = Dog("Buddy", "Bulldog")
dog.speak()  # Buddy (Bulldog) barks

Polymorphism

Polymorphism allows objects of different classes to be treated as objects of a common superclass. The most common use of polymorphism is when a subclass overrides a method in the superclass.

class Animal:

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

    def speak(self):
        print(f"{self.name} makes a sound")

class Cat(Animal):

    def speak(self):
        print(f"{self.name} meows")

class Dog(Animal):

    def speak(self):
        print(f"{self.name} barks")

class Bird(Animal):

    def speak(self):
        print(f"{self.name} chirps")

animals = [Dog("Buddy"), Cat("Whiskers"), Bird("Tweety")]

for animal in animals:
    animal.speak()

# Buddy barks
# Whiskers meows
# Tweety chirps
  • All objects (Dog, Cat, Bird) can be treated as instances of Animal, but each object calls its own version of speak(). This is an example of polymorphism in action.

Encapsulation

Encapsulation involves bundling data (attributes) and methods that operate on the data into a single unit, i.e., a class. It also helps restrict direct access to some of the object's components, which is done by using private and protected attributes.

class BankAccount:

    def __init__(self, owner, balance=0):
        self.owner = owner
        self.__balance = balance  # Private attribute

    def deposit(self, amount):
        if amount > 0:
            self.__balance += amount
        else:
            print("Deposit amount must be positive!")

    def withdraw(self, amount):
        if 0 < amount <= self.__balance:
            self.__balance -= amount
        else:
            print("Invalid withdrawal amount!")

    def get_balance(self):
        return self.__balance

account = BankAccount("Alice", 1000)
account.deposit(500)
account.withdraw(200)
print(account.get_balance())  # 1300

# Direct access to __balance would raise an error
# print(account.__balance)  # AttributeError
  • The __balance attribute is private and cannot be accessed directly from outside the class. It can only be accessed through the provided methods;
  • Private attributes: Denoted by __, they are meant to be accessed only within the class;
  • Public attributes: Can be accessed from outside the class;
  • Getter and Setter Methods: Used to safely access and modify private attributes;

Composition

Composition involves using instances of other classes as attributes to represent relationships between objects. It is an alternative to inheritance and emphasizes “has-a” relationships.

class Engine:

    def start(self):
        print("Engine starting...")

class Car:

    def __init__(self, make, model):
        self.make = make
        self.model = model
        self.engine = Engine()  # Car "has-a" Engine

    def start(self):
        print(f"Starting {self.make} {self.model}...")
        self.engine.start()

my_car = Car("Honda", "Civic")
my_car.start()

# Starting Honda Civic...
# Engine starting...
  • Here, the Car class has an Engine object as an attribute, representing composition. A car "has an" engine.

Abstraction

Abstraction allows hiding the complexity of the system by exposing only the essential parts. In Python, abstract classes can be used to achieve abstraction. Abstract classes cannot be instantiated and must have at least one abstract method.

from abc import ABC, abstractmethod

class Shape(ABC):

    @abstractmethod
    def area(self):
        pass

class Circle(Shape):

    def __init__(self, radius):
        self.radius = radius

    def area(self):
        return 3.14 * (self.radius ** 2)

class Rectangle(Shape):

    def __init__(self, width, height):
        self.width = width
        self.height = height

    def area(self):
        return self.width * self.height

# Create objects
circle = Circle(5)
rectangle = Rectangle(4, 6)

print(circle.area())  # 78.5
print(rectangle.area())  # 24
  • The Shape class is abstract, and both Circle and Rectangle provide concrete implementations for the area method.

Class vs Instance Variables

  • Instance Variables: These are unique to each instance of a class. They are defined inside the __init__ method using self;
  • Class Variables: These are shared by all instances of a class. They are defined within the class but outside of any method;
class Person:

    species = "Homo sapiens"  # Class variable

    def __init__(self, name, age):
        self.name = name  # Instance variable
        self.age = age  # Instance variable

# Create objects
p1 = Person("Alice", 30)
p2 = Person("Bob", 25)

print(p1.species)  # Homo sapiens
print(p2.species)  # Homo sapiens

p1.species = "Homo sapiens sapiens"  # Modifying class variable for p1 only
print(p1.species)  # Homo sapiens sapiens
print(p2.species)  # Homo sapiens

Method Resolution Order (MRO)

In the case of multiple inheritance, Python uses the C3 linearization algorithm to determine the order in which methods are inherited from multiple classes.

class A:

    def hello(self):
        print("Hello from A")

class B(A):

    def hello(self):
        print("Hello from B")

class C(A):

    def hello(self):
        print("Hello from C")

class D(B, C):
    pass

# Create object of class D
obj = D()
obj.hello()  # Hello from B

print(D.mro())
# [<class '__main__.D'>, <class '__main__.B'>, <class '__main__.C'>, <class '__main__.A'>, <class 'object'>]
  • The MRO in this case decides that B comes before C in the inheritance hierarchy.