Named Arguments — Design

Issue: #1244, split out of #1186 (Rosetta batch 3).

Why

Trailing default values already work:

function : Train(data : Matrix, epochs : Int := 10, rate : Float := 0.01, verbose : Bool := false) ~ Model

But a caller can only drop arguments from the end. To turn on verbose they must restate epochs and rate, and a reader of Train(data, 10, 0.01, true) cannot tell which number is which. Research code is full of calls like this: a model, an optimizer, a sampler, each with a handful of knobs that have sensible defaults. Named arguments fix both problems:

Train(data, verbose: true);
Train(data, rate: 0.1, epochs: 50);

Decisions

1. Syntax: name: value

The same form as Swift, Kotlin and C#. Spacing is free (name : value also parses).

RejectedWhy
name = value= is equality in Objeck: F(x = 1) is already a valid Bool argument.
name := valueReads as an assignment to a variable called name in the caller.

An argument that starts with a plain identifier followed by : has no meaning today. A ternary's : follows its ? (c ? a : b), and a lambda's follows its ~ Type. So the form is unambiguous: the parser checks one token of lookahead at the start of each argument. Instance variables (@x:) are not parameter names and are rejected.

2. Rules

3. Overloads

For each overload of the name, the call is mapped onto its parameters by name. Overloads the call cannot be mapped onto (unknown name, missing required parameter) are dropped. The rest go through ordinary resolution, with the reordered arguments. More than one survivor that type-matches equally well is the existing ambiguity error.

Only original methods are mapped. The alternates generated for trailing defaults (below) share the original's parameter names, and mapping them as well would give one call several equivalent targets.

4. Skipping a parameter that has a default

Default values are implemented by alternates. F(a, b := 1, c := 2) also gets F(a) and F(a, b). Each is a thin forwarder that assigns the missing defaults in the callee's own context and calls F(a, b, c) (GenerateParameterMethods, IntermediateEmitter for IsAlt()). A named call whose supplied set is a prefix (F(a, b: 5)) is just that alternate.

A call that skips a middle parameter (F(a, c: 5)) needs a forwarder for the set {a, c}, and a call that reorders (F(c: 5, a: 1)) needs its arguments put back in parameter order. Generating a forwarder for every subset is exponential, and same-typed subsets would collide as overloads. So:

A program with no named calls generates nothing, so every library and every existing program compiles byte-identically.

Defaults are still evaluated in the callee, as today. They are never copied to the call site, where names in a default expression would resolve against the caller.

5. What it applies to

TargetSupportedNotes
Functions and methods of classes in the program being compiledyes (phase 1)
Constructors (New)yes (phase 1)the most common place for options
Library methods and constructors (String->Pad, Hash->New, ...)yes (phase 2)see below
Virtual and interface methodsno: laterthey cannot have defaults; an interface cannot hold the concrete forwarder
Function values and lambdasnoa function type has no parameter names
Primitive receivers (v->Clamp(...))nolibrary functions; parameter 0 is the receiver

A named argument to a target that does not support it is a compile error that says why, not a silent positional call.

6. Phase 2: library methods

A release .obl kept a method's parameter types but not their names, and a caller cannot generate a forwarder inside another binary. Phase 2 (shipped) adds:

  1. Parameter names in every .obl. After each method's entries, a library build writes the parameter count, then each parameter's bare name and whether it has a default (IntermediateMethod::Write). The library magic is now 0xffb7. The compiler refuses a library with the old magic (0xffb6) and asks for it to be rebuilt. The VM accepts both, because an .obe never contains the names.
  2. One mask forwarder per library method with two or more defaults (GenerateMaskForwarder, library targets only). F$m takes every parameter plus an Int whose bit j says the caller gave parameter j. It assigns each default the caller skipped, under if((mask and bit) = 0), in the callee's context, then calls F. That is one extra method per such method, not one per subset. Methods with more than 62 parameters get none.
  3. Binding at the call site (BindNamedLibraryArguments). The call shape is mapped onto each overload, in the receiver's class and its library parents, through the recorded names:
    • a call that skips nothing becomes a positional call, its arguments put in parameter order (a default alternate covers any trailing defaults it leaves out);
    • a call that skips a default calls F$m, with a typed placeholder (Nil, false, '\0', 0 or 0.0) for each skipped parameter, and the mask as a literal.

When the parameter order differs from the written order, each argument is evaluated in the order written into a hidden local, then loaded in parameter order (EmitNamedArgumentTemps, EmitCallArgument). This works in every chain form too. A function-typed parameter cannot be skipped or reordered by name, because it has no placeholder or local to hold it. The error asks for it to be passed in place.

Standard-library parameter names are now public API: renaming one breaks named callers. That is accepted, as in Kotlin, C# and Swift. Library reviews should treat a parameter rename like a signature change.

Found on the way: GenerateParameterMethods used offset 0 to mean "no default found". When the first parameter had a default and a later one did too, it started at 1, so F() was never generated, in a program or a library. A library call that named a method only its parent class declared skipped the named-argument path. The chained form then bound the names by position, silently. A backstop in the library path now reports any names left unconsumed.

Tests: named_arguments_library.obs (standard-library methods: reordering, mixing, written order, all-defaults), bad_named_argument_library.obs (an unknown name lists the recorded ones), bad_named_argument_primitive.obs, and check_lib_named_args.py. That last one builds a library and links a program against it, covering a skipped middle default, constructors, inherited methods (on a variable and chained) and an unknown name, in three modes.

Phase 1 plan

  1. Parser: ParseExpressionList (calling parameters only) accepts IDENT ':' expression and records the name next to the expression in the ExpressionList. It enforces positional before named. It records the call shape for forwarder generation.
  2. Context analysis:
    • In AnalyzeMethodCall (program classes), BindNamedArguments maps a named call onto each original overload and binds it to the forwarder for its shape. Several overloads taking one call is an ambiguity error that asks for positional arguments.
    • Give the errors above for function values and bad names (library targets were an error until phase 2). A guard in AnalyzeMethodCall reports any named call no path consumed, so names are never dropped silently.
  3. Forwarder generation: GenerateNamedForwarders is GenerateParameterMethods generalized from "every suffix" to "this order". Each forwarder gets its own copy of each default assignment's variable: Declaration::Copy() shares the original's, which the alternates can afford because their slots match the original's and a forwarder's do not. The emitter loads the original's parameters from the forwarder's own slots.
  4. Tests: named_arguments.obs covers reordering, skipping, constructors, evaluation order, overloads and the JIT (three modes). A bad_named_argument_*.obs file covers each error. rosetta_optional_parameters.obs gets its header corrected and a named-argument form of the task.
  5. Docs: a FEATURES.md section.

Note

reverse is a keyword, so it cannot name a parameter (reverse : Bool is "Expected identifier"). Choose another name, such as descending, for a flag that callers will pass by name.

Not in scope