Aggregate, Delegate, Mixin, and Decorate

First published in Micro Mart #1424, August 2016

The traditional approach to object-oriented programming is to use inheritance to model is-a relationships (a Car is-a Vehicle), and aggregation to model has-a relationships (a Car has-a Engine). This article will explain the more modern approach, which is to use aggregation (also called composition), whenever possible, and only use inheritance when necessary. Although Python 3 is used for the examples, the ideas apply equally to Python 2, Java, C#, and C++.

Aggregate and Delegate

Many classes are specifically designed to be inherited from, and for these inheritance is the right approach. For example, the Python standard library's html.parser module provides an excellent HTMLParser class made to be used in this way. Here's an example of using inheritance:

class Point:
    def __init__(self, x=0, y=0):
        self.x = x
        self.y = y
    def distance_to_origin(self):
        return math.hypot(self.x, self.y)
    def manhattan_length(self, other):
        return math.fabs(self.x - other.x) + math.fabs(self.y - other.y)

class CircleI(Point):
    def __init__(self, x=0, y=0, radius=1):
        super().__init__(x, y)
        self.radius = radius
    def distance_to_origin(self):
        return super().distance_to_origin()
    def edge_to_origin(self):
        return super().distance_to_origin() - self.radius

The advantage of using inheritance is that users of our subclass can use all the operators and methods that are defined in the base class. So now, if we write circle = CircleI(3, 5, 2), we can access circle.x and circle.y, as well as circle.radius.

But inheritance can be disadvantageous too. For example, Manhattan length doesn't really make sense for a circle, but we get that method even though we don't want it. Similarly, any methods added to Point at a later date will also be available to CircleI whether they make sense for circles or not. Inheritance can also expose us to the risk of accidentally by-passing validation because we get direct rather than mediated access to inherited attributes. We can eliminate these problems by aggregating rather than inheriting, and delegating access to the aggregated instance's attributes. Here is another circle class, this time using aggregation:

class CircleA1:
    def __init__(self, x=0, y=0, radius=1):
        self._point = Point(x, y)
        self.radius = radius
    def distance_to_origin(self):
        return self._point.distance_to_origin()
    def edge_to_origin(self):
        return self._point.distance_to_origin() - self.radius
    @property
    def x(self):
        return self._point.x
    @x.setter
    def x(self, x):
        self._point.x = x
    @property
    def y(self):
        return self._point.y
    @y.setter
    def y(self, y):
        self._point.y = y

Instances of CircleA1 don't have the unwanted manhattan_length() and won't have any other unwanted Point methods that may be added later. Furthermore, because we have to provide delegates to access the aggregated Point's attributes, we can fully control access—for example, we can add circle-specific constraints. Unfortunately, delegating properties takes several lines of code per attribute, and even method delegates require a one-liner as CircleA1's distance_to_origin() and edge_to_origin() methods illustrate. So aggregation provides control, avoids unwanted features, but at the cost of extra code. Here's an alternative that can scalably handle any number of attributes:

class CircleA2:
    def __init__(self, x=0, y=0, radius=1):
        self._point = Point(x, y)
        self.radius = radius
    def distance_to_origin(self):
        return self._point.distance_to_origin()
    def edge_to_origin(self):
        return self._point.distance_to_origin() - self.radius
    def __getattr__(self, name):
        if name in {"x", "y", "distance_to_origin"}:
            return getattr(self._point, name)
    def __setattr__(self, name, value):
        if name in {"x", "y"}:
            setattr(self._point, name, value)
        else:
            super().__setattr__(name, value)

