My understanding of Inheritance in Object-Oriented Programming is that you use it to add behaviors/methods, such as set
methods.
A lot of mainstream object-oriented languages, and Java and Kotlin are no exception, lump multiple separate things together under the single mechanism of Inheritance.
So, in some sense you are right: inheritance is used to add behaviors, in particular add behaviors in such a way that you only need to describe the difference between the behaviors of the subclass to the superclass. This is sometimes called Differential Code Re-Use.
However, the problem is that this is not the only thing inheritance does. Inheritance also does something else: it creates a Subtyping relationship between the superclass and the subclass. In other words, B
is not just a subclass of A
, it is also a subtype of A
. Which means inheritance is not just used to add behaviors, it is also used to add contracts.
There are languages where those two aspects are separate, where there are separate mechanisms for differential code re-use and subtyping. But they are not mainstream.
(Unless you count dynamic languages which only have differential code re-use as part of the language, and the typing only happens in the programmer's head.)
You can't remove methods/functionality inherited from other interfaces/classes, only override them. Mutability seems like added functionality (e.g. adding setters
). Adding mutability may be unwise in general, but it's making a subclass do something the parent cannot, which is how OOP is supposed to work, no? Is there something special about mutability that changes that?
If you are looking at it only from the point of view of differential code re-use, then you are correct: this is exactly what it is designed for. You can re-use the code from A
and you only define what are the differences between B
and A
. But the problem is that the only way to achieve differential code re-use in Java and/or Kotlin is inheritance, but inheritance also creates a subtyping relationship. And that means, that B
needs to obey all the rules for subtyping.
So, what are those rules for subtyping?
Today, the best way we know how to characterize subtyping, especially in the presence of mutability and aliasing, is what is commonly called the Liskov Substitution Principle or LSP.
The LSP was introduced by Barbara Liskov in a keynote address at the OOPSLA conference 1987, titled Data Abstraction and Hierarchy:
If for each object o1 of type S there is an object o2 of type T such that for all programs P defined in terms of T, the behavior of P is unchanged when o1 is substituted for o2, then S is a subtype of T.
A similar definition is also given in A Behavioral Notion of Subtyping (1994):
Let ϕ(x) be a property provable about objects x of type T. Then ϕ(y) should be true for objects y of type S where S <⦂ T.
Now, these are some pretty abstract definitions. The main advantage of these definitions, which are based on the behavior of the programs, is that these definitions also work for mutability and aliasing. Earlier approaches were less precise, e.g. the co- and contra-variance rules for functions do not work with mutability or aliasing, and the proof-theoretic approach by Bertrand Meyer only works for mutability.
Thankfully, we can derive some rules which are simpler to interpret than those abstract definitions:
There are three static restrictions for subroutine signatures:
- Subroutines are contravariant in their parameter types.
- Subroutines are covariant in their result types.
- Subroutines in a subtype can only throw exceptions which are subtypes of the supertype's subroutine's exceptions.
#1 and #2 are not new, they are simply the standard variance rules for functions – they had been known for decades. #3 was already stated by Meyer, and is actually the same thing as #2 if you consider exceptions part of the result type of a subroutine.
So far, none of this should be surprising.
What's more interesting, are the four behavioral restrictions. These are restrictions on the runtime behavior of instances of subtypes. They are stated in the form of invariants (statements that must always be true, where "always" means "after construction and before and after every subroutine call"), preconditions (statements that must be true before every subroutine call), and postconditions (statements that must be true after every subroutine call):
- Preconditions cannot be strengthened in a subtype.
- Postconditions cannot be weakened in a subtype.
- Invariants must be preserved in a subtype.
- The History Constraint.
#1 and #2 are again just re-statements of the signature constraints #1 and #2 (which are just the variance rules for functions) in terms of behavioral contracts expressed as preconditions and postconditions. #3 was already known to Meyer.
One of the two big things and big innovations about Liskov's and Wing's way of phrasing this, is the History Constraint. The history constraint says that, an observer who observes an instance of the subtype through the interface of the supertype must not be allowed to observe a history that he could not observe of an instance of the supertype. (The other bing thing is the idea of "substitutability", i.e. that B
is a subtype of A
IFF instances of B
can be substituted by instances of A
.)
There's a lot to unpack here. So, we have "an observer", which is simply a program O. This program is passed an instance of A
, and it can call any subroutine it wants, however often it wants, whenever it wants. Now, you pass it an instance of B
, which is perfectly fine, since B
is a subtype of A
, and thus, any instance of B
is also an instance of A
.
We also have a different program, the "manipulator" M, which gets passed the same instance of B
, and which can use any subroutine of B
to manipulate this instance.
What the history constraint says, is that no matter what crazy things M does to the instance and no matter what clever tricks O uses to inspect the instance, it must be impossible for O to observe a history that it could not observe if M only knew about A
.
And that is the problem with your code. Your code does not violate any of the signature constraints and it does not violate any precondition or postcondition, i.e. it does not violate the first two behavioral constraints.
It may or may not violate behavioral constraint #3 regarding invariants. Unfortunately, you have not really shown how your interfaces are used and what your requirements are, so we don't actually know what the invariants of A
are. But it is possible that A
could have the invariant that X
never changes after construction. If that were the case, then B
would violate the constraint that invariants must be preserved.
This, BTW, demonstrates an important lesson about practical implications of the LSP. The LSP itself talks about "for all programs the behavior is unchanged" and it simply mentions "provable properties". But in a real world, that is too restrictive. For example, an important reason for subtyping is that subtypes being special cases may allow more efficient implementations of subroutines. But, being faster is technically a changed behavior, and thus the LSP wouldn't allow you to make specialized, faster, subtypes.
Similarly, "any provable property" is incredibly broad, since there may be a lot of weird, obscure properties that are provable about a program.
That's why, in the real world, when we analyze whether a subtype violates the LSP, we must always look at the contract of the supertype. In other words, instead of looking at all possible programs and all possible provable properties, we are only looking at the subset of programs that we actually want to write and we are only looking at the subset of desirable, relevant properties. And the way we do that, is by specifying the contract of the supertype. Ideally, in some formal language, but more often than not simply in the documentation, or even implicitly, i.e. without even stating the contract out loud.
Since you don't specify the contract of A
, in particular not its invariants, we can't really tell whether or not B
violates them or not. The only thing we can tell is that it would be possible to specify an invariant that is guaranteed to hold for A
but is violated by B
. In the pure phrasing of Liskov, this is enough to be a violation, but in reality, we would look at what the actual guarantees are that A
makes in its documentation.
However, whether or not B
violates the invariant constraint is irrelevant, because it definitely violates the history constraint, in a rather trivial manner: since A
is immutable, it is impossible to construct a history where X
has different values at different points in time, yet, that is possible with B
. Therefore, B
violates the history constraint, which means B
is not a subtype of A
. (Or, since Kotlin is not actually able to prevent you from doing this, we should alternately say that it is not type-safe to make B
a subtype of A
.)
This shows why Liskov's and Wing's innovation was so important. The old variance rules for functions could not deal with mutability. The proof-theoretic approach of Meyer could not deal with aliasing. Only the behavioral approach plus the history constraint can actually deal with the problem caused by mutability plus aliasing.
Why is this important? Where can this break?
Imagine, A
is written in 2018 by a programmer in Brazil. In 2019, a programmer in New Zealand writes a nifty library foolib
which uses A
. Because A
is immutable, this programmer can use some clever tricks to speed up their library, like memoizing the value of X
and never ask for it again. In 2020, a programmer in Hungary writes B
. In 2021, a programmer in Canada writes a nifty library barlib
which uses B
. Since B
is mutable, they can do some nifty tricks like storing intermediate results in X
to speed up their library. In 2022, you write some program using B
which uses both theses nifty libraries. So, you construct a single instance of B
and pass that to both libraries to do their nifty processing.
Boom. foolib
blows up because barlib
is manipulating what foolib
thinks is a perfectly safe immutable A
instance behind its back.
Even worse: foolib
doesn't blow up, but gives wrong results!
Problems like this can only happen when you have aliasing (i.e. different pieces of code referring to the same instance) and mutability. And only the history constraint can protect you from this.
Let's examine one of your questions again:
it's making a subclass do something the parent cannot, which is how OOP is supposed to work, no? Is there something special about mutability that changes that?
Now we can look at this a bit more precisely. The problem is not that B
can do something that A
cannot do. The problem is that B
can do something that breaks A
's contract. The problem is not "doing something that A
cannot do", but "doing something that A
shouldn't be able to do".
You are not merely adding new behavior. You are also changing existing behavior. For example, adding a new, mutable field, would have been perfectly fine. The precise problem is that through allowing to mutate X
, you have not only added the subroutine setX
, you have also implicitly changed the behavior of getX
, which before had the guarantee that it always returns the same value, and you have now removed this guarantee.