Python - Abstract Classes (ABC)

Abstract Base Classes provide a way to define interfaces when you want to enforce that derived classes implement particular methods. Unlike informal interfaces relying on duck typing, ABCs make...

Key Insights

  • Abstract Base Classes (ABCs) enforce interface contracts in Python, preventing instantiation of incomplete classes and ensuring subclasses implement required methods through the abc module.
  • ABCs support both abstract methods (must be implemented) and concrete methods (inherited by default), along with abstract properties, class methods, and static methods for comprehensive interface design.
  • Use ABCs when building frameworks, plugin systems, or any architecture requiring guaranteed interfaces across multiple implementations—they catch missing implementations at instantiation time rather than runtime.

Understanding Abstract Base Classes

Abstract Base Classes provide a way to define interfaces when you want to enforce that derived classes implement particular methods. Unlike informal interfaces relying on duck typing, ABCs make contracts explicit and fail fast when implementations are incomplete.

from abc import ABC, abstractmethod

class PaymentProcessor(ABC):
    @abstractmethod
    def process_payment(self, amount: float) -> bool:
        pass
    
    @abstractmethod
    def refund_payment(self, transaction_id: str) -> bool:
        pass

# This will raise TypeError: Can't instantiate abstract class
# processor = PaymentProcessor()

The ABC class serves as a metaclass helper. Alternatively, use ABCMeta directly:

from abc import ABCMeta, abstractmethod

class PaymentProcessor(metaclass=ABCMeta):
    @abstractmethod
    def process_payment(self, amount: float) -> bool:
        pass

Both approaches are equivalent; ABC is syntactic sugar for metaclass=ABCMeta.

Implementing Abstract Classes

Subclasses must implement all abstract methods before instantiation:

class StripeProcessor(PaymentProcessor):
    def __init__(self, api_key: str):
        self.api_key = api_key
    
    def process_payment(self, amount: float) -> bool:
        print(f"Processing ${amount} via Stripe")
        # Actual Stripe API integration here
        return True
    
    def refund_payment(self, transaction_id: str) -> bool:
        print(f"Refunding transaction {transaction_id}")
        return True

# This works - all abstract methods implemented
processor = StripeProcessor("sk_test_123")
processor.process_payment(99.99)

Missing even one abstract method prevents instantiation:

class IncompleteProcessor(PaymentProcessor):
    def process_payment(self, amount: float) -> bool:
        return True
    # Missing refund_payment implementation

# TypeError: Can't instantiate abstract class IncompleteProcessor
# with abstract method refund_payment
# processor = IncompleteProcessor()

Mixing Abstract and Concrete Methods

ABCs can provide default implementations alongside abstract requirements:

from abc import ABC, abstractmethod
from datetime import datetime
from typing import Dict, Any

class DataStore(ABC):
    @abstractmethod
    def save(self, key: str, value: Any) -> None:
        pass
    
    @abstractmethod
    def load(self, key: str) -> Any:
        pass
    
    # Concrete method with default implementation
    def save_with_timestamp(self, key: str, value: Any) -> None:
        timestamped_value = {
            'data': value,
            'timestamp': datetime.now().isoformat()
        }
        self.save(key, timestamped_value)
    
    # Concrete method that uses abstract methods
    def exists(self, key: str) -> bool:
        try:
            self.load(key)
            return True
        except KeyError:
            return False

class RedisStore(DataStore):
    def __init__(self):
        self._store: Dict[str, Any] = {}
    
    def save(self, key: str, value: Any) -> None:
        self._store[key] = value
    
    def load(self, key: str) -> Any:
        return self._store[key]

store = RedisStore()
store.save_with_timestamp('user:123', {'name': 'Alice'})
print(store.exists('user:123'))  # True

This pattern allows you to build common functionality in the base class while enforcing implementation of critical operations.

Abstract Properties

Properties can also be abstract, enforcing that subclasses provide specific attributes:

from abc import ABC, abstractmethod

class DatabaseConnection(ABC):
    @property
    @abstractmethod
    def connection_string(self) -> str:
        pass
    
    @property
    @abstractmethod
    def is_connected(self) -> bool:
        pass
    
    @abstractmethod
    def execute(self, query: str) -> Any:
        pass

