Inheritance and Polymorphism

Inheritance clicked for me when I was building a content management system and realized I was copying the same methods across different content types. Articles, videos, and images all needed titles, creation dates, and publishing logic, but each had unique behaviors too. That’s when inheritance transformed from an abstract concept into a practical tool for eliminating code duplication while preserving flexibility.

Python’s inheritance model is remarkably flexible compared to languages like Java or C++. You can inherit from multiple classes, override methods selectively, and even modify the inheritance chain at runtime. This flexibility is powerful, but it also means you need to understand the underlying mechanisms to avoid common pitfalls.

Building Your First Inheritance Hierarchy

Let’s start with a practical example that demonstrates the core concepts. I’ll use a media library system because it naturally illustrates how different types of objects can share common behavior while maintaining their unique characteristics:

class MediaItem:
    def __init__(self, title, creator, year):
        self.title = title
        self.creator = creator
        self.year = year
        self.views = 0
    
    def play(self):
        self.views += 1
        return f"Playing {self.title}"
    
    def get_info(self):
        return f"{self.title} by {self.creator} ({self.year})"

class Movie(MediaItem):
    def __init__(self, title, director, year, duration):
        super().__init__(title, director, year)
        self.duration = duration
    
    def play(self):
        result = super().play()  # Call parent method
        return f"{result} - Duration: {self.duration} minutes"

The Movie class inherits all the functionality from MediaItem but adds movie-specific features like duration tracking. This demonstrates the power of inheritance—you can reuse common functionality while extending it for specific needs. The super() function lets us call the parent class’s methods, which is crucial for extending behavior rather than completely replacing it. This approach eliminates code duplication while maintaining the ability to customize behavior for different types of media.

Understanding Method Resolution Order

Python uses a specific algorithm called Method Resolution Order (MRO) to determine which method to call when you have complex inheritance hierarchies. This becomes important when you’re dealing with multiple inheritance:

class Playable:
    def play(self):
        return "Generic playback started"

class Downloadable:
    def play(self):
        return "Playing downloaded content"

class StreamingVideo(Playable, Downloadable):
    def __init__(self, title, url):
        self.title = title
        self.url = url

video = StreamingVideo("Python Tutorial", "https://example.com")
print(video.play())  # "Generic playback started"

The MRO determines that Playable.play() gets called because Playable appears first in the inheritance list. Understanding MRO helps you predict and control method resolution in complex hierarchies. Python uses the C3 linearization algorithm to create a consistent method resolution order that respects the inheritance hierarchy while avoiding ambiguity.

Polymorphism in Action

Polymorphism is where object-oriented programming really shines. The ability to treat different types of objects uniformly, while still getting type-specific behavior, makes your code incredibly flexible. Here’s how it works in practice:

class AudioBook(MediaItem):
    def __init__(self, title, narrator, year):
        super().__init__(title, narrator, year)
        self.current_chapter = 1
    
    def play(self):
        super().play()
        return f"Playing chapter {self.current_chapter} of {self.title}"

class Podcast(MediaItem):
    def __init__(self, title, host, year, episode_number):
        super().__init__(title, host, year)
        self.episode_number = episode_number
    
    def play(self):
        super().play()
        return f"Playing episode {self.episode_number}: {self.title}"

# Polymorphism allows uniform treatment
media_library = [
    Movie("The Matrix", "Wachowski Sisters", 1999, 136),
    AudioBook("Dune", "Scott Brick", 2020),
    Podcast("Python Bytes", "Michael Kennedy", 2023, 350)
]

for item in media_library:
    print(item.play())  # Each type implements play() differently

This polymorphic behavior lets you write code that works with any type of media item without knowing the specific type at compile time. You can add new media types later without changing the existing code that processes the library. This is the essence of the open/closed principle—your code is open for extension but closed for modification.

Advanced Method Overriding Techniques

Sometimes you need more control over method overriding than simple replacement. Python provides several techniques for sophisticated method customization:

class SecureMediaItem(MediaItem):
    def __init__(self, title, creator, year, access_level="public"):
        super().__init__(title, creator, year)
        self.access_level = access_level
        self._access_log = []
    
    def play(self):
        # Add security check before calling parent method
        if not self._check_access():
            return "Access denied"
        
        # Log the access attempt
        from datetime import datetime
        self._access_log.append(datetime.now())
        
        # Call parent method and modify result
        result = super().play()
        return f"[SECURE] {result}"
    
    def _check_access(self):
        # Simplified access control
        return self.access_level == "public"
    
    def get_access_history(self):
        return f"Accessed {len(self._access_log)} times"

This pattern of calling the parent method and then modifying its behavior is incredibly common in real-world applications. You’re extending functionality rather than replacing it entirely, which maintains the contract that other code expects.

Abstract Base Classes and Interface Design

Python’s abc module lets you define abstract base classes that enforce certain methods must be implemented by subclasses. This is particularly useful when you’re designing frameworks or APIs:

from abc import ABC, abstractmethod

class MediaProcessor(ABC):
    @abstractmethod
    def process(self, media_item):
        """Process a media item - must be implemented by subclasses"""
        pass
    
    @abstractmethod
    def get_supported_formats(self):
        """Return list of supported formats"""
        pass
    
    def validate_format(self, format_type):
        """Concrete method available to all subclasses"""
        return format_type in self.get_supported_formats()

class VideoProcessor(MediaProcessor):
    def process(self, media_item):
        if isinstance(media_item, Movie):
            return f"Processing video: {media_item.title}"
        return "Unsupported media type"
    
    def get_supported_formats(self):
        return ["mp4", "avi", "mkv"]

class AudioProcessor(MediaProcessor):
    def process(self, media_item):
        if isinstance(media_item, AudioBook):
            return f"Processing audio: {media_item.title}"
        return "Unsupported media type"
    
    def get_supported_formats(self):
        return ["mp3", "wav", "flac"]

Abstract base classes provide a contract that subclasses must follow, making your code more predictable and easier to maintain. They’re especially valuable in team environments where different developers are implementing different parts of a system.

Composition vs Inheritance

While inheritance is powerful, it’s not always the right solution. Sometimes composition—building objects that contain other objects—provides better flexibility and maintainability. The classic rule is “favor composition over inheritance,” and Python makes both approaches natural:

class MediaMetadata:
    def __init__(self, title, creator, year):
        self.title = title
        self.creator = creator
        self.year = year
        self.tags = []
    
    def add_tag(self, tag):
        if tag not in self.tags:
            self.tags.append(tag)

class PlaybackEngine:
    def __init__(self):
        self.current_position = 0
        self.is_playing = False
    
    def play(self):
        self.is_playing = True
        return "Playback started"
    
    def pause(self):
        self.is_playing = False
        return "Playback paused"

# Composition: MediaPlayer contains other objects
class MediaPlayer:
    def __init__(self, title, creator, year):
        self.metadata = MediaMetadata(title, creator, year)
        self.engine = PlaybackEngine()
        self.playlist = []
    
    def play(self):
        return self.engine.play()
    
    def get_title(self):
        return self.metadata.title

This composition approach gives you more flexibility than inheritance. You can easily swap out different playback engines or metadata systems without changing the core MediaPlayer class.

In our next part, we’ll dive into Python’s special methods (magic methods) that let you customize how your objects behave with built-in operations. You’ll learn how to make your classes work seamlessly with Python’s operators, built-in functions, and language constructs.