A legendary conundrum of object oriented design.
Mathematicians tell us that a circle is a special kind of ellipse, one
whose eccentricity is 0, and whose foci coincide.
When we're designing object-oriented systems, the phrase "A is a kind
of B" is supposed to ring all sorts of warning bells telling us that A
should inherit from B.
Yet, if we go ahead and make classes Circle and Ellipse,
where Circle inherits from Ellipse, we
could be letting ourselves in for all sorts of problems.
Consider the following C++ declarations:
class Ellipse
{
private:
double f1x;
double f1y;
double f2x;
double f2y;
double k;
public:
Ellipse (double ifx1, double ify1, double ifx2, double ify2, double ik);
Ellipse (Ellipse const &ie);
virtual void draw_me (drawing_surface &surf);
virtual void get_foci (double &f1x, double &f1y, dobule &f2x, double &f2y) const;
virtual double eccentricity() const;
virtual void translate (double ncx, double ncy);
virtual void rotate (double radians);
virtual void affine (double a, double b, double c, double d, double e, double f);
virtual ~Ellipse() {}
};
class Circle: public Ellipse
{
public:
Circle (double icx, double icy, double radius);
Circle (Circle const &);
virtual void draw_me (drawing_surface &surf); // does something more efficient?
virtual double eccentricity() { return 0.0; }
virtual void rotate (double radians); { /* no-op and no need to redraw */ }
virtual void affine (double a, double b, double c, double d, double e, double f); // Uh-oh...
};
Circle has a special constraint on it, that its foci
shoud coincide (that is, f1x == f2 x && f1y == f2y).
Yet this constraint is not invariant under affine transformations. Any
implementation of Ellipse::affine is likely to break any
Circle it's performed on. Yet affine is
a reasonable operation on the general run of ellipse, and it always results
in another ellipse!
What can you do?
-
Punt, and let Ellipse::affine break Circles.
This isn't nice to do to people using your code. But of course, it may
do the job.
-
Forget about Ellipse::affine. But this may be the
most important thing your Ellipse can do.
-
Forget about making Circle inherit from Ellipse,
that is, code two separate classes. Of course, Circle
won't be able to do the things Ellipse can any more.
-
Forget about Circle altogether, and let Ellipse
do all the work. Your customer will then take a micrometer to any
printed diagrams using Ellipse and go ballistic when rounding
errors make them go slightly out of shape.
-
Use the envelope / letter idiom: an abstract class AEllipse
without affine, concrete classes Ellipse
(with affine) and Circle that inherit from
AEllipse, as well as a smart pointer type that inherits
from AEllipse, and can be set to either Ellipse
or Circle. This is extremely complicated and will make
you finish a year late and two million dollars over budget. You may
be lucky enough to have a boss enlightened enough to realize that this
was the salesman's fault for lowballing the bid. Who knows?
Of course, this problem is not restricted to these particular geometric
shapes. In fact, it is a very common occurrence. Any time you have:
-
A more general class of possible objects
-
A more specialized class of objects, a subset of the more general class
based upon a more restrictive invariant
-
A reasonable operation on the more general class which breaks the more
specialized class
this problem is likely to crop up. I'm sure you can think of dozens
of cases.
So what is a designer to do? Notice that all of the examples
above involved different expectations by the customer. The
answer is, then,
-
Design before you start coding. Make sure your design meets your
customer's requirements. Don't let anyone distract you from this goal by telling you to do it "the right way". If you meet your design goals, you are doing it "the right way", by definition!
-
Make sure a code library meets your design requirements before you buy
it, even if it looks like it solves your problem.
-
CYA. Make sure that someone higher up, or even the customer, signs
off on your design decisions.