class PostgresConnection(DatabaseConnection):
    def __init__(self, host: str, database: str):
        self._host = host
        self._database = database
        self._connected = False
    
    @property
    def connection_string(self) -> str:
        return f"postgresql://{self._host}/{self._database}"
    
    @property
    def is_connected(self) -> bool:
        return self._connected
    
    def execute(self, query: str) -> Any:
        if not self.is_connected:
            raise RuntimeError("Not connected")
        return f"Executing: {query}"

db = PostgresConnection("localhost", "mydb")
print(db.connection_string)

Abstract properties can also have setters:

class ConfigurableService(ABC):
    @property
    @abstractmethod
    def timeout(self) -> int:
        pass
    
    @timeout.setter
    @abstractmethod
    def timeout(self, value: int) -> None:
        pass

Abstract Class Methods and Static Methods

Abstract methods work with @classmethod and @staticmethod decorators:

from abc import ABC, abstractmethod

class DocumentParser(ABC):
    @classmethod
    @abstractmethod
    def supported_formats(cls) -> list[str]:
        pass
    
    @staticmethod
    @abstractmethod
    def validate_format(content: str) -> bool:
        pass
    
    @abstractmethod
    def parse(self, content: str) -> Dict[str, Any]:
        pass

class JSONParser(DocumentParser):
    @classmethod
    def supported_formats(cls) -> list[str]:
        return ['.json', '.jsonl']
    
    @staticmethod
    def validate_format(content: str) -> bool:
        return content.strip().startswith(('{', '['))
    
    def parse(self, content: str) -> Dict[str, Any]:
        import json
        return json.loads(content)

print(JSONParser.supported_formats())
print(JSONParser.validate_format('{"key": "value"}'))

Virtual Subclasses and Registration

ABCs support virtual subclasses through registration, allowing classes to be recognized as implementations without inheritance:

from abc import ABC, abstractmethod

class Serializable(ABC):
    @abstractmethod
    def to_dict(self) -> dict:
        pass

class ThirdPartyClass:
    def to_dict(self) -> dict:
        return {'type': 'third_party'}

# Register as virtual subclass
Serializable.register(ThirdPartyClass)

obj = ThirdPartyClass()
print(isinstance(obj, Serializable))  # True
print(issubclass(ThirdPartyClass, Serializable))  # True

Use __subclasshook__ for structural subtyping based on method presence:

class Drawable(ABC):
    @classmethod
    def __subclasshook__(cls, subclass):
        return (hasattr(subclass, 'draw') and 
                callable(subclass.draw))

class Circle:
    def draw(self):
        print("Drawing circle")

class Square:
    pass

print(issubclass(Circle, Drawable))  # True
print(issubclass(Square, Drawable))  # False

Practical Use Case: Plugin System

ABCs excel in plugin architectures where you need guaranteed interfaces:

from abc import ABC, abstractmethod
from typing import List, Dict, Any

class DataExporter(ABC):
    @abstractmethod
    def export(self, data: List[Dict[str, Any]]) -> str:
        pass
    
    @property
    @abstractmethod
    def file_extension(self) -> str:
        pass
    
    def export_to_file(self, data: List[Dict[str, Any]], filename: str) -> None:
        content = self.export(data)
        full_path = f"{filename}.{self.file_extension}"
        with open(full_path, 'w') as f:
            f.write(content)

class CSVExporter(DataExporter):
    @property
    def file_extension(self) -> str:
        return "csv"
    
    def export(self, data: List[Dict[str, Any]]) -> str:
        if not data:
            return ""
        headers = ",".join(data[0].keys())
        rows = [",".join(str(v) for v in row.values()) for row in data]
        return "\n".join([headers] + rows)

class JSONExporter(DataExporter):
    @property
    def file_extension(self) -> str:
        return "json"
    
    def export(self, data: List[Dict[str, Any]]) -> str:
        import json
        return json.dumps(data, indent=2)

# Plugin registry
exporters: Dict[str, type[DataExporter]] = {
    'csv': CSVExporter,
    'json': JSONExporter,
}

def export_data(data: List[Dict[str, Any]], format: str, filename: str):
    exporter_class = exporters.get(format)
    if not exporter_class:
        raise ValueError(f"Unknown format: {format}")
    
    exporter = exporter_class()
    exporter.export_to_file(data, filename)

data = [{'name': 'Alice', 'age': 30}, {'name': 'Bob', 'age': 25}]
export_data(data, 'csv', 'output')
export_data(data, 'json', 'output')

This pattern ensures all exporters implement required methods, making the plugin system robust and maintainable. New exporters simply inherit from DataExporter, and the type system enforces compliance at development time.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.