Welcome, guest | Sign In | My Account | Store | Cart

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.

Python, 63 lines
 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