Object-oriented programming (OOP) is a programming paradigm that has become the most popular approach to software development in recent years.

Python, a popular programming language, also supports OOP concepts. In this article, we will explore the fundamentals of object-oriented programming in Python, including the principles of OOP.

OOP is based on four principles: encapsulation, inheritance, polymorphism, and abstraction. Let's take a look at each principle in detail.

Encapsulation

Encapsulation is one of the fundamental concepts in object-oriented programming (OOP) that helps in creating secure and robust code. In Python, encapsulation is the process of hiding the internal implementation details of a class from the outside world, and restricting access to the class's attributes and methods. This is achieved by using access modifiers, such as public, private, and protected, to control the visibility of class members.

Here's an example code snippet that demonstrates encapsulation in Python:

class Car:
    def __init__(self, make, model, year):
        self._make = make          # protected attribute
        self._model = model        # protected attribute
        self.__year = year         # private attribute

    # public method
    def get_car_details(self):
        return f"{self._make} {self._model} ({self.__year})"

    # private method
    def __get_car_age(self):
        current_year = 2023
        return current_year - self.__year

# create a Car object
my_car = Car("Toyota", "Camry", 2019)

# accessing protected attributes
print(my_car._make)   # output: Toyota
print(my_car._model)  # output: Camry

# accessing private attribute
# this will result in an AttributeError
print(my_car.__year)

# accessing public method
print(my_car.get_car_details())  # output: Toyota Camry (2019)

# accessing private method
# this will result in an AttributeError
print(my_car.__get_car_age())

In this code, we define a Car class with three attributes: make, model, and year. We use the underscore prefix to indicate that make and model are protected attributes, while the double underscore prefix is used to indicate that year is a private attribute.

The Car class also has a public method called get_car_details that returns a string with the make, model, and year of the car. Additionally, we define a private method called __get_car_age that calculates the age of the car based on the current year and the year of manufacture.

When we create a Car object, we can access the protected attributes using the underscore prefix. However, we cannot access the private attribute or private method directly. Attempting to do so will result in an AttributeError because they are not visible outside of the class.

Inheritance

Inheritance is one of the fundamental concepts of object-oriented programming (OOP) that allows us to create new classes based on existing classes. In Python, inheritance is implemented using the class keyword and the name of the parent class in parentheses.

In Python, there are three types of inheritance:

  • Single inheritance: This is the most common type of inheritance in Python. In single inheritance, a child class inherits from a single parent class.

  • Multiple inheritance: In multiple inheritance, a child class inherits from multiple parent classes.

  • Multi-level inheritance: In multi-level inheritance, a child class inherits from a parent class, which in turn, inherits from its own parent class.

Here are some code examples that demonstrate each type of inheritance in Python:

Single Inheritance

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

    def eat(self):
        print(f"{self.name} is eating.")

    def sleep(self):
        print(f"{self.name} is sleeping.")


class Dog(Animal):
    def __init__(self, name, breed):
        super().__init__(name)
        self.breed = breed

    def bark(self):
        print(f"{self.name} is barking.")


my_dog = Dog("Buddy", "Labrador Retriever")
my_dog.eat()   # output: Buddy is eating.
my_dog.bark()  # output: Buddy is barking.

In this example, we have a parent class Animal and a child class Dog that inherits from the parent class. The Dog class adds a new attribute breed and a new method bark to the Animal class.

Multiple Inheritance

class Flyer:
    def fly(self):
        print(f"{self.name} is flying.")


class Swimmer:
    def swim(self):
        print(f"{self.name} is swimming.")


class Duck(Flyer, Swimmer):
    def __init__(self, name):
        self.name = name


my_duck = Duck("Donald")
my_duck.fly()   # output: Donald is flying.
my_duck.swim()  # output: Donald is swimming.

In this example, we have two parent classes Flyer and Swimmer, and a child class Duck that inherits from both parent classes. The Duck class can now access methods from both parent classes.

Multi-level Inheritance

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

    def eat(self):
        print(f"{self.name} is eating.")

    def sleep(self):
        print(f"{self.name} is sleeping.")


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

    def walk(self):
        print(f"{self.name} is walking.")


