6 ms·
I feel like the real Liskov Subsitution Principle should be "if you have a subtype P of type S, redesign your code so that there are no subtypes". Seriously th
by gregkerzhner 6y ago
I feel like the real Liskov Subsitution Principle should be "if you have a subtype P of type S, redesign your code so that there are no subtypes".
Seriously though, I have been a professional developer in a classical object oriented language for the last 5 years and have written 0 class hiarchies. Common functionality can be mixed in by composing "parent" objects around common child objects. Passing multiple types of objects to a single function is better handled via interfaces, not inheritance.
Is there a good reason to have a class hierarchy in modern programming?
- thisiszilff 6y agoTo an extent that is what it ends up being 90% of the time given that a lot of the inheritance people use don't satisfy the LSP (Liskov Substitution Principle). We've been using mypy with our python codebase and it will complain if you violate the LSP so much so that it prevents most inheritance. We mainly subclass for simple data models when it's a case of "every model has these 10 fields" and there are next to no methods on the data model classes.
- mumblemumble 6y agoYou're contradicting yourself a bit here. Implementing an interface is just a special case of inheritance. The fundamental reason why some languages make a formal distinction between interfaces and purely abstract base classes is that using the "interface" keyword is effectively signing a contract that says you will not do this one thing (implementing virtual methods), and in return the compiler will allow that base type to participate in multiple inheritance. edit: I should also mention that it's entirely possible to violate LSP using only interface inheritance. Java's collections do this: Collections.unmodifiableList() returns a List<T> that can't be modified. But List<T> is rotten with methods for mutating the list. So what happens if you try to mutate one of these? You get a rather surprising exception. I suppose you could argue that this isn't technically a violation of LSP, because the JavaDoc for List<T>::add says that it might throw UnsupportedOperationException. But UnsupportedOperationException is a RuntimeException, not a checked exception, and it's rare for Java developers to make a habit of handling UnsupportedOperationException when mutating collections. So I'd argue that it's only a part of the method's spec in the most pedantic of ways. IMO, the more pragmatic perspective is that creating a subtype that inherits a whole bunch of interface methods that it has no intention of implementing is just about as clear-cut a violation of LSP as you can get.
- rovolo 6y agoYep, the read-only List<T> implementations definitely feel like a violation. For the benefit of people using older languages, I'd like to point out that newer languages tend to split collection interfaces into ReadOnly and ReadWrite variants, where ReadWrite subclasses ReadOnly. This means that a mutable list can be assigned to a read-only list variable, but a read-only list can't be assigned to a read-write list variable (because you can't write to an unmodifiable collection).
- nybble41 6y agoThere is a third variant which should also be considered: Immutable. It offers exactly the same interfaces as ReadOnly but with the additional guarantee that not only can you not mutate the object through that reference, no one else can either. Enumerating an Immutable collection or getting the item at a particular index multiple times is certain to always give you the same result, which allows simpler code and (with compiler support) better optimization. This would be superfluous in Rust since the borrow checker ensures that no one else can mutate data that you have a reference to, but most other languages with mutation would benefit. However, the only mutable-by-default language I am aware of which directly distinguishes between read-only ("const") references and immutable data is D.
- hinkley 6y agoI read an interview somewhere with Bloch where he said he became aware of this problem while architecting the collections api, but thought the alternative had too many interfaces and would confuse people. He ended up repeating the mistake made with Arrays in the JVM. I sat down and did the math once while contemplating writing my own collections API, and found a much smaller representation based on the observation that the read-only parts of some collections are equivalent, and hence don't deserve a separate type. This got me down to about 12% more interfaces, which I thought was probably worth it for essentially splitting everything in two, especially since it reduces the amount of Type Variance the user has to deal with (especially before they added inference to type declarations). For collections, most of the substitution problem is around shared state, and the differences between readers and writers. So now I'm thinking about borrow semantics in for instance Rust. When you are handed an object reference, you are only getting part of the interface for that object, unless you have made arrangements for the caller to give you control of the internal state of that object. Most, but certainly not 100%, of the covariance that I see in interfaces revolves around updating state. For instance you don't have to make a contains() function covariant to fulfill LSP. Because obviously the answer to "does this set of integers contain the string 'foo'" is no, it doesn't. Meanwhile put() or update() do care a great deal about the inputs. I'm wondering now if you could completely (or at least by default) fold borrow semantics straight into your type system and dispense with most or all of the out of band syntax that Rust requires...
- mrkeen 6y ago> Is there a good reason to have a class hierarchy in modern programming? I have a bad reason :D I like it when objects are simple to instantiate, but sometimes I'm forced to use Spring(Boot) with beans and auto-wiring and such. In src/main, my FooController is autowired up by Spring, but in src/test, I want to instantiate FooController for use in unit tests. In order to have my cake and eat it too, I extract out all the spring dependencies into a very small shim subclass SpringFooController, which extends FooController. They have all the same methods and types etc, but all of the SpringFooController methods' implementations are just one-liner calls into superclass methods. What does this get me? SpringFooController is picked up by Spring at runtime, but contains no logic needing to be tested. FooController contains all the logic, but no spring dependencies, so it can be instantiated in unit tests as an ordinary object.
- mercer 6y agoWow. I've not used Spring, but that sounds terribly tedious and indirect. I imagine it's worth it though?
- jimbokun 6y ago"terribly tedious and indirect" would be a pretty accurate slogan for Spring. With all the functionality Spring gives you out of the box, it's probably still worth it. But barely.
- mrkeen 6y agoNot sure if you're asking about Spring or my workaround. Is Spring worth it? The only reason I'd reach for it over something like http://sparkjava.com/ http://sparkjava.com/ is because of its good integration with https://swagger.io/tools/swagger-ui/ https://swagger.io/tools/swagger-ui/. It's the easiest way I know to help out your front-end devs. Whether you like the workaround really depends on how much you dislike Spring, and how much you buy into that whole frameworks-bad-libraries-good argument. As an example of a feature, here's how Spring might validate receiving a book as a POST body. It's as simple as adding @Valid: @PostMapping("/books") Book newBook(@Valid @RequestBody Book newBook) { return repository.save(newBook); } My thoughts on the above: 1) Did i import the right @Valid. There's no compile-time checking, I'll only know it works by trying it. 2) How can I test it? Not in a unit test. Calling newBook and expecting @Valid to fire is a rookie error. I need to spin up some Spring machinery to be able to test anything. 3) Is validation enabled? How do I know? 4) OK, the validator appeared to work in test. Do I have the same Spring config in the test and prod profiles (check yaml, environment variables, CLI args, and @Config objects)? Does validation also work in prod? 5) What does it look like when it rejects input? I assume Spring will return a 400, but what's the error message. Can I set it? Is it plaintext or json? Is it logged? Is it metred? How? 6) Its return type is Book. It always returns a Book? Not an Either<Error, Book>? Not a CompletableFuture<Book>? 7) It accepts a Book. This means that an INVALID BOOK IS INSTANTIATED, and then checked (I think?) after instantiation. This means when you write your Book class, you need to deliberately make invalid state representable, so that Spring has something to validate! Here's the gist of what I'd write instead. (Not a direct comparison, I added features I wouldn't know how to integrate with @Valid) @PostMapping("/books") Response<Book> newBook(@RequestBody String strBook) { return Book.parse(strBook) .handle( (Book book) -> { metrics.something(); logger.something(); repository.save(book) .map(Response::new); }, (Error err) -> { metrics.registerError(err); logger.logError(err); return Response.of(400, "Invalid book submitted: " + err.msg); }); } The method that I wrote can be 'just run' and tested - no need to fire up Spring. You only have a Book immutable and type-safe again. I didn't detail the workaround, but it's essentially this: //SpringBookController extends BookController @PostMapping("/books") Response<Book> newBook(@RequestBody String strBook) { super.newbook(strBook); } // BookController Response<Book> newBook(String strBook) { ... // basically the big example from above minus the @RequestBody and @PostMapping }
- salehenrahman 6y ago> I feel like the real Liskov Subsitution Principle should be "if you have a subtype P of type S, redesign your code so that there are no subtypes". Inheritance (a construct to allow you to create class hierarchies) is not the same thing as subtyping. For instance, an integer is a subtype of a real number (assuming both, in some hypothetical language, are distinct primitive types). In fact, LSP will work perfectly fine even in languages that don't support inheritance. LSP decries that that the behaviour of a particular operation matches the expressed intent of said operation. Going back to our integer being a subtype of real number example, the "<" operator will work perfectly fine whether any of the operands are either an integer or a real number. So, I definitely agree with the sentiment "class hierarchies are useless", but I can't say the same about LSP. In fact, if someone is passing me an object, I expect it to behave the way it does, as is decried by LSP. Also, you may argue that I am being pedantic, however I fear some people may take what you are implying a bit literally (e.g. never subtype anything), so I thought I'd clarify.
- dllthomas 6y ago> Going back to our integer being a subtype of real number example, the "<" operator will work perfectly fine whether any of the operands are either an integer or a real number. If we're talking about theoretical "real numbers", then in constructive mathematics I believe "<" isn't decidable on reals. Whether that matters for the point you're trying to make, I have no idea. If we're talking about floats, we have a concrete example of weird behavior of "<" leading to programming error: in Haskell, you can make a Set of any type that supports "<" (by virtue of being an instance of Ord - things that can be ordered). However, following ieee754, NaN is not equal to NaN. Which means that you can wind up accumulating arbitrarily many copies of NaN in your Set. (You don't usually want to be comparing floats for equality anyway, but it's still not good behavior.)
- salehenrahman 6y agoInteresting to hear about "<" is not decidable on reals. I'm naive, and I'm curious to know where I can read more about that. Edit: OK, now I get it. Equality/inequality verification is undecidable for irrational numbers.
- seemslegit 6y agoInterfaces are inheritance as far as OOP design principles are concerned.
- hinkley 6y agoYou're into structural versus nominal typing here, which is an important distinction (and tool) to point out.
- seemslegit 6y agoFirst, nominal is assumed because 'structural typing' was not a thing in any major language when the Liskov substitution principle was coined. Second, this is independent of structural vs nominal typing - implementation of an interface can be thought of as inheritance of a class whose methods all have noop implementations, and in fact this is (almost) how it's done in languages that don't have interfaces like C++
- dragonwriter 6y agoInterfaces are types, and concrete classes implementing them are subtypes. OTOH, it's probably easier to avoid LSP violations with interfaces than concrete supertypes. > Is there a good reason to have a class hierarchy in modern programming? Well, implementation inheritance facilitates code reuse; there's some ways to do that that aren't traditional class heirarchies, but they mostly seem morally equivalent to multiple inheritance with partially-abstract base classes. Ergonomically, though, I think some of them don't do as much to encourage inappropriate subytyping or implementation sharing compared to classical purely-class-based inheritance.
- yann-gael 6y agoYes, that's the reason why it is important to distinguis h between types and implementation classes. Types should describe a taxonomy of "things" as well as possible and according to the need of the client. Implementation classes should be used to reuse/factor out as much code as possible (DRY) while providing implementations for the types. Java interfaces are nice because they can be used as types while one or many classes can implement them.
- mehrdadn 6y agoThe Visitor pattern is useful for some designs and it can be quite natural with inheritance. GUIs also tend to lend themselves nicely to inheritance. Streams are also generally nice to implement with inheritance as well. More generally, dynamic components that interoperate with each other also inevitably lend themselves well to a class design. Lots of things are nice to implement with class hierarchies actually. That said, you don't need an OOP language or "classes" to write an object-oriented program (with subtyping etc.); you can do it in C with message passing, callbacks, etc... Windows basically forces you to do this when using the Win32 GUI API. It's not really a counterexample though. It's not 100% exactly the same thing (and you might have some interesting discussions around this) but for the most part it can be quite similar in spirit and mostly just a matter of doing everything as classes would do, except manually. If you haven't had a need for serious OOP then either your job involves writing a different type of code (e.g. data crunching might be better with just functions everywhere) or you need to invest more time learning object-oriented design. If it's just the usual "a Dog is an Animal" thing to you then you're missing out on an entire world. A huge slice of programmers either don't know to take it seriously, don't want to take it seriously, or don't have to take it seriously, and end up either hating OO design or finding it pointless or trivial. But it's quite a deep subject that is generally taught rather poorly and superficially (and possibly far, far too early) in courses; you're lucky if they even mention LSP, and there's so much beyond that that they likely won't touch at all. There's probably enough to it to talk about it in a graduate course if not more. And in practice, coming up with a good OO design can be quite challenging (and I'm talking about cases where OO is the solution); languages that force you to do everything as classes and objects often end up making a complete mockery out of it (like Java), but if you ever have to do things that are very dynamic (making a plugin architecture is often like this) it can be almost impossible to avoid. P.S. Inheritance (which results in class hiearchies) is just a mechanism that's used for more than just OOP. It's also used for mixin purposes, which aren't really about OOP at all, but just about code reuse, especially in a language where there's no better solution. I haven't mentioned things like these above, but they come up a lot too. (e.g. the HTTP server in Python has a ThreadingMixin base class, though you can probably have a discussion about whether it's the best solution or not.)
- zozbot234 6y ago
- hinkley 6y agoMore precise, and somewhat freeing, advice: Redesign your code so that the calling code does not (need to) know about subtypes.
- ngvrnd 6y ago>>> Is there a good reason to have a class hierarchy in modern programming? rarely, but perhaps not "never".
- lowbloodsugar 6y agoThe thing you've spotted is that a square is a "rectangle with constraints". A Fox is not an implementation of an Animal: it is an Animal with constraints (fur = orange, tail = bushy, says = "what?"). An integer is not an implementation of a Real, it is a constrained set of specific reals. Some programming languages do this. IIRC XML Schemas allow constraints of derived "types". Prolog, obv. Cyc, for sure. In UML we talk about generalizations and specializations, a superclass is more general, a derived class is more specific. But specialized means "I do X in a specific way", as opposed to a new constraint like "You can't do X with me". And this is where we get into the immutable object discussion, and note that if we had a Square class, then you couldn't change its height but you could say square.with_height(10) and it would return a rectangle. Then, one wonders, if you had rectangle(10,5).with_height(10) would it return a square?! And suddenly we are in the world of "type" being utterly ephemeral and dependent only on the state of an object, and then we would only have free objects, while "Interfaces" or "Types" would actually be constraints, and a Square would be "a rectangle with two equal dimensions" and now we are writing Cyc Theorems.
- mirekrusin 6y agoYes, there are good reasons to use class hierarchies but they don't pop up all the time. For example recently I've used it in tsql [1] - template sql combinator library; inheritance is not a big part there, just a small bit where there is sanitised-identifier class that's a child of a sanitised class. Some combinator functions require sanitised-identifier (unusally table name) but most of combinators don't care; as long as it's sanitised value they're ok with it. It's good to have flow keeping track of this relation and reporting violations. [1] https://github.com/appliedblockchain/tsql/ https://github.com/appliedblockchain/tsql/
- sedatk 6y agoClass hierarchy is simpler but can be harmful with complex dependency relationships.
- contravariant 6y agoWhen done well inheritance allows you to denote which parts of your class could be overridden. I don't think it's wrong to think of inheritance as an implicit interface, but I'm not 100% sure if all languages have interfaces powerful enough to replace inheritance. If you're careful with what parts you allow subclasses to access and override then it's not too different from using interfaces, and not too hard to refactor when you want to split it out into interfaces. I also think class hierarchies are simply the natural way humans think of concepts, and inheritance seems to work well if you're inheriting from more abstract classes to more concrete implementations.