Neat-Lang / neat

The Neat Language compiler. Early beta?
BSD 3-Clause "New" or "Revised" License
110 stars 9 forks source link

Convert single class inheritance to composition #33

Open divyekapoor opened 10 months ago

divyekapoor commented 10 months ago

Single class inheritance is functionally identical to a delegation pattern (the hidden *this is made explicit). The problem with inheritance is that it couples state and implementation by breaking encapsulation.

The alternative is to be inheritance free and when inheritance has to be simulated, use a delegation pattern.

Eg. (in pseudocode)


class X implements Base {
  X(Base b) { this.b = b; }
  X(int x, int y) { this.b = Base(x, y); }

  Base::* = delegate(this.b);  // direct inheritance
  Base::override_fn = delegate(this.other_b);
  Base::custom_override(a, b) {
    // impl..
  }
}

it’s very important to not allow private member leakage as refactors of a widely used base class with non trivial internal state become impossible because of long distance stateful couplings of internal state. I’ve seen this several times in widely used codebases at Google while working on Search and all such refactors of class hierarchies were close to no-go as they were 3 levels of stateful class couplings and no one could predict if a local change in the internals of the base class was safe (and thread safe) because of the internal accesses done by “derived!!! classes”.

Even if the code was completely audited for safety, there could be a PR in flight that relied on the old thread safety assumptions inside the internal code and a silent corruption due to that would be pretty bad.

To make this a bit more concrete - I’m sure you can understand that if a stateful class was implementing a docId counter, such a piece of code would be widely reused with small variations and changing the base class would be an extremely delicate operation.

For reasons like this and others, the modern trend is towards trait delegation rather than traditional inheritance.

Here are 2 blog posts that I think you might find helpful in the context of this issue:

https://www.divye.in/2019/06/modern-programming-never-use.html - OOP inheritance = conflation of type composition and implementation composition in the implementation space (bad! breaks dependency injection).

https://www.divye.in/2022/02/public-static-is-harmful-it-has-no-home.html (slightly off topic, the main link being that this is an example where type-composition and code composition were conflated in the space of types - it's the inverse problem of inheritance).

The only way to avoid both of these traps is to ensure that Types and Implementations can't be coupled. The correct relationship between them is through the delegation pattern as above.

Thanks for taking the time to read this. I realize this is a major change that’s being proposed. I hope you’ll consider it with due care. The best and only time to resolve something like this is early in the evolution of the language. I really liked how Neat is structured. Like you, I seek an alternative to the C++ mess and Rust doesn’t cut it for me.

Thanks and best wishes! Divye

divyekapoor commented 10 months ago

Summary:

  1. Inheritance is functionally identical to composition with a hidden parameter (see blog post).
  2. Class inheritance breaks dependency injection through a hardcoded dependency (and thus breaks testability).
  3. The delegation pattern is superior in almost all ways than inheritance (and also supports multiple base classes if needed) with clean semantics.

Requested Outcome: Disengage the conflation of type composition and code composition through the delegation pattern.

FeepingCreature commented 10 months ago

Private members are not accessible by subclasses in Neat. (I don't think?) I haven't added protected yet.

Generally speaking, Neat deemphasizes inheritance to begin with. I think the dangers of inheritance are overstated because people use it for things where it doesn't belong. As a result, I haven't run into any of the issues you speak of, but they seem to me like issues of communication more so than language design.

Also, sumtypes let you upgrade a class by just writing a new BaseclassV2 and putting it in a sumtype with Baseclass in your library. This should significantly reduce the overhead.

I don't follow your point about dependency injection. You should be defining an interface, that both your implementation and the test implementation inherit from.

I'm not planning to make any of those changes, as my experience with OOP doesn't bear them out. If you want to add composition-based features to the language, be my guest, macros are right there - if there's some missing macro feature that prevents parity between macro OOP and builtin OOP please let me know and I'll add it.

divyekapoor commented 10 months ago

Thanks for the response!

Private members are not accessible by subclasses in Neat. (I don't think?) I haven't added protected yet.

Sounds good - maybe consider not adding them at all?

Generally speaking, Neat deemphasizes inheritance to begin with. +1!

I think the dangers of inheritance are overstated because people use it for things where it doesn't belong

Thought experiment: What's a bright line test where inheritance does / does not belong? (The original use for inheritance was for polymorphic dispatch in a language which did not support generics / template types - essentially introducing covariance and contravariance [1] "sneakily" through polymorphic dispatch to more specialized classes and through static binding to base classes - the advent of generics and traits / concepts made this largely obsolete).

It's none of my business, though having no inheritance will probably simplify the variable scope resolution, type checking (the type is the type, it's unique and it's not related to any other type other than through interfaces), type casting (no dynamic_cast issues without vtables/RTTI), any reflection based code (no need for special "getSuperClass()" type methods), the compiler's function binding code (static dispatch only) and you'll guarantee a language without hidden vtable function pointers. Bonus: macro type system will be clean.

Also, sumtypes let you upgrade a class by just writing a new BaseclassV2 and putting it in a sumtype with Baseclass in your library.

Consider what would happen to generics, reflection and macros if this is the path being chosen.

I don't follow your point about dependency injection. You should be defining an interface, that both your implementation and the test implementation inherit from.

Yes - we both agree. However, if Cat inherits from Animal, there's no way to stub out the Animal's code in the test code's construction of Cat - the Cat constructor will hard code the Animal constructor. If the test wants to stub out the Animal implementation, it is disallowed by the language. This isn't a language feature, it's a bug.

(Minor point: Cat inherits from Animal is the same as Cat(Animal a), the 2nd one is dependency injection compatible, the first one has no way to inject an Animal type in the constructor - the closest we can get are:

  1. Cat(Animal a): super(a) // invoke copy constructor and every inherited type has to implement this constructor separately by convention. In essence, inheritance isn't the path that yields bulletproof clean code.
  2. Cat(Animal_Arg a1, Animal_Arg a2, Animal_Arg a3) : super(a1, a2, a3) // which leaks the constructor args throughout the inheritance hierarchy and all constructors need to be inherited and if the base classes change, all of the derived classes change. Messy.)

I'm not planning to make any of those changes, as my experience with OOP doesn't bear them out. If you want to add composition-based features to the language, be my guest, macros are right there - if there's some missing macro feature that prevents parity between macro OOP and builtin OOP please let me know and I'll add it.

Fair enough. Thanks for the option. I'll pass. My motivation to spend time in these threads is to share learnings from hard fought experience. In my daily job I build frameworks where I've had more opportunity than most to see the type system's greatness, edge cases and failings up close. In a sense, I'm a language designer's power user and I've spent tens of thousands of lines of metaprogramming building frameworks used at industrial scale.

Here, I have the opportunity to influence your thinking at the early stages of a promising project - a stage where I believe some of my effort will yield disproportionate long term results. My goal here is to not be a crank. If I'm anywhere close to doling out gray bearded advice in this area, I will have been successful.

In summary, my last pieces of advice are:

  1. Ditch inheritance - it's less flexible. Generics/Templates cover the gap.
  2. Where runtime polymorphic dispatch is absolutely a requirement (eg. gaming engines, data driven systems), allow for a Rust-like dyn interface which creates a vtable. This introduces multiple inheritance safely (since the interfaces are not allowed to change the "shape" of the data and do not have diamond inheritance type aliasing issues).
  3. Implicitly, this means that there's no "protected" in the language and encapsulation is never broken.

I wish you the best! Neat is promising.

Please feel free to close out this ticket.

Cheers! Divye

[1] https://en.wikipedia.org/wiki/Covariance_and_contravariance_(computer_science)#Interfaces