Here, instead of handling each property individually, we delegate accesses to the aggregated point using special methods. Furthermore, we can also delegate methods as we've done here for the Point.distance_to_origin() method. Note that __getattr__() and __setattr__() are not symmetric: __getattr__() is only called when the attribute hasn't been found by other means, so there's no need to call the base class. For delegating readable properties and for methods, __getattr__() is sufficient, since this returns the property's value or the bound method which can then be called. But for writable properties we must also implement __setattr__(). Note also that we don't have to check the names at all—we could simply do the return call since __getattr__ is only called if the named attribute hasn't already been found. (And if the attribute isn't in the delegatee, Python will correctly raise an AttributeError.)

Incidentally, it is possible to add methods to a class using a class decorator that eval()s the methods into existence right after the class is created.

Mixins

There is one kind of inheritance which is often used in conjunction with conventional inheritance or with aggregation: mixin inheritance. A mixin is a class which has no data, only methods. For this reason mixins normally don't have an __init__() and any class that inherits a mixin does not need to use super() to call the mixin's __init__(). In effect, a mixin class provides a means of splitting up the implementation of one class over two or more classes — and allows us to reuse mixins if their functionality makes sense for more than one of our classes. A mixin will often depend on the class that inherits it having particular attributes, and these may need to be added if they aren't already present.

The Python standard library's socketserver module provides a couple of mixin classes to make it easy to create either forking or threading servers.

The point and circle classes shown above all have a distance_to_origin() method, which in the case of the circle classes is either inherited or delegated to the aggregated point. An alternative approach is to create a mixin that provides this method and any others that are common to our classes. For example:

class DistanceToMixin:
    def distance_to_origin(self):
        return math.hypot(self.x, self.y)
    def distance_to(self, other):
        return math.hypot(self.x - other.x, self.y - other.y)

class PointD(DistanceToMixin):
    __slots__ = ("x", "y")
    def __init__(self, x=0, y=0):
        self.x = x
        self.y = y
    def manhattan_length(self, other=None):
        if other is None:
            other = self.__class__() # Point(0, 0)
        return math.fabs(self.x - other.x) + math.fabs(self.y - other.y)

The PointD class gets its distance_to_origin() method and a new distance_to() method from the DistanceToMixin. We can also inherit the mixin in our circle classes, and if we use aggregation there will be no need to provide a distance_to_origin() delegate in CircleA1 or to have "distance_to_origin" in CircleA2's __getattr__() method.

Although Python supports multiple inheritance, this feature is best avoided since it can greatly complicate code and result in subtle bugs (which is why Java doesn't allow it). However, in the case of mixins, because they hold no data, it is safe to multiply inherit as many mixins as we like — and up to one normal class too. For example:

class MoveMixin:
    def move_up(self, distance):
        self.y -= distance
    def move_down(self, distance):
        self.y += distance
    def move_left(self, distance):
        self.x -= distance
    def move_right(self, distance):
        self.x += distance
    def move_by(self, dx, dy):
        self.x += dx
        self.y += dy

class PointDM(DistanceToMixin, MoveMixin):
    __slots__ = ("x", "y")
    def __init__(self, x=0, y=0):
        self.x = x
        self.y = y
    def manhattan_length(self, other=None):
        if other is None:
            other = self.__class__() # Point(0, 0)
        return math.fabs(self.x - other.x) + math.fabs(self.y - other.y)

class CircleA2DM(DistanceToMixin, MoveMixin):
    def __init__(self, x=0, y=0, radius=1):
        self._point = PointDM(x, y)
        self.radius = radius
    def edge_to_origin(self):
        return self._point.distance_to_origin() - self.radius
    def __getattr__(self, name):
	return getattr(self._point, name)
    def __setattr__(self, name, value):
        if name in {"x", "y"}:
            setattr(self._point, name, value)
        else:
            super().__setattr__(name, value)

The PointDM class has its own properties (x and y), its own manhattan_length() method, plus all the DistanceToMixin and MoveMixin methods. Similarly, we can create our circle class to inherit the two mixin classes and thereby acquire all their methods, as CircleA2DM illustrates. Note also that CircleA2DM simply delegates any attribute (readable property or method), to its aggregated PointDM (self._point).

Class Decorators

Mixins are probably the best way to add extra methods to two or more classes. However, thanks to Python's dynamic nature, it is possible to create classes and then add extra features (e.g., methods) to them. Suppose we have some functions like these:

def distance_to_origin(self):
    return math.hypot(self.x, self.y)
def distance_to(self, other):
    return math.hypot(self.x - other.x, self.y - other.y)
def move_up(self, distance):
    self.y -= distance
def move_down(self, distance):
    self.y += distance
def move_left(self, distance):
    self.x -= distance
def move_right(self, distance):
    self.x += distance
def move_by(self, dx, dy):
    self.x += dx
    self.y += dy

They are functions not methods (despite self), because they are declared at the top-level outside of any class. But we can add them as methods to existing classes if we have a suitable class decorator:

@add_methods(distance_to_origin, distance_to, move_up,
             move_down, move_left, move_right, move_by)
class Point2:
    __slots__ = ("x", "y")
    def __init__(self, x=0, y=0):
        self.x = x
        self.y = y
    def manhattan_length(self, other=None):
        if other is None:
            other = self.__class__() # Point(0, 0)
        return math.fabs(self.x - other.x) + math.fabs(self.y - other.y)

@add_methods(distance_to_origin, distance_to, move_up,
             move_down, move_left, move_right, move_by)
class Circle2:
    def __init__(self, x=0, y=0, radius=1):
        self._point = Point2(x, y)
        self.radius = radius
    def edge_to_origin(self):
        return self._point.distance_to_origin() - self.radius
    def __getattr__(self, name):
	return getattr(self._point, name)
    def __setattr__(self, name, value):
        if name in {"x", "y"}:
            setattr(self._point, name, value)
        else:
            super().__setattr__(name, value)

A class decorator takes a class as its sole argument, and returns a new class — usually the original class with some extra features added. This updated or new class completely replaces the original.

def add_methods(*methods):
    def decorator(Class):
        for method in methods:
            setattr(Class, method.__name__, method)
        return Class
    return decorator

The add_methods() function is a function that takes zero or more positional arguments (in this case functions), and returns a class decorator that when applied to a class will add each of the functions as methods to the class. When Python encounters @add_methods, it calls it as a function with the given arguments. Inside the add_methods() function, we create a new function called decorator() which adds the methods to the Class that is passed to the decorator, and at the end returns the modified class. Finally, add_methods() returns the decorator() function it has created. The decorator() function is then called in turn, with the class on the following line (e.g., Point2 or Circle2) as its argument. This class then has the extra methods added to it, after which it replaces the original class.

Python provides rich support for object-oriented programming, making it possible to take full advantage of this paradigm — while also allowing us to program in procedural style (i.e., using plain functions), or any mixture of the two which suits our needs.

For more see Python Programming Tips

Top