It's not about inheritance, it's about substitutability of types. In languages that support duck typing (JavaScript, Python, compile-time polymorphism of C++ templates, etc...), or structural typing (TypeScript, Go, etc...), the two types don't have to form an inheritance relationship at all. E.g., this JavaScript code will work just fine, even if there's no inheritance in sight:
var cat = {
getSpecies: () => 'Cat',
vocalize: () => 'Meow!'
}
var dog = {
getSpecies: () => 'Dog',
vocalize: () => 'Woof!'
}
var growls = ['Growl!', 'Grrr!', 'Rumble-rumble...', '(Blank stare)'];
var growlIndex = 0;
var growler = {
getSpecies: () => 'Growler',
vocalize: () => {
var index = growlIndex;
growlIndex++; if (growlIndex === growls.length) growlIndex = 0;
return growls[index];
}
}
// There's, effectively, an implicit abstract type Animal
function GreetAnimal(animal) {
console.log('Human: Hi, there!');
console.log(`${animal.getSpecies()}: ${animal.vocalize()}`);
}
GreetAnimal(cat);
GreetAnimal(dog);
GreetAnimal(growler);
Often, when using composition, you'll allow for the ability to plug in different implementations of something into the composite; in class-based statically typed languages, the composite would have a reference to a subobject of an abstract type/interface, so you'll have inheritance in there. And in duck-typed languages, you have an implicit type, even if there's no inheritance.
Also, it doesn't even have to be about objects; it can apply to functions too.
For example, suppose you have a tree structure with a method that visits every node in the tree, and allows you to pass in a function (or a lambda) of the form void MyFunc(Node n)
(or (node) => { ... }
) that allows you to access each node; the documentation says that this function must not modify the tree structure (but may modify the contents of the node itself), as the code in the Tree class relies on the tree itself not being modified.
The signature of the function is a kind of an abstract type, and the requirement in the documentation is a specification of the abstract behavior required of that type, and all its implementers. A concrete function that you pass in is a concrete implementation of this type. If you pass in a function that modifies the tree structure, you've just violated LSP.
Now, in this case, it would have been better if the design was such that you cannot easily break LSP in this way - e.g. instead of passing the node itself (thus allowing the caller to modify the child pointers), only pass the contents. But this is not always possible, and sometimes, the requirements on the behavior of the type are not easily designed away.
Suppose you need to write some kind of algorithm that processes a bunch of objects, and that requires the user to provide a way to decide the ordering of these objects. You can use the standard int compare(a, b)
approach, where the negative value indicates that a
comes before b
, a zero indicates they are the same in terms of ordering, and a positive value indicates that b
should come before a
. You also require that the ordering functions makes sense as an ordering function: if a < b
and b < c
, then the function should also say that a < c
(the ordering "transfers" as you'd expect; it doesn't suddenly say that a == c
, there's no rock-paper-scissors kind of thing where c < a
, etc.).
So if you supply a comparison function that, say, behaves like this:
compare(a, b); // returns -1 (a is before b)
compare(b, a); // returns -1 (b is before a)
you break Liskov with respect to your algorithm. You can't easily design that away - it's the responsibility of the users of your library to provide a sensible implementation of the int compare(a, b)
type, or take on the risk of not doing so (the risk being, your function could crash, produce nonsensical result, or it might work, but then when you publish a new version where you change the internals, their code will break even though it's a non-breaking change, and it's on them, because they didn't adhere to the contract).
In some other context (e.g. when implementing rock-paper-scissors), the behavior expected from int compare(a, b)
might be specified differently (the type, in the LSP sense, is not entirely defined by just the signature (or by an interface)). So, the same implementation may break LSP in one context, and be valid in another.
I guess another way to look at it is that what's a compiler considers to be a type, is not quite the same as what you, the developer (either as an author or as a user of some piece of code) consider to be a type; typically, types, in the sense relevant to us developers, cannot be entirely expressed in the language itself - and we know this intuitively; everyone knows that if a code compiles, it doesn't necessarily mean that it works as intended. In a sense, LSP (that is, Liskov & Wing 1994 paper) captures that in a more precise way.