Lightning bolt and Python code snippet with "Python OOP" in blocky caps

Python OOP

In this lesson, we’ll delve deeper into advanced topics in Object-Oriented Programming (OOP) in Python, such as inheritance, method resolution order (MRO), abstract classes, multiple inheritance, and property decorators.

These concepts will help you write more flexible and reusable object-oriented code.

Inheritance Revisited

Last time, we introduced the concept of inheritance, where a class (called a subclass) inherits attributes and methods from another class (the parent class). This time, we’ll explore more advanced uses of inheritance, including method overriding and using the super() function.

Overriding Methods

When a subclass defines a method with the same name as a method in its parent class, it overrides the parent class’s method. This allows the subclass to provide its own implementation for the method.

Example:

class Animal:
    def make_sound(self):
        return "Some generic sound"

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

Here, the Dog class overrides the make_sound() method of the Animal class. When you call make_sound() on a Dog object, it will use the Dog class’s version of the method.

dog = Dog()
print(dog.make_sound())  # Output: Woof!

Using super() to Call Parent Methods

You can use the super() function to call a method from the parent class inside a subclass. This is useful when you want to extend the functionality of a parent class’s method rather than completely override it.

Example:

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

    def make_sound(self):
        return "Some generic sound"

class Dog(Animal):
    def __init__(self, name, breed):
        super().__init__(name)  # Call the parent class's constructor
        self.breed = breed

    def make_sound(self):
        return super().make_sound() + " Woof!"

In this example, the Dog class’s __init__() method calls the parent class’s __init__() using super(), ensuring that the name attribute is initialized. Similarly, super().make_sound() calls the parent’s make_sound() method and extends its behavior.

Multiple Inheritance

Multiple inheritance occurs when a class inherits from more than one parent class. While this can be useful in some scenarios, it can also introduce complexity, especially when methods with the same name exist in multiple parent classes.

Example:

class Animal:
    def make_sound(self):
        return "Some generic sound"

class Pet:
    def play(self):
        return "Playing"

class Dog(Animal, Pet):
    def make_sound(self):
        return "Woof!"

Here, Dog inherits from both Animal and Pet, meaning it can access the make_sound() method from Animal and the play() method from Pet.

dog = Dog()
print(dog.make_sound())  # Output: Woof! (overridden method)
print(dog.play())        # Output: Playing

While multiple inheritance can be powerful, it can also lead to issues when different parent classes define methods with the same name. In such cases, Python uses the method resolution order (MRO) to determine which method to call.

Method Resolution Order (MRO)

In Python, MRO defines the order in which methods are inherited when multiple inheritance is involved. Python uses the C3 linearization algorithm to determine this order. You can check the MRO of a class using the __mro__ attribute or the mro() method.

Example:

class A:
    def do_something(self):
        return "Doing something in A"

class B(A):
    def do_something(self):
        return "Doing something in B"

class C(A):
    def do_something(self):
        return "Doing something in C"

class D(B, C):
    pass

# Checking the MRO
print(D.mro())

Output:

[<class '__main__.D'>, <class '__main__.B'>, <class '__main__.C'>, <class '__main__.A'>, <class 'object'>]

Here, the method resolution order for class D is D → B → C → A. This means if D calls a method, Python will first look in D, then in B, then in C, and finally in A.

Using MRO in Practice:

d = D()
print(d.do_something())  # Output: Doing something in B

In this case, D calls the do_something() method in class B, because B appears first in the MRO.

Abstract Classes and Methods

An abstract class is a class that cannot be instantiated directly and is meant to be subclassed. Abstract classes typically contain one or more abstract methods, which are methods that are declared but contain no implementation. Subclasses must implement these abstract methods.

Abstract classes are useful when you want to enforce certain methods to be implemented in all subclasses.

Defining Abstract Classes

In Python, you can define abstract classes using the abc module, which provides the ABC class and the abstractmethod decorator.

Example:

from abc import ABC, abstractmethod

class Animal(ABC):
    @abstractmethod
    def make_sound(self):
        pass

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

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

In this example:

  • Animal is an abstract class because it inherits from ABC and has an abstract method make_sound().
  • Both Dog and Cat must implement the make_sound() method, as required by the abstract class.

Trying to Instantiate an Abstract Class:

# animal = Animal()  # This would raise an error: TypeError: Can't instantiate abstract class Animal

You cannot create an instance of Animal because it contains an abstract method, but you can instantiate the subclasses that provide concrete implementations of the abstract method.

Properties and Getters/Setters

In Python, you can control access to attributes using properties, which allow you to define methods that act like attributes. This is often used to implement getters and setters.

Using property()

You can create a property using the property() function or the @property decorator. This allows you to add logic when getting or setting an attribute, while still accessing it as if it were a regular attribute.

Example:

class Dog:
    def __init__(self, name, age):
        self._name = name
        self._age = age

    @property
    def age(self):
        return self._age

    @age.setter
    def age(self, value):
        if value < 0:
            raise ValueError("Age cannot be negative")
        self._age = value

In this example, age is a property with a getter and a setter. The setter ensures that the age cannot be set to a negative value.

