What would OOP without inheritance look like?
So last week I wrote a bit about what’s wrong with inheritance. This week, as something lighter and fun, I’d like to propose a hypothetical object-oriented language design that does not have inheritance at all. Less as a proposal, more to give you an idea of what thinking similarly (but without inheritance’s flaws) would look like. Let’s dive right in. Here’s what we’d like to try to achieve, given the problems we discussed last week:
- Design goal 1: Decouple types and implementations
- Design goal 2: Do away with inheritance entirely
- Design goal 3: Still be able to do everything we liked before
There’s a few interesting consequences of these early decisions. For one, there’s no such thing as “protected” anymore (this is a concept inherently intertwined with inheritance), and we can actually purge the notions of “public” and “private” as well. Public things are what’s in the interface, which are separated from the things in the implementation, which are otherwise automatically private.
class Name interface {
// Other interfaces that this interface extends.
implements OtherInterface;
// Methods on this interface uniquely.
void action(String);
// "public static" declarations, scoped to the type.
static Name fromString(String x) { ... }
} implementation {
// Inside the implementation, we can do full type inference, and
// so have no *requirement* for type signatures.
var some_field;
// Implementations of methods, type signatures appear in interface.
action(x) {
...
}
// Private methods, not in interface, no type signature required.
private_action(x) {
...
}
// Our thought experiment.
// To replace some uses of inheritance, we can have directives that
// generate certain function implementations like 'forward'
forward OtherInterface to some_field;
}
This is actually a combined declaration of two distinct things: a type and a class.
The interface Name
is a type, while class Name
is an implementation.
This also displays an attempt to replace the convenience of inheritance with forward
that generates functions like:
function_from_OtherInterface(x, y, z) {
return some_field.function_from_OtherInterface(x, y, z);
}
In essence, it just proxies a method call off to the specified object.
There are a few very interesting things about forward
:
1. This is how multiple inheritance gets implemented behind the scenes
You’re probably aware of how the type punning trick makes single inheritance work, but have you ever dived into how multiple inheritance works? The compiler takes internal representations that look like this:
struct Base1 {
void *vtable;
int a;
}
struct Base2 {
void *vtable;
int b;
}
struct Derived {
union {
void *vtable;
struct Base1 parent1;
}
struct Base2 parent2;
int c;
}
Then it fills in the vtables inside those two structs in Derived
as well as its own.
Type punning works fine for whatever the special “first parent” is, but for everything else, something special needs to be done.
Inside those other vtables, it generates functions that look exactly like what forward
generates above.
(In effect, to call a Base2
method on Derived
, just adjust the this
pointer and jump to the original method. That’s what that proxy method is doing.)
So to some extent, all we’re doing is un-hiding the magic here. Instead of pretending multiple inheritance is magic, you’re consciously saying “forward this method call to this object.”
2. “Type punning” can still be done as an optimization
…almost.
If one writes code like:
class Derived interface {
implements Base;
} implementation {
// We need value types for this to work
val base;
// We need some added restriction here, one possibility is
// "do not override any methods from Base."
forward Base to base;
}
In this example, the class Derived
actually doesn’t need its own private vtable
, so the pointer to Derived
also directly points to base
if it’s a value type inside the class.
(In other words, if it’s directly there instead of a reference to elsewhere.)
Suddenly, the type punning trick becomes viable.
In this situation, the functions that would be generated by forward
are actually “no-ops” before they call the method they proxy to.
Typically, all the proxy function does is re-adjust the this
pointer before calling the other method.
In this example, that pointer doesn’t need adjusting.
The compiler can recognize situations like this, and avoid generating proxies, and just re-use the implementations from the “base class” directly.
But the reason we need that added restriction of no overrides is…
3. Open recursion has been done away with
This might be the most experimental part of this design. Open recursion, recall, means late-binding. So when one method on an object calls another method, it might start to get a derived class’s overridden implementation instead. That can no longer happen, not without explicitly making it happen.
Let’s start with a simple “base class”:
class Writer interface {
void putch(char);
void putstr(char *);
} implementation {
putch(ch) { ... }
putstr(str) { ... }
}
Now we’d like to write a SimpleWriter
where putch
is defined in terms of putstr
so that implementation doesn’t need to be provided in further derived classes.
Previously, we’d just make a subclass and override that function.
Now, if we want to do the same thing, we create a class that provides an implementation of this interface, in terms of this interface.
class SimpleWriter implements Writer {
var base
SimpleWrite(Writer impl) { base = impl; }
forward Writer to base;
putch(ch) { char buf[2] = { ch, 0 }; putstr(buf); }
}
Now if we want to make use of this default implementation, we do something like this:
class MyWriter implements Writer {
var simple = SimpleWriter.new(this);
forward Writer to simple;
putstr(str) {
// My implementation
}
}
You can start to see why this is call open recursion.
We forward Writer
to simple
, but we pass this
in to simple, and simple
forwards back to us.
Recursion!
You can maybe start to see why people complain that open recursion is hard to reason about. (Uncontrolled recursion is generally very hard to reason about. This is part of the reason verification tools often work with functions that are provably terminating—if you can prove it terminates, then its recursion is tightly controlled, and it becomes reasonable again.) But once we’re forced to actually look at things like this, we also start to see how we could simplify things with a different design.
interface BasicWriter {
void putstr(char *);
}
interface Writer {
implements BasicWriter;
void putch(char);
}
Now we’re no longer actually doing much in the way of recursion.
The circularity has been straightened out, our implementation for Writer
would only depend on a BasicWriter
implementation, instead of on another implementation of Writer
itself.
We’re still passing in this
, but its a more restricted form that doesn’t see us implementing an interface in terms of itself.
So with forward
we only get recursion when we ask for it, and we can come up with designs that only ask for restricted forms of it.
4. Forwarding is object-compositional
Consider this code:
class Negate interface {
implements Expression;
} implementation {
val child;
val equiv = Subtract.new(Literal.new(0), child);
Negate(Expression c) { child = c; }
forward Expression to equiv;
}
We’re doing something rather special here.
We constucted the behavior for Negate
out of the behavior implemented for classes like Subtract
and Literal
.
We’re getting our implementation from the implementations of a composition of several other classes.
This is something inheritance just… doesn’t do.
There has to be a “base class” to re-use.
In this case, we could almost get away with inheriting from Subtract
, but that’s only because this is a really simple example.
Do a little computation in the constructor to figure out what equiv
should be, and suddenly you’d need some kind of “dynamic inheritance” or something wild like that.
So forward
gets us the ability to compose behavior together, even dynamically, not merely inherit from a single statically-determined base class.
Designing code for composition is something I haven’t even started talking about yet, but it’s a very important idea.
5. Forwarding can get even wilder!
Consider forwarding to a mutable variable.
Close a file handle?
Mutate that variable to point to a different implementation representing a closed file handle.
Suddenly there’s no more “is file open or closed” logic laying around inside your implementations.
Some old OO languages experimented with a become
construct that could change what class an object had, but this was regarded as a bad idea not worth it.
I’m not sure this approach actually suffers from the same problems.
If I had a million dollars… or 5
The most interesting things about forward
are the lack of implicit open recursion, and the ability to compose together behavior.
I think some people have a sense that innovation, especially in OO-like languages, has basically ceased. I hope the above leaves you with the impression that this isn’t because there aren’t ideas rolling around. Innovation has come to a halt mostly because Java/C# and dynamic languages have sucked the air out of the room. Everyone’s come to agree about what OO means, and are apparently unwilling to deviate.
Innovation always slows (and sometimes regresses) when you can’t critique things.
Right now I think we’re suffering from that, not because there’s actually a dominant ideology, but because most sensible people see the “religious war” between ideologies as toxic.
I hope presenting forward
as an alternative to inheritance works as a tool to critique object-orientation while minimizing the raising of hackles on the part of people who hate critiques of object-orientation.
Some final notes
I first encountered the idea of forwarding in the context of something called “attribute grammars” as a graduate student.
I originally wrote this post using the word “proxy” to distinguish the idea from that form of forwarding, but it turns out forwarding is also the name used for this concept in object-oriented programming circles.
So I’ve now edited the post (2018-7-16) to use forward
instead.
This concept is also similar to what’s called the delegation pattern in OO circles.
I originally was not sure if any OO language had introduced explicit support for the concept. Then someone on reddit pointed out that Kotlin has a feature called class delegation that works similarly. The documentation is a bit sparse but it seems to be the same idea. (Amusingly, wikipedia seems to draw a distinction between delegation and forwarding that seems to be in contradiction with Kotlin’s use of the word. Oh well.)