Polymorphism and Abstract Classes
Polymorphism is the ability for objects of different types to respond to the same interface (method name) in their own way. Python's polymorphism is implemented through a unique approach called duck typing.
The Concept of Polymorphism
# Core of polymorphism: same method name, different behavior
class Korean:
def greet(self) -> str:
return "Hello! (Korean)"
class English:
def greet(self) -> str:
return "Hello!"
class Japanese:
def greet(self) -> str:
return "Konnichiwa!"
def say_hello(person) -> None:
"""Works with anything that has greet()"""
print(person.greet())
people = [Korean(), English(), Japanese()]
for person in people:
say_hello(person) # Polymorphism: same call, different results
Duck Typing
"If it walks like a duck and quacks like a duck, then it is a duck."
— James Whitcomb Riley
Python judges objects by their behavior (methods/attributes), not their type. You don't need to inherit from a specific type — you just need the required methods.
class Duck:
def quack(self) -> str:
return "Quack!"
def walk(self) -> str:
return "Waddle waddle"
class Person:
def quack(self) -> str:
return "I'm imitating a duck: Quack!"
def walk(self) -> str:
return "Walking on two feet"
class Robot:
def quack(self) -> str:
return "Beep! (Duck sound simulation)"
def walk(self) -> str:
return "Walking mechanically"
def make_it_quack(duck_like) -> None:
"""Anything with quack() and walk() can be used"""
print(duck_like.quack())
print(duck_like.walk())
print()
for creature in [Duck(), Person(), Robot()]:
make_it_quack(creature)
Duck Typing in Practice
# Process anything that behaves like a file object
import io
def process_text(file_like) -> str:
"""Only needs a read() method — doesn't matter if it's a real file"""
return file_like.read().upper()
# In-memory file (StringIO)
memory_file = io.StringIO("hello world")
print(process_text(memory_file)) # HELLO WORLD
# A custom class that acts like a file
class FakeFile:
def __init__(self, content: str):
self._content = content
def read(self) -> str:
return self._content
fake = FakeFile("python is awesome")
print(process_text(fake)) # PYTHON IS AWESOME
Abstract Classes
An abstract class cannot be instantiated directly and defines methods (abstract methods) that must be implemented by subclasses. Python uses the abc module for this.
from abc import ABC, abstractmethod
class Shape(ABC):
"""Abstract base class — cannot be instantiated directly"""
def __init__(self, color: str = "black"):
self.color = color
@abstractmethod
def area(self) -> float:
"""Must be implemented by subclasses"""
...
@abstractmethod
def perimeter(self) -> float:
"""Must be implemented by subclasses"""
...
# Abstract classes can also have concrete methods
def describe(self) -> str:
return (f"{self.__class__.__name__} | color: {self.color} | "
f"area: {self.area():.2f} | perimeter: {self.perimeter():.2f}")
def scale(self, factor: float) -> None:
raise NotImplementedError(f"{self.__class__.__name__}.scale() not implemented")
# Attempting to instantiate abstract class directly
try:
s = Shape() # TypeError!
except TypeError as e:
print(f"Error: {e}")
Implementing Abstract Methods
import math
class Circle(Shape):
def __init__(self, radius: float, color: str = "black"):
super().__init__(color)
if radius <= 0:
raise ValueError("Radius must be positive.")
self.radius = radius
def area(self) -> float: # Must implement abstract method
return math.pi * self.radius ** 2
def perimeter(self) -> float: # Must implement abstract method
return 2 * math.pi * self.radius
def scale(self, factor: float) -> None:
self.radius *= factor
class Rectangle(Shape):
def __init__(self, width: float, height: float, color: str = "black"):
super().__init__(color)
self.width = width
self.height = height
def area(self) -> float:
return self.width * self.height
def perimeter(self) -> float:
return 2 * (self.width + self.height)
def scale(self, factor: float) -> None:
self.width *= factor
self.height *= factor
class RegularPolygon(Shape):
def __init__(self, sides: int, side_length: float, color: str = "black"):
super().__init__(color)
if sides < 3:
raise ValueError("Number of sides must be at least 3.")
self.sides = sides
self.side_length = side_length
def area(self) -> float:
return (self.sides * self.side_length ** 2) / (4 * math.tan(math.pi / self.sides))
def perimeter(self) -> float:
return self.sides * self.side_length
shapes: list[Shape] = [
Circle(5, "red"),
Rectangle(4, 6, "blue"),
RegularPolygon(6, 3, "green"), # Regular hexagon
]
for shape in shapes:
print(shape.describe())
# Polymorphism: same interface for all Shape subclasses
total_area = sum(s.area() for s in shapes)
largest = max(shapes, key=lambda s: s.area())
print(f"\nLargest shape: {largest.__class__.__name__} (area: {largest.area():.2f})")
ABCMeta and ABC
from abc import ABCMeta, abstractmethod
# Method 1: Use ABCMeta directly as metaclass
class Animal(metaclass=ABCMeta):
@abstractmethod
def speak(self) -> str:
...
# Method 2: Inherit from ABC (recommended, more concise)
class Vehicle(ABC):
@abstractmethod
def move(self) -> str:
...
@classmethod
@abstractmethod
def create_default(cls) -> "Vehicle":
"""Abstract class method"""
...
@staticmethod
@abstractmethod
def fuel_type() -> str:
"""Abstract static method"""
...
@property
@abstractmethod
def speed(self) -> float:
"""Abstract property"""
...
class ElectricCar(Vehicle):
def __init__(self, max_speed: float):
self._speed = max_speed
def move(self) -> str:
return "Driving quietly on electricity."
@classmethod
def create_default(cls) -> "ElectricCar":
return cls(max_speed=150.0)
@staticmethod
def fuel_type() -> str:
return "Electric"
@property
def speed(self) -> float:
return self._speed
car = ElectricCar.create_default()
print(car.move()) # Driving quietly on electricity.
print(car.fuel_type()) # Electric
print(car.speed) # 150.0
Implementing a Plugin System
from abc import ABC, abstractmethod
from typing import ClassVar
class PaymentProcessor(ABC):
"""Abstract payment processor — plugin interface"""
_processors: ClassVar[dict[str, type]] = {}
def __init_subclass__(cls, processor_name: str = "", **kwargs):
super().__init_subclass__(**kwargs)
if processor_name:
PaymentProcessor._processors[processor_name] = cls
@abstractmethod
def process_payment(self, amount: float, currency: str) -> dict:
...
@abstractmethod
def refund(self, transaction_id: str, amount: float) -> dict:
...
@classmethod
def get_processor(cls, name: str) -> "PaymentProcessor":
if name not in cls._processors:
available = list(cls._processors.keys())
raise ValueError(f"Unknown processor: {name}. Available: {available}")
return cls._processors[name]()
class StripeProcessor(PaymentProcessor, processor_name="stripe"):
def process_payment(self, amount: float, currency: str) -> dict:
return {
"status": "success",
"processor": "Stripe",
"amount": amount,
"currency": currency,
"transaction_id": f"STR-{hash(amount):x}",
}
def refund(self, transaction_id: str, amount: float) -> dict:
return {"status": "refunded", "transaction_id": transaction_id, "amount": amount}
class PayPalProcessor(PaymentProcessor, processor_name="paypal"):
def process_payment(self, amount: float, currency: str) -> dict:
return {
"status": "success",
"processor": "PayPal",
"amount": amount,
"currency": currency,
"transaction_id": f"PP-{hash(amount):x}",
}
def refund(self, transaction_id: str, amount: float) -> dict:
return {"status": "refunded", "transaction_id": transaction_id, "amount": amount}
# Select processor at runtime
for processor_name in ["stripe", "paypal"]:
processor = PaymentProcessor.get_processor(processor_name)
result = processor.process_payment(100.0, "USD")
print(result)
Abstract Classes vs Interfaces
Python has no interface keyword like Java. Instead, interfaces are expressed in two ways.
# Method 1: ABC with only abstract methods (used as interface)
class Drawable(ABC):
@abstractmethod
def draw(self) -> str: ...
@abstractmethod
def resize(self, factor: float) -> None: ...
class Saveable(ABC):
@abstractmethod
def save(self, path: str) -> bool: ...
@abstractmethod
def load(self, path: str) -> bool: ...
# Implementing interfaces via multiple abstract class inheritance
class SVGImage(Drawable, Saveable):
def __init__(self, content: str):
self.content = content
def draw(self) -> str:
return f"SVG render: {self.content[:20]}..."
def resize(self, factor: float) -> None:
print(f"Resize SVG by {factor}x")
def save(self, path: str) -> bool:
print(f"Save to {path}")
return True
def load(self, path: str) -> bool:
print(f"Load from {path}")
return True
# Method 2: Protocol (Python 3.8+) — covered in the next chapter
Practical Example: 2D Shape Class Hierarchy
from abc import ABC, abstractmethod
import math
from typing import Iterator
class Shape2D(ABC):
"""Abstract base class for 2D shapes"""
def __init__(self, x: float = 0.0, y: float = 0.0):
self.x = x # Center x coordinate
self.y = y # Center y coordinate
@abstractmethod
def area(self) -> float: ...
@abstractmethod
def perimeter(self) -> float: ...
@abstractmethod
def contains(self, px: float, py: float) -> bool:
"""Whether point (px, py) is inside the shape"""
...
def distance_to(self, other: "Shape2D") -> float:
"""Distance between centers of two shapes"""
return math.sqrt((self.x - other.x) ** 2 + (self.y - other.y) ** 2)
def __lt__(self, other: "Shape2D") -> bool:
return self.area() < other.area()
def __eq__(self, other: object) -> bool:
if not isinstance(other, Shape2D):
return NotImplemented
return abs(self.area() - other.area()) < 1e-9
def __repr__(self) -> str:
return f"{self.__class__.__name__}(center=({self.x}, {self.y}), area={self.area():.2f})"
class Circle(Shape2D):
def __init__(self, cx: float, cy: float, radius: float):
super().__init__(cx, cy)
self.radius = radius
def area(self) -> float:
return math.pi * self.radius ** 2
def perimeter(self) -> float:
return 2 * math.pi * self.radius
def contains(self, px: float, py: float) -> bool:
return math.sqrt((px - self.x) ** 2 + (py - self.y) ** 2) <= self.radius
class Rect(Shape2D):
def __init__(self, cx: float, cy: float, width: float, height: float):
super().__init__(cx, cy)
self.width = width
self.height = height
def area(self) -> float:
return self.width * self.height
def perimeter(self) -> float:
return 2 * (self.width + self.height)
def contains(self, px: float, py: float) -> bool:
return (abs(px - self.x) <= self.width / 2 and
abs(py - self.y) <= self.height / 2)
class Canvas:
def __init__(self, width: float, height: float):
self.width = width
self.height = height
self._shapes: list[Shape2D] = []
def add(self, shape: Shape2D) -> None:
self._shapes.append(shape)
def total_area(self) -> float:
return sum(s.area() for s in self._shapes)
def find_containing(self, px: float, py: float) -> list[Shape2D]:
return [s for s in self._shapes if s.contains(px, py)]
def largest(self) -> Shape2D | None:
return max(self._shapes) if self._shapes else None
def sorted_by_area(self) -> list[Shape2D]:
return sorted(self._shapes)
def __len__(self) -> int:
return len(self._shapes)
def __iter__(self) -> Iterator[Shape2D]:
return iter(self._shapes)
canvas = Canvas(100, 100)
canvas.add(Circle(0, 0, 5))
canvas.add(Rect(10, 10, 8, 6))
canvas.add(Circle(20, 20, 3))
print(f"Shape count: {len(canvas)}")
print(f"Total area: {canvas.total_area():.2f}")
print(f"Largest shape: {canvas.largest()}")
print(f"Sorted by area: {canvas.sorted_by_area()}")
print(f"Shapes containing (0,0): {canvas.find_containing(0, 0)}")
Expert Tips
1. Virtual Subclasses with __subclasshook__
from abc import ABC, abstractmethod
class Printable(ABC):
@abstractmethod
def print(self) -> None:
...
@classmethod
def __subclasshook__(cls, subclass):
"""Recognize any class with a print method as Printable"""
if cls is Printable:
if hasattr(subclass, "print") and callable(subclass.print):
return True
return NotImplemented
class MyDocument:
def print(self) -> None: # Without inheriting Printable
print("Printing document")
print(issubclass(MyDocument, Printable)) # True — thanks to __subclasshook__
print(isinstance(MyDocument(), Printable)) # True
2. Providing Default Implementations in Abstract Classes
class Validator(ABC):
@abstractmethod
def validate(self, value) -> bool:
"""Pure abstract with no default implementation"""
...
def validate_all(self, values: list) -> list[bool]:
"""Concrete method that uses the abstract method"""
return [self.validate(v) for v in values]
def assert_valid(self, value) -> None:
if not self.validate(value):
raise ValueError(f"Validation failed: {value!r}")
Summary
- Polymorphism: same interface, different implementations — Python's core strength
- Duck typing: judge by behavior, not type — minimize
isinstance()checks - Abstract classes (ABC): enforce interfaces + share concrete methods
@abstractmethod: force subclass implementation- Abstract class vs Protocol: ABC requires explicit inheritance, Protocol uses structural subtyping (next chapter)