dog = Dog("Buddy", 3)
print(dog.age)  # Output: 3

dog.age = 5     # Valid
# dog.age = -1  # Raises ValueError: Age cannot be negative

Properties are useful for adding validation or other logic when accessing or modifying attributes.

The __str__() and __repr__() Methods

In Python, the __str__() and __repr__() methods are used to control how an object is displayed when printed or converted to a string.

  • __str__(): Defines the informal string representation of an object, meant to be readable and user-friendly. It’s used by print() and str().
  • __repr__(): Defines the official string representation of an object, meant to be unambiguous and suitable for debugging.

Example:

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

    def __str__(self):
        return f"Dog(name={self.name}, age={self.age})"

    def __repr__(self):
        return f"Dog('{self.name}', {self.age})"

Now when you print the object or call str() or repr():

dog = Dog("Buddy", 3)
print(dog)           # Output: Dog(name=Buddy, age=3) (uses __str__)
print(repr(dog))     # Output: Dog('Buddy', 3) (uses __repr__)

It’s good practice to implement both __str__() and __repr__() to control how objects are displayed.

Example: Building a Simple OOP Program with Advanced Concepts

Let’s build a program

that models vehicles using advanced OOP concepts like inheritance, abstract classes, and properties.

Vehicle Class (Abstract):

from abc import ABC, abstractmethod

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

    @abstractmethod
    def start_engine(self):
        pass

    def __str__(self):
        return f"{self.year} {self.make} {self.model}"

Car Class (Subclass):

class Car(Vehicle):
    def __init__(self, make, model, year, electric):
        super().__init__(make, model, year)
        self.electric = electric

    def start_engine(self):
        if self.electric:
            return "Car is starting silently (electric engine)"
        else:
            return "Car engine is starting with a roar"

Using the Classes:

# Creating instances
tesla = Car("Tesla", "Model S", 2022, electric=True)
mustang = Car("Ford", "Mustang", 2020, electric=False)

# Using methods
print(tesla)                # Output: 2022 Tesla Model S
print(tesla.start_engine()) # Output: Car is starting silently (electric engine)

print(mustang)              # Output: 2020 Ford Mustang
print(mustang.start_engine()) # Output: Car engine is starting with a roar

This example demonstrates how to use abstract classes, inheritance, and the __str__() method to create a flexible vehicle model.

Key Concepts Recap

  • Multiple inheritance allows a class to inherit from more than one parent class, and Python resolves method conflicts using the MRO.
  • Abstract classes define a blueprint for other classes and cannot be instantiated directly.
  • Properties provide a way to add logic to attribute access and modification using getter and setter methods.
  • The __str__() and __repr__() methods control how an object is displayed when printed or converted to a string.

Exercises

  1. Create an abstract class Shape with an abstract method area(). Implement subclasses Circle and Rectangle that calculate the area for each shape.
  2. Build a class Person with a property age that raises a ValueError if the age is set to a negative number.
  3. Create a class Animal with a make_sound() method. Implement subclasses Dog and Cat, each with their own version of make_sound(). Use polymorphism to loop over a list of animals and call make_sound() on each.
  4. Create a class Library that keeps track of books and implements the __str__() and __repr__() methods to provide readable and debugging-friendly representations.

Next time, we’ll explore Error Handling in Python, including using exceptions to make your programs more robust and reliable.

FAQ

Q1: What’s the difference between __str__() and __repr__()? When should I use each one?

A1:

  • __str__(): This method defines the informal string representation of an object. It’s intended to be human-readable, making it useful for displaying the object’s details in a user-friendly way (e.g., when using print() or str()). Example:
  def __str__(self):
      return f"Dog(name={self.name}, age={self.age})"
  • __repr__(): This method defines the official or formal string representation of an object. It’s meant to be unambiguous and is useful for debugging. The goal is to return a string that could recreate the object when passed to eval(). Example:
  def __repr__(self):
      return f"Dog('{self.name}', {self.age})"

Use __str__() when you want a user-friendly representation and __repr__() when you need a detailed or debugging-friendly output.

Q2: What’s the purpose of @property and how is it different from using regular getter and setter methods?

A2: The @property decorator allows you to define methods that act like attributes. This lets you add logic when accessing or modifying an attribute, while still accessing it as if it were a simple variable.

Without @property, you would need to explicitly call getter and setter methods:

class Dog:
    def get_age(self):
        return self._age
    def set_age(self, value):
        if value < 0:
            raise ValueError("Age cannot be negative")
        self._age = value

With @property, you can write:

class Dog:
    @property
    def age(self):
        return self._age

    @age.setter
    def age(self, value):
        if value < 0:
            raise ValueError("Age cannot be negative")
        self._age = value

Now, you can access and set age like a regular attribute, but the setter logic still applies:

dog.age = 5  # Calls the setter
print(dog.age)  # Calls the getter

Q3: How does Python handle multiple inheritance when there are conflicts, like methods with the same name in different parent classes?

A3: When there are multiple parent classes with methods of the same name, Python uses the Method Resolution Order (MRO) to determine which method to call. The MRO is the order in which Python looks for a method in the class hierarchy.

