Method resolution¶
How x.method(args) is resolved, in what order, and why a user extension method can never
displace a built-in one.
Status: decided. The rule is enforced by CE2097 (issue #239).
The rule¶
Built-in beats perk beats extension. A method the compiler defines is always chosen before a user extension method of the same name. A perk implementation is the only sanctioned way to replace one.
Stated as precedence, highest first:
| provider | example | can a user replace it? | |
|---|---|---|---|
| 1 | built-in | arr.len(), n.to_str(), p.hash(), xs.push(1) |
not by an extension -- CE2097 |
| 2 | perk implementation | extend Point with Hashable: fn hash() u64 |
this is the replacement mechanism |
| 3 | extension method | extend i32 squared() i32 |
only for names no built-in carries |
The consequence that motivates the error: because every layer resolves a built-in first, an extension method whose name collides with one is compiled and then never called. It is not lower-priority; it is unreachable.
The three layers¶
The same precedence is implemented three times, and all three must agree. Any disagreement is a bug of the class #239 collected:
| layer | file | what it decides |
|---|---|---|
| validation | semantics/passes/types/calls/methods.py:validate_method_call |
which family checks arity and argument types |
| inference | semantics/passes/types/method_registry.py |
what the call expression's type is |
| codegen | backend/expressions/calls/dispatcher.py:emit_method_call |
which body actually runs |
Inference is the layer that goes wrong quietly. validate_assignment_compatibility opens with
if value_type is None: return, so a family that fails to infer does not report anything --
the annotation is simply never checked, and the mismatch reaches the backend as a CE0017
internal error. Two separate defects hid there for weeks:
- Every primitive return type was un-inferred, because the inference layer read the
builtin-method registry, which the backend populates at import time -- and the pipeline
imports codegen lazily, after semantic analysis.
let u32 b = f64val.to_bits()compiled and silently truncated the 64-bit pattern. string.to_str()/string.hash()were un-inferred for a second reason:METHOD_TYPE_REGISTRY.infer_method_typeis first-match-wins, and the string checker matched on the receiver type alone. It claimed every method name on astring, and a claim whose inferrer then returnsNoneends the chain rather than falling through.
Two rules follow, and both are load-bearing:
- A checker must claim only what it can actually type. First-match-wins makes an over-broad claim indistinguishable from a missing family.
- A semantics pass must not read the builtin-method registry except for the struct/enum
hash/clonepair, which the derive pass registers from semantics. Everything else in that registry is backend-populated, so from semantics it answers differently depending on what else the process imported.semantics/generics/builtin_methods.pyexists to give that question one deterministic answer.
The built-in families¶
builtin_method_exists(receiver_type, method_name) in
semantics/generics/builtin_methods.py is the single seam. It mirrors validate_method_call's
receiver dispatch, family for family:
- arrays (fixed and dynamic) --
len,get,push,pop,iter,clone,hash, ... - string -- the stdlib string methods, plus the primitive
to_str/hash - stdio (
stdin/stdout/stderr) and File - primitives --
to_str,hash, and the float-onlyto_bits - containers --
Result,Maybe,Own,List,HashMap - the compiler-derived pair --
hash()andclone(), auto-derived in the derive pass for every struct and enum
tests/unit/test_builtin_method_seam.py pins that list against validate_method_call's in
both directions. Two places answering one question drift; that is the #248 lesson (if the
same question is asked in six places, the fix is a seam, not a fallback).
The family order¶
Inside the built-in step, both layers try the families in one canonical order:
perk -> derived hash -> derived clone -> function-value clone -> primitive -> extension fallback
The receiver kinds are disjoint -- a primitive is a BuiltinType, the derived pair applies
to StructType/EnumType, the function-value clone to FunctionType -- so the order is
arbitrary. What matters is that validation and codegen state the SAME one: the two layers
used to state it oppositely, and a type that ever satisfied two families would have
dispatched differently per layer with no diagnostic (#273).
tests/unit/test_method_resolution_family_order.py pins the order in both files.
Why extensions lose¶
Precedent is one-sided. Every language with extension-method-like features resolves the type's own members first:
| language | rule |
|---|---|
C# (the model docs/language-guide.md cites) |
instance methods always win; extension methods are considered only when no instance method applies |
| Kotlin | "members always win over extensions", with a docs section warning about it |
| Rust | inherent methods beat trait methods silently; two applicable traits is E0034, a hard error; #[derive(Hash)] plus a manual impl Hash is E0119, a hard error |
| Go | no extension methods at all -- methods must live in the type's package, and redeclaration is a compile error |
| Java | no extension methods; static methods hide rather than override, a long-standing source of confusion |
| JavaScript | user wins -- which produced SmooshGate: TC39's Array.prototype.flatten broke MooTools-patched sites and had to be renamed flat |
Sushi's auto-derived hash/clone are the #[derive] analogue, and Sushi has no opt-out
from derivation. So a colliding extension is not merely lower-priority, it is unreachable by
construction -- which meets the project's own bar for erroring rather than warning, recorded in
the CW3505 deletion note (internals/errors/warnings.py): if the situation cannot possibly
do what the user wrote, it is an error, not a warning. CE4007 (perk vs extension) and
CE0101 (duplicate extension method) are already hard errors for strictly milder collisions.
Why perks win¶
A perk implementation is Sushi's equivalent of writing a manual trait impl, and it deliberately
takes precedence at all three layers -- calls/methods.py resolves perks before the built-in
families, visitor.py prefers a perk method during inference, and the codegen dispatcher runs
its perk step before the auto-derived ones. tests/perks/test_perk_override_hash.sushi pins it.
perk Hashable:
fn hash() u64
struct Point:
i32 x
i32 y
# The supported way to replace the compiler-derived hash.
extend Point with Hashable:
fn hash() u64:
return 999999 as u64
Perk implementations are collected into PerkImplementationTable and never enter
ExtensionTable, so CE2097 cannot see them. That is a structural property, not a special
case in the check -- and it is pinned by a test so a future refactor merging the two tables
trips over it.
CE2097¶
error [CE2097]: extension method 'hash()' conflicts with the built-in 'P.hash()'.
| extend P hash() u64:
` --+--
= note: 'P.hash()' is defined by the compiler
= help: a built-in method is always chosen before an extension method, so this one could
never be called -- rename it, or provide 'hash()' through a perk implementation
('extend P with <Perk>'), which does take precedence
The check (semantic_analyzer.py:_check_extension_shadows_builtin) walks ExtensionTable and
asks the seam about each (target type, method name) pair. Its placement is constrained at
both ends:
- after the derive pass, which registers the struct/enum
hash/clone; - after the generic-extension merge loop, because a monomorphized
extend Box@(i32) hash()only enters the extension table there. Running earlier is exactly why that shape went uncovered in the first cut.
It keys on whether a built-in genuinely exists for that pair, never on the bare method name --
so a type that carries no such built-in (a struct the compiler could not derive hash for, say)
can still be extended with a hash() of its own.
Which types an extension applies TO¶
A separate question from precedence, and settled by the same principle. Status: decided, issue #393.
A concrete type argument in an extension target is a CONSTRAINT, not a type-parameter name.
extend Box@(i32)applies toBox@(i32)and to nothing else.
| declaration | applies to |
|---|---|
extend Box@(T) f() -- fully generic |
every instantiation of Box |
extend Box@(i32) f() -- fully concrete |
Box@(i32) only |
extend Box@(i32) f() and extend Box@(string) f() |
legal -- two types, two methods |
extend Box@(T) f() and extend Box@(i32) f() |
rejected -- CE0101, relational |
extend Pair@(i32, U) f() -- partially concrete |
rejected -- CE2098 |
The argument used to be stored as a type-parameter name, so extend Box@(i32) tag()
registered for every instantiation of Box: it answered a Box@(string) receiver, and it was
a CE0000 as soon as the body touched the type. A perk implementation on the same target had
always scoped correctly -- one question, two answer sites, which is the #239 class exactly.
A PERK IMPLEMENTATION reads the same table. extend Box@(T) with Show is a template
and extend Box@(i32) with Show is a constraint, exactly as above, and a partially
concrete target is the same CE2098. The template is re-filed out of Program.perk_impls
and into Program.generic_perk_impls -- the same move a generic extension makes, and for
the same reason: the typecheck pass, the backend's declaration and definition loops and
the unit fingerprint all walk perk_impls assuming a concrete self. One copy per
instantiation the program names is registered in the perk-impl table under the
instantiation's interned name and appended to the DECLARING unit's AST, so it is an
ordinary implementation from there on.
The instantiation runs before the functions are monomorphized, and that order is
load-bearing: a @(S: Show) constraint is checked while a generic function is
monomorphized, so Box@(i32) has to already say it implements Show or the call is
CE4006 for a type that does. It also puts Drop within reach: TypeQueries.drops reads
the perk table, so the copy registered here is in the set before derive, effects and
the borrow pass ask whether the instantiation owns a resource.
A LATE instantiation gets its copy too (#555). A type named only inside a generic body
-- let Box@(T) b in outer@(T), or the return of a generic it calls -- is interned while
that body is substituted, after the first cut. Every instantiation the tables hold with no
copy yet is cut afterwards, to a fixpoint, so Box@(string) has its show() whether the
program spelled it or a substitution produced it. The constraint check reads the templates
beside the registered copies: a template applies to every instantiation of its base name by
construction, so @(S: Show) holds for a late Box@(string) before its copy is cut.
Why the overlap is rejected rather than resolved by most-specific-wins. Under
specialization, whether the template's body is dead code would depend on which instantiations
exist ELSEWHERE in the program: with only Box@(string) live the template method is compiled
and never called, and one Box@(i32) anywhere makes it live again. That is a reachability rule
keyed on the rest of the program, and CE2097 above is built on the opposite rule -- an
unreachable declaration is a diagnostic. Rejecting is also forward-compatible: an error can
become working code later, while removing specialization later breaks programs.
Why the partial form is rejected too. It removes the ordering question entirely. Two fully
concrete targets cannot overlap, and template-versus-concrete is strictly ordered, so
Pair@(i32, U) against Pair@(T, string) -- equally specific, neither more so -- cannot
arise. Partial ordering is where Rust's specialization has stalled for years. Rust and Haskell
reject overlap unless you opt in (E0592; {-# OVERLAPPING #-}); C++, C#, Swift and Kotlin
allow it and pay for a formal specificity ordering.
The escape is a perk implementation on the concrete target, which already outranks extension methods in the ladder above and already scopes correctly.
The UFCS epic's additions (the ladder is untouched)¶
Three extension capabilities joined without moving any rung. An array target
resolves like any other concrete type, and extend T[] instantiates per element type
at the CALL SITE ($array templates; ruling 3). A method-level type parameter
(name@(U)) is solved from the arguments and never enters the ExtensionTable — the
template answers by unification, and the copy's symbol carries the solved arguments.
An error channel (| E) changes what a resolved call YIELDS (the interned
Result@(T, E)), not how it resolves. The one new resolution-adjacent diagnostic is
CE2515, a FALLBACK where CE2008 would fire: the method is missing on a
Result/Maybe receiver and present on its payload type, which is an unhandled channel,
not a typo. Resolution still runs first — a method found on the wrapper itself
(.realise) is rung 1 as always. The decision record is
ufcs-combinators.md.
The static method: a name behind the TYPE's dot¶
A separate question again, and the constructor half of method resolution. Status: decided, issue #542.
A name behind a type's dot is a MEMBER of that type: a variant, or a static method. Never both. A local of the same name wins first.
One namespace, reached with a dot. Rust reaches the same place with ::; Sushi does not
need a second path operator, because "a name has exactly one home" already says it.
The spelling¶
A static marker before the method name. It is read AFTER the target type, so a generic
target, a namespaced target and a method-level type parameter all fall out with no
lookahead.
struct Vec:
i32 x
i32 y
extend Vec static at(i32 x, i32 y) Vec:
return Vec(x, y)
let Vec v = Vec.at(3, 4)
static is a reserved word. It is not re-admitted in method_name, so v.static() and
a method named static are not writable.
What a static IS, and is not¶
| a static | an instance method | |
|---|---|---|
| receiver | none -- no implicit self, and no mode to declare |
implicit self, borrowed unless marked |
| call site | on the TYPE name: Vec.at(3, 4) |
on a value: v.sum() |
| parameters | ordinary, and the modes are the ordinary four | the same, plus the receiver's |
| return | ordinary; \| E opts into the channel exactly as elsewhere |
the same |
| visibility | none of its own -- as visible as its target type | the same |
| in a perk | never -- a perk has no Self (CE4014) |
that is what a perk contracts |
Everything in the right column that is not about the receiver is unchanged. A static's
parameters BORROW unless marked nom; its owning return is the caller's; its | E
channel wraps a bare success at the return seam. The one thing it lacks is a receiver,
and the two positions that could name one are one fault with one code:
- a receiver MODE in the signature --
extend Vec static at(poke self), - a mention of
selfin the body.
Both are CE0134, tier 2, with the caret on whichever was written.
Which targets¶
Every target an extension may name, except an array. A struct, an enum, a
primitive (extend f64 static of_int(i32 v) f64:), a built-in generic
(extend List@(i32) static of_one(i32 v) List@(i32):) and a generic target, which
is a template like any other: one copy per instantiation. A generic static's type
arguments are solved in two steps, as ONE resolution (#573). First from the ARGUMENTS,
for every target type parameter that a parameter names (nom R src names R), exactly
as a generic free function solves its own. Then from the PROPAGATION STAMP at the
binding site, for every type parameter still unsolved -- there is no receiver to read
it from. Cage.holding(9) with a plain T item is solved by the first step and needs
no annotation; Cage.empty() is solved by the second, and BufReader.new(nom f, 8192)
is the stdlib's example of the first: R comes from the handle, in every position,
which is what lets a | E static be written at all -- a Result-valued call is never
stamped, so the stamp alone (the rule from #542 to #573) left match, .realise and
?? all refused.
An ARRAY target is CE2104. An array type has no spelling in an expression position
-- i32[].two() is a parse error, and no form reaches it -- so the declaration would
compile and never be callable. That is CE2097's hazard, and the answer is the same one:
if the situation cannot possibly do what the user wrote, it is an error.
A type parameter that NEITHER step reaches is CE2060, and the text names both
sources: no argument names it, and this position declares no type. Cage.empty() in a
println hole is the shape; Pair.of_first(3) in one names B alone, because the
argument solved A. Binding the result answers it when the return names the target
(let Cage@(i32) a = Cage.empty()); when the return does NOT name the target, a
parameter that names the type parameter or the signature is what has to change, because
a method carries no call-site @(...) slot at all (Known Limitation 7). The test is
narrow on purpose -- it fires only when the base name declares a static of that name --
because a generic ENUM in an unstamped position is a variant construction whose stamp
the surrounding statement supplies, and Result.Ok(0) is 6,559 of those. Before the
check, both shapes reached the backend as a CE0055 ICE; the built-in twin
(println("{List.new().len()}")) still does, and is #570.
struct Cage@(T):
T item
extend Cage@(T) static holding(T item) Cage@(T):
return Cage(item)
let Cage@(i32) a = Cage.holding(9) # T from the argument; the declared type agrees
println("{Cage.holding(9).item}") # T from the argument alone
The instantiate pass collects what the arguments solve, through the same solver, so
every copy is cut for Cage<i32> before the typecheck pass looks it up; an argument
only the typecheck pass can type interns the instantiation late, through the seam the
method-generic rung uses, with the static's own copy queued for the fixpoint round.
The stamp is the reason a static is not only ergonomics. A free function whose T
names only the RETURN cannot be inferred (CE2060) and has to spell box_new@(i32)(); a
static reads the binding site instead. It is also why new is available as a static's
name and not as a free function's (CE6001).
The two collisions¶
A name has exactly one home, so both are refused where they are written:
| written | refusal |
|---|---|
| a static and an instance method of one name on one type | CE0101, the duplicate-extension rule it always was |
| a static and a VARIANT of one name on one enum | CE2103, relational -- the variant would always win, which is CE2097's hazard |
CE2045 grew a second half for the same reason: a name behind an enum's dot could have been either member, so its help names both escapes.
The built-in statics are static methods¶
List.new, List.with_capacity, HashMap.new, Own.alloc, f64.from_bits and
f32.from_bits are static methods on their types -- one rule, not two. They are NAMED
in one table (semantics/statics.py:BUILTIN_STATICS) and each is still emitted by its
container's own narrow handler, because a container static has no ExtendDef to resolve
and so has nothing yet to converge onto. The general path DEFERS to that table rather
than refusing what it cannot find; #553's lesson is why the narrow handlers stay until a
test proves the general path covers them.
The seams¶
| question | where |
|---|---|
| does this name denote a TYPE, of any kind | semantics/statics.py:names_a_type -- the scope pass and the typecheck pass both ask it |
| which type does this receiver name, and does it declare that static | passes/types/calls/statics.py -- the validation half and the inference half both read it |
| instance or static (they share one table) | ONE filter at the end of resolve_extension_method; resolve_method(..., static=True) skips the perk rung outright |
| what modes do the arguments cross in | CalleeKind.STATIC_METHOD -- a new kind, not a widened METHOD, because a receiver-less callee asks a different question. Gate: tests/unit/test_callee_mode_matrix.py |
| the alias fold | fold_namespaced_static, unchanged in shape: it asks whether the namespace holds a type, so geo.Vec.origin() folds like hm.HashMap.new() (#506) |
The refusal for a type whose dot holds no such member is CE2102. It replaced a CE1001 "use of undeclared identifier 'Box'" for a struct declared three lines above: the fault was the POSITION, not the name, and the fix is in the scope pass, which now lets a type name through in a receiver position and leaves the answer to the pass that has the method tables.
Prior art¶
| the term | how it is spelled | |
|---|---|---|
| Rust | associated function | no marker; a method is one that takes self. :: is a different operator from . |
| Swift | type method | static func origin() -> Vec |
| C++ | static member function | static Vec origin(); |
| Java, C#, Dart, PHP, JavaScript | static method | static Vec origin() |
| Python | static / class method | @staticmethod, @classmethod |
| Ruby, Objective-C, Smalltalk | class method | def self.origin |
| Kotlin, Scala | companion object member -- no statics | companion object { fun origin() } |
| Go, Zig | no term -- a package or namespace function | bufio.NewReader(f) |
Sushi says static method, which is what most of that table says.
docs/design/unit-storage.md had already reserved the word: var took unit-level
storage precisely so that "static" could keep meaning a function called on a type name.
Sushi has no static STORAGE.
Java's statics hide rather than override, a long-standing confusion. It cannot occur here: Sushi has no inheritance.
The Rust route -- no marker, distinguish by an explicit self in the parameter list --
was priced at ~416 declarations in the corpus and rejected. The Go route -- no functions
behind a dot at all -- would have turned each of the 230 List.new() sites into
list_new@(i32)(), and it leaves variants behind the dot regardless: Type.Variant
outnumbers a static call 22 to 1 (10,469 to 479).
What a static does NOT do¶
- No
::. A second path operator to disambiguate what a dot already means is a bigger change than the feature, and on a struct the dot is not ambiguous at all. - No static in a perk. No
Self(HANDLES.md R7). CE4014. - No overloading. A name has one home; both collisions above are refusals.
- No export through a BINARY
.slib. A binary library ships no extension method at all today, instance or static, so this is a pre-existing limit and not a static one. A SOURCE.slibships the declaration as text and a static exports through it.
A perk method and an extension method differ in one thing¶
The target type comes from a different place. Nothing else separates them.
An extension method names its target in its own declaration (extend i32 squared()); a
perk-implementation method takes it from the extend X with P header. Every other
property is shared, and the compiler shares the code that reads it: one body validator
(passes/types/signatures.py:_validate_method_body), one CalleeKind.METHOD for
parameter modes, and one backend path -- a perk method is wrapped as a synthetic
ExtendDef and emitted through the extension emitter.
The error channel was the last exception, and HANDLES.md ruling R1 removed it. A
perk method declares | E in the same shape an extension method does, on the CONTRACT
and on every implementation alike:
perk Source:
fn read_one() i32 | SourceError
extend Counter with Source:
fn read_one() i32 | SourceError:
return self.value # the success returns BARE; the seam wraps it
The channel is part of the signature, so the contract and the implementation must agree: a contract that declares one and an implementation that omits it, an implementation that invents one the contract has not got, and two channels over different error types are all the same mismatch. CE0133 is the relational diagnostic -- the primary at the implementation, a note at the contract method.
A perk method has no Self type, so a contract cannot say "returns another one of me".
That is the one thing a perk still cannot express, and it is why .share() is written
on each handle rather than on a contract.
Where the rule lives. The classification is decided ONCE, in the collect pass
(semantics/generics/extension_targets.py:classify_extension_target), because that is the
pass whose struct and enum tables say which bare names are declared types -- Box@(T) and
Box@(Point) are spelled identically and mean opposite things. The answer is carried on the
declaration (ExtendDef.target_shape) and on its collected signature
(GenericExtensionMethod.target_key), so the instantiate and monomorphize passes read it instead of deciding
again from a different set of visible types. instantiation_key is the one authority for the
mangled name a concrete target matches; the perk-impl table built its own and joined the
arguments differently, so a two-argument concrete target registered under a name no receiver
ever resolved to.
Related¶
docs/design/closures.md-- the same rule stated forList@(T)extension methodsdocs/language-guide.md-- the Extension Methods section, user-facingdocs/design/type-identity.md,docs/design/move-semantics.md-- sibling decision records