Plan: Fix #4088 — Default Argument Dispatch on Abstract Types #4976
Replies: 1 comment
-
Design Revision: Defaults as Type-Level Method OverloadsAfter discussion, we're revising the mental model for default arguments. Instead of treating defaults as call-site syntactic sugar, we treat them as defining reduced-arity methods on the type itself: is sugar for: Each type carries its own reduced-arity method that knows its own defaults. Dispatch through vtables naturally gives you the right defaults for the concrete type, regardless of how the call site sees the receiver (union, intersection, interface, or trait). Impact on Interfaces/TraitsThe original plan scoped out interfaces/traits, arguing that using the interface's own default was correct behavior. Under the new model, that's wrong — the interface's default is just the interface's own reduced-arity overload, but vtable dispatch should pick the concrete type's overload at runtime. interface Greetable
fun greet(name: String, greeting: String = "hi"): String
class Formal
fun greet(name: String, greeting: String = "Good day"): String =>
greeting + ", " + name
let g: Greetable = Formal
g.greet("Sean") // Current behavior: "hi, Sean" (interface's default)
// New model: "Good day, Sean" (Formal's default)Impact on SubtypingIf interface Greetable
fun greet(greeting: String = "hi"): String
class Formal
fun greet(greeting: String): String => greeting // no defaultCurrently This is a breaking change, but arguably the current behavior is already broken: Requiring defaults on implementors makes the contract honest — each type is responsible for its own reduced-arity behavior. Next StepA revised plan incorporating this broader model follows. |
Beta Was this translation helpful? Give feedback.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
-
Change classification: Fixed (bug fix) for issue #4088. Requires a release notes file and
changelog - fixedPR label.Problem
When calling a method on an abstract type (union, intersection) with omitted arguments, the compiler fills in defaults from a single "representative" method chosen by
lookup_union()/lookup_isect(). At runtime, the actual concrete type may have different defaults. The representative's defaults are used for all members — silently applying the wrong defaults or compiling code that should fail.The same issue exists for interfaces/traits when the interface declares a default that differs from a concrete implementation's default. However, when calling through an interface, using the interface's own default is the expected behavior (the interface defines the behavioral contract). This fix focuses on union and intersection types, where there is no single canonical declaration.
Approach: Selector Coloring with Shim Functions
For each concrete type that participates in a union/intersection and has defaulted parameters, create per-concrete-type "shim" methods with reduced arity. These shims:
The shims participate in selector coloring (the
paint.calgorithm) and get their own vtable slots. At the call site, a reduced-arity call dispatches through the shim's vtable slot, ensuring each concrete type uses its own defaults at runtime.Scope
Implementation Steps
Step 1: Annotate Call Sites (expr pass —
call.c)Goal: Detect when default arguments are applied on a union/intersection receiver, validate that all members have compatible defaults, and annotate the call AST for later use by codegen.
Changes in
method_application():After
extend_positional_args()andapply_named_args()but BEFOREcheck_arg_types():!partial. For partial application (partial = true),TK_NONEargs represent unfilled positions, not default-needing positions —check_arg_types()skips them. Annotating partial application calls would cause incorrect shim dispatch at codegen time.TK_NONE(need defaults).method_receiver_type(lhs).TK_UNIONTYPEorTK_ISECTTYPEand there areTK_NONEargs:a. For TRAILING defaults only (all
TK_NONEargs are at the end of the positional list): record the explicit arg count. This will use the shim path.b. For NON-TRAILING defaults (gaps due to named args skipping middle params): check whether all members have the SAME default expression for each gap position. If they differ, emit an error. If they're the same, proceed normally (the shared default is correct for all members).
check_arg_types()succeeds (which fills in defaults from the representative for type-checking), annotate theTK_CALLnode with the explicit arg count usingast_setdata(ast, (void*)(uintptr_t)(explicit_count + 1)). The+1distinguishes "0 explicit args" from "no annotation" (NULL data).check_arg_types()is unchanged — it still fills in defaults from the representative method for type-checking. The annotation is what tells codegen to dispatch through a shim instead of the full method.New helper function:
check_union_defaults()— iterates union/intersection members, looks up the method on each vialookup(), and validates that all members have defaults at the specified parameter positions. Two distinct error conditions:"cannot omit argument '%s': not all union members provide a default""cannot omit argument '%s': union/intersection members have different defaults for this parameter"Step 2: Create Shim Methods (reach pass —
reach.c)Goal: For each concrete type that participates in an abstract type dispatch and has defaulted parameters, create shim
reach_method_tentries with reduced arity.Two creation points for shims — one for concrete subtypes, one for the abstract type itself:
a. Concrete subtype shims: New function
add_default_shim_to_subtype()— called fromadd_rmethod_to_subtype()after creating the normal forwarding method. For each concrete subtype:lookup().TK_PARAMisTK_SEQif a default exists,TK_NONEif not).TK_LOCATION(__loc). Since__locexpands to the CALL SITE location, all members produce the same value — no shim is needed. If a parameter hasTK_LOCATIONon some members but a non-TK_LOCATIONdefault on others, the__locmembers are treated as not having a default for shim purposes (error in the expr pass).k:reach_method_twithparam_count = k.kentries from the parent method'sparamsarray (abstract type's param types, ensuring consistent mangled names across all concrete types).mangled_nameviamake_mangled_name()— naturally different from the full method since it encodes fewer param types.name(genname) via newgenname_fun_shim():"{cap}_{method_name}[_TypeArgs]$d{k}". The$d{k}suffix distinguishes shims from real methods. The$character is not valid in Pony identifiers, preventing collisions (it already appears in compiler-generated hygienic IDs).forwarding = trueand newdefault_shim = trueflag.fun = deferred_reify_dup(m->fun)— required becausemake_prototype()andmake_signature()accessm->fun->astto determine method kind. Without validfun, these crash.r_methods(for lookup) andr_mangled(for painting).b. Abstract type shims: New function
add_default_shims_to_abstract()— called fromadd_rmethod()afteradd_rmethod_to_subtypes()completes, conditioned on the type being abstract. For each valid trailing reduced arityk:reach_method_twithoutforwardingordefault_shimflags (abstract types don't get bodies —genfun_method_bodies()skips them).fun = deferred_reify_dup(m->fun)— required becausemake_prototype()accessesm->fun->astBEFORE the trait early return.mangled_nameas concrete subtypes' shims → same vtable slot.r_methodsandr_mangled.New field on
reach_method_t:bool default_shim. Initialized tofalseby existingmemset(0, ...). Explicitly set totrueonly on shim methods. No serialization code exists forreach_method_t— it is transient.No collision: Shim genname (
box_foo$d2) differs from real method genname (box_foo). Shim mangled name (e.g.,box_foo$d2_U32_None) differs from real method mangled name (e.g.,box_foo_U32_String_None).Step 3: Paint Phase — No Changes Needed
Shims have different mangled names from full methods, so they automatically get separate vtable slots. All concrete types' shims for a given arity share the same mangled name → same vtable slot. The painting algorithm handles this automatically.
Step 4: Codegen — Function Signatures (genfun.c)
No changes needed. Existing infrastructure (
genfun_allocate_compile_methods,genfun_method_sigs,make_signature,make_prototype) usesm->param_countandm->paramsto build LLVM function types. Shims with reducedparam_countandparamsget correct signatures automatically.Step 5: Codegen — Shim Body Generation (genfun.c)
New function:
genfun_default_shim()— modeled ongenfun_forward(). Called fromgenfun_method()whenm->default_shimis true. Generates a shim body on a concrete type:reach_method(t, m->cap, n->name, m->typeargs). Returns the full-arity method.m_real->fun->ast→ast_childidx(method_ast, 3).codegen_startfun(c, c_m->func, ..., m_real->fun, ...). Pass the REAL method'sdeferred_reification_tsoc->frame->reifyhas correct type parameter mappings — critical for generic methods.m_real->param_count + 1.args[0] = LLVMGetParam(c_m->func, 0).i = 0tom->param_count - 1): pass through from shim parameters with type casts.i = m->param_counttom_real->param_count - 1):TK_LOCATIONdefaults are never reached (excluded from shim creation in Step 2).deferred_reify(m_real->fun, def_arg, opt)— substitutes type parameters with concrete types.gen_expr(c, reified_def).Default expression codegen safety: Default expressions are self-contained (no references to other params), typically literals/primitives/constructors. Method AST has been through all passes.
gen_expr()works because the shim has a valid function context, module globals, and receiver. Deferred reification handles generic defaults.Integration with
genfun_method():default_shimcheck goes BEFOREforwardingcheck (shims have both flags).Step 6: Codegen — Call Site Dispatch (gencall.c)
After resolving the method via
reach_method(), check the TK_CALL annotation:Both argument loops must be limited to
m->param_count:m->param_countargsm->param_countargs, use shim'sfunc_typeMessage send path must be skipped: When
shim_data > 0, forceis_message = false. The shim handles calling the real sender internally.New lookup function:
reach_method_shim()— mirrors cap normalization fromreach_method(), computes shim genname viagenname_fun_shim(), looks up inr_methods.Step 7: Handle Behaviors (actor message sends)
Shims for behaviors are simple functions (NOT sender/handler pairs) that fill in defaults and call the real behavior's sender:
c_m_real->func)No
func_handleroradd_dispatch_case()needed. Follows thegenfun_forward()pattern for behaviors.Prototype naming: When
m->fun->astisTK_BE,make_prototype()generates a sender prototype (appending_send). Shims use this too sincem->forwarding = truesuppresses handler creation.can_inline_message_send(): Updated defensively — whenm->default_shimis true, usesreach_method_shim()to find matching shim on concrete types. In practice, this path is skipped becauseis_message = falsefor shim dispatch.Step 8: Partial Application
Unaffected. Partial application creates closures at the expression level. The shim annotation logic is guarded with
!partial(Step 1). Fixing partial application with per-type defaults would require runtime closure dispatch — out of scope.Step 9: Tests
Good Pony (should compile):
Bad Pony (should error):
"cannot omit argument 'y': not all union members provide a default"Behavior tests:
Step 10: Release Notes
Create a release notes file. Bug fix classification.
changelog - fixedlabel.Files Modified
src/libponyc/expr/call.ccheck_union_defaults()src/libponyc/reach/reach.hbool default_shim; declarereach_method_shim()src/libponyc/reach/reach.csrc/libponyc/codegen/genname.hgenname_fun_shim()src/libponyc/codegen/genname.cgenname_fun_shim()src/libponyc/codegen/genfun.cgenfun_default_shim()body generatorsrc/libponyc/codegen/gencall.cgen_call(); updatecan_inline_message_send()test/libponyc/badpony.cctest/libponyc/goodpony.ccAppendix: Non-Trailing Default Gaps
Shims work by reduced arity — a shim for arity
kaccepts the firstkpositional arguments and fills in defaults for the rest. This naturally handles trailing defaults (omitting arguments from the end), but not non-trailing gaps where named arguments skip over middle defaulted parameters.Example
After
apply_named_args(), the positional argument list is[42, TK_NONE, true]— there's a gap at position 1 (y), not at the trailing end. No single arity value represents "providedxandzbut noty":(x)and fills in bothyandz(x, y)and fills in justzxandz, fill iny"Why shims don't cover this
Handling arbitrary subsets of defaulted parameters would require either:
ddefaulted parameters, that's2^d - d - 1additional shims beyond the trailing ones (exponential growth).Neither is justified for what is an uncommon calling pattern. Named arguments that skip middle parameters are rare in practice — most default-argument usage either omits trailing arguments or provides all arguments explicitly.
Current behavior
When a non-trailing gap is detected on a union/intersection call:
"cannot omit argument '%s': union/intersection members have different defaults for this parameter".This is an improvement over the current behavior, which silently applies the representative's default regardless of which concrete type is dispatched at runtime. The error makes the incompatibility visible; the programmer can provide the argument explicitly to resolve it.
Beta Was this translation helpful? Give feedback.
All reactions