7 ms·
> This problem is impossible to fix and it is a general problem - when overwriting a method, you can never be sure that semantics could break when the base clas
by judofyr 5y ago
> This problem is impossible to fix and it is a general problem - when overwriting a method, you can never be sure that semantics could break when the base class is changed.
I'm not sure what's so special about overwriting methods here. There are many cases where semantics can break as a dependency changes. One example would be whether a callback is executed on the same thread (or event-loop tick) or in the background. A function might assume that the callback should be pure and thus in a later version switch where it's being executed. If your code depended on it being executed in a certain way, then it'll now be broken. The problem here is that there were certain constraints of the function which you weren't aware of (possibly because it wasn't documented).
A base class will have a similar constraints that you, as a subclasser, must be aware of.
Your stack example is also a good example of where inheritance is actually useful:
class CountingStack(...) extends Stack {
override def push(element) returns nothing =
count += 1; super.push(element)
override def pop() returns element =
count -= 1; super.pop(element)
}
Assuming that the Stack class specifies that all modifications will go through the push and pop methods, this ensures that the count is maintained regardless of how many other utility methods that exists on Stack (i.e. clear()). You don't have to extend the CountingStack with extra methods as the Stack methods gain more methods.
- valenterry 5y agoVery good point actually. If we cannot assume synchronous execution, then semantics can break exactly as you said. That a rather orthogonal problem from the inheritance one and my suggested solution is to make use of pure functional programming (hence purity can be assumed unless the method signature indicates that an effect might happen). Here is an example of how a potentially async Stack might look like: class Stack(...) extends Stack { def push(element) returns IO[nothing] = ... def pop() returns IO[element] = ... } The IO would describe an action that can be ran at a later point, which means when the method has being called and returned, nothing has happened until you call `run(io)`. That way you will be immediately aware of the behavior and adapt your Countstack as follows: class CountingStack(underlyingStack) extends Stack { override def push(element) returns IO[nothing] = underlyingStack.push(element).andThen(nothing => count += 1; return nothing) override def pop() returns element = underlyingStack.pop(element).andThen(element => count += 1; return element) } That ensures that order of events is being kept and also that failures (e.g. a concurrent thread dies in the middle) are handled. > Assuming that the Stack class specifies that all modifications will go through the push and pop methods This is a severe restriction on how the Stack can be implemented then and it bears the risk of someone violating this specification by accident (think about the famous equals/hashCode specification in Java).
- thu2111 5y agoYou can push things into the type system only so far before you drop off a cost/benefit ratio cliff, unfortunately. Fundamentally, even in FP languages that expose side effects in the type system, you can still easily make APIs with undocumented semantics and in which users will break as the underlying component evolves. In fact Haskell is quite notorious for an ecosystem that seems to believe Haskell's type system obviates the need for good documentation, an illusion the Java world fortunately never labored under. I think in recent years there's been an uptick in clever sounding attacks on common PL features like inheritance and exceptions, many of which look suspiciously like motivated reasoning. At the very least the arguments are extremely weak. This article ends by saying: "Personally, for code reuse and extensibility, I prefer composition and modules." But these are orthogonal. Languages like Kotlin have built-in support for inheritance, modules and composition. It's not an either/or approach, and there are lots of high quality, highly successful codebases that use inheritance extensively which would be pretty unimaginable without it. I use libraries that use inheritance every day and it's very rarely a problem: only in cases where someone made a bad API with it, and you can get bad APIs that rely on composition or badly modularised APIs too. I don't feel like one problem is more common than another.
- nicoburns 5y ago> There are many cases where semantics can break as a dependency changes. One example would be whether a callback is executed on the same thread (or event-loop tick) or in the background. A function might assume that the callback should be pure and thus in a later version switch where it's being executed. Rust's Send and Sync traits solve this particular problem by putting the thread-send safety in the type signature. If the library changes to use a background thread and you have passed it a non-threadsafe closure then you'll get a compile error.
- zozbot234 5y ago> I'm not sure what's so special about overwriting methods here. There are many cases where semantics can break as a dependency changes. The problem with implementation inheritance specifically is that there is no single responsibility for establishing this semantics. Every method call on overridable methods incurs a dispatch step involving the whole of an arbitrarily extensible inheritance hierarchy, and every method can be called by an unknown extent of user code. You just can't give a proper semantics to it without looking at the whole-program level, which is antithetical to modularity.