2005-06-12

OOP without inheritance

Well, I've read Schärli's Traits thesis, which is getting uptake in Perl 6 and Fortress [PDF] among other places. And it makes me furiously to think.

Do we really need inheritance (or delegation, which is inheritance at the object level) in a traits-based world? Why bother with overriding methods when they can be just replaced selectively? Sending to super is a marginal feature, and can easily be simulated by selective including and renaming from traits.

Here's my current vision, best if eaten by <date> yada yada:

Code is encapsulated in methods, so we still have methods, but classes go away in favor of two sort-of-new concepts, behaviors and types. A behavior is just a set of methods and associated private state variables. The methods in the behavior can be local (in which case they are not visible outside the behavior, and are basically just subroutines), standard, or abstract. You cannot instantiate a behavior as such, nor can a variable be typed (in a statically typed language) to a behavior. It's just a pile of things that an object might be able to do. If a behavior wants to expose its private state, it does so with a getter and/or setting method; a smart language will make it easy to specify that you want these things.

Types are used to instantiate objects and declare variables. They are composed out of behaviors: the methods available on an object of type T are the non-local methods provided by the behaviors out of which T is composed. Unless the type is itself abstract, any abstract methods in one behavior must be supplied by a standard method in another behavior, a sort of peg-and-hole operation. You can bring in a single method from a behavior, suppress a method in a behavior, or rename a method in a behavior when constructing a type; that allows you to compose behaviors that don't quite fit together perfectly. All this is verified when the type is compiled; a clever IDE can notice discrepancies and warn the programmer about them.

We also need a notion of private vs. public methods, but it's not clear to me whether this should be declared directly on the method (i.e., where the behavior is) or at the type level. That's just notational, however. With that established, we can give an implementation-independent definition of subtypes and supertypes, declared at the type level. The compiler verifies that the methods of a declared {sub,super}type are a {super,sub}set of the type currently being specified, and that arguments are appropriately contravariant and results covariant (in a statically typed language) so as to provide minimum requirements for Liskov substitutability.

I'm not sure yet what the constructor/destructor story might be: I like factory methods better than constructors anyhow, and perhaps that's the Right Thing.

Ideas? Comments? WAGs?

5 comments:

Anonymous said...

I think one of the key points of traits that makes them simpler than multiple inheritance is that they don't have state. Instead, they declare getters and setters (or whatever), and the class implements the state.

If you put private fields into the traits, you'll just get the diamond inheritance problem all over again. The class needs to decide whether two traits share the same state or not.

John Cowan said...

In this model, there is no sharing of state between behaviors incorporated into the same type: each behavior takes responsibility for its own portion of the state. They can happen to use the same names for state variables, but that's irrelevant.

See my earlier posting on divisions, which segment classes into groups of methods, each group taking responsibility for its own state. This is essentially the same notion but in reverse: the implementation of a type is the result of composing behaviors.

After all, there's no fundamental difference between referencing a getter/setter pair from elsewhere and referencing state variables directly. Indeed, in Self you can't tell at the point of reference whether you are {getting,setting} a slot or calling a {zero,one}-argument method.

I think the statelessness of the traits model was an accidental result of the fact that Squeak instance variables are referenced by name at runtime.

Anonymous said...

Interesting. That would certainly work when you don't want to share state (which is already the case with delegation).

It seems like plugging multiple behaviors together to make a class could be quite elegant when behaviors are designed to fit together. But in many cases there's going to be some glue code to adapt them. In a language like Java, two behaviors might be implemented as separate classes, two instance variables would point to them, and the glue code would sit in the class itself. But in a langage where code is disallowed in classes, you'll need to write a glue behavior that probably isn't useful outside of the class it's intended for.

John Cowan said...

That could be done in this model by allowing the declaration of type-local behavior, which is semantically like any other behavior but whose name is not visible outside the type. It wouldn't even have to have a name, in fact, which would make it easy to write simple single-behavior types.

Anonymous said...

For a few years now, I've been designing a language and studying others to find the "best subset" of features for expressiveness/flexibility. I found amazing expressiveness in Google's Go language, and I recently came across traits from JetBrain's Kotlin.

This blog post very closely describes my goals for traits, and I'd like to share my thoughts on combining traits with Go's style for a very expressive flexible language. For context, I'll explain Go's approach first:

Traditional inheritance is built on embedding references to behavior (method pointers / vtables) directly within the type (and thus the object); and multiple inheritance gets messy because multiple structures must be embedded within the same object.

Go's approach leaves objects with their own flat representation, and abstracts dynamic behavior into interfaces. This seems more sensible than embedding multiple structures just so they can be picked out "cleanly"(?) when needed (which is only sometimes). It works like this:

1) Classes implement interfaces automatically, just by having the required methods (looks like duck typing, but is statically-typed!).

2) Methods are declared separately as "extensions", allowing anything can be fit to an interface without altering the underlying type. Classes are thus replaced with raw "structs".

3) Inheritance is replaced by a form of transparent composition, with conflicts resolved explicitly (i.e. loss of transparency in the face of ambiguity).

This separation of methods, structs, and interfaces using flat-composition is already only a step away from traits. Here are my thoughts:

Interface instances consist of a reference to an object, and an array the applicable methods for that object. Default method implementations would allow types lacking those methods to be compatible without extra methods (2). By allowing structs to "absorb" these default methods as their own, interfaces have become traits!

For example (the _'s are just for spacing):

trait Foo {
___func meth1();
___func meth2() { ... }
}

func Foo.meth3() { ... }


Foo (as an interface) requires meth1 and meth2, but provides a default implementation for meth2. Some struct could "use" Foo (as a trait) and get a copy of meth2 for itself (as an extension). Meth3 is an extension method of Foo (called on Foo “interface instances”), and thus not "part of" Foo itself. In your terms, meth1 is “abstract”, meth2 is “standard”, and “local” methods can be implemented as extensions like meth3.

Structs are composed of traits in addition to their own data members, and methods may be given as extensions. Traits may contain private data members, which can be merged with members of other traits in a composition (see here).

If trait members may only be suppressed if still provided by another trait or in the struct itself (i.e. "don't use THIS one"), then traits may still be used as interface-types exactly as before (a major gain). Non-required methods can be external (as meth3), and traits can be composed of sub-traits as needed.

Since my language is based on structs, construction consists of just providing explicit values for the data members; so factory methods are easily allowed.

Trait methods are copied for each type that uses them, but an implementation is generated for the “interface” model when dynamic behavior is needed (e.g. perhaps something stores an array of "Foo" values of any sort).