14

I am reading about the SOLID principles, but it seems like the Liskov-Substitution Principle primarily refers to programs that use inheritance.

From my understanding people are shifting more towards composition over inheritance. If that is the case, does the "L" in SOLID still apply? If so what would be an example of its use if one almost never relies on inheritance?

5 Answers 5

31

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.

5
  • 2
    You may add C++ templates to your list, as they are duck-typed (C++ concepts is an attempt to introduce structural subtyping)
    – Ben Voigt
    Oct 20, 2021 at 15:38
  • @BenVoigt - right, that's an example too; anything that fits the concept, regardless of the exact form the actual code might take. My intent was to provide a few examples to get the OP thinking in the right direction. Without much information about the OP's background, my primary focus wasn't on being thorough, it was more on coming up with things that are likely to feel familiar and convey the ideas. Oct 20, 2021 at 16:24
  • Yes I understand the list is not meant to be exhaustive, I just don't want readers concluding that it only applies to dynamic languages.
    – Ben Voigt
    Oct 20, 2021 at 16:45
  • 1
    @BenVoigt - I guess that's a good reason for an edit, added two more examples Oct 20, 2021 at 17:00
  • Growler is cute
    – Basilevs
    Oct 21, 2021 at 10:18
5

I am reading about the SOLID principles, but it seems like the Liskov-Substitution Principle primarily refers to programs that use inheritance.

The Liskov Substitution Principle has nothing to do with Inheritance. The LSP is about Subtyping.

From my understanding people are shifting more towards composition over inheritance. If that is the case, does the "L" in SOLID still apply?

Since the LSP has nothing to do with inheritance, whether or not you use inheritance is irrelevant to whether or not the LSP applies.

If so what would be an example of its use if one almost never relies on inheritance?

Here are two simple examples from Ruby:

  • Class inherits from Module, but is not a subtype. (You can multiply-inherit from modules, but you can only inherit from one class; ergo, you cannot substitute a class for a module without breaking your code.)
  • StringIO has no inheritance relationship with IO, i.e. neither one does inherit from the other, nor do they both inherit from a common ancestor (except of course they both inherit from Object). Yet, depending on how you look at it, you can either say that StringIO is a subtype of IO, or (and that is my preferred interpretation) you can say that both IO and StringIO are sibling subtypes of an unnamed IOLike protocol.

Maybe a code example will help:

class Foo
  def initialize
    @backing_store = []
  end

  def <<(element)
    @backing_store << element
    self
  end

  def to_s
    -@backing_store.join(', ')
  end
end

class Bar
  def initialize
    @backing_store = +''
    @first_element = true
  end

  def <<(element)
    if @first_element
      @backing_store << element
      @first_element = false
    else
      @backing_store << ', ' << element
    end

    self
  end

  def to_s
    -@backing_store
  end
end

Both of these classes are subtypes of the unnamed Appendable protocol. In addition, Bar is a subtype of Foo[String] but not of Foo[T] in general. Conversely, Foo[String] (but not Foo[T] in general) is also a subtype of Bar. In other words, Foo[String] and Bar are the same type.

