Python Part 9: Object-oriented Programming In Python (OOP Pillars)

Python Part 9: Object-oriented Programming In Python (OOP Pillars)


Please Subscribe Youtube| Like Facebook | Follow Twitter

Object-Oriented Programming (OOP Pillars) In Python

In this article, we will provide a detailed overview of Object-Oriented Programming In Python (OOP Pillars) and provide examples of how they are used in Python programming.

The Pillars of Object-Oriented Programming

OOP is built upon four key pillars: encapsulation, inheritance, polymorphism, and abstraction. In this article we explore them as in previous article we explored classes and objects.

Encapsulation

Encapsulation in Python refers to the practice of bundling data and methods within a class, allowing for data hiding and access control. By hiding the internal implementation details, encapsulation separates the object’s interface from its implementation. This separation prevents accidental modifications and enforces data integrity.

Python provides three levels of encapsulation: public, protected, and private. Each level has its own conventions and restrictions on accessing class members.

Public Encapsulation

  • Public members are accessible from anywhere in the program.
  • There are no naming conventions or restrictions on accessing public members.
  • Public members can be accessed directly from outside the class.

Protected Encapsulation

  • Protected members are conventionally indicated by prefixing with a single underscore (_).
  • Protected members can be accessed within the class itself and its subclasses.
  • Although they can be accessed outside the class, it is generally recommended to treat them as internal implementation details.

Private Encapsulation

  • Private members are conventionally indicated by prefixing with double underscores (e.g., __attribute).
  • Private members are name-mangled by the interpreter, prefixing the class name to avoid accidental access.
  • Private members can only be accessed within the class itself.
  • Accessing private members from outside the class should generally be avoided, as it violates encapsulation principles.

Example:

class Person:
    def __init__(self, name, age):
        self.name = name                    # Public attribute
        self._age = age                     # Protected attribute
        self.__salary = 5000                # Private attribute

    def display_info(self):
        print(f"Name: {self.name}")
        print(f"Age: {self._age}")
        print(f"Salary: {self.__salary}")

    def _increase_age(self):
        self._age += 1

    def __increase_salary(self, amount):
        self.__salary += amount


person = Person("John", 30)

# Accessing public attributes
print(person.name)                 # Output: John

# Accessing protected attributes
print(person._age)                 # Output: 30
person._increase_age()
print(person._age)                 # Output: 31

# Accessing private attributes (Name Mangling)
print(person._Person__salary)      # Output: 5000
person._Person__increase_salary(1000)
print(person._Person__salary)      # Output: 6000

# Calling public methods
person.display_info()

# Output:
# Name: John
# Age: 31
# Salary: 6000

Output

John
30
31
5000
6000
Name: John
Age: 31
Salary: 6000

In this example, we have a Person class with different levels of encapsulation.

Public encapsulation: The name attribute is public, and it can be accessed directly outside the class.

Protected encapsulation: The _age attribute is conventionally considered protected, and it can be accessed within the class and its subclasses. It is modified using the _increase_age method.

Private encapsulation: The __salary attribute is considered private, and its name is mangled to _Person__salary. It can be accessed using this mangled name, but it is not recommended. It is modified using the __increase_salary method.

By using different levels of encapsulation, you can control the visibility and accessibility of class members, providing encapsulation and data hiding to ensure proper usage and maintainability of your code.

Inheritance

In Python, inheritance is achieved by deriving a new class from an existing class. Inheritance allows a new class, known as a derived or child class, to inherit attributes and methods from a base or parent class. The child class can then extend or override the inherited functionality and add new attributes and methods specific to its own requirements. This mechanism promotes code reuse and modularity, as common functionality can be defined in a base class and shared among multiple derived classes.

To implement inheritance in Python, we use the syntax class DerivedClass(BaseClass): to create a new class that inherits from an existing class. The derived class inherits all the attributes and methods of the base class and can override or extend them as needed.

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

    def speak(self):
        raise NotImplementedError("Subclasses must implement the speak method")


class Dog(Animal):
    def speak(self):
        return "Woof!"


class Cat(Animal):
    def speak(self):
        return "Meow!"


dog = Dog("Buddy")
cat = Cat("Whiskers")

print(dog.name)       # Output: Buddy
print(dog.speak())    # Output: Woof!

print(cat.name)       # Output: Whiskers
print(cat.speak())    # Output: Meow!

Output

Buddy
Woof!
Whiskers
Meow!

In this example, we have a base class Animal that defines the name attribute and a speak method. The derived classes Dog and Cat inherit from Animal and override the speak method with their own implementations.

