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++.
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.
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
).
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
Your Privacy • Copyright © 2006 Qtrac Ltd. All Rights Reserved.