4
  • "Subtyping" suggests inheritance, but yes, I see what you are saying. Oct 20, 2021 at 14:46
  • @RobertHarvey, curiously, I suppose it depends on what you mean by inheritance. A type could be a subtype of another without e.g. class inheritance. All types in ad-hoc polymorphism are of that kind, but even types in inclusion polymorphism could be so as well. That being said, internally for the language, we could assume that for a type to be a subtype of another, it implies the subtype respecting the contract of the supertype, and we could euphemistically call that subtype inheritance. Not sure if in programming language theory it would be called so, though.
    – edalorzo
    Oct 20, 2021 at 16:05
  • 1
    @RobertHarvey: E.g. Go has subtyping but not inheritance. Ruby has inheritance but not subtyping (not in the language, at least, although it does happen in the developer's head). Bard had subtyping and inheritance as orthogonal concepts. Subtyping predates inheritance by decades, really. Oct 20, 2021 at 21:36
  • @JörgWMittag "Ruby has inheritance but not subtyping", but you use Ruby to demonstrate how the LSP applies to subtyping? If we're speaking conceptually and not technically, inheritance is surely a form of subtyping in the same way that IO is a "subtype" of a not-even-real IOLike. Basically, I find the mixture of conceptual and technical to be confusing, and apparently so do others. Oct 21, 2021 at 16:16
3

As long as the classes conform to a common interface where they can be substituted in a process that expects such a common interface, it doesn't matter whether you use inheritance or not. This can be done in languages that support interfaces like Java or duck-typing like others.

import java.util.List;
import java.util.ArrayList;

public class Test{
    public static void main(String args[]){
        List<A> list = new ArrayList<A>();
        list.add(new B());
        list.add(new C());
        
        for (A item: list){
            item.doThis();
        }
    }
}

interface A {
    void doThis();
}

class B implements A {
    
    public void myOwnStuff(){
        
    }
    
    public void doThis(){
        System.out.println("B");
    }
}

class C implements A {
    
    public void whatEver(){
        
    }
    
    public void doThis(){
        System.out.println("C");
    }
}

OUTPUT:
B
C

Note: inside the for loop you cannot access methods that are not part of the A interface.

9
  • 2
    Just wanted to point out that interface implementation is a kind of inheritance (interface inheritance); the implementation inheritance vs interface inheritance distinction is primarily conceptual and applies across languages - it's not based on language-specific notions of interface-types vs class-types (as in Java and C#), or keywords such as implements and extends. Oct 20, 2021 at 8:13
  • 1
    @Filip I believe interface implementation is not a form of “inheritance”, is a mechanism of subtyping. Consider Google Go, it is not a OOP language and there is no such thing as inheritance in Go. Yet it has interfaces that behave like statically-typed duck typing. They are just a subtyping feature. So as far as I can see interface implementation is not inheritance. Also, worth mentioning that “interface inheritance” is typically when an interface extends another one (again just a subtyping feature), not when a class “implements” one.
    – edalorzo
    Oct 20, 2021 at 11:08
  • @edalorzo "interface implementation" - i meant in the sense of Java where you type 'B implements A', my point being that the lanugage's use of a different keyword doesn't mean much on its own. But yes, it is also a mechanism that allows for subtyping, but so is class inheritance (which is a combination of interface inh. and implementation inh.) - neither of those guarantee that what you've got is a proper subtype in the LSP sense. And of course, you don't have to have any of that - case in point: JavaScript. 1/2 Oct 20, 2021 at 12:26
  • 1
    @edalorzo "'interface inheritance' is when an interface extends another one" - maybe in casual speak, and in the context of languages that have a distinct interface construct (or protocol, or whatever). However "interface inheritance" & "implementation inheritance" are two software design and language design concepts that have existed for decades; "interface" there refers to public members of the parent (which can be an interface, a class, or whatever your language has). Interface inheritance is a language mechanism for enforcing that a derivative provides certain members. 2/2 Oct 20, 2021 at 12:26
  • 1
    @FilipMilovanović where are you getting this definition? I've never heard anyone refer to implementing an interface as inheriting that interface. When people say to prefer composition over inheritance they're not saying you shouldn't use interfaces in languages that have them. Usually it implies the opposite. You should use interfaces instead of having a class hierarchy.
    – Blake
    Oct 20, 2021 at 19:20
1

Remember that Liskov wrote:

What is wanted here is something like the following substitution property: 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.

As others have pointed out, there is no notion of inheritance here. And although in OOP languages inheritance does create a subtyping hierarchy, it is certainly not the only subtyping feature.

For example, in languages like Java, thanks to numeric promotion, wherever you expect a long you could pass a byte or an int, and wherever you expect a double you could pass a float or even an int. And thanks to boxing and unboxing wherever int is expected you could pass a java.lang.Integer and vice-versa.

None of these forms of ad-hoc polymorphism use inheritance in any way.

It is implied in the Liskov substitution principle that types form is-a relationships and when this precondition is broken then we could say the substitution is not satisfied.

However, a language type system could allow us to define type relationships that are sound from the type system point of view, but not from Liskov’s principle perspective. For example, mathematically speaking, it seems like every integer is also a real number, but is it true that every Java int is a float? It is not. Still, even though when loss of precision may occur when automatically promoting an int to a float, a widening primitive conversion never results in a run-time exception in Java. Thus, even when its type system allows it, these widening conversions break the Liskov substitution principle.

int big = 1234567890;
float approx = big;
System.out.println(big - (int)approx); //-46

To continue with other examples of subtypes that do not use inheritance, you can consider Java generics and how it creates subtyping rules a la carte using covariance and contravariance. For example a List<Number> is not a supertype of List<Integer>, hence where List<Number> appears I cannot pass a List<Integer>; however a List<? extends Number> is a supertype of List<Integer>. These are type relationships expressed in a way that makes the type system more malleable.

To rescue a comment I left in another answer, you could also consider Google Go (which is not an OOP language) where there is no such thing as an inheritance. Yet it has interface definitions that behave like statically-typed duck typing, allowing the language to define subtype relationships.

interface Stringer {
  String() string
}

In Go any type that has a String a method is considered a subtype of the Stringer interface without the need of statically expressing an explicit relationship with the interface.

You may want to consider other languages that have no notion of inheritance, not even interfaces, like some functional programming languages with structural type systems, like SML and Haskell, where type hierarchies are built using algebraic data types.

For example, in SML, a hierarchy of mathematical expressions could be defined as:


datatype expression = Number of int 
                    | Neg of expression
                    | Add of (expression * expression)
                    | Sub of (expression * expression)
                    | Mult of (expression * expression)
                    | Div of (expression * expression)

Where Number(5) is an expression just as Add(Number(1), Number(2)) is. Structurally, the variants create different kinds of expressions, all considered of the same type.

If you try to define that in Java today, you would probably resort to sealed interfaces and record classes.

sealed interface Expression
        permits Number, Neg, Exp, Add, Mult, Div {}

record Number(int c) implements Expression{}
record Neg(Expression expression) implements Expression{}
record Exp(Expression expression,int exponent) implements Expression{}
record Add(Expression left, Expression right) implements Expression{}
record Mult(Expression left, Expression right) implements Expression{}
record Div(Expression left, Expression right) implements Expression{}

So, you can probably see the usefulness of algebraic data types as an alternative to even interface inheritance to express type relationships.

In a way, the algebraic type definition from SML and the sealed interface/record type relationships in Java using interface inheritance, both represent a way to define subtype hierarchies of the kind that could be used to represent the Liskov principle. How these relationships are expressed is just a matter of syntax. So, perhaps the Liskov substitution principle is called that way for a reason: a principle is something beyond syntax.

Developers only familiar with OOP type systems might use some terms as euphemisms of others when in a broader perspective of programming languages they are not. Subtyping allows the Liskov substitution principle and in most OOP languages I know this takes the form of interface types, as a subtyping mechanism. From my point of view, interfaces, tacit or implicit, are not a form of inheritance. But if we take interfaces out of the equation, then what is left to define type relationships of the Liskov type? Only ad-hoc polymorphism, as I have already exemplified above.

1
  • Regarding my discussion with you on the other answer - yes, don't get me wrong, I fully agree with what you're saying about Go and your Stringer interface here (another term for this is structural typing); it's just that the other answer offered Java's "Class implements Interface" construct as an example of subtyping without inheritance, but as you point out, this is different, because in Java the relationship is explicitly expressed and hard-coded. (One of the problems is that many of the terms we use have overloaded meanings; but hey, that's normal in a fast growing industry/field). Oct 20, 2021 at 14:56
-2

Would be the same with interfaces / protocols. Technically they just specify that a class has certain methods either certain parameters and a certain return value. The LSP and common sense will tell you that this is pretty useless if two objects supporting the same interface or protocol behave in incompatible ways.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.