Inheritance is not the most difficult concept to grasp, and it is a skill all developers in modern OOP languages should have well under control. However, as with most things in development, it seems a lot simpler than it turns out to be. One complexity around class inheritance in C# is the effective implementation and usage of abstract classes. There are various kinds of abstract class implementations that I have come across, but there is only one effective version in my experience, and it is the style I encourage NML developers to follow.
Functionality vs Behavior
Abstract classes, and I blame C# developer interviews for this, have become known as:
A way to provide common functionality to inheritors
This is indeed something abstract classes do, but it paints a distorted picture of what is expected when it comes to implementation.
A more accurate description is:
A way to implement common behavior for inheritors
On the surface of it, they sound basically like the same thing, but they are vastly different in consequence.
Common functionality is about implementing methods and properties that can be called by inheritors, whereas common behavior is about cementing the rules and expectations of a suite of closely related inheritors.
If this still sounds the same to you, keep reading!
What invariably happens when developers look at abstract classes as common functionality, is that they abstract away methods and properties that inheritors call while performing their operation. Developers fall into the trap of thinking whoever else next adds to the suite of classes that inherit from their abstract class, will have the same insights and knowledge that they originally had.
All the abstracting away of methods and properties result in nothing more than just a contained helper class. And helper classes are the number one suspect in bad OOP implementations. More on that another time.
The whole purpose of related classes is that they promise to behave almost the same. They serve similar purposes in different contexts. What they should do is provide the same function or service to using code, but vary in exactly what output or side-effects they have. What they should not do is implement the same interfaces and abstract classes, but have wildly different output or side effects.
Encapsulating behavior in abstract classes must entail precisely describing the steps of what the function or service does, in the abstract class, not in the inheritors. An inheriting class’ only job in the relationship is to provide specificity to the behavior when the abstract class requires it.
What does that all mean?
Here are some rules and guidelines I attempt to encourage at NML when it comes to effective inheritance hierarchies:
Public entry points
Do your concretes have public entry points? If so you are implementing common functionality and not common behavior. Always keep the public entry point on the most abstract level. Generally, that will be the abstract class.
What that does is to force you to encapsulate the shared behavior that all inheritors must necessarily have as a consequence. If you find yourself thinking: “I need only my version to do X too, but for that I need to modify the base”, then you need to stop and take a long look at whether you are still implementing the function/service that the inheritance is encapsulating.
If you find yourself seeing the same code structures and flow in your concretes, you are probably looking at behavior that should be moved to a more abstract level. Seeing a try-catch for the same exception around similar-looking code, or sequences of method calls or property assignments, are signs that your concretes have taken hold of some behavior, and you need to investigate whether you are breaking the DRY principle and need to move code to the base.
When your abstract class has a set of protected methods that is not called from within the abstract class itself, you are almost certainly implementing shared functionality, and not shared behavior.
There are only 2 reasons for declaring a method or property as protected in an abstract class (OK, there might be more, but very infrequently):
- It is declared “protected abstract”, which indicates that there is some action or knowledge that can only be provided by the concrete, as a consequence of the specific variability the concrete is providing.
- It is declared “protected virtual” indicating there is some behavior or knowledge that a concrete class can use to specify something different for when the default is not adequate.
In both the above cases, however, there should always be a call from the class that originally defined the method or property as “protected abstract/virtual”.
Liskov should apply
If your concretes do not adhere to the Liskov substitution principle, you are implementing shared functionality and not common behavior. Liskov is part of the SOLID principles for a reason, and this is definitely one place where this principle is important to ensure your implementation is robust.
Public entry points (again)
Do you use the “virtual” or “abstract” keywords on your public entry points? If so, you are inviting other developers to break the spirit of the behavior you are trying to encapsulate. Without the base implementation controlling the core behavior, nothing really stops an inheritor from adding just that one deviation that steps over the line of the function or service that the suite is supposed to provide.
It is important to think of inheritance hierarchies in terms of behavior, and not just in terms of variation. The above strategy has served NML well, and it allows us to not only build robust implementations for behavior that can support variation, but also helps us identify when we are overreaching on an inheritance implementation, and perhaps should rather introduce a different function or service for the requirement.