Skip to content

Latest commit

 

History

History
479 lines (352 loc) · 12.7 KB

File metadata and controls

479 lines (352 loc) · 12.7 KB

Class Partials: Prior Art

Authors: Lea Verou

Contents
  1. Userland patterns
    1. Subclass factories (mixins)
    2. Delegation Pattern / Controllers { #delegation }
    3. Prototype mutations
    4. Instance mutations
  2. Other languages
    1. Multiple inheritance languages
    2. Partials

Userland patterns

Subclass factories (mixins)

This is the prevailing pattern for web components:

const M1 = SuperClass => class M1 extends SuperClass {}
const M2 = SuperClass => class M2 extends SuperClass {}
class A extends M2(M1(SuperClass)) {}

Libraries:

The Mixins proposal proposed a declarative syntax for mixins with automatic deduplication.

Problems:

  • Pollution of the inheritance chain, no separation between identity and traits
  • Requires additional code to deduplicate mixins (e.g. @open-wc/dedupe-mixin)
  • Hinders debugging, since the class' string representation does not reflect its actual superclass, but always shows the mixin parameter.
  • Every mixin needs to call super.methodName?.() defensively to avoid breaking things

Delegation Pattern / Controllers { #delegation }

A common OOP pattern is to achieve composition via delegation (or forwarding), where the implementing class uses a separate object to abstract and reuse behavior.

The pattern typically involves either one-way linking (host class → delegate or vice versa) or two-way linking (host class ↔ delegate).

When the use case calls for new API surface, the host class will hold a reference to the delegate object and forward certain calls and property accesses to it.

The following snippet demonstrates the three possible relationships:

class Delegate1 {
  foo () { /* ... */ }
  bar1() { /* ... */ }
  qux1 = 1;
}
class Delegate2 {
  constructor(host) {
    this.host = host;
  }

  foo () { /* ... */ }
  bar() { /* ... */ }
  qux2 = 1;
}

class MyClass {
	constructor() {
    // host → delegate
    this.delegate = new Delegate1();
    // delegate → host
    new Delegate2(this);
    // host ↔ delegate
    this.delegate2 = new Delegate2(this);
  }

  // API surface glue code
  foo() { return this.delegate.foo(); }
  bar1() { return this.delegate.bar1(); }
  get qux1() { return this.delegate.qux1; }
  set qux1(value) { this.delegate.qux1 = value; }

  // Renamed to avoid naming conflicts
  foo2() { return this.delegate2.foo(); }
  bar2() { return this.delegate2.bar(); }
  get qux2() { return this.delegate2.qux2; }
  set qux2(value) { this.delegate2.qux2 = value; }
}

In Web Components, this pattern is known as Controllers. The ElementInternals API is a native example of this pattern in the web platform. Lit even has a Controller primitive to facilitate this pattern.

Pros:

  • Separate state and inheritance chain makes it very easy to reason about
  • Can be added and removed at any time, even on individual instances
  • Can have multiple controllers of the same type for a single class
  • Delegate does not need to be built for this purpose. E.g. in many objects the delegate is simply another object (a DOM element in Web Components, a data structure, etc.)

Problems:

  • (Major) No way to add API surface to the class, so use cases that need it involve a lot of repetitive glue code.
  • Because the glue code is manually authored by the author of the implementing class, it may not be up to date with the latest API surface of the controller.
  • No good way to extend existing methods in the host class, e.g. to add side effects to certain lifecycle hooks (at least not out of the box).
  • No way to check whether a given class implements a certain delegate or not, even with a shared contract about what property the delegate is stored in (since instance fields cannot be inspected without creating an instance)
  • No way to add a delegate to a class from the outside, the implementing class needs to create the delegate itself.

Prototype mutations

Another pattern for behavior sharing is to mutate the prototype of the implementing class:

class Mixin1 {
  foo() {
    console.log('foo from Mixin1');
  }
}

class A extends B {}

// elided for brevity
import { copyOwnDescriptors } from './utils.js';

copyOwnDescriptors({
  from: Mixin.prototype, to: Class.prototype,
  exclude: ['constructor']
});
copyOwnDescriptors({
  from: Mixin, to: Class,
  exclude: ['length', 'name', 'prototype'],
});

Libraries:

It is notable that Cocktail attempts to address naming conflicts via composition:

Cocktail automatically ensures that methods defined in your mixins do not obliterate the corresponding methods in your classes. This is accomplished by wrapping all colliding methods into a new method that is then assigned to the final composite object. [...] The return value of the composite function is the last non-undefined return value from the chain of colliding functions.

Issues:

  • no way for a mixin to run code at element construction time or subclass definition time (at least not without some sort of convention, like e.g. an init() method or similar that the implementing class needs to call).
  • No way to disentangle where everything comes from (for debugging, devtools, etc).
  • No way to test whether a class implements a given mixin, either via instanceof or some other mechanism.

Instance mutations

Another pattern is to mutate the instance of the implementing class:

import Mixin from './mixin.js';
import { applyMixin } from './utils.js';

class A {
  constructor(instance) {
    applyMixin(this, Mixin);
  }
}

traits.js applied this pattern by composing traits into a single prototype that could then be used to make instances:

const TraitA = {
   methodA() { console.log("A"); }
};

const TraitB = {
   methodB() { console.log("B"); }
};

const Combined = Trait.compose(TraitA, TraitB);
const obj = Object.create(Object.prototype, Combined);

obj.methodA(); // "A"
obj.methodB(); // "B"

While extending instances allows for more flexibility in terms of when and how to apply the mixin, it is considerably slower and makes it harder to reason about the API from a class reference.

Other languages

Multiple inheritance languages

These languages facilitate partials through multiple inheritance, but make no distinction between identity and behavior, it's just superclasses all the way down.

Python doesn’t have an explicit mixin concept, but supports multiple inheritance:

class M1:
	def foo(self): print("M1")

# M2, M3 elided

class A(M1, M2, M3):
	pass

Notes:

  • Naming collisions are resolved via a predefined precedence order(MRO) based on the order of inclusion. The first class in the list takes precedence.
  • Additional classes can be added post-hoc via __bases__ but that is considered an anti-pattern and not all base changes are legal.

C++

class M1 {
public:
	void foo() { std::cout << "M1"; }
};

// M2, M3 elided

class A : public M1,
          public M2,
          public M3 {
	// ...
};

Eiffel

Apparently one of the most elegant solutions for multiple inheritance.

class M1
	feature foo do print("M1") end
end

-- M2, M3 elided

class A inherit M1 M2 M3
	-- ...
end
  • Supports renaming at the point of inclusion. Any references to the renamed method in the original class continue to work.
  • No generic super; Precursor must specify which superclass to use (e.g. Precursor {M1})

CLOS

(defclass m1 () ())

(defmethod foo ((x m1))
  (format t "M1~%"))

; m2, m3 elided

(defclass a (m1 m2 m3) ())

Partials

This includes primitives like mixins, traits, protocols, interfaces, etc.

TBD

trait M1 {
	public function foo() {
		echo "M1";
	}
}

// M2, M3 elided

class A extends B {
	use M1, M2, M3;
	// ...
}
mixin M1 {
	void foo() { print("M1"); }
}

// M2, M3 elided

class A extends B with M1, M2, M3 {
	// ...
}

Notes:

Rust traits { #rust-traits }

pub trait M1 {
	fn foo(&self) { println!("M1"); }
}

// M2, M3 elided

// no way to do extends B,
// only composition or delegation
struct A;

impl M1 for A {}
impl M2 for A {}
impl M3 for A {}

Notes:

  • Framed largely as interfaces with "default implementations".
  • Mixin to class relationship defined separately from the class itself, allowing for individual overrides of the mixin's methods.
protocol M1 {}
protocol M2 {}
class A: B, M1, M2 {
    // ...
}
trait M1 {
	def foo: String = "M1"
}

// M2, M3 elided

class A extends B with M1 with M2 with M3 {
	// ...
}

Classes can extend one superclass, but can have multiple traits, and traits can extend other traits or classes.

abstract class A:
  val message: String
class B extends A:
  val message = "I'm an instance of class B"
trait C extends A:
  def loudMessage = message.toUpperCase()
class D extends B, C

val d = D()
println(d.message)  // I'm an instance of class B
println(d.loudMessage)  // I'M AN INSTANCE OF CLASS B

Notes:

  • Naming collisions are resolved via a predefined precedence order (class linearization) based on the order of inclusion. The last trait in the list takes precedence.
  • Cannot do class A with M1 with M2, with can only come after extends.
  • Traits affect what super.f() resolves to (which may come from a trait or a superclass)
class M1 a where
	m1 :: a -> a
class M2 a where
	m2 :: a -> a
class A a where
	a :: a -> a

class A a where
	a :: a -> a deriving (M1, M2)

In Java, interfaces can contain logic, but it is framed as a "default implementation" rather than code reuse. As a result, superclass methods take precedence over interface methods.

interface M1 {
	default void foo() {
		System.out.println("M1");
	}
}

// M2, M3 elided

class A extends B implements M1, M2, M3 {
	// ...
}

Notes:

  • super:
    • super skips interfaces and only follows the inheritance chain.
    • For interfaces, there is InterfaceName.super.methodName() to call a specific default implementation from the overriding method.
  • Naming conflicts:
    • If two interfaces define a default method with the same signature, the class must explicitly override that method to resolve the ambiguity, otherwise compilation fails.
    • Superclasses take precedence over interfaces.

C# interfaces

interface M1 {
	void foo() { Console.WriteLine("M1"); }
}

// M2, M3 elided


class A extends B implements M1, M2, M3 {
	// ...
}

Kotlin interfaces

interface M1 {
	fun foo() { println("M1"); }
}

// M2, M3 elided

class A : B(), M1, M2, M3 {
	// ...
}
module M1
	def foo
		puts "M1"
	end
end

# M2, M3 elided

class A < B
	include M1
	include M2
	include M3
end

Notes:

  • Separate syntax for instance methods (include) and class methods (extend).
  • Despite being specified anywhere in the class body, their placement does not affect precedence order.
  • super resolves to the same method up the chain (which may come from a module or a superclass)
  • In terms of precedence order, modules sit between the implementing class and its superclass. prepend can change that.

Groovy mixins

class M1 {
	def foo() { println "M1" }
}

// M2, M3 elided

class A extends B {}

A.metaClass.mixin M1, M2, M3