In this essay, I want to explore the topic of how to write classes that can be easily extended/maintained for the future. We would like to design classes in a way such that it is easy to implement a new class as well as add functionality. We shall explore some of the common patterns for this.

The problem of extending classes

Suppose we have the following model:

abstract class Animal{}

class Cat extends Animal {}
class Human extends Animal {}

You are asked to count the legs of a collection of animals:

public int countLegs(List<Animal> animals) { return 0; } // TODO

There are a few ways to do this. For example, we could bake in a switch in that method.

public int countLegs(List<Animal> animals) {
    int totalLegs = 0;
    for (Animal animal: animals) {
        if (animal instanceof Cat) {
            totalLegs += 4;
        } else if (animal instanceof Human) {
            totalLegs += 2;
        } else {
            throw new IllegalArgumentException("Unknown animal");
        }
    }
    return totalLegs;
}

Now suppose you want to add a new type of Animal e.g. Fish. You now need to find call sites where such subtype switches happen. Ideally you’ll want to just create a new Fish class and expect the code everywhere else outside the class to continue work. The solution above does not do this because we’ll need to modify countLegs to take into account this new class.

Solution: Adhoc polymorphism

One common solution to this is to make use of adhoc polymorphism. Simply put, we define a functionality/method on the superclass, and at the call site, it will call the implementation of the subclasses.

abstract class Animal {
    public abstract int getLegs();
}

Then our countLegs method could be

public int countLegs(List<Animal> animals) {
    int totalLegs = 0;
    for (Animal animal: animals) {
        totalLegs += animal.getLegs();
    }
    return totalLegs;
}

Now when we create a Fish class, we only need to be concerned with this new class because the abstract class interface defines all the possible methods that can be called on an Animal. Except until you need to extend the universe of possible methods which brings us to …

Extending behaviour

Things are going fine for a while, and now you realized you need to also calculate the total weight of the animals. How should we implement

public int sumWeight(List<Animal> animals)

We could adopt the pattern above and add a method to the abstract class:

abstract class Animal {
   public abstract int countLegs(); 
   public abstract double getWeight(); 
}

However, we need to go through all the subclasses of Animal and implement the new method. Ideally we want to just implement the weight method in one class.

Solution: Foldable

To guard against future growth of such methods, we could introduce a fold method which effectively acts as the class switch. This also provides type safety while switching on the class.

abstract class Animal {
    public abstract <T> T fold(
            Function<Cat, T> ifCat,
            Function<Human, T> ifHuman,
            Function<Fish, T> ifFish
    );
}
// When implementing a new class
class Fish extends Animal {
    @Override
    public <T> T fold(Function<Cat, T> ifCat,
                      Function<Human, T> ifHuman,
                      Function<Fish, T> ifFish) {
        return ifFish.apply(this);
    }
}
// We also need to modify existing methods
public int countLegs(List<Animal> animals) {
    int totalLegs = 0;
    for (Animal animal : animals) {
        totalLegs += animal.fold(c -> 4, h -> 2, f -> 0);
    }
    return totalLegs;
}

The advantage of this approach is that adding new behaviour/methods for manipulating a Collection<Animal> does not affect existing classes. The cost comes when we have to extend animal. We’ll have to touch every single subclass of Animal. The other downside is the call site needs to know the logic for folding when an argument could be made that this should be intrinsic.

Solution: The visitor pattern

Our previous “foldable” strategy is very closely related to the visitor pattern. Similar to its goal, a “Visitor lets you define a new operation without changing the classes of the elements on which it operates.”

This solution requires a slightly more complex set up.

abstract class Visitor {
    // Here you define all the classes you care about
    abstract public void visitCat(Cat c);
    abstract public void visitHuman(Human h);
}

abstract class Animal {
    public abstract void accept(Visitor visitor);
}

// Example set up required for a new class
class Cat extends Animal {
    @Override public void accept(Visitor visitor) {visitor.visitCat(this); }
}
// Extending operation is as simple as creating a new visitor class
class LegCountVisitor extends Visitor {
    // Keep track of result
    private int totalLegs = 0;
    int getTotalLegs() { return totalLegs; }

    @Override
    public void visitCat(Cat c) { totalLegs += 4; }

    @Override
    public void visitHuman(Human h) { totalLegs += 2; }
}

// Call site needs to create a new visitor
public int countLegs(List<Animal> animals) {
    LegCountVisitor legVisitor = new LegCountVisitor();
    animals.forEach(a -> a.accept(legVisitor));
    return legVisitor.getTotalLegs();
}

The upside of this approach is the visitor can be more complex because it can have more fields.

Conclusion

We have seen that each solution has its drawbacks and it’s about trade-offs at the end of the day. Adhoc polymorphism is useful when we know the set of operations/behaviours that we need to do to our datatypes. The foldable pattern makes it easy to extend operations/behaviour but more cumbersome when adding a new subclass. The visitor pattern takes the idea further, “formalizing” the fold method.

Unfortunately there is no one size fits all solution to this. As an engineer, one must evaluate which is the more likely situation and implement the appropriate pattern.