Avoiding inheritance
Consider avoiding inheritance
Class inheritance as discussed in the previous chapters, is an often used concept in OOP. It creates a very strong relationship between classes. The construct of the relationship is relatively easy to understand. Class inheritance is highly readable and maintainable. On the other hand, inheritance is also inflexible. Inheritance could easily lead to a large hierarchy of classes with many dependencies, resulting in the yo-yo problem. A complex inheritance graph and the strongly coupled relationships make maintenance, like updating or extending the code, potentially very difficult or even impossible.
In addition, long inheritance chains can have a negative impact on the performance. Trying to access properties on a long prototype chain requires traversing a lot of objects, which has a negative impact on the lookup time. Breaking up long prototype chains can be a way to avoid possible performance problems.
There are many design principles that one can follow to make a class inheritance design more understandable, flexible, and maintainable. Instead of inheritance we can also use composition. Composition usually involves more code duplication and the class relationship may be a little harder to understand, but it is more flexible and not strongly coupled, which means that a lot of the problems associated with inheritance can be avoided. In general, unless there is a good reason to use inheritance, it is better to use composition instead.
In general, the aim of OOP, and of the associated design principles, is to write clear, understandable, flexible, and maintainable programs, rather than writing less code. Requirements change, applications evolve, more and more features need to be added and code needs to be updated. If the software design is complicated and inflexible, maintenance or development itself become time consuming and expensive and maybe even impossible while meeting all requirements.
Liskov Substitution Principle
In this section we will discuss the Liskov substitution principle (LSP). The LSP is one of the five SOLID design principles, intended to make object-oriented designs more understandable, flexible, and maintainable.
The LSP states that instances of a superclass must be replaceable by instances of its subclasses without breaking the code. In other words: instances of subclasses must maintain the same behavior as instances of their superclass.
A famous violation of the Liskov substitution principle is the
circle–ellipse problem (or the square–rectangle problem).
It makes logical sense to let a Circle
class derive from a Ellipse
class.
After all, mathematically a circle is a special case of an ellipse.
Consider the next example. An Ellipse
class that has a method stretchX(x)
that stretches ellipse instances in one direction.
This method may be used in functions throughout the code like for instance in an animation or wherever.
What if we add a Circle
class that extends class Ellipse
?
class Ellipse {
#distX;
#distY;
constructor(x,y) {
this.#distX = x;
this.#distY = y;
}
stretchX(x) {
this.#distX += x;
return [this.#distX, this.#distY];
}
}
class Circle extends Ellipse {
#diameter;
constructor(x) {
super();
this.#diameter = x;
}
}
BTW. Note that Circle
overrides the constructor
:
super()
has no arguments.
Now code will break or will behave in an unexpected way, anywhere stretchX(x)
is called on a circle instance.
The method will return [ NaN, undefined ]
.
An often used solution is to override the LSP violating method: stretchX(x)
in the above example.
We may for instance add stretchX(x) { return [this.#diameter, this.#diameter]; }
to the Circle
class to override stretchX(x)
in the Ellipse
class.
Now the circle will not stretch in one direction if method stretchX(x)
is called on a circle instance; the circle will simply remain an unchanged circle.
This means that for every change of superclass we need to check if we need to adapt the subclass(es).
If, for instance, we add a scale()
method to the Ellipse
class, we probably need to override it in the Circle
class.
In order not to violate the LSP, the overriding method in the subclass needs to accept, at least, the same arguments as the overridden method in the superclass. You cannot call a method, being passed to all arguments, on an instance of the subclass if the overriding method breaks on any of the arguments. In addition, the return value of the overriding method in the subclass needs to be of the same kind, with the same features, complying with the same, or stricter, rules as the return value of the superclass method.
Overriding a method is just one of the possible (less than optimal) solutions to the circle–ellipse problem.
You may, for example, consider to let both Ellipse
and Circle
inherit from a ConicSection
class instead.
In many cases however, the best way to avoid LSP violations (and other problems) is to use composition instead of inheritance. Composition will be explained next.
Composition
Inheritance typically creates an is-a relationship. A Cadillac is a car.
Composition typically creates a has-a relationship. A car has an engine. Composition is one of the fundamental concepts in object-oriented programming. Any inheritance relationship can be converted into a composition relationship. In a composition construct, we could create an object instance of a class inside another class.
class ClassA {}
class ClassB {
#data;
constructor() {
this.#data = new ClassA();
}
}
Let's apply this to the ellipse-circle problem:
class Ellipse {
#distX;
#distY;
constructor(x, y) {
this.#distX = x;
this.#distY = y;
}
stretchX(x) {
this.#distX += x;
return [this.#distX, this.#distY];
}
scale(s) {
return [this.#distX * s, this.#distY * s];
}
}
class Circle {
#circ;
constructor(d) {
this.#circ = new Ellipse(d, d);
}
scale(s) {
return this.#circ.scale(s);
}
}
const myCircle = new Circle(5);
console.log(myCircle.scale(2)); // logs: [ 10, 10 ]
Now Circle
is not a subclass and Ellipse
is not a superclass. They are both separate classes (with a loosely coupled relationship).
The Circle
class now has access to the functionality of scale()
without inheriting it from the Ellipse
class.
Calling method scale()
on a circle instance calls method scale()
on private property #circ
being an ellipse instance.
Unlike with inheritance, it is not possible to call the stretchX(x)
method on a Circle
instance; it does not have such method
(or more accurate: the method is not accessible due to encapsulation).
We do not have to override stretchX(x)
. Changing scale(s)
in the Ellipse
class will not easily break the Circle
class.
And adding methods to Ellipse
will not affect the Circle
class.
Another example
Consider the situation where we want to define classes of different animals characterized by their abilities to move forward in certain ways.
We may think of creating a superclass Animal
containing methods like walk
, fly
, swim
, etc.
and let all the different animals extend this superclass.
But then subclass Dog
will inherit method fly
, which is obviously not what we intended.
It violates the LSP if calling fly
on a Dog
instance will break the code.
It also (kind of) violates an other SOLID principle:
the interface segregation principle, stating that
no code should be forced to depend on methods it does not use (later more about this).
And what if we started off with an Animal
class containing only walk
and fly
methods.
And later we want to create a Fish
class extending Animal
. Now we need to modify Animal
by adding
a swim
method. But what if this superclass is part of a library, and we cannot change it? It actually violates an other SOLID principle;
the open-Closed principle, stating that
classes should be open for extension, but closed for modification.
Creating separate animal classes instead, with each containing the appropriate move forward methods,
means we will have to chance the swim
method on all animals that can swim, if we want to improve or add something to this method.
We will have multiple methods that essentially do the same thing, potentially resulting in a lot of code to update when things need to change.
So, let's build this application using composition instead of inheritance:
class CanWalk {
walk() { return "Step step" }
}
class CanFly {
fly() { return "Flap flap" }
}
class CanSwim {
swim() { return "Splash splash" }
}
// ***
class Dog {
#canWalk;
#canSwim;
constructor() {
this.#canWalk = new CanWalk();
this.#canSwim = new CanSwim();
}
walk() {
return this.#canWalk.walk();
}
swim() {
return this.#canSwim.swim();
}
}
const lassie = new Dog;
console.log(lassie.walk()); // logs: "Step step"
console.log(lassie.fly()); // TypeError: lassie.fly is not a function
Lassie has-an ability to walk and to swim instead of Lassie is-an animal that inherits abilities from an Animal
class.
The move ahead classes are reusable as we can use the CanFly
class on as many animals as we want.
We have split a class containing all methods into smaller and more specific classes. We have segregated methods from each other. This is the basic idea of the Interface Segregation Principle, although this example did not involve interfaces (JavaScript lacks support for interfaces).
In JavaScript we can simplify the above example as shown in the example below, although this is technically not class composition in the sense that it creates loosely coupled relationships between classes.
function walk() { return "Step step" };
function fly() { return "Flap flap" };
function swim() { return "Splash splash" };
// ***
class Dog {}
Dog.prototype.walk = walk;
Dog.prototype.swim = swim;
const lassie = new Dog;
console.log(lassie.walk()); // logs: "Step step"
console.log(lassie.fly()); // TypeError: lassie.fly is not a function
BTW. This is also an example of abstraction, as described earlier.
BTW. Re-assigning the prototype
is bad but possible with constructor functions.
With a class this will throw a TypeError
.
Mixins
In JavaScript there is no common pattern or special construct for mixins. There is not even a unambiguous common idea of what a mixin is. Proposed patterns for mixins in JavaScript often basically are composition patterns. An often proposed pattern is as follows:
const moveForward = {
walking() { return "Step step" },
flying() { return "Flap flap" },
swimming() { return "Splash splash" },
};
class Dog {
walk() { return this.walking() }
swim() { return this.swimming() }
};
Object.assign(Dog.prototype, moveForward);
const lassie = new Dog;
console.log(lassie.walk()); // logs: "Step step"
console.log(lassie.fly()); // TypeError: lassie.fly is not a function
Or:
const walk = {walk() { return "Step step" }};
const fly = {fly() { return "Flap flap" }};
const swim = {swim() { return "Splash splash" }};
class Dog {
walk() { return this.walk() }
swim() { return this.swim() }
};
Object.assign(Dog.prototype, {...walk, ...swim});
const lassie = new Dog;
console.log(lassie.walk()); // logs: "Step step"
console.log(lassie.fly()); // TypeError: lassie.fly is not a function
Note that this is nothing different than composition as described in the previous section. It's just that composition can be achieved in multiple ways.
The often used description of mixins involves two distinct implementations:
- The creation of a has-a relationship between a class and another class (or object or function), to avoid problems associated with inheritance.
- The creation of a class (or object or function), the mixin, that can be "included" in as many classes as you like. The mixin is reusable.
One can also argue that the first describes (class) composition, and only the second describes an actual mixin. In this view, a mixin may or may not be created using composition.
A different mixin concept
Justin Fagnani proposes a different concept for JavaScript mixins in his blog post "Real" Mixins with JavaScript Classes. He proposes the mixin to be a function that can insert a class, containing the mixin logic, into the inheritance chain. This pattern is based on inheritance instead of composition, while the mixin remains reusable. The mixin acts as a "subclass factory", parameterized by the superclass.
It relies on two features of JavaScript classes: class
can also be used
inside an expression
where it returns a new (anonymous) class each time it's evaluated,
and extends
accepts arbitrary expressions that return classes or constructors.
// defining the Mixins:
const walk = function(superclass) {
return class extends superclass {
walk() { return "Step step" };
}
};
const swim = function(superclass) {
return class extends superclass {
swim() { return "Splash splash" };
}
};
class Animal {}
class Dog extends walk(swim(Animal)) {} // mixins applied
const lassie = new Dog;
console.log(lassie.walk()); // logs: "Step step"
console.log(lassie.swim()); // logs: "Splash splash"
class Fish extends swim(Animal) {} // mixin applied
const nemo = new Fish;
console.log(nemo.swim()); // logs: "Splash splash"
console.log(nemo.walk()); // TypeError: nemo.walk is not a function