Traits¶
A trait specifies obligations, provides reusable behavior, or both at once. It says which fields, methods, getters, setters, class methods, or factories a type must provide, and it can supply a body for any of them — but it does not store data or own concrete identity.
Body or no body¶
A member with no body is an obligation: something a class using the trait must provide. A member with a body is a default: reusable behavior every user of the trait gets for free, in terms of whatever else the trait requires. The two live in the same declaration, mixed freely — nothing marks the difference except whether a body follows:
trait Sized:
def __len__(self: ~Self) -> int
trait Cache[K, V]:
def get(self: ~Self, key: K) -> V | none
def put(self, key: K, value: V) -> none
def is_fresh(self: ~Self, key: K) -> bool
def get_or_put(self, key: K, build: () -> V) -> V:
cached = self.get(key)
if cached is not none and self.is_fresh(key):
return cached
value = build()
self.put(key, value)
return value
trait Buildable:
classmethod from_default(cls) -> Self
Cache's small required core — get, put, is_fresh — is
exactly what get_or_put needs to build the rest of a usable cache
on top of it. Both live in one place: the obligations a type must meet
and the behavior it gets for meeting them are one declaration, read
top to bottom, not two cross-referencing ones.
A classmethod obligation reads the same way as a method one, body or no body deciding which it is:
trait Countable:
classmethod zero(cls) -> Self
classmethod one(cls) -> Self
classmethod count(cls, n: int) -> Self:
total = cls.zero()
for _ in range(n):
total = total + cls.one()
return total
ABC for the
required core, a mixin class for the derived behavior — and the ABC
half imposes ABCMeta on every subclass, which can collide with a
metaclass a class already needs for something else:
class ModelMeta(type):
...
class ModelBase(metaclass=ModelMeta):
...
class SizedABC(ABC):
@abstractmethod
def __len__(self) -> int:
raise NotImplementedError
class SizedModel(ModelBase, SizedABC):
def __len__(self) -> int:
return 0
ModelMeta is made compatible with ABCMeta —
the obligation has forced a metaclass choice onto the class. Lucid's
Sized above needs no metaclass and no decorator: a body is what
turns a member into an implementation rather than a requirement, so a
class with any remaining bodyless member from a trait or parent class
is abstract for construction purposes, checked before it can be
instantiated, the same useful guarantee ABCMeta gives without
requiring one.
Trait conflicts¶
Two traits can name the same member without colliding, as long as at most one of them supplies a body. An obligation is satisfied by whichever default provides it — nothing to resolve:
trait Renderable:
def render(self: ~Self) -> str
trait DebugRenderable(Renderable):
def debug(self: ~Self) -> str:
return "<debug " + self.render() + ">"
class Widget(Renderable, DebugRenderable):
def render(self: ~Self) -> str:
return "widget"
Widget provides render, which satisfies Renderable's
obligation and is exactly what DebugRenderable.debug was already
written to call — one implementation, two traits both accounted for,
nothing to reconcile.
Two defaults for the same member are the real conflict: two traits each supplying a body means two candidate implementations, and nothing says which one a class using both should get. The class must resolve it explicitly:
trait A:
def greet(self: ~Self) -> str:
return "hello from A"
trait B:
def greet(self: ~Self) -> str:
return "hello from B"
class C(A, B):
override def greet(self: ~Self) -> str:
return A.greet(self)
Field, getter, and setter obligations compose¶
A field, a getter, and a setter obligation are each a claim about one
capability — read access, write access, or both — and satisfaction
follows the same width-subtyping already used for mutable, read-only,
and immutable views (Mutable, read-only, and immutable views): whatever provides at least the capability asked
for satisfies the obligation, the same way a mutable T is usable
wherever the narrower ~T is expected.
A stored field provides both read and write access, so it satisfies a
getter-only obligation, a setter-only obligation, or a plain
field obligation, on its own:
trait Readable:
getter x(self) -> float
class Point:
x: float # satisfies Readable: reading a field needs no write access
getter and setter pair together provide exactly what a plain
field does — read and write, nothing more — so the pair satisfies a
plain field obligation the same way a field satisfies the pair:
trait Located:
x: float
class ComputedPoint:
getter x(self) -> float:
return self._x
setter x(self, value: float):
self._x = value
getter alone does not satisfy a setter obligation, or the
reverse: read and write are independent capabilities, and neither
implies the other.
Final field obligations¶
A final field obligation does not compose the way plain field,
getter, and setter obligations did above — it asks for something
stronger. final only promises that the binding is never rebound to
a different object; it says nothing about that object's own contents
(see Final fields). A getter does not satisfy it,
even alone with no setter:
trait HasModel:
final model: InferenceModel
class FixedModel:
final model: InferenceModel # satisfies HasModel
class RebuiltModel:
getter model(self) -> InferenceModel: # does not satisfy HasModel
return InferenceModel(self.weights, self.metadata, {:})
RebuiltModel handing back a different object on
every call, which is exactly what final rules out. A getter
and setter pair satisfies it even less: a setter is an explicit
write path, and final rules out any write path existing at all.
There is currently no way to mark a getter as provably returning the
same object every call, so a final field obligation can only be
satisfied by a final field.
Every class is checked for unimplemented obligations before it can be
constructed. A class with any remaining bodyless member from a trait
or parent class is abstract for construction purposes, matching
Python's useful abstract-class instantiation check without using
decorators. Lucid gets that check without requiring classes to inherit
from ABC or take on ABCMeta as a metaclass.
Multiple inheritance in Python¶
Python multiple inheritance uses the same base-class list for several different jobs: class inheritance, obligations, reusable behavior, metaclass selection, and MRO construction. Those jobs interfere with each other.
Common pitfalls include:
- MRO order changes which implementation a method call reaches
- cooperative
super()only works when every class in the chain follows the same calling convention - base classes can bring incompatible constructor requirements
Cooperative super()¶
Cooperative multiple inheritance requires every class in the chain to accept and forward compatible arguments. One class that does not participate breaks the chain.
class Audited:
def save(self, *, audit: bool = True):
if audit:
record_audit()
return super().save(audit=audit)
class Timestamped:
def save(self):
self.updated_at = now()
return super().save()
class Document(Audited, Timestamped, BaseDocument):
pass
Document().save()
Audited.save forwards audit to Timestamped.save, but
Timestamped.save does not accept that keyword. The method chain only works
when every participant follows the same forwarding convention.
Incompatible constructors¶
Base classes can require incompatible initialization protocols.
class FileBacked:
def __init__(self, path: Path):
self.path = path
class NetworkBacked:
def __init__(self, host: str, port: int):
self.host = host
self.port = port
class Cache(FileBacked, NetworkBacked):
pass
Cache. One parent needs a
path, the other needs host and port, and neither constructor explains how to
initialize the other base.
Lucid avoids those pitfalls by separating the roles. A class may use class inheritance — inheriting from at most one other class — because a class is the only one of the two roles that owns stored data. It can use any number of traits, since a trait without stored state cannot produce the collision above, whatever mix of obligations and defaults it declares. If two traits' defaults collide, the class must resolve the conflict explicitly.
Explicit overrides¶
A method that replaces an inherited implementation — the class's one class
parent's, or a trait's — must be marked override:
class Timestamped:
updated_at: float
def save(self):
self.updated_at = now()
class Document(Timestamped):
override def save(self):
super.save()
write_to_disk(self)
override as governs a higher-kinded parameter's
arity (Higher-kinded parameters):
the checker warns and offers an autofix while drafting, but the marker
must be written into the source before the API is accepted. That
protects against both
directions of the same mistake — a parent or trait gaining a method that
silently starts shadowing an unrelated method of the same name with no
signal anywhere, and an intended override whose name or signature no longer
matches anything, silently becoming an unrelated new method while the
original goes on being called elsewhere.
Because a class has at most one class parent, calling through to the
overridden implementation is unambiguous — super always means that one
parent, never a position in an MRO, so it takes no parentheses: there is
nothing to call, only the one parent to name. A linter checks that an override
method calls it, but this check is a suggestion, not a rule: some overrides
exist specifically to replace an inherited implementation entirely, such as
a class resolving a conflict between two traits, and the warning can be
suppressed for those.
Final methods¶
final applies to a method the same way it applies to a field or a
class: the same keyword, one relationship fixed permanently — here, that
the method can be overridden at all.
class Timestamped:
final def save(self):
self.updated_at = now()
class Document(Timestamped):
override def save(self): # error: save is final
...
final is not valid on a bodyless member. It protects a method's
implementation from being replaced, and an obligation has no
implementation to protect — there is nothing there yet for final to
fix in place.
No structural traits¶
Lucid traits are nominal. A class satisfies a trait only by explicitly
listing it in the class header — class Foo(SomeTrait) — never
merely by happening to define members with matching names and types.
This holds even for the numeric capability traits such as
SupportsInt: int satisfies them because it explicitly inherits from
them, not because it happens to define __int__.
Python's typing.Protocol takes the opposite approach: any object with
matching methods satisfies a Protocol, whether or not its author ever
intended to make that promise. That costs something real, not just style.
A trait obligation is a promise about behavior, not a description of a
method table, and having a matching method is not the same as making
that promise. Two unrelated methods can share a name and signature by
coincidence without sharing a meaning: close(self) -> none on a
file handle releases a resource; close(self) -> none on a
negotiation finalizes a deal. Structural matching only ever compares
shape, never intent, so a Closeable-shaped structural trait would
silently accept both, and code written for one meaning could be handed
the other with no error at all. Only the author of a class can say
which promise, if any, a method is actually making — which is exactly
what listing the trait by name in the class header does and mere
structural matching cannot.
Nominal declaration is also cheaper to check. Satisfying a trait is verified once, at the class's own definition, against the fixed, usually short list of traits that class actually names. Structural satisfaction has no such fixed point: it would have to be rechecked at every place a value meets a trait-typed slot, comparing that slot's full member signatures against whatever value happens to show up there, for as long as either side might change.
Implementing a trait after the fact¶
Nominal satisfaction has a real cost of its own: it normally means writing
class Foo(SomeTrait) at Foo's own definition, which is not
possible when Foo is a builtin or a third-party type you do not control.
Sometimes an existing type should satisfy a trait its own author never
wrote it against.
implement attaches an implementation to a type from outside that type's own
definition:
ThirdPartyBuffer did not declare Sized when it was defined; this
implement block is what makes it satisfy that trait, checked the same way
any other implementation is — does this block actually provide everything
Sized requires.
implement is not unrestricted. It is valid only if its author owns the
trait or the type — never neither. That rule, not a runtime check, is
what keeps two unrelated packages from each attaching a different
implement Sized for ThirdPartyBuffer and leaving which one wins to import
order: for any trait/type pair, at most one project in the whole
dependency graph is ever eligible to write it, because ownership of each
name is unique. The members an implement block adds are also only visible
where the trait itself is imported, not ambiently on every
ThirdPartyBuffer everywhere — the same reasoning that removed global
and nonlocal: nothing about a value's usable surface should change
because of something declared elsewhere that the reader never imported.
Subclassing cannot substitute for this. class Buf(ThirdPartyBuffer,
Sized): ... only covers instances constructed through Buf — it does
nothing for a buffer that already exists, built by code that is never going
to construct your subclass, which is the case this problem actually shows
up in. It does not compose, either: two independent subclasses of
ThirdPartyBuffer, each adding a different capability, cannot later be
combined into one class, because that class would need two class parents,
which class inheritance does not allow (One class parent). implement
has neither limit — it changes what the original type itself satisfies, for
every instance, and any number of unrelated implement blocks for the
same type coexist without ever needing to be reconciled into one class.
Python's typing.Protocol solves the same retrofitting problem
differently: any type with matching methods satisfies a Protocol
automatically, without its author writing anything at all. That is exactly
the accidental-collision risk described above — a type can match a
Protocol's shape by coincidence, without its author ever intending to
make that promise, and Python has no way to tell an intentional
implementation from a lucky guess. implement gets structural typing's actual
benefit — an existing type outside your control can come to satisfy a
trait — without that risk. It is still an explicit, checked declaration,
not a shape match: someone has to write it, the checker verifies it actually
provides what the trait requires, and the ownership rule guarantees
that whoever wrote it was entitled to make that claim about that type.
Retrofitting is possible; accidental retrofitting is not.
Higher-kinded traits¶
A trait can be generic over a type constructor instead of an ordinary
type, using F[_] (see
Higher-kinded parameters). Inside such a trait, Self
takes its kind from how the trait's own members use it: subscripting
Self with a type parameter the member declares itself, rather than one
of the class's own, is what makes Self behave as a constructor rather
than an already-saturated type. No separate marker is needed on Self
the way one is needed on a free-standing parameter like F — the
trait's member list is already the explicit, checked-once contract that
a free-standing generic parameter does not have.
list was not defined with Functor, so it satisfies it through
implement:
implement Functor for list:
classmethod map[A, B](cls, tree: Self[A], f: (A) -> B) -> Self[B]:
return [f(x) for x in tree]
tree_map in Multiple dispatch uses:
Which to reach for is the same question either way: dispatch is enough when
each container's traversal only needs to be locally correct on its own; a
bounded, higher-kinded parameter earns its cost when every implementer,
including ones that do not exist yet, needs to be provably checked against
the same signature.