30.5 Using ABCs for isinstance() Checks
One of the most powerful applications of Abstract Base Classes (ABCs) is their ability to enforce a consistent interface across disparate classes, enabling reliable type checking. While duck typing is a cornerstone of Python’s philosophy, there are scenarios where explicitly checking for the presence of a specific protocol or capability is necessary and safer. The isinstance() function, when used with an ABC, transcends simple class inheritance checks; it verifies that an object adheres to the contract defined by the ABC, even if the object’s class doesn’t explicitly inherit from it.
The Problem with isinstance() and Concrete Classes
Using isinstance() with concrete base classes is often fragile and breaks the principles of polymorphism and duck typing. It tightly couples your code to a specific implementation hierarchy, making it inflexible. For example, checking isinstance(my_sequence, list) would fail for a tuple or a custom sequence class, even if that class perfectly implements the sequence protocol. This forces you to write multiple checks or limits the reusability of your code.
def naive_total_length(sequence):
if isinstance(sequence, list):
return len(sequence)
elif isinstance(sequence, tuple):
return len(sequence)
# ... and so on for other sequence types
else:
raise TypeError("Object is not a supported sequence type")
The Solution: isinstance() with ABCs
ABCs provide a robust alternative. Instead of checking for a concrete type, you check for an abstract type that defines a required behavior. The collections.abc module contains ABCs for common protocols like Container, Iterable, Sequence, and Mapping. Using these with isinstance() allows your function to accept any object that fulfills the contract, making your code more generic and powerful.
from collections.abc import Sequence
def robust_total_length(sequence):
if not isinstance(sequence, Sequence):
raise TypeError("Object must be a Sequence")
return len(sequence)
# This function now works with lists, tuples, ranges, and custom sequences.
print(robust_total_length([1, 2, 3])) # Output: 3
print(robust_total_length((1, 2, 3))) # Output: 3
print(robust_total_length(range(10))) # Output: 10
How Virtual Subclasses Enable Runtime Checks
The magic that makes this work is the concept of virtual subclasses. A class becomes a virtual subclass of an ABC by being registered with it using the register() method or the @ABCMeta.register decorator. Importantly, the ABC’s __subclasshook__ method can also dynamically recognize a class as a subclass based on its methods, without explicit registration. The isinstance() and issubclass() functions consult this hook.
When you write isinstance(obj, MyABC), Python doesn’t just look at the class’s MRO. It also checks if MyABC.__subclasshook__(obj.__class__) returns True. This allows ABCs to recognize classes that implement the required methods, even if they are completely unrelated in the inheritance hierarchy.
from abc import ABC, ABCMeta
class MyCustomSequence(metaclass=ABCMeta):
@classmethod
def __subclasshook__(cls, other_cls):
# Check if the class has a __getitem__ and __len__ method
if cls is MyCustomSequence:
if all(hasattr(other_cls, attr) for attr in ('__getitem__', '__len__')):
return True
return NotImplemented
# This class does NOT inherit from MyCustomSequence but implements the methods.
class MySpecialList:
def __init__(self, data):
self._data = list(data)
def __getitem__(self, index):
return self._data[index]
def __len__(self):
return len(self._data)
# The __subclasshook__ allows isinstance to work.
special_list = MySpecialList([1, 2, 3])
print(isinstance(special_list, MyCustomSequence)) # Output: True
Best Practices and Common Pitfalls
Prefer ABCs over Concrete Types: Always use ABCs from
collections.abc(e.g.,Iterable,Sequence,Mapping) forisinstancechecks in public APIs. This makes your code maximally flexible and future-proof.Check for the Smallest Necessary Interface: Check for the most fundamental ABC that defines the functionality you need. If you only need to iterate over an object, check for
Iterable, notSequence. This allows your function to work with generators and sets, not just indexable sequences.Pitfall: Overuse of isinstance(): Relying on
isinstance()can sometimes be a code smell, indicating a design that fights against duck typing. First, ask if you can just use the object’s methods and embrace an EAFP (Easier to Ask for Forgiveness than Permission) style with try/except blocks. Useisinstance()for validation at system boundaries (e.g., validating function input) or when you need to guarantee a specific set of behaviors.Edge Case: ABCs and Type Annotations: Using ABCs in type hints (e.g.,
def func(arg: Sequence[int]): ...) is complementary to runtimeisinstancechecks. Type checkers like mypy will ensure the passed argument is compatible with the ABC, but runtime checks are still necessary if you need to enforce the type during execution, especially in contexts where type hints are ignored.
from collections.abc import MutableSequence
def process_dynamic_list(data: MutableSequence[str]) -> None:
# A type checker ensures 'data' is compatible with MutableSequence[str]
# For runtime validation, we use isinstance
if not isinstance(data, MutableSequence):
raise TypeError("Must be a mutable sequence of strings")
data.append("validated")
# ... rest of the function logic
# This is correctly validated.
process_dynamic_list([])