The “horrible for memory” argument is entirely wrong, but it is an objectively “bad practice”. When you inherit from a class, you don't just inherit the fields and methods you are interested in. Instead, you inherit everything. Every method that it declares, even if it isn't useful for you. And most importantly, you also inherit all its contracts and guarantees that the class provides.
The acronym SOLID provides some heuristics for good object-oriented design. Here, the Interface Segregation Principle (ISP) and the Liskov Substitution Principle (LSP) have something to say.
The ISP tells us to keep our interfaces as small as possible. But by inheriting from ArrayList
, you get many, many methods. Is it meaningful to get()
, remove()
, set()
(replace), or add()
(insert) a child node at a particular index? Is it sensible to ensureCapacity()
of the underlying list? What does it mean to sort()
a Node? Are users of your class really supposed to get a subList()
? Since you cannot hide methods you don't want, the only solution is to have the ArrayList as a member variable, and forward all methods that you actually want:
private final ArrayList<Node> children = new ArrayList<>();
public void add(Node child) { children.add(child); }
public Iterator<Node> iterator() { return children.iterator(); }
If you really want all the methods you see in the documentation, we can move on to the LSP. The LSP tells us that we must be able to use the subclass wherever the parent class is expected. If a function takes an ArrayList
as parameter and we pass our Node
instead, nothing is supposed to change.
Compatibility of subclasses starts with simple things like type signatures. When you override a method, you can't make the parameter types more strict since that might exclude uses that were legal with the parent class. But that is something the compiler checks for us in Java.
But the LSP runs much deeper: We have to maintain compatibility with everything that is promised by the documentation of all parent classes and interfaces. In their answer, Lynn has found one such case where the List
interface (which you have inherited via ArrayList
) guarantees how the equals()
and hashCode()
methods are supposed to work. For hashCode()
you are even given a particular algorithm that must be implemented exactly. Let's assume you have written this Node
:
public class Node extends ArrayList<Node> {
public final int value;
public Node(int value, Node... children) {
this.value = value;
for (Node child : children)
add(child);
}
...
}
This requires that the value
cannot contribute to the hashCode()
and cannot influence equals()
. The List
interface – which you promise to honour by inheriting from it – requires new Node(0).equals(new Node(123))
to be true.
Because inheriting from classes makes it too easy to accidentally break a promise that a parent class made, and because it usually exposes more methods than you intended, it is generally suggested that you prefer composition over inheritance. If you must inherit something, it is suggested to only inherit interfaces. If you want to reuse behaviour of a particular class, you can keep it as a separate object in an instance variable, that way all its promises and requirements aren't forced on you.
Sometimes, our natural language suggests an inheritance relationship: A car is a vehicle. A motorcycle is a vehicle. Should I define classes Car
and Motorcycle
that inherit from a Vehicle
class? Object-oriented design is not about mirroring the real world exactly in our code. We cannot easily encode the rich taxonomies of the real world in our source code.
One such example is the employee-boss modelling problem. We have multiple Person
s, each with a name and address. An Employee
is a Person
and has a Boss
. A Boss
is also a Person
. So should I create a Person
class that is inherited by Boss
and Employee
? Now I have a problem: the boss is also an employee and has another superior. So it seems like Boss
should extend Employee
. But the CompanyOwner
is a Boss
but isn't an Employee
? Any kind of inheritance graph will somehow break down here.
OOP isn't about hierarchies, inheritance, and re-use of existing classes, it is about generalizing behaviour. OOP is about “I have a bunch of objects and want them to do a particular thing – and I don't care how.” That's what interfaces are for. If you implement the Iterable
interface for your Node
because you want to make it iterable, that's perfectly fine. If you implement the Collection
interface because you want to add/remove child nodes etc., then that's fine. But inheriting from another class because it happens to give you all that is not, or at least not unless you have given it careful thought as outlined above.
ArrayList<Node>
, compared to implementList<Node>
?