Method Overloading for Pony #6010
SeanTAllen
started this conversation in
ponyc
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Method Overloading for Pony
Method overloading lets a type provide multiple definitions of the same method name. Two mechanisms, shipped together:
Iftype specialization. Multiple definitions of the same method distinguished by
iftypeguards on type parameters. Resolved at reification time. The compiler can prove a stronger return type; the language can now express it. Zero runtime cost — after reification, one body exists per method.Type overloading. Multiple definitions of the same method distinguished by parameter types. Resolved at compile time by the static types of the arguments. Enables idiomatic callable objects via
apply/updatesugar for multi-typed access patterns.Both mechanisms resolve overloads at compile time — there is no virtual-method-table dispatch and no new runtime mechanism. When a union-typed argument's members each match exactly one overload, the compiler generates an implicit
match(the same code the programmer would write by hand) to route to the correct pre-resolved overload at runtime. This is existing Pony pattern matching, not a new dispatch mechanism.Terminology
A method group is the set of all definitions that share a method name within a type. In a type without overloading, each method group has one member.
A method definition (or just "definition") is one specific entry in a method group: a name, a parameter signature, and a body. Type overloading adds multiple definitions to a method group by giving them different parameter types. Each definition is an independent method with its own return type, partiality, and body.
An iftype specialization is a guarded variant of one definition. The unguarded definition is the default. Each specialization shares the default's name and arity, but may have a different body and a different (subtype-compatible) return type (see Iftype Rule 3). The return type may also be the same as the default's — body-only specialization is valid. A definition can have multiple specializations with non-overlapping guards (see Iftype Rule 2). After reification, exactly one variant (the matching specialization, or the default) exists.
When both mechanisms apply to the same method group, they compose in sequence: type overloading selects a definition (by argument type at the call site), then iftype specialization selects the variant within that definition (by type parameter constraint at reification time).
Throughout this document, "definition" always means one specific parameter-signature entry. "Method group" or "method name" means the collection of definitions sharing a name. The two mechanisms operate at different levels: type overloading distinguishes definitions within a method group; iftype specialization distinguishes variants within one definition.
Mechanism 1: Iftype Specialization
Motivating example
The core value proposition: the same method call returns a more useful capability when the type system can prove it is safe.
When
A <: Any #share, viewpoint adaptation through any readable receiver is the identity --this->A = Afor all receivers in{ref, val, box}. The specialization's body type-checks because_valuehas typethis->A, which equalsAunder the guard. The return typeAis a subtype ofthis->Afor every receiver capability.Consumer code:
Without iftype specialization, the caller gets
this->Ain both cases, which through arefreceiver isref->U8 = val(happens to be the same for val types, but for aContainer[SomeClass ref]the difference is meaningful -- the default returnsthis->SomeClasswhile a hypothetical specialization could return a more precise type).The secondary motivating case,
Array.clone()returningiso^whenAis shareable, is a long-standing feature request (#1784) and is discussed in the "Secondary Example: Clone" section below.Rules
Default required. Every definition that has iftype specializations must have exactly one unguarded body. This is the default. (In a method group with type overloads, each definition independently satisfies this rule -- one default per definition, not one per method group.)
Non-overlapping guards. A definition can have multiple iftype specializations. Guards must not overlap: if two guards could both be satisfied by any valid type parameter binding, the definition is rejected at the definition site.
The overlap check uses open-world reasoning (the same approach as the type parameter collapse check in Type Overloading Rule 6): it asks whether a type could exist that satisfies both guards, not whether one currently does. This prevents a new type added in a dependency from causing a previously-valid definition to silently change behavior.
For capability constraint guards, overlap is checked by intersecting the capability sets. For nominal guards (
A <: FooandA <: Bar), overlap is possible if any type could implement bothFooandBar— the same open-world reasoning as the collapse check's constraint intersection precision. For compound guards: a conjunction guard overlaps another guard if a type assignment could satisfy the conjunction AND the other guard simultaneously. A disjunction guard overlaps another guard if ANY branch of the disjunction overlaps — e.g.,(A <: Foo) or (A <: Bar)overlapsA <: Foobecause the first branch is identical to the other guard.When one guard references a class-level type parameter and another references a method-level type parameter, the overlap check considers both scopes simultaneously — "any valid type parameter binding" spans all type parameters in scope, not just those from one level.
When exactly one guard matches the type parameter binding, that specialization is used. When no guard matches, the default is used. The non-overlapping rule ensures these are the only two cases.
Subtype relationship. The specialization's return type must be a subtype of the default's (the unguarded body's) return type (covariance) — it may be a strict subtype or the same type. Parameter types must be identical to the default's — specializations cannot widen or narrow parameter types. Receiver capability must match. A specialization with the same return type as the default provides a different body (implementation), not a different type — this is valid for optimization or behavioral specialization (e.g., a faster serialization path for numeric types).
Partial reification. The subtype check uses partial reification: the specialization's function type must be a subtype of the default's for every well-formed instantiation of the type parameters within the guard's constraints. For capability constraint guards (
#share,#send,#read, etc.), this means checking each concrete capability in the constraint set.Example: a specialization guarded by
A <: Any #sendthat returnsA:The check enumerates over the guard's capability set. A guard of
#share(={val, tag}) passes becauseval->val = valandval->tag = tagfor all receivers. A guard of#send(={iso, val, tag}) fails becauseisothrough avalreceiver does not hold. For nominal guards (A <: Hashable val), the capability is extracted from the constraint's capability annotation (valin this case) and the viewpoint adaptation check uses that single capability. When the nominal guard has no explicit capability (A <: Hashable), the default capability of the type applies (the same default the compiler uses for type expressions without a capability annotation). For compound guards: with a conjunction (and), the check enumerates over the product of each constrained parameter's capability set — e.g.,(A <: Any #share) and (B <: SomeClass ref)checks all combinations of A's and B's capabilities. With a disjunction (or), the check must hold for each branch independently — e.g.,(A <: Any #share) or (A <: SomeClass ref)checks all capabilities in#shareAND all capabilities implied bySomeClass ref, since the body could run under either branch.Disjunction guard utility. Because the body of a disjunction-guarded specialization must type-check under every branch independently, only operations available under the weakest branch can be used. A disjunction guard avoids duplicating a body across two specializations that do the same thing. To branch on individual conditions within a disjunction, use
iftypeexpressions in the body.Parameter types in specializations. A specialization must have the same parameter types as the default. Callers are type-checked against the default's parameter types (Resolution Algorithm Step 2), so different parameter types on a specialization would be invisible at call sites and misleading to readers.
Default argument compatibility. Every parameter that has a default value in the default definition must also have a default value in the specialization. The specialization may provide different default values but must not make a default-valued parameter required. This preserves the invariant that any call shape accepted by the default is also accepted by the specialization.
Viewpoint adaptation soundness. When the default's return type involves
this->, the subtype check must hold for all receiver capabilities that can reach the method and produce distinctthis->resolutions.Receiver set depends on the method's capability annotation. For
fun box(the common case for methods returningthis->), the receiver set is{ref, val, box}. Forfun ref, only{ref}needs checking (iso and trn are converted to ref before dispatch). Forfun val, only{val}. The derivation below is forfun box:Why
{ref, val, box}forfun box. The question is which receiver capabilities can reach afun boxmethod and produce distinctthis->resolutions that need checking:ref,val,box: each subtypes toboxand produces a distinct viewpoint adaptation (ref->A,val->A,box->A). Each must be checked independently.trn: subtypes toboxand can callfun boxmethods. But the compiler'sdowncast_iso_trn_receiver_to_ref()function (lookup.c) convertstrnreceivers torefbeforethis->resolution viaviewpoint_reifythis(). Checking againstrefalready coverstrn.iso: goes through automatic receiver recovery, which converts the receiver torefbefore method dispatch. Also covered byref.The receiver set
{ref, val, box}is therefore complete. Checking these three capabilities covers all five that can reach the method.Safe example:
Unsound example caught by this rule:
Body type-checking. Both the default body AND each specialization body are independently type-checked. The subtype relationship on the function type is a necessary but not sufficient condition -- the body must also type-check under the guard's constraints.
Resolution at reification. When type parameters become concrete, the compiler evaluates each definition's
iftypeguards (if any). The specialization whose guard is satisfied is used; if no guard is satisfied, the default is used. The non-overlapping rule guarantees at most one guard matches. After reification, exactly one body exists per definition.Resolution in generic contexts. When the receiver's type parameters are still abstract but the generic context's constraints are sufficient to satisfy the guard, the specialization is selected.
Generic union-constrained type parameters. (This rule spans both mechanisms: it governs how type overloading resolves when the argument is an abstract type parameter with a union constraint.)
When a type parameter is constrained to a union and every member of the union matches exactly one definition in the method group (via normal most-specific resolution), the compiler resolves the dispatch automatically. No manual
iftypenarrowing is required — the constraint already tells the compiler everything it needs. The return type of the call is the union of the matched definitions' return types (collapsing to a single type when all definitions return the same type).Compile error when a union member matches zero definitions or is ambiguous across multiple definitions — the same errors as for concrete union-typed arguments (Type Overloading Rule 3), reported per member.
This rule applies only to union constraints whose members are concrete types. Trait or interface constraints (including union members that are traits) cannot be enumerated, so they follow the normal resolution rules — the abstract type parameter is checked as a single type, not decomposed into members.
Traits and interfaces can declare specializations. A trait or interface can declare iftype-guarded signatures alongside the default. Through a trait reference, the specialization is selected when the type parameter is known to satisfy the guard — the same resolution as on concrete types.
Conformance. A concrete type implementing a trait must provide specializations that cover the trait's declared guards. For each guarded signature in the trait, the concrete type must have a specialization whose guard is at least as broad — the trait's guard must imply the concrete type's guard (any type assignment satisfying the trait's guard also satisfies the concrete type's guard). The concrete type's specialization is selected whenever the trait promises one. The concrete guard may be broader (selected in additional cases beyond what the trait guarantees), which is sound. The specialization's function type must be a subtype of the trait's specialization's function type. This is the same subtype relationship as Rule 3, applied per-guard.
Additional specializations. A concrete type can have specializations the trait does not declare. Through the trait, only the trait's declared specializations (and the default) are visible. Through the concrete type, all specializations are visible.
Generic trait references. Through a trait reference where the type parameter is still abstract (e.g., a function taking
Cloneable[A]with no constraint onA), the caller gets the default's return type — the guard cannot be evaluated. If the generic context's constraints onAare sufficient to satisfy the guard, the specialization is selected (same as Rule 7).Structural typing (interfaces). Interfaces can declare specializations with the same rules. The structural matcher checks guarded signatures alongside default signatures: for each guarded signature in the interface, the concrete type must have a specialization with a compatible guard (the interface's guard must imply the concrete type's guard — i.e., any type assignment satisfying the interface's guard also satisfies the concrete type's guard). This is the same direction as nominal conformance above: the concrete type's specialization is selected whenever the interface promises one. The specialization's function type must be a subtype of the interface's specialization's function type. When the concrete type also has type overloads, structural matching selects the overload per Type Overloading Rule 9 before checking guard conformance. When the interface and concrete type have different type parameter names or different numbers of type parameters, the guard comparison uses reification-based type argument substitution: the interface's type arguments are substituted into the interface's guard expression (the same approach the compiler's
is_nominal_sub_structuraluses for method signature comparison), and the resulting concrete guard is compared against the concrete type's guard. Positional mapping alone is wrong when the concrete type has more type parameters than the interface. This adds complexity to structural matching but follows from the same principle — what you can express concretely, you can express through an abstraction.Zero runtime cost. After reification, there is one method per vtable slot. No dispatch overhead.
Constructors. Iftype specialization applies to constructors with the same rules. The consumer writes the same constructor call; the guard on the type parameter determines which body runs. A specialization shares the default's constructor capability — changing the capability would violate function subtyping (Rule 3).
Behaviors. Iftype specialization applies to behaviors. The "return type" is always
None. The subtype rule is trivially satisfied. Behavior specializations provide a different body.Partiality. A partial default can have a non-partial specialization (strictly better). A non-partial default cannot have a partial specialization. This falls out of the subtype rule -- a partial function type is not a subtype of a non-partial one.
Method-level type parameters in guards. Iftype guards can reference method-level type parameters, not just class-level ones.
Within a single definition, the resolution order is: infer method type arguments from argument types first, then evaluate the iftype guard against those bindings. This is the within-definition order for iftype specialization (Resolution Algorithm Step 5). The interaction between method-level type argument inference and cross-definition overload selection (Steps 2-3) is prohibited — see Type Overloading Rule 18.
The specialization's subtype relationship and viewpoint checks apply to the method's own type parameters the same way they apply to class-level ones.
Syntax: parser specification
The iftype guard appears in the method signature after the return type and before the partial marker (
?) and the=>:In the AST, the iftype guard is a child of the method node, positioned after the return type node and before the question mark and body nodes. Each guard is an
iftypeguard expression: either a single constraint (A <: B) or a conjunction/disjunction of constraints ((A <: X) and (B <: Y),(A <: X) or (A <: Y)). A definition can have multiple specializations, each with its own guard (see Rule 2). The non-overlapping check (Rule 2) applies to compound guards the same way: two guards overlap if a type assignment could satisfy both simultaneously.Compound guard examples (on a class
Foo[A, B]):For partial methods, the
?comes after the guard:Error messages
Mechanism 2: Type Overloading
In this section, "overload" is used interchangeably with "definition" -- each type overload is one definition in the method group.
Syntax
Consumer code:
Rules
Overloads must differ in nominal type. Two overloads whose parameters differ only in capability (
String refvsString val) are rejected at the definition site. Capability-based dispatch would require runtime capability introspection, which Pony erases. Type aliases are unfolded before this check: two overloads using different alias names that unfold to the same underlying type are duplicates and are rejected.Most-specific-nominal resolution. When an argument's static type matches multiple overloads, the most specific overload wins.
Definition of "most specific." Overload X is more specific than overload Y when every parameter type in X is a subtype of the corresponding parameter type in Y (all positions, not just one). X is the most specific candidate when it is more specific than every other candidate. If no candidate is more specific than all others, the call is ambiguous and is a compile error.
Single-parameter example:
Multi-parameter example (all positions must agree):
Ambiguous example (incomparable parameter types):
Multi-parameter example (mixed specificity across positions):
Union and intersection arguments.
Union arguments. When the argument's static type is a union
(A | B), the union as a whole is a subtype of a parameter typePonly if bothA <: PandB <: P. When no single overload accepts the full union, the compiler checks each union member individually: if every member matches exactly one overload, the compiler generates an implicitmatchto dispatch to the correct overload at runtime. The return type is the union of the matched overloads' return types.The compiler generates the equivalent of:
Compile error when a union member matches zero overloads or is ambiguous across multiple overloads:
Intersection arguments. When the argument's static type is an intersection
(A & B), the intersection is a subtype of each constituent:(A & B) <: Aand(A & B) <: Bboth hold. This means an intersection-typed argument can match multiple overloads simultaneously. When multiple overloads match and no single overload's parameter type is more specific than all others (Resolution Algorithm Step 3), the call is a compile error.For type parameters constrained to a union of concrete types, see Iftype Specialization Rule 7: the compiler resolves the dispatch automatically when every union member matches exactly one definition. For intersection constraints, the type parameter satisfies all constituents simultaneously, so it can match multiple overloads — when no single overload is most specific, the call is ambiguous and is a compile error. Note that
iftypenarrowing does not help for intersection constraints — the type parameter already satisfies both branches, soiftypeguards on either branch are tautological.Ephemeral argument types. Ephemeral argument types (
iso^,trn^) participate in capability subtyping during Resolution Algorithm Step 2 but do not alter nominal type identity. A consumedisoreference to typeTmatches any overload whose parameter typePsatisfiesT <: Pnominally andiso^ <: cap(P)for capability. When this results in multiple candidates, Resolution Algorithm Step 3 rejects the call as ambiguous.Arity overloading. Overloads can differ in parameter count. An overload with N parameters is distinct from one with M parameters (N != M).
Arity + default argument ambiguity check. When default arguments cause two overloads to accept the same call shape, the ambiguity is rejected at the definition site. A call shape is a specific argument count — the number of positional arguments supplied at the call site. The check enumerates all call shapes (argument counts) reachable by omitting arguments with defaults; if any call shape is accepted by both overloads AND neither overload is strictly more specific than the other at the shared positions, the definition is rejected. An overload X is strictly more specific than Y at the shared positions when X's type is a subtype of Y's at every shared position AND a proper subtype at at least one position. Identical types at every shared position means neither is strictly more specific — this is the genuine ambiguity case (like the Logger example).
Named arguments do not disambiguate. Named arguments (
where) are per-overload. Resolution happens on positional argument types and arity first; named arguments are validated against the selected overload afterward. Two overloads that differ only in named parameter names or types are ambiguous and rejected at the definition site.Allowing named arguments to disambiguate would mean the same positional call shape dispatches to different methods depending on which named argument appears — the caller must know which named arguments belong to which overload, and the compiler would need to try all overloads to see which named arguments match. This is implicit.
Type parameter collapse. When a type parameter could equal a concrete type in another overload at reification, the conflict is reported at the class definition, not at instantiation. The consumer never sees a confusing error from inside generic instantiation.
When the variation is on the type parameter itself, use iftype specialization instead:
Completeness. The collapse check uses each definition's parameter types for pairwise comparison, consistent with the Resolution Algorithm Steps 2-3. (Specializations share the default's parameter types, so there is no distinction.) The check covers:
A = B)(String, A)vs(String, USize)collapses whenA = USize. Element-wise comparison applies.Array[A]vsArray[String], orArray[Array[A]]vsArray[Array[String]]-- since Pony's generics are invariant, each nesting level reduces by invariance:Array[X] = Array[Y]iffX = Y)Any valcould be instantiated with a union type like(String val | USize val). The collapse check must detect thatAcould equal the exact parameter type of the other overload, including union types.store(this->A)vsstore(String)collapses whenA = Stringbecause viewpoint adaptation through any valid receiver resolvesthis->StringtoString. The check resolvesthis->for all valid receiver capabilities before comparing.Decidability. The collapse check is decidable because Pony's type parameters have finite constraint sets (capability constraints bound the possible instantiations, and structural/nominal constraints bound the type) and generics are invariant. The check is a bounded search over possible type parameter equalities, including union-typed instantiations (check if the other overload's parameter type satisfies the type parameter's constraint).
Constraint intersection precision. When evaluating whether two type parameters with different constraints could collapse, the check must determine whether any concrete type could satisfy both constraints simultaneously. The check uses open-world reasoning: it asks whether a type could exist that satisfies both constraints, not whether one currently does. This is conservative — it may reject overloads that would work with the current set of declared types — but it prevents a new type added in a dependency from retroactively breaking existing code. For class/primitive constraints (
K: String,I: USize), this is straightforward — distinct final classes have no common subtype regardless of what else exists. For trait/interface constraints, the check must determine whether any concrete type could implement both traits — and since Pony traits are open (any type can implement them), the answer is generally yes unless the constraints are provably incompatible (e.g., different capability requirements that cannot coexist).Each overload has its own return type and partiality. Type overloads are compile-time resolved and each is an independent method. The caller sees the return type and partiality of the selected overload.
Traits and interfaces can declare type-overloaded methods. Each overload is a distinct method requirement. A concrete type satisfies the trait or interface if it provides all declared overloads. Through the trait or interface, only the declared overloads are visible.
Interfaces (structural types) can declare overloaded methods. Each overload is an independent structural requirement. A type with overloads
apply(String)andapply(USize)structurally satisfies an interface requiring onlyapply(String). A type must provide all overloads declared by the interface to satisfy it.Structural subtyping. Each overload is independently matchable against interface requirements. The structural matcher matches interface method signatures against individual overloads by parameter types, not just by method name. For each interface method signature, the matcher selects the most specific overload whose parameter types satisfy the interface requirement (using the same "most specific" definition as Resolution Algorithm Step 3). If multiple overloads qualify and none is most specific, it is a compile error at the point where the structural subtype check is needed. Each matched interface method signature maps to exactly one overload's vtable slot. When multiple interface requirements map to the same concrete definition via contravariance (the concrete definition's parameter types are supertypes of several interface requirements' parameter types), each interface requirement resolves to that one vtable slot — this is valid. The resolution algorithm needs no special handling, but the vtable builder must map multiple interface entries to the single concrete slot. When a concrete definition has default arguments (making it callable at multiple arities), the structural matcher matches the interface requirement against the definition's minimum-arity signature — default arguments are not visible through the interface. When the matched overload also has iftype specializations, guard conformance follows Iftype Specialization Rule 8.
Concrete types can have additional overloads. A class implementing a trait can have overloads the trait does not declare. Through the trait, only the trait's overloads are visible. Through the concrete type, all overloads are visible.
Intersection-typed receivers. When the receiver type is an intersection of traits or interfaces (e.g.,
(Gettable & Indexable)), the visible method group is the union of each constituent's declared overloads. If both constituents declare an overload with the same parameter types, the overload appears once (deduplicated by parameter signature; return type conflicts from trait composition are resolved by existing rules, unchanged by this proposal). The assembled method group must satisfy Rule 15 (receiver capability consistency) and method kind consistency — if two constituents contribute definitions with different receiver capabilities or different method kinds, the intersection type produces an invalid method group and the type is rejected at the point where the intersection is used as a receiver.Trait and interface composition creates overloaded method groups. When a class implements multiple traits or interfaces that each declare a method with the same name but different parameter types, the class gains an overloaded method group assembled from the contributions of each trait or interface. Today this is a compile error (duplicate method name). With type overloading, the composed definitions form a valid method group if they satisfy the usual rules.
The same applies to interfaces (structural typing):
The assembled method group must satisfy all the usual rules: overloads must differ in nominal type (Rule 1), the collapse check must pass (Rule 6), receiver capability must be consistent (Rule 15), and generic methods cannot mix with overloads (Rule 18). When two traits contribute the same parameter signature, that is not overloading — the class provides one implementation satisfying both traits, same as today.
Behaviors. Type overloading applies to behaviors. Resolution is compile-time. Each overloaded behavior is a distinct message type with its own vtable slot, dispatched independently through the actor mailbox. FIFO ordering is preserved across all behaviors on the actor.
FFI and bare functions. FFI declarations cannot be overloaded. C has no overloading; a second declaration with the same symbol name is an error. Bare functions (
@methods, which use C calling convention) cannot be overloaded for the same reason — they export unmangled C symbols and overloads would collide.addressofon overloaded methods.addressoftakes the address of a method for FFI use. When the method is overloaded,addressofis ambiguous — the programmer must disambiguate by providing the expected function pointer type as context (same as partial application disambiguation). Without expected-type context,addressofon an overloaded method is a compile error.Constructor overloading. Type overloading applies to constructors with the same rules as methods. Overloaded constructors share a name but differ in parameter types.
This is distinct from Pony's existing named constructors (
new create,new from_array, etc.), which differ by constructor name.embedfield initialization rules are unchanged -- each overloaded constructor independently type-checks its field initializations.Receiver capability consistency across overloads (enforced). All overloads of the same method name must have the same receiver capability. Mixed receiver capabilities are rejected at the definition site.
This rule applies to the assembled method group after trait flattening — if two traits contribute overloads with different receiver capabilities, the implementing type is rejected.
Method kind consistency. All definitions in an overload group must be the same method kind (
fun,be, ornew). Mixing kinds is rejected at the definition site. This prevents a method name from being synchronous for some argument types and asynchronous for others —fun tagandbeshare thetagreceiver capability, so Rule 15 alone would not catch the combination. This rule also applies after trait flattening.Object literals. Object literals support overloaded methods with the same rules as classes. Object literals create anonymous types that go through the same scope pass.
.>chaining..>(chain) works with overloaded methods. Overload resolution selects the method for the call (for side effects, partiality, and capability checking), but the return type is discarded and the receiver is returned instead. The chain type is computed from the receiver and the selected overload's receiver capability. Because all overloads of the same method must have the same receiver capability (Rule 15), the chain type is consistent regardless of which overload is selected.No method-level type parameters on overloaded methods. If a method group has multiple definitions (type overloads), none of them can have method-level type parameters. A method is either generic or overloaded, not both. Type parameters on the enclosing type are unaffected — those resolve at reification, not at the call site. This avoids an ordering ambiguity between type argument inference and overload resolution.
This rule applies to the assembled method group after trait flattening, not just locally-defined methods. If one trait declares a generic method
foo[T](x: T)and another trait declares a non-generic overloadfoo(x: String), a concrete type implementing both traits has a method group that mixes generic and non-generic definitions — rejected at the definition site. Consequence: a trait declaring a generic method prevents implementing types from adding non-generic overloads of that name (and vice versa).Partial application
Partial application of overloaded methods is a compile error when ambiguous.
When no expected type context disambiguates,
receiver~method()on an overloaded method is a compile error:When expected-type context exists, it disambiguates:
When adding an overload can break existing code. Adding an overload to an existing method does not always break downstream code, but it can. Code that previously compiled may stop compiling or silently change behavior in these cases:
process(String)alongside existingprocess(Stringable), callers passingStringarguments silently switch to the new definition. If the new definition has different behavior, this is a semantic change with no compiler diagnostic at the call site. This is the classic C++/Java overloading hazard and is inherent to most-specific resolution.Adding an overload whose parameter types do not overlap with any existing definition's parameter types breaks nothing — no existing call site can match the new definition. The risk is specific to overloads that are more specific than (or overlap with) an existing definition.
Resolution for partial application. The compiler computes the function type each overload would produce via partial application, matches against the expected type at the use site, and selects the unique match. No match or multiple matches is a compile error with guidance to add a type annotation.
Partial application as an argument to an overloaded call. When a partial application of an overloaded method is passed as an argument to another overloaded call, the expected type needed to disambiguate the partial application depends on the outer call's resolution, which depends on the argument type — a circularity. This is a compile error: the programmer must annotate the partial application's type explicitly.
Lambda and object literal arguments. When an argument at an overloaded call site is a lambda or object literal whose type is inferred from context, the compiler uses trial resolution:
Trial resolution is bounded by the number of overloads (small in practice) and reuses the existing lambda type-checking machinery.
Isolation requirement. Each trial must operate on a cloned copy of the lambda's AST. The compiler's lambda type-checking (
expr_lambda) mutates the AST in place (setting inferred types on nodes), so a failed trial on the original AST would corrupt subsequent trials. The implementation must clone the lambda AST before each trial and discard the clone on failure.Nested trial resolution prohibited. If a lambda body under trial resolution contains a call that itself requires trial resolution (another overloaded call with a lambda argument), the compiler rejects the outer call with "provide explicit types." Nested speculation would compound to N^k cost (N candidates at k nesting levels) and the rollback semantics become ambiguous. The programmer must annotate the inner lambda's types to eliminate the nested ambiguity.
Multiple lambda arguments. When multiple argument positions are lambdas, trial resolution is holistic: each candidate is tried with all lambda arguments simultaneously. A candidate succeeds only if every lambda body type-checks against that candidate's expected types. This avoids the situation where different candidates succeed at different argument positions.
Interaction with iftype specialization
When a method group has both type overloads and iftype specializations:
These are independent: each definition can have its own iftype specializations (with non-overlapping guards). The consumer's mental model: argument type selects the definition, then type parameter constraint selects the variant within it.
Specialization-to-default association. When a method group has multiple definitions (type overloads), each iftype specialization must be associated with exactly one default. Since specializations must have the same parameter types as their default (Iftype Rule 3), the association is unambiguous: match by parameter types.
Error messages
When every member of a union matches exactly one overload, the call succeeds — the compiler generates an implicit
matchto dispatch at runtime. The error above occurs only when a member has no matching overload (or is ambiguous). The same auto-resolution applies to union-constrained type parameters (see Iftype Rule 7).Reserved Method Names
Certain method names have special meaning to the compiler and runtime. Overloading restrictions apply:
Cannot be type-overloaded (compiler looks up by name, expects one definition):
_final-- looked up byast_getfor finalization codegen. Type overloading would produce multiple nodes for one name._event_notify-- looked up by name in codegen for ASIO event dispatch. Must be a single definition._init-- looked up byadd_specialin reach.c for primitives. Must be a single definition.Main.create-- codegen looks up the Main actor'screateconstructor by name, expecting a single definition with a specificEnvparameter. This restriction is scoped to theMainactor only.Main.runtime_override_defaults-- verified byverify/fun.cwith a specific signature. Scoped to theMainactor only.This restriction applies to the assembled method group after trait flattening, not just locally-defined methods. If two traits each provide a definition of
_finalwith different parameter types, a concrete type implementing both traits violates the single-definition requirement and is rejected at the definition site.Iftype specialization is allowed for these methods. A specialization produces one body per definition after reification, so
ast_getstill returns one node. Example:_finalcould have a specialization guarded byA <: DisposableActor tagfor type-dependent cleanup. (In practice,_event_notifyhas a fixed signature imposed by theAsioEventNotifytrait and the runtime, so specializing it is unlikely to be useful — but it is not prohibited by the mechanism.)Sugar-called methods (
apply,update,dispose,has_next,next) have no special restrictions. Sugar desugars to a normal call; overload resolution applies to the generated call.Resolution Algorithm
For any given call site with known static argument types and known type parameter bindings, exactly one definition is selected. The compiler's obligation when resolution is ambiguous is a compile error, never silent selection.
Steps:
Filter by arity. Keep definitions whose required parameters can all be filled by the combination of positional and named arguments at the call site, and whose total parameter count (including defaults) is not exceeded. Named arguments (
where) fill specific parameters by name; positional arguments fill parameters left-to-right. A definition passes the arity filter when: |required params whose names are NOT among the supplied named arguments| <= positional arg count <= (total params - named-arg-supplied params). The minimum uses set difference, not simple subtraction, because a named argument targeting an optional parameter does not reduce the number of required positional slots. This ensures that sugar-generated calls likeupdate(key, value)correctly match 2-parameter definitions.Filter by argument types. From arity-matching definitions, keep those where each argument type is a subtype of the corresponding parameter type. (Specializations share the default's parameter types, so there is no distinction between "default's types" and "specialization's types" here.) When a union-typed argument (concrete value or union-constrained type parameter) fails the subtype check against all definitions as a whole, the compiler resolves per-member: if every member matches exactly one definition, the dispatch succeeds. For concrete union values, the compiler generates an implicit
match; for type parameters, it resolves at reification (see Iftype Rule 7). The return type is the union of matched definitions' return types. If any member matches zero or more than one definition, the call is a compile error. Intersection-constrained type parameters and trait-constrained type parameters follow normal resolution — checked as a single type, not decomposed.Numeric literal resolution. When an argument has the deferred type
TK_LITERALorTK_OPERATORLITERAL(a numeric literal, or an operator expression on unresolved literals, whose concrete type has not yet been determined), the compiler filters definitions for that argument position to those whose parameter type is a numeric type. If exactly one numeric definition remains after filtering all argument positions, the literal is coerced to that definition's parameter type. If multiple numeric definitions match (e.g.,apply(n: USize)andapply(n: U32)), the call is a compile error -- the caller must provide an explicit type annotation (e.g.,USize(42)orlet x: USize = 42). Non-numeric definitions are eliminated for that argument position (a numeric literal cannot match aStringparameter).Select most specific. If multiple candidates remain, select the one whose parameter types are most specific: every parameter type must be a subtype of the corresponding parameter in all other candidates. A single more-specific position does not win if another position is less specific. If no single most-specific candidate exists (incomparable types), compile error. (Specializations share the default's parameter types, so there is no distinction to navigate here.)
Mixed-arity candidates. When candidates have different formal parameter counts (because default arguments make both accept the call's arity), comparison uses only the positions supplied by the call site. Positions beyond the call's argument count are not compared — those parameters have defaults and were not supplied. If the supplied positions are identical across candidates, neither is more specific and the call is ambiguous (caught by Rule 4's definition-site check in most cases, but the resolution algorithm handles the residual).
Validate named arguments. Check that any named arguments (
where) at the call site match the selected definition's parameter names and types. Invalid named arguments are an error against the selected definition, not a reason to re-run resolution.Apply iftype specialization. If the selected definition has iftype specializations, check each guard against the type parameter constraints. If exactly one guard is satisfied, use that specialization. If no guard is satisfied, use the default. (The non-overlapping rule in Iftype Rule 2 guarantees that at most one guard can match.)
Each step produces exactly one result or an error. The algorithm is deterministic.
Complexity bounds (implementor note). Steps 1-2 are linear in the number of definitions (D). Step 3 is O(D²) pairwise comparison. D is small in practice (<10 definitions per method group). The type parameter collapse check (Type Overloading Rule 6) is O(M²) in the number of definitions checked pairwise, with per-pair cost from constraint intersection. The arity + default argument ambiguity check (Rule 4) is 2^K in the number of default arguments (K), but K is typically 0-3. The non-overlapping guard check (Iftype Rule 2) is O(S²) pairwise in specializations per definition (S), with per-pair cost depending on guard complexity: capability set intersection for simple guards, constraint satisfiability for compound guards. For disjunction guards with M and N branches, the per-pair cost is O(M×N) satisfiability checks — exponential in the number of disjunction terms, but bounded by practical guard sizes (typically 2-3 branches).
Compile-time cost for non-overloaded code (implementor note). The symbol table change from 1:1 to 1:N affects every name lookup. For non-overloaded code, every set is size 1. The implementation should fast-path the single-element case to minimize overhead on existing code.
Resolution annotates the call site. After resolution, the selected definition is attached to the call site's AST node so downstream passes (partial application, codegen) can identify the selected definition without re-running resolution.
Interaction with
matchnarrowing. Inside amatcharm, the receiver type may be narrowed to a more specific concrete type. Overload resolution operates on the narrowed type, using the same algorithm. This requires no special handling -- the narrowed type is the static type at the call site, and the resolution algorithm uses static types.Interaction with
matchvalue patterns. Value patterns (| 42 =>) compile to a call toeqon the match operand. Wheneqis overloaded, the pattern value's type selects the overload through normal resolution.Interaction with
recoverblocks. Inside arecoverblock, outer variable types are restricted by the recovery rules. Resolution uses the type as visible inside the block -- the restricted type, not the outer type. No special handling is needed; the restricted type is the static type at the call site.Tuple arguments. Tuples are structurally typed by position.
(String, USize)and(USize, String)are different nominal types for resolution purposes. Element-wise most-specific comparison applies:(String, USize)is more specific than(Stringable, USize)becauseString <: Stringablein the first position.When To Use Which Mechanism
If your methods differ in parameter types: use type overloading. The argument type at the call site determines which method runs.
If your methods share parameter types but specialize on generic constraints: use iftype specialization. The type parameter binding determines which variant runs.
If both apply: type overloading resolves first (by argument type, selecting a definition), then iftype specialization resolves within that definition (by type parameter constraint).
Do not use iftype guards to distinguish argument types -- use type overloading for that. Iftype guards constrain type parameters (
A,T), not the types of individual parameters. If you want different behavior forStringarguments vs.USizearguments, write two definitions with different parameter types, not one definition with a guard.Tooling Impact
Method overloading affects the self-hosted tool ecosystem. This section specifies what each tool must handle, not how it should implement handling.
Compiler infrastructure (critical path):
The compiler's symbol table (
ast_set/ast_get) is currently 1:1 name-to-AST-node. Overloading requires name-to-node-group. The blast radius of this change spans:ast_set()/ast_get()-- must store and return multiple nodes per name. The scope pass must allow duplicate method names when the duplicates form valid overload groups.scope.c:set_scope()-- currently rejects duplicates; must distinguish "valid overload" from "invalid redefinition."lookup.c:lookup_nominal()-- returns a single node; must return an overload set.finalisers.c--_finallookup must continue to expect one node (reserved method names are excluded from overloading).codegen/genfun.c-- special method handling for_final,_event_notifymust continue to expect one node.reach.c-- mangled names include parameter type mangles, but the current mangle scheme assigns"o"to all non-primitive nominal types. Two overloads likedispatch(Printable)vsdispatch(Loggable)would produce identical mangled names. The mangle scheme must be extended to distinguish nominal types (e.g., by incorporating the type name into the mangle). Machine-word primitives already have distinct mangle characters and would work without changes.Self-hosted tool library (
pony_compiler):AST.get()andAST.find_in_scope()return(AST | None), not an array. Must be extended to return all overloads of a name.DefinitionResolvercallsfind_in_scope(name) as ASTand pushes a single result. Must handle overload sets.pony-doc:
pony-lsp:
signaturesarray to present multiple signatures (one per overload).pony-lint:
PublicDocstringrule must check docstrings per-overload.Secondary Example: Clone
The
Array.clone()case is the most-discussed motivation for iftype specialization. The goal: whenA <: Any #share,clone()returnsArray[A] iso^instead ofArray[A] ref^, giving the caller a sendable copy without an unsafe cast.Body correctness note. The specialization wraps the default body in
recover iso ... end. Inside the recover block, all elements are#share(the guard ensuresA <: Any #share), so the array contents are sendable and the recover block can lift the result toiso. However, the body accessesthis(which isbox) inside the recover block. The recovery rules require that non-sendable outer references not be accessed inside the block. Whether accessingthisfields (_ptr,_size) is permitted depends on how the compiler handles field access throughboxin recover blocks -- fields of value types likeUSizeandPointermay be extracted before the recover block, or the compiler may permit read-only access toboxreferences for value-typed fields. The exact body structure for the stdlib implementation requires verification against the compiler. The mechanism is sound regardless -- the subtype relationship and viewpoint adaptation checks hold -- only the body's specific recovery pattern needs validation.Migration note. This specialization changes the return type from
ref^toiso^. The aliased form ofiso^istag, while the aliased form ofref^isref. Callers using type inference (let result = arr.clone()) will infertaginstead ofref. This is a breaking change for callers who rely on the inferredreftype. Library authors should verify that their specialization's aliased return type is a subtype of the default's aliased return type, not just the ephemeral form.This example is secondary to
Container.getabove because the body requires careful handling ofrecover isoand the return type change can break callers via type inference. TheContainer.getexample demonstrates the same core value proposition -- same call, better type when provably safe -- with a body that type-checks straightforwardly and a return type change that is genuinely non-breaking.All reactions