5

I know about the LSP, which requires that objects of a superclass shall be replaceable with objects of its subclasses without breaking the application.

However I've been building frontend applications for 5 years now and I have never come across a situation where I would replace an object of a superclass by an object of its subclass, perhaps because:

  • Classes are not that used in JS: composition is favored over inheritance and composition is simpler when only using objects and functions
  • When an object is used and it is needed to replace it with an object of its subtype, it often goes along with a change in feature, so a change in behavior is expected
  • Complex objects are often framework-specific objects that rarely implement inheritance: think about Vue components, redux stores, or Angular services

The only places where I encountered the LSP was in clean code courses and job interviews, and 90% of these encounters only mentioned the very abstract example where the Square class shouldn't extend the Rectangle class.

So, it surprises me that the LSP is often mentioned as a cornerstone of clean code, yet I have never seen it in the wild. Perhaps I haven't seen it because the inheritance is not always explicit? Can I see a realistic bit of code where the LSP applies?

5

2 Answers 2

12

LSP defines what it means to be a subtype; it's not defined in terms of inheritance per se. It says that an instance of some type can be considered a subtype of some other type if providing that instance where this other type is expected doesn't produce surprises or breaks the code (in either subtle or obvious ways) - the paper from which it stems gives a more precise definition.

So it's not about the act of replacing stuff, it's about conforming to an abstraction defined by something else. So, whenever you're using inheritance or composition to plug into some other component, or library, or framework, you're applying LSP. Same if you're building some code that other components can extend or be plugged into. The notion of "replacement" is more in the sense that different implementations can be plugged in (e.g. a test double can be replaced with an actual implementation).

When an object is used and it is needed to replace it with an object of its subtype, it often goes along with a change in feature, so a change in behavior is expected

The behavior that LSP talks about is the abstract behavior as defined by the supertype (what it is, what it does and/or represents at that level of abstraction). It's not about the detailed behavior of the subtype, but about making sure that the subtype doesn't break the expectations set by the supertype.

Perhaps I haven't seen it because the inheritance is not always explicit?

That's a good hunch.

It's perhaps not obvious, but in dynamic languages, the abstraction may not even have an explicit representation, it could be entirely specified in the docs (or rather, the docs publicize the expectations of the (private) implementation details). Consider various array methods JavaScript provides, like map or filter or reduce. These functions are written so that anyone can "plug into" the service they offer; you do it by providing your own function that conforms to certain expectations. E.g., for map you have to map the given element to some object and return it. For filter you have to return a boolean that indicates if the element should be kept. For reduce you, have to return the accumulated/reduced value. Each of these specifies (1) what the function signature must look like, and (2) what the function itself does or represents, in the context of each array method. That's a type, that's the abstraction that you must confirm to. It's like a single-function interface, if you like.

Now, in this particular example, the abstractions are pretty general, and you can make use of them in all kinds of ways without breaking them. Obviously, if you don't confirm to the function signature required, they won't even work, so that's a blatant Liskov violation right there. But you can break them in more subtle ways - if you do something that technically works but that's too far outside these specs, you may end up introducing surprising behavior and bugs into your own code. Especially in a team setting, where people may have different assumptions about what someone else's code does. E.g., it would be bad if an innocuous-looking function someone passed to map had undocumented side effects that only become apparent after examining the implementation1.

In frameworks like Angular, if you want to plug into lifecycle events of a component, you have to implement certain methods (e.g. ngAfterContentInit). If you don't, that particular bit of functionality is unavailable to you. That's an also an example of LSP. In React functional components, you are expected to understand the fact that useState relies on the call order (so they can't be in conditionals), and it's part of the "contract" that you shouldn't mutate the state, but that you should return a modified copy instead. That's also an abstraction2 you have to conform to.

The assumption that you (or anyone else) will confirm to these expectations in the sense of LSP, is what allows framework authors to write their code without knowing anything about your (or anyone else's) code.

Of course, you can use the same principles internally in your own projects (and you likely already have).


1 The problem is that, usually, we can't express everything we'd like about a type/abstraction using the features of the language itself, so we have to describe some of the assumptions and constraints in the documentation. (And even if we could, there's always the danger of overspecifying/overconstraining.) The Liskov & Wing paper is a somewhat abstract/mathy computer science paper that explores, among other things, some of the ways a language can be designed so that its users can express these constraints in code. In practice, if you're building these abstractions, you'll likely have to document some of the expectations outside of the public part of the code (the public interfaces, the public API). Conversely, if you're conforming to an abstraction, you'll have to take these accompanying semantics into account.

2 There's a reason all these principles use the word "abstraction", and not just "interface" or "abstract class" - "abstraction" is a more general term, it is any kind of "contract" between you some other reusable component. In general, considering other languages as well, this contract may be enforced by the compiler (e.g inheritance), perhaps in conjunction with the design (e.g. using some pattern, like Strategy), it may be based on conventions (like using a certain naming convention or doing things a certain way, and making use of libraries or tools that rely on that), etc.

6
  • 1
    Thanks for pointing out that LSP is about subtyping not subclassing. When Barbara Liskov came up with what we now call LSP, she was working in Abstract Data Types, not OOP, and her language CLU is an ADT-based language and doesn't have classes. (It has "clusters" which are type definitions for ADTs, similar to Pascal Units or C header files with forward-declared structs.) I think languages like Java where classes are types and subclassing is subtyping confuse people tremendously, even though Java has types that aren't classes and subtyping that isn't subclassing. Commented Sep 23, 2020 at 16:19
  • 1
    The confusing thing about JavaScript is that the "types" that the LSP talks about do not exist in the program, they exist in the programmer's head. But, e.g. the ECMAScript Language Specification has introduced the notion of interface, e.g. the Iterable interface, and e.g. Array is a subtype of Iterable, even though nothing like Iterable actually exists except in the English text of the spec, and the programmer's head. Commented Sep 23, 2020 at 16:22
  • ""abstraction" is a more general term, it is any kind of "contract" between you some other reusable component." – The Java interface keyword is a good example of that. Java was primarily inspired by Objective-C, and Java's interface language feature is directly taken from Objective-C's protocols. ObjC protocols, in turn, come from Smalltalk's protocols, where they are a documentation and IDE convention, not a language feature. So, here you have three things of two completely different kinds (language features and documentation conventions) with two completely different names … Commented Sep 25, 2020 at 15:38
  • (interface and protocol), with two identical kinds named differently, and two different kinds named identically, and yet, they are actually all the same, and they all provide the concept of abstraction. Commented Sep 25, 2020 at 15:39
  • @JörgWMittag True. People mostly think the word "abstraction" refers to some specific language mechanism built into the type system, but it's really a design technique, and so goes beyond that. We keep forgetting (or fail to notice) that many of the tools and libraries we use rely on some form of abstraction, and that these same principles apply at larger scales as well. Interfaces and abstract classes on one end of the spectrum, but also data formats, conventions, tiny domain-specific languages, query languages, markup languages, APIs, mediating systems, etc. Commented Sep 25, 2020 at 21:01
3

Complex objects are often framework-specific objects that rarely implement inheritance: think about Vue components, redux stores, or Angular services

You may not implement inheritance within your own code, but these kind of framework elements are actually great examples of substitutability in action: they have to have features the framework expects.

Take for instance the data property on a Vue component: the framework will call a method you have provided, and expect it to behave in a particular way. If you don't provide that method, or make it require parameters that the framework doesn't pass, you have violated the "contract", because your component can't be substituted where expected.

If you wrote a custom function, you could pass in your "broken" component and pass the extra parameters to the data method, at which point it would work, but would have broken the LSP: you can't use the custom component (the child / subclass) in all the places where you can use a normal component (the parent / superclass).

Note that in a lot of OO languages, there is a clear difference between "class" and "instance" that doesn't really exist in JS, so the normal description of "child class meeting contract of parent class" isn't always a good one; but the principle still applies.

3
  • Oh okay, so that is LSP? I thought it was just the semver requirement that apps should not break when minor-bumping or pach-bumping dependencies
    – Nino Filiu
    Commented Sep 23, 2020 at 13:58
  • @NinoFiliu It's nothing to do with version bumps; if you write a component where the data property isn't a valid method, it's going to break stuff.
    – IMSoP
    Commented Sep 23, 2020 at 14:09
  • @NinoFiliu I've added an extra paragraph. The key is that you could write custom code to work with your custom components, but they'd still be breaking LSP.
    – IMSoP
    Commented Sep 23, 2020 at 14:22

Your Answer

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

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