Authors: Lea Verou
Contents
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
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.
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
instanceofor some other mechanism.
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.
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):
passNotes:
- 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.
class M1 {
public:
void foo() { std::cout << "M1"; }
};
// M2, M3 elided
class A : public M1,
public M2,
public M3 {
// ...
};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;Precursormust specify which superclass to use (e.g.Precursor {M1})
(defclass m1 () ())
(defmethod foo ((x m1))
(format t "M1~%"))
; m2, m3 elided
(defclass a (m1 m2 m3) ())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:
- Interesting:
onclause to specify intended superclass. mixin classto define a class that can also be used as a mixin.
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 BNotes:
- 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,withcan only come afterextends. - 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:superskips 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.
interface M1 {
void foo() { Console.WriteLine("M1"); }
}
// M2, M3 elided
class A extends B implements M1, M2, M3 {
// ...
}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
endNotes:
- 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.
superresolves 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.
prependcan change that.
class M1 {
def foo() { println "M1" }
}
// M2, M3 elided
class A extends B {}
A.metaClass.mixin M1, M2, M3