The Two Sides of Classes
Class inheritance either adds new attributes to a class, or changes existing attributes. However, this distinction is not made in Python's class system. This recipe is a stab at providing that separation. It takes advantage of metaclasses and class decorators to do it.
For this recipe the two sides of class inheritance are called mixins and overlays. Mixins add new attributes to a class. Overlays change existing attributes, which in the case of methods means changing the behavior of the methods. However, overlays do not add any attributes.
Separation of Concerns
Another issue with class inheritance is that abstraction rarely breaks down into perfect trees with a clear separation of concerns. Python allows multiple inheritance, which can help, but requires cooperation between classes in the diamond hierarchy. Mixins and overlays help with this problem. Hopefully, that will be evidenced by the recipe and subsequent examples.
With this approach, the main single inheritance line can focus on the core abstraction and mixins/overlays can be used to extend the classes in other directions. This does not solve all the problems regarding separation of concerns, but it solves some. I hope to address the rest in another recipe that centers around delegation through a component architecture on instances.
Interfaces
Since Python 2.6 we have had Abstract Base Classes providing a mechanism for promising what interfaces an object provides. I'll show in one of the examples how an ABC can be split into a interface portion and a mixin portion. The recipe takes advantage of the ABC functionality in the Python type system. I expect that mixins and overlays would be a good fit with one of my other recipes (Recipe 577711).
The Recipe Classes
This recipe provides a standard approach to applying mixin classes without using inheritance. This is done through a metaclass. The metaclass builds a __mixins__ attribute on the class and provides a mixes_in class decorator. Applying that decorator to the class will add the attributes in __mixins__ to the decorated class. However, if any of those names are already bound on the name then it will fail. The decorator returns the modified class.
Note: Traditional mixins are typically done through multiple inheritance, as opposed to class decorators.
This recipe also provides a companion to mixin classes, called overlays. This is done through a metaclass in exactly the same way as the mixins, but provides an __overlays__ attribute and a class decorator called overlays. In contrast to mixins, if an overlay attribute is missing on the decorated class, it will fail. This is because the decorator will return a new class that inherits from the decorated class, and overrides the attributes in __overlays__.
Note: Metaclasses are used here because we need to pull from the class namespace there. A class decorator does not afford us the same functionality without more complexity.
Note: This recipe should work fine in 2.7 with a switch to the __metaclass__ syntax.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 | from collections.abc import ABCMeta, abstractmethod
class MixinError(TypeError): pass
class MixinMeta(type):
"""Build classes that easily mix in to other classes."""
def __new__(self, name, bases, namespace):
cls = super(MixinMeta, self).__new__(self, name, bases, namespace)
if "__mixins__" not in namespace:
cls.__mixins__ = tuple(name for name in namespace if name != "__module__")
return cls
def mixes_in(cls, target):
"""Class decorator to add the __mixins__ of cls into target.
If any mixin attribute's name is already bound on the target,
raise a MixinError.
The target is returned, modified.
"""
# check for all mixin attrs before adding
for attr in cls.__mixins__:
if hasattr(target, attr):
raise MixinError("Attribute already exists: %s" % attr)
for attr in cls.__mixins__:
setattr(target, attr, getattr(cls, attr))
return cls.register(target)
class OverlayError(TypeError): pass
class OverlayMeta(type):
"""Build classes that easily wrap the methods of other classes."""
def __new__(self, name, bases, namespace):
cls = super(OverlayMeta, self).__new__(self, name, bases, namespace)
cls.__overlays__ = tuple(name for name in namespace if name != "__module__")
return cls
def overlays(cls, target):
"""Class decorator to wrap the target's methods.
If any overlay attribute's name is not bound on the target,
raise an OverlayError.
The target is used as the base class for a new class, which is
returned.
"""
# check for all overlay attrs before adding
for attr in cls.__overlays__:
if not hasattr(target, attr):
raise OverlayError("Expected attribute: %s" % attr)
class Temp(target):
__doc__ = target.__doc__
for attr in cls.__overlays__:
locals()[attr] = getattr(cls, attr)
del attr
Temp.__name__ = target.__name__
return Temp
|
Examples
Here are some examples of how mixins and overlays work.
The Mapping ABC
You will see here how we can use abstract base classes to define interfaces and then provide overlays to for implementations. Two approaches to the Mapping ABC are provided, along with an example of using them.
Notice how the overlay class provides the implementation for the abstract methods. The mixin class provides the implementation for the non-abstract classes. We do leave __getitem__ abstract though, when implemented in the overlaid class. Also, __len__ and __iter__ are not in the overlay either, so the class is still abstract.
# interfaces
class SizedInterface(metaclass=ABCMeta):
__slots__ = ()
@abstractmethod
def __len__(self): pass
@classmethod
def __subclasshook__(cls, C):
if cls is Sized:
if any("__len__" in B.__dict__ for B in C.__mro__):
return True
return NotImplemented
class IterableInterface(metaclass=ABCMeta):
__slots__ = ()
@abstractmethod
def __len__(self): pass
@classmethod
def __subclasshook__(cls, C):
if cls is Iterable:
if any("__iter__" in B.__dict__ for B in C.__mro__):
return True
return NotImplemented
class ContainerInterface(metaclass=ABCMeta):
__slots__ = ()
@abstractmethod
def __contains__(self, x): pass
@classmethod
def __subclasshook__(cls, C):
if cls is Container:
if any("__contains__" in B.__dict__ for B in C.__mro__):
return True
return NotImplemented
class MappingInterface(SizedInterface, IterableInterface, ContainerInterface):
__slots__ = ()
@abstractmethod
def __getitem__(self, key): pass
@abstractmethod
def get(self, key, default=None): pass
@abstractmethod
def keys(self): pass
@abstractmethod
def items(self): pass
@abstractmethod
def values(self): pass
@abstractmethod
def __eq__(self, other): pass
@abstractmethod
def __new__(self, other): pass
class OtherMappingInterface(SizedInterface, IterableInterface, ContainerInterface):
__slots__ = ()
@abstractmethod
def __getitem__(self, key): pass
Approach 1:
class MappingOverlay(MappingInterface, metaclass=OverlayMeta):
__slots__ = ()
def get(self, key, default=None):
try:
return self[key]
except KeyError:
return default
def __contains__(self, key):
try:
self[key]
except KeyError:
return False
else:
return True
def keys(self):
return KeysView(self)
def items(self):
return ItemsView(self)
def values(self):
return ValuesView(self)
def __eq__(self, other):
if not isinstance(other, Mapping):
return NotImplemented
return dict(self.items()) == dict(other.items())
def __ne__(self, other):
return not (self == other)
@MappingOverlay.overlays
class Mapping(MappingInterface):
__slots__ = ()
@abstractmethod
def __getitem__(self, key):
raise KeyError
class MyMapping(Mapping):
NAMES = ("one", "two", "three")
def __getitem__(self, key):
if key not in self.NAMES:
raise KeyError
return None
def __iter__(self):
return iter(self.NAMES)
def __len__(self):
return len(self.NAMES)
Approach 2:
class Mapping(OtherMappingInterface, metaclass=OverlayMeta):
__slots__ = ()
def __contains__(self, key):
try:
self[key]
except KeyError:
return False
else:
return True
class MappingMixin(OtherMappingInterface, metaclass=MixinMeta):
def get(self, key, default=None):
try:
return self[key]
except KeyError:
return default
def keys(self):
return KeysView(self)
def items(self):
return ItemsView(self)
def values(self):
return ValuesView(self)
def __eq__(self, other):
if not isinstance(other, Mapping):
return NotImplemented
return dict(self.items()) == dict(other.items())
def __ne__(self, other):
return not (self == other)
@Mapping.overlays
@MappingMixin.mixes_in
class MyMapping(OtherMappingInterface):
NAMES = ("one", "two", "three")
def __getitem__(self, key):
if key not in self.NAMES:
raise KeyError
return None
def __iter__(self):
return iter(self.NAMES)
def __len__(self):
return len(self.NAMES)
Approach 3:
class CombinedMeta(OverlayMeta, MixinMeta): pass
class Mapping(OtherMappingInterface, metaclass=CombinedMeta):
__overlays__ = ("__contains__",)
__mixins__ = ("get", "keys", "items", "values", "__eq__", "__ne__")
__slots__ = ()
@abstractmethod
def __getitem__(self, key):
raise KeyError
def __contains__(self, key):
try:
self[key]
except KeyError:
return False
else:
return True
def get(self, key, default=None):
try:
return self[key]
except KeyError:
return default
def keys(self):
return KeysView(self)
def items(self):
return ItemsView(self)
def values(self):
return ValuesView(self)
def __eq__(self, other):
if not isinstance(other, Mapping):
return NotImplemented
return dict(self.items()) == dict(other.items())
def __ne__(self, other):
return not (self == other)
@Mapping.overlays
@Mapping.mixes_in
class MyMapping(OtherMappingInterface):
NAMES = ("one", "two", "three")
def __getitem__(self, key):
if key not in self.NAMES:
return Super().__getitem__(key)
return None
def __iter__(self):
return iter(self.NAMES)
def __len__(self):
return len(self.NAMES)
A Taxonomy of the Universe? or Making Plato Proud or Making Kant Proud
class CombinedMeta(OverlayMeta, MixinMeta): pass
class Eater(metaclass=MixinMeta):
def eat(self, food):
...
def poop(self):
...
class Sleeper(metaclass=MixinMeta):
def go_to_sleep(self):
...
def wake_up(self):
...
class Thinker(metaclass=MixinMeta):
def reasons(self, data):
...
class DeepThinker(metaclass=OverlayMeta):
def reasons(self, data, depth=3):
if not depth:
return data
conclusion = super().reasons(data)
return self.reasons(data+conclusion, depth-1)
@Eater.mixes_in
@Sleeper.mixes_in
@Thinker.mixes_in
class Person(metaclass=CombinedMeta):
__overlays__ = ()
__mixins__ = (
Eater.__mixins__+
Sleeper.__mixins__ +
Thinker.__mixins__)
class Manager:
def delegates(self, task):
...
def decides(self, choice):
...
class Boss(Person, Manager):
# traditional mixin
@Manager.mixes_in
class Parent(Person):
pass
@DeepThinker.overlays
class Philosopher(Person):
pass
One Shape Fits All
Ran out of time tonight...
class Circle:
pass
class Ellipse:
pass