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.
Table of Contents
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 fromABC
and has an abstract methodmake_sound()
.- Both
Dog
andCat
must implement themake_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 byprint()
andstr()
.__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
- Create an abstract class
Shape
with an abstract methodarea()
. Implement subclassesCircle
andRectangle
that calculate the area for each shape. - Build a class
Person
with a propertyage
that raises aValueError
if the age is set to a negative number. - Create a class
Animal
with amake_sound()
method. Implement subclassesDog
andCat
, each with their own version ofmake_sound()
. Use polymorphism to loop over a list of animals and callmake_sound()
on each. - 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 usingprint()
orstr()
). 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 toeval()
. 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 takescls
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 takeself
orcls
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.