Super keyword

The super() function in Python is primarily used to call methods and constructors from the parent class.

When you use super() with parentheses, such as super().__init__(), it allows you to call the parent class’s constructor and initialize the parent class’s attributes.

Similarly, you can use super().method_name() to call a method from the parent class, where method_name is the name of the method you want to invoke.

Example

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

    def greet(self):
        return f"Hello, I'm {self.name}"


class ChildClass(ParentClass):
    def __init__(self, name, age):
        super().__init__(name)  # Call the parent class's constructor
        self.age = age

    def greet(self):
        parent_greet = super().greet()  # Call the parent class's greet() method
        return f"{parent_greet}. I'm {self.age} years old."


child = ChildClass("Adam", 25)
print(child.name)             # Output: Adam
print(child.age)              # Output: 25
print(child.greet())          # Output: Hello, I'm Adam. I'm 25 years old.

Output

Adam
25
Hello, I'm Adam. I'm 25 years old.

In this example, we have a parent class ParentClass with an __init__() constructor and a greet() method. The child class ChildClass inherits from the parent class and overrides the greet() method.

Inside the ChildClass constructor, we use super().__init__(name) to call the parent class’s constructor and pass the name parameter. This ensures that the parent class’s initialization is performed before adding any additional attributes specific to the child class.

In the greet() method of the child class, we use super().greet() to call the parent class’s greet() method and obtain the parent’s greeting message. We then append the child class’s specific information, such as the age, to create a modified greeting.

When we create an instance of the ChildClass and print the outputs, we can see that the parent class’s constructor and methods are properly accessed through the super() function

Multiple Inheritance In Python

Python supports multiple inheritance, which allows a class to inherit attributes and methods from multiple parent classes. This means that a child class can inherit from more than one base class.

To demonstrate multiple inheritance, let’s consider an example

class ParentClass1:
    def method1(self):
        print("Method 1 from ParentClass1")


class ParentClass2:
    def method2(self):
        print("Method 2 from ParentClass2")


class ChildClass(ParentClass1, ParentClass2):
    def child_method(self):
        print("Child method")


child = ChildClass()
child.method1()     # Output: Method 1 from ParentClass1
child.method2()     # Output: Method 2 from ParentClass2
child.child_method()  # Output: Child method

Output

Method 1 from ParentClass1
Method 2 from ParentClass2
Child method

In this example, we have two parent classes, ParentClass1 and ParentClass2, each with their own methods. The ChildClass inherits from both ParentClass1 and ParentClass2.

The ChildClass can now access methods from both parent classes. It can call method1() from ParentClass1, method2() from ParentClass2, as well as its own child_method().

When creating an instance of ChildClass and calling the methods, you will see the respective outputs as mentioned in the comments.

Multiple inheritance allows classes to inherit and combine the behavior of multiple parent classes, providing flexibility in designing complex class hierarchies. However, it’s important to carefully consider the design and potential complexities that can arise when using multiple inheritance.

Diamond problem

The diamond problem is a specific issue that can arise in programming languages that support multiple inheritance, including Python. It occurs when a class inherits from two or more classes that have a common base class. This can lead to ambiguity in method resolution, causing conflicts and making it unclear which version of a method should be used.

To mitigate the diamond problem, Python uses a method resolution order (MRO) algorithm called C3 linearization. The MRO determines the order in which the base classes are searched for a method or attribute. It follows a specific set of rules to ensure a consistent and unambiguous order.

Here’s an example that demonstrates the diamond problem and how Python resolves it:

class A:
    def method(self):
        print("Method in A")


class B(A):
    def method(self):
        print("Method in B")


class C(A):
    def method(self):
        print("Method in C")


class D(B, C):
    pass


d = D()
d.method()  # Output: Method in B

Output

Method in B

In this example, we have four classes: A, B, C, and D. Both B and C inherit from A, and D inherits from both B and C. All classes define a method called method().

When d.method() is called, Python follows the MRO to determine the order in which the base classes are searched for the method. In this case, the MRO is D -> B -> C -> A. So, Python looks for the method() in class D first, then in B, then in C, and finally in A.

As a result, the output is “Method in B”. This is because B is the first class in the MRO that defines the method(), so its implementation is used.

Python’s MRO algorithm effectively resolves the diamond problem by providing a well-defined order for method resolution. However, it’s important to be aware of potential conflicts and understand the MRO to design your class hierarchy and override methods appropriately.

Note