The C3 linearization algorithm determines the MRO, and you can check it using:

print(MyClass.mro())

Example:

class A:
    def do_something(self):
        return "Doing something in A"

class B(A):
    def do_something(self):
        return "Doing something in B"

class C(A):
    def do_something(self):
        return "Doing something in C"

class D(B, C):
    pass

d = D()
print(d.do_something())  # Output: Doing something in B
print(D.mro())

In this example, D inherits from both B and C, but the method from B is used because B comes first in the MRO.

Q4: Can I have multiple @property decorators for different attributes in a class?

A4: Yes, you can use multiple @property decorators for different attributes within the same class. Each property should have its own getter, and if necessary, a setter and deleter.

Example:

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

    @property
    def width(self):
        return self._width

    @width.setter
    def width(self, value):
        if value <= 0:
            raise ValueError("Width must be positive")
        self._width = value

    @property
    def height(self):
        return self._height

    @height.setter
    def height(self, value):
        if value <= 0:
            raise ValueError("Height must be positive")
        self._height = value

In this example, width and height each have their own @property decorators, along with their respective setters. You can access and modify them like regular attributes.

Q5: What’s the benefit of using abstract classes instead of regular classes?

A5: Abstract classes are useful when you want to enforce a certain interface or method structure across multiple subclasses. By defining abstract methods, you ensure that all subclasses implement those methods, which can help prevent errors and ensure consistency in the design of your program.

Example:

from abc import ABC, abstractmethod

class Animal(ABC):
    @abstractmethod
    def make_sound(self):
        pass

Any subclass of Animal must implement the make_sound() method:

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

If a subclass doesn’t implement make_sound(), it cannot be instantiated, forcing developers to implement the required methods.

Q6: Can abstract classes have regular (non-abstract) methods?

A6: Yes, abstract classes can have both abstract methods and regular methods. Abstract methods must be implemented by subclasses, while regular methods can be inherited or overridden by subclasses.

Example:

from abc import ABC, abstractmethod

class Animal(ABC):
    @abstractmethod
    def make_sound(self):
        pass

    def sleep(self):
        return "Sleeping"

In this example, the sleep() method is a regular method that can be inherited by subclasses, while make_sound() is an abstract method that must be implemented.

Q7: What is the difference between @classmethod and @staticmethod?

A7:

  • @classmethod: A class method takes cls as its first parameter and can access class-level data. It can modify the class state that applies across all instances of the class. Example:
  class Dog:
      species = "Canis familiaris"

      @classmethod
      def change_species(cls, new_species):
          cls.species = new_species
  • @staticmethod: A static method doesn’t take self or cls as its first parameter. It can’t access or modify instance or class-level data. Static methods are used when you need a function that logically belongs to a class but doesn’t depend on the class or instance data. Example:
  class Dog:
      @staticmethod
      def bark():
          return "Woof!"

Use @classmethod when you need access to the class itself, and @staticmethod when you’re writing a utility method that doesn’t modify the class or instance data.

Q8: What’s the difference between super() and directly calling the parent class’s method?

A8: super() provides a more flexible and maintainable way to call methods from a parent class, especially when dealing with multiple inheritance. It ensures that the correct method from the next class in the MRO is called, not necessarily the immediate parent class.

Using super() Example:

class A:
    def say_hello(self):
        print("Hello from A")

class B(A):
    def say_hello(self):
        super().say_hello()
        print("Hello from B")

In the above example, super() calls A’s say_hello() method from inside B’s say_hello() method.

Directly calling the parent class’s method:

If you call the parent class’s method directly (e.g., A.say_hello(self)), it bypasses the MRO and can cause issues with multiple inheritance.

example:

class C(B, A):
    pass

In this case, using super() ensures that the method resolution follows the correct order (C → B → A). Directly calling A.say_hello() would disrupt the MRO.

Q9: Can properties be read-only?

A9: Yes, you can create a read-only property by defining a getter without a corresponding setter. This ensures that the attribute can be accessed but not modified.

Example:

class Dog:
    def __init__(self, name):
        self._name = name

    @property
    def name(self):
        return self._name

In this example, name is a read-only property because it only has a getter. If you try to set it, Python will raise an AttributeError:

dog = Dog("Buddy")
print(dog.name)  # Output: Buddy
# dog.name = "Max"  # Raises AttributeError: can't set attribute

Q10: How can I handle cases where multiple inheritance causes conflicts with the same method in different parent classes?

A10: In cases where multiple parent classes have methods with the same name, Python uses Method Resolution Order (MRO) to determine which method to call. If you need to resolve the conflict yourself, you can use super() to explicitly call the desired method.

Example:

class A:
    def do_something(self):
        return "Doing something in A"

class B(A):
    def do_something(self):
        return "Doing something in B"

class C(A):
    def do_something(self):
        return "Doing something in C"

class D(B, C):
    def do_something(self):
        return super().do_something()  # Follows MRO (B -> C -> A)

If you want to control which method is called, you can directly specify the parent class:

class D(B, C):
    def do_something(self):
        return C.do_something(self)  # Explicitly call C's method

This lets you resolve conflicts based on your specific needs.

Similar Posts