Skip to main content
Advertisement

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)
Advertisement