In Python, the pass statement is a placeholder that allows you to create empty code blocks or functions without causing a syntax error. It is often used as a temporary placeholder when you want to define a block of code that does nothing or when you’re working on an incomplete implementation.

Polymorphism

Polymorphism in Python refers to the ability of objects to take on different forms or exhibit different behaviors based on the context in which they are used. It allows objects of different classes to be treated as objects of a common base class, providing flexibility and extensibility in code design.

Polymorphism is typically achieved through method overriding. Please note that method overloading (multiple methods with the same name but different parameters) is not supported directly. However, you can achieve similar functionality by using default parameter values or by using *args and **kwargs to handle different method signatures.

Method Overriding (Runtime Polymorphism)

Method overriding occurs when a child class provides a specific implementation for a method that is already defined in its parent class. The child class’s method “overrides” the behavior of the parent class’s method. Here’s an example demonstrating method overriding in Python:

class Animal:
    def speak(self):
        return "Animal speaks"


class Dog(Animal):
    def speak(self):
        return "Woof! I'm a dog"


class Cat(Animal):
    def speak(self):
        return "Meow! I'm a cat"


class Bird(Animal):
    def speak(self):
        return "Chirp! I'm a bird"


# Polymorphic behavior with method overriding
def animal_speak(animal):
    print(animal.speak())


animal = Animal()
dog = Dog()
cat = Cat()
bird = Bird()

animal_speak(animal)  # Output: Animal speaks
animal_speak(dog)     # Output: Woof! I'm a dog
animal_speak(cat)     # Output: Meow! I'm a cat
animal_speak(bird)    # Output: Chirp! I'm a bird

Output

Animal speaks
Woof! I'm a dog
Meow! I'm a cat
Chirp! I'm a bird

In this example, we have a base class Animal with a method speak(). The Dog, Cat, and Bird classes inherit from the Animal class and provide their own implementations of the speak() method.

When the animal_speak() function is called with different objects, it demonstrates polymorphic behavior through method overriding. Each object invokes its own version of the speak() method, overriding the behavior defined in the parent class Animal.

This allows us to treat objects of different classes as objects of the common base class (Animal in this case) while still executing the specific behavior implemented in the child classes.

Abstraction

In Python, abstract classes and interfaces are not built-in language features like in some other programming languages such as Java or C#. However, Python provides a mechanism to define abstract classes using the abc (Abstract Base Classes) module, which can be used to achieve similar functionality.

Abstract classes in Python are classes that cannot be instantiated and are meant to be inherited from by concrete (non-abstract) classes. They can contain both abstract methods (methods without implementations) and concrete methods (methods with implementations).

The abc module provides the ABC metaclass and the abstractmethod decorator, which are used to define abstract classes and abstract methods, respectively. By inheriting from an abstract class and implementing its abstract methods, a concrete class fulfills the contract defined by the abstract class.

Here’s an example that demonstrates the use of the abc module to define an abstract class and implement an interface-like behavior in Python:

from abc import ABC, abstractmethod

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

    @abstractmethod
    def draw(self):
        pass

class Circle(Shape):
    def calculate_area(self):
        return 3.14 * self.radius ** 2

    def draw(self):
        print("Drawing a circle.")

circle = Circle()
circle.radius = 5
print(circle.calculate_area())  # Output: 78.5
circle.draw()                   # Output: Drawing a circle.

Output

78.5
Drawing a circle.

In this example, we define the Shape class as an abstract class by inheriting from the ABC metaclass. We include two abstract methods in the Shape class: calculate_area() and draw(). The Circle class inherits from Shape and implements these abstract methods.

By creating an instance of the Circle class, we actively invoke the calculate_area() and draw() methods. The abstract class Shape serves as an interface-like contract that ensures the implementation of these methods in the Circle class.

Although Python lacks strict interfaces found in other languages, we can utilize abstract classes from the abc module to achieve a similar level of contract enforcement and promote abstraction and code modularity.

Hence, while Python does not provide built-in constructs for abstract classes and interfaces, the abc module enables us to define abstract classes and attain interface-like behavior.

Using abstract classes enhances code organization, reusability, and maintainability.

Conclusion

Encapsulation, inheritance, polymorphism, and abstraction are four fundamental concepts in Python object-oriented programming. Together, they provide a powerful and flexible foundation for creating modular, maintainable, and extensible code.

Python Beginner Tutorial Series

Please Subscribe Youtube| Like Facebook | Follow Twitter


Leave a Reply

Your email address will not be published. Required fields are marked *