class Dog(Mammal):
    def __init__(self, name, age, breed):
        super().__init__(name, age)
        self.breed = breed

    def bark(self):
        print(f"{self.name} is barking.")


my_dog = Dog("Buddy", 3, "Labrador Retriever")
my_dog.eat()   # output: Buddy is eating.
my_dog.sleep() # output: Buddy is sleeping.
my_dog.walk()  # output: Buddy is walking.
my_dog.bark()  # output: Buddy is barking.

In this example, we have a parent class Animal, a child class Mammal that inherits from the Animal class, and a grandchild class Dog that inherits from the Mammal class. The Dog class adds a new attribute breed and a new method bark to the Mammal class. The Dog class can now access methods from both parent classes.

Polymorphism

Polymorphism is another important concept in object-oriented programming (OOP) that allows objects of different classes to be treated as if they were of the same class. In Python, polymorphism is implemented using method overriding and method overloading.

Method overriding is when a child class provides its own implementation for a method that is already defined in its parent class. Method overloading is when a class has multiple methods with the same name but different parameters.

Here are some code examples that demonstrate polymorphism in Python:

Method Overriding

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

    def speak(self):
        pass


class Dog(Animal):
    def speak(self):
        return "woof"


class Cat(Animal):
    def speak(self):
        return "meow"


my_dog = Dog("Buddy")
my_cat = Cat("Luna")
print(my_dog.speak())  # output: woof
print(my_cat.speak())  # output: meow

In this example, we have a parent class Animal and two child classes Dog and Cat that inherit from the parent class. Both child classes override the speak method with their own implementation.

Method Overloading

class Calculator:
    def add(self, x, y):
        return x + y

    def add(self, x, y, z):
        return x + y + z


my_calc = Calculator()
print(my_calc.add(2, 3))      # output: TypeError: add() missing 1 required positional argument: 'z'
print(my_calc.add(2, 3, 4))   # output: 9

In this example, we have a class Calculator with two methods named add. The first add method takes two arguments and the second add method takes three arguments. This is an example of method overloading.

Polymorphism with Built-in Functions

print(len("hello"))  # output: 5
print(len([1, 2, 3]))  # output: 3

In this example, we have used the built-in function len with two different objects - a string and a list. Despite being different objects, the len function works with both of them. This is an example of polymorphism.

Abstraction

Abstraction is a fundamental concept in object-oriented programming (OOP) that allows us to hide the implementation details of a class from the outside world and only expose a simplified interface to the user. This simplifies the code and makes it easier to understand and maintain.

In Python, abstraction can be achieved using abstract classes and interfaces. An abstract class is a class that cannot be instantiated and is meant to be subclassed. An interface is a collection of abstract methods that defines a contract for its implementing classes.

Here's an example of abstraction in Python using abstract classes:

from abc import ABC, abstractmethod

class Shape(ABC):
    @abstractmethod
    def area(self):
        pass

    @abstractmethod
    def perimeter(self):
        pass


class Circle(Shape):
    def __init__(self, radius):
        self.radius = radius

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

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


class Rectangle(Shape):
    def __init__(self, width, height):
        self.width = width
        self.height = height

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

    def perimeter(self):
        return 2 * (self.width + self.height)


my_circle = Circle(5)
my_rectangle = Rectangle(3, 4)
print(my_circle.area())       # output: 78.5
print(my_circle.perimeter())  # output: 31.400000000000002
print(my_rectangle.area())    # output: 12
print(my_rectangle.perimeter())  # output: 14

In this example, we have an abstract class Shape that defines two abstract methods area and perimeter. The Circle and Rectangle classes inherit from the Shape class and implement their own versions of the area and perimeter methods. The Shape class acts as an interface and provides a contract for its implementing classes.

If we try to create an instance of the Shape class directly, we will get a TypeError because it is an abstract class:

my_shape = Shape()  # output: TypeError: Can't instantiate abstract class Shape with abstract methods area, perimeter