Pregunta

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?

¿Fue útil?

Solución

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.

Otros consejos

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.

Licenciado bajo: CC-BY-SA con atribución
scroll top