Python is a powerful and beginner-friendly programming language, and concepts like inheritance, polymorphism, and encapsulation are key to writing organized, reusable, and flexible code. These ideas might sound advanced, but they’re actually quite approachable once broken down. In this guide, we’ll explain each concept in a simple way, using clear examples tailored for beginners. We’ll also include practical exercises to help you practice. By the end, you’ll understand how to use these concepts to create better Python programs. Let’s dive in!
Part 2: Inheritance
What is Inheritance?
Inheritance is like passing down traits from parents to children. In Python, it allows one class (called a subclass or derived class) to inherit attributes and methods from another class (called a superclass or base class). This means you can reuse code from the superclass and extend or modify it in the subclass without rewriting everything.
For example, if you have a general Animal class with basic behaviors, a Dog class can inherit from Animal and add or change behaviors specific to dogs.
Part 2a: Key Concepts of Inheritance
Creating a Subclass
To create a subclass, you define a new class and put the name of the superclass in parentheses.
Here’s an example:
class Animal:
def speak(self):
print("Some sound")
class Dog(Animal):
def speak(self):
print("Woof!")
# Create a Dog object
dog = Dog()
dog.speak() # Output: Woof!
In this example:
-
Animal is the superclass with a generic speak method.
-
Dog is the subclass that overrides the speak method to make a dog-specific sound.
-
When we call dog.speak(), Python uses the Dog class’s version of speak.
Using super()
The super() function lets a subclass call methods or the constructor (__init__) from its superclass. This is useful when you want to extend the superclass’s behavior instead of replacing it entirely.
Example:
class Animal:
def __init__(self, name):
self.name = name
def speak(self):
return f"{self.name} makes a sound"
class Dog(Animal):
def __init__(self, name, breed):
super().__init__(name) # Call the superclass's __init__
self.breed = breed # Add a new attribute
def speak(self):
return f"{self.name} the {self.breed} says Woof!"
dog = Dog("Buddy", "Golden Retriever")
print(dog.speak()) # Output: Buddy the Golden Retriever says Woof!
Here:
-
Dog inherits from Animal and uses super().__init__(name) to set the name attribute from the Animal class.
-
Dog adds its own breed attribute and overrides the speak method.
Single Inheritance vs. Multiple Inheritance
-
Single Inheritance: A subclass inherits from one superclass, as in the Dog and Animal example above.
-
Multiple Inheritance: A subclass inherits from multiple superclasses. This is more complex but powerful.
Example of multiple inheritance:
class Flyer:
def fly(self):
return "Flying high!"
class Swimmer:
def swim(self):
return "Swimming fast!"
class Duck(Animal, Flyer, Swimmer):
def __init__(self, name):
super().__init__(name)
duck = Duck("Daffy")
print(duck.speak()) # Output: Daffy makes a sound
print(duck.fly()) # Output: Flying high!
print(duck.swim()) # Output: Swimming fast!
Here, Duck inherits from Animal, Flyer, and Swimmer, so it can use methods from all three classes.
Method Resolution Order (MRO)
When using multiple inheritance, Python needs to decide which superclass’s method to use if there’s a name conflict. This is determined by the Method Resolution Order (MRO), which Python computes using the C3 linearization algorithm. You can view a class’s MRO using the __mro__ attribute or the mro() method.
Example:
print(Duck.__mro__)
# Output: (<class '__main__.Duck'>, <class '__main__.Animal'>, <class '__main__.Flyer'>, <class '__main__.Swimmer'>, <class 'object'>)
This shows the order Python checks for methods: Duck → Animal → Flyer → Swimmer → object (the base class of all classes in Python).
Part 2b: Practice with Inheritance
Let’s create a Shape class with an area method and subclasses Circle and Rectangle that override it. We’ll also create an ElectricCar class that inherits from a Car class.
Shape and Subclasses
import math
class Shape:
def area(self):
return 0 # Default area for a generic shape
class Circle(Shape):
def __init__(self, radius):
self.radius = radius
def area(self):
return math.pi * 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
# Test the classes
circle = Circle(5)
rectangle = Rectangle(4, 6)
print(f"Circle area: {circle.area():.2f}") # Output: Circle area: 78.54
print(f"Rectangle area: {rectangle.area()}") # Output: Rectangle area: 24
Here:
-
Shape is the superclass with a default area method.
-
Circle and Rectangle override the area method with their own calculations.
-
Each subclass has its own __init__ to set specific attributes (radius for Circle, width and height for Rectangle).
ElectricCar Inheriting from Car
class Car:
wheels = 4 # Class attribute
def __init__(self, brand, model):
self.brand = brand
self.model = model
def drive(self):
return f"The {self.brand} {self.model} is driving on {self.wheels} wheels!"
class ElectricCar(Car):
def __init__(self, brand, model, battery_capacity):
super().__init__(brand, model)
self.battery_capacity = battery_capacity
def drive(self):
return f"The {self.brand} {self.model} is driving silently on {self.wheels} wheels with a {self.battery_capacity} kWh battery!"
# Test the classes
car = Car("Toyota", "Corolla")
electric_car = ElectricCar("Tesla", "Model 3", 75)
print(car.drive()) # Output: The Toyota Corolla is driving on 4 wheels!
print(electric_car.drive()) # Output: The Tesla Model 3 is driving silently on 4 wheels with a 75 kWh battery!
Here:
-
ElectricCar inherits from Car and uses super() to call Car’s __init__.
-
It adds a battery_capacity attribute and overrides the drive method to include electric-specific details.
Part 3: Polymorphism
What is Polymorphism?
Polymorphism means “many forms.” In Python, it allows different classes to be treated the same way if they share the same method names. This makes your code flexible because you can write functions that work with objects of different classes, as long as they have the required methods.
Part 3a: Aspects of Polymorphism
Overriding
Overriding happens when a subclass provides its own version of a method defined in the superclass. We saw this in the Dog and Shape examples, where speak and area were redefined in the subclasses.
Overloading
Python doesn’t support traditional method overloading (defining multiple methods with the same name but different parameters, like in Java). Instead, you can use default arguments or the functools.singledispatch decorator to achieve similar functionality.
Example with default arguments:
class Calculator:
def add(self, a, b, c=0):
return a + b + c
calc = Calculator()
print(calc.add(2, 3)) # Output: 5
print(calc.add(2, 3, 4)) # Output: 9
Here, the add method can handle two or three arguments thanks to the default value of c.
For more advanced overloading, you can use functools.singledispatch:
from functools import singledispatchmethod
class Processor:
@singledispatchmethod
def process(self, data):
return f"Processing {data}"
@process.register
def _(self, data: int):
return f"Processing integer: {data * 2}"
@process.register
def _(self, data: str):
return f"Processing string: {data.upper()}"
processor = Processor()
print(processor.process(5)) # Output: Processing integer: 10
print(processor.process("hello")) # Output: Processing string: HELLO
Here, process behaves differently based on the type of data.
Duck Typing
Python uses duck typing, which means it doesn’t care about an object’s type as long as it has the required methods or attributes. The saying is: “If it walks like a duck and quacks like a duck, it’s a duck.”
Example:
class Dog:
def speak(self):
return "Woof!"
class Cat:
def speak(self):
return "Meow!"
def make_it_speak(animal):
print(animal.speak())
dog = Dog()
cat = Cat()
make_it_speak(dog) # Output: Woof!
make_it_speak(cat) # Output: Meow!
The make_it_speak function works with any object that has a speak method, regardless of its class.
Abstract Base Classes (ABCs)
An Abstract Base Class (ABC) defines methods that subclasses must implement. You use the abc module to create ABCs, and the @abstractmethod decorator to mark methods that subclasses must override.
Example:
from abc import ABC, abstractmethod
class Payment(ABC):
@abstractmethod
def pay(self, amount):
pass
class CreditCardPayment(Payment):
def pay(self, amount):
return f"Paid {amount} using Credit Card"
class PayPalPayment(Payment):
def pay(self, amount):
return f"Paid {amount} using PayPal"
# Function to process payment (polymorphism)
def process_payment(payment: Payment, amount):
print(payment.pay(amount))
# Test the classes
credit_payment = CreditCardPayment()
paypal_payment = PayPalPayment()
process_payment(credit_payment, 100) # Output: Paid 100 using Credit Card
process_payment(paypal_payment, 50) # Output: Paid 50 using PayPal
Here:
-
Payment is an abstract base class with an abstract pay method.
-
Subclasses CreditCardPayment and PayPalPayment must implement pay.
-
The process_payment function works with any Payment subclass, demonstrating polymorphism.
Trying to instantiate Payment directly or a subclass without implementing pay will raise an error:
# This will raise TypeError: Can't instantiate abstract class Payment
payment = Payment()
Part 3b: Practice with Polymorphism
Let’s implement the Payment example fully:
from abc import ABC, abstractmethod
class Payment(ABC):
@abstractmethod
def pay(self, amount):
pass
class CreditCardPayment(Payment):
def pay(self, amount):
return f"Paid {amount} using Credit Card"
class PayPalPayment(Payment):
def pay(self, amount):
return f"Paid {amount} using PayPal"
def process_payment(payment: Payment, amount):
print(payment.pay(amount))
# Test the polymorphic behavior
credit_payment = CreditCardPayment()
paypal_payment = PayPalPayment()
process_payment(credit_payment, 100) # Output: Paid 100 using Credit Card
process_payment(paypal_payment, 50) # Output: Paid 50 using PayPal
This shows how polymorphism allows process_payment to work with different payment types, as long as they implement the pay method.
Part 4: Related Concepts
Encapsulation
Encapsulation is about bundling data (attributes) and methods together in a class and controlling access to them. It helps protect an object’s data from being modified in unexpected ways.
Protected and Private Attributes
Python uses naming conventions to indicate access levels:
-
Protected: Prefix an attribute with a single underscore (_attr). This is a hint to other developers not to access it directly, but it’s not enforced.
-
Private: Prefix an attribute with double underscores (__attr). Python mangles the name to make it harder to access from outside the class.
Example:
class Car:
def __init__(self, brand, model):
self.brand = brand # Public
self._fuel = 100 # Protected
self.__engine_status = "off" # Private
def start_engine(self):
self.__engine_status = "on"
return f"Engine is {self.__engine_status}"
car = Car("Toyota", "Corolla")
print(car.brand) # Output: Toyota
print(car._fuel) # Output: 100 (but should avoid accessing)
# print(car.__engine_status) # Error: AttributeError
print(car.start_engine()) # Output: Engine is on
Here:
-
brand is public and can be accessed directly.
-
_fuel is protected, so you should avoid accessing it directly (though you can).
-
__engine_status is private, and Python mangles its name (e.g., to _Car__engine_status), making it inaccessible from outside.
Getter and Setter with @property
To control access to attributes, you can use the @property decorator to create getters and setters.
Example:
class Car:
def __init__(self, brand, model):
self.brand = brand
self._fuel = 100
@property
def fuel(self):
return self._fuel
@fuel.setter
def fuel(self, value):
if value < 0 or value > 100:
raise ValueError("Fuel must be between 0 and 100")
self._fuel = value
car = Car("Toyota", "Corolla")
print(car.fuel) # Output: 100
car.fuel = 50 # Sets fuel to 50
print(car.fuel) # Output: 50
# car.fuel = 150 # Error: ValueError: Fuel must be between 0 and 100
The @property decorator lets you access fuel like an attribute (car.fuel) while controlling how it’s set or retrieved.
Composition
Composition is when one class contains an object of another class, instead of inheriting from it. It’s often used when a “has-a” relationship makes more sense than an “is-a” relationship.
Example:
class Engine:
def __init__(self, horsepower):
self.horsepower = horsepower
def start(self):
return f"Engine with {self.horsepower} horsepower started!"
class Car:
def __init__(self, brand, model, horsepower):
self.brand = brand
self.model = model
self.engine = Engine(horsepower) # Composition
def start(self):
return f"{self.brand} {self.model}: {self.engine.start()}"
car = Car("Toyota", "Corolla", 150)
print(car.start()) # Output: Toyota Corolla: Engine with 150 horsepower started!
Here, Car contains an Engine object (composition) rather than inheriting from Engine. This is useful because a car “has” an engine, not “is” an engine.
Practice Exercises
Exercise 1: Shape Hierarchy
Extend the Shape example by adding a Square subclass and testing polymorphism with a function that calculates areas.
import math
class Shape:
def area(self):
return 0
class Circle(Shape):
def __init__(self, radius):
self.radius = radius
def area(self):
return math.pi * 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
class Square(Shape):
def __init__(self, side):
self.side = side
def area(self):
return self.side ** 2
def print_area(shape):
print(f"Area: {shape.area():.2f}")
# Test polymorphism
circle = Circle(5)
rectangle = Rectangle(4, 6)
square = Square(3)
print_area(circle) # Output: Area: 78.54
print_area(rectangle) # Output: Area: 24.00
print_area(square) # Output: Area: 9.00
Exercise 2: Payment System
We already showed the Payment abstract class example. Try extending it by adding a BankTransferPayment subclass and testing it.
from abc import ABC, abstractmethod
class Payment(ABC):
@abstractmethod
def pay(self, amount):
pass
class CreditCardPayment(Payment):
def pay(self, amount):
return f"Paid {amount} using Credit Card"
class PayPalPayment(Payment):
def pay(self, amount):
return f"Paid {amount} using PayPal"
class BankTransferPayment(Payment):
def pay(self, amount):
return f"Paid {amount} using Bank Transfer"
def process_payment(payment: Payment, amount):
print(payment.pay(amount))
# Test the classes
credit_payment = CreditCardPayment()
paypal_payment = PayPalPayment()
bank_payment = BankTransferPayment()
process_payment(credit_payment, 100) # Output: Paid 100 using Credit Card
process_payment(paypal_payment, 50) # Output: Paid 50 using PayPal
process_payment(bank_payment, 75) # Output: Paid 75 using Bank Transfer
Exercise 3: Encapsulation and Composition
Create a Car class with a private __fuel attribute, a getter/setter for fuel, and an Engine class used via composition.
class Engine:
def __init__(self, horsepower):
self.horsepower = horsepower
def start(self):
return f"Engine with {self.horsepower} horsepower started!"
class Car:
def __init__(self, brand, model, horsepower):
self.brand = brand
self.model = model
self.__fuel = 100 # Private attribute
self.engine = Engine(horsepower) # Composition
@property
def fuel(self):
return self.__fuel
@fuel.setter
def fuel(self, value):
if value < 0 or value > 100:
raise ValueError("Fuel must be between 0 and 100")
self.__fuel = value
def drive(self):
return f"{self.brand} {self.model} is driving with {self.fuel}% fuel. {self.engine.start()}"
car = Car("Tesla", "Model 3", 300)
print(car.drive()) # Output: Tesla Model 3 is driving with 100% fuel. Engine with 300 horsepower started!
car.fuel = 80
print(car.fuel) # Output: 80
# car.fuel = 150 # Error: ValueError
Conclusion
Inheritance, polymorphism, and encapsulation are core concepts in Python’s object-oriented programming. Inheritance lets you reuse and extend code by creating subclasses that build on superclasses. Polymorphism allows different classes to be treated uniformly if they share method names, using techniques like overriding, duck typing, or abstract base classes. Encapsulation protects data with access control, and composition provides an alternative to inheritance for “has-a” relationships.
By practicing with the Shape, Payment, and Car examples, you’ll see how these concepts work together to create flexible, maintainable code. Start by creating simple classes and experimenting with inheritance and polymorphism. Try using @property for encapsulation and composition for complex objects. With time, these concepts will become second nature, and you’ll be building robust Python programs like a pro!
Leave a Reply