A number of programming languages offer a feature called “Named Arguments” or “Labeled Arguments”, which makes some function calls much more readable and safer.
Let’s see how hard it would be to add these in Rust.
Consider a function such as:
fn rect(x: u32, width: u32, y: u32, height: u32, background: Option<Color>, foreground: Option<Color>, line: Option<Color>) -> Widget {
// ...
}
rect(X, Y, WIDTH, HEIGHT, LINE, None, None); // Oops, I have completely confused the order of arguments.
It would be nice if we could leverage Rust’s syntax and/or type system help us avoid confusing these arguments. This is a feature often called Named Arguments or Labeled Arguments, which is available in Python, OCaml, Swift, Dart, … with strong typing in some of these languages that support it.
In Rust, one could imagine writing
// In this example, `{( ... )}` means that we're using named arguments.
// Other syntaxes are possible.
fn rect{(x: u32, width: u32, y: u32, height: u32, background: Option<Color> = None, foreground: Option<Color> = None, line: Option<Color> = None)} -> Widget
{
// ...
}
rect{(x = X, y = Y, width = WIDTH, height = HEIGHT, line = LINE)}; // Arguments are automatically reordered. Default values are automatically inserted. Above error becomes impossible.
Wouldn’t that be more readable and safer?
What this post is about
This is not about syntax, only about semantics.
I was toying with a few ideas on how to implement named arguments without breaking the existing type system. I have a few macro-based prototype implementations. It’s way too early to turn them into a RFC, but I hope that these can serve as a basis for further conversation.
With these prototypes, we can write 1:
// Define the function.
define!(fn(rect, x: u32, width: u32, y: u32, height: u32, background: Option<Color> = None, foreground: Option<Color> = None, line: Option<Color> = None) -> Widget {
// ...
})
// Call the function, macro-style.
// Arguments are re-ordered as needed and optional arguments are replaced with their default value if needed.
call!(rect, x = X, y = Y, width = WIDTH, height = HEIGHT, line = LINE);
// Or, equivalently, builder-style.
rect::setup()
.x(X)
.y(Y)
.width(WIDTH)
.height(HEIGHT)
.line(LINE)
.call();
// On the other hand, the following fails to type-check, because we've forgotten
// required argument `height`.
call!(rect, x = X, y = Y, width = WIDTH, line = LINE);
// ... or equivalently
rect::setup()
.x(X)
.y(Y)
.width(WIDTH)
.line(LINE)
.call();
// And the following fails to type-check because we've used the same argument
// twice:
call!(rect, x = X, y = Y, x = X, width = WIDTH, height = HEIGHT, line = LINE);
// ... or equivalently
rect::setup()
.x(X)
.y(Y)
.x(X)
.width(WIDTH)
.height(HEIGHT)
.line(LINE)
.call();
A bit more verbose but much safer, right?
At the time of this writing, the prototype only support module-level fn
declarations, not functions within impl
or trait
or closures. I don’t think that there’s any strong blocker for either, I just haven’t attempted implementing these yet. These are left as future works.
The general idea
The general idea is that macro define!
creates a type-level final state machines that behaves as follows:
- the state is the set of named arguments that have already been passed;
- the initial state is {} (the empty set);
- from a state S and for each argument a: T that doesn’t appear in S, there is a transition materialized
by a function
S::a(Self, T) -> S2
, where S2 = S ∪ {a}; - a state S is accepting if and only if S contains all required arguments;
- when a state S is accepting, we materialize this with a function
S::call()
that calls our original function.
For the moment, all this is implemented by generating one struct
type per state S and storing all of them
within a semi-hidden module.
Macro call!
just chains calls to these functions S::a(...)
. The type system catches when we’re attempting
to use the same argument twice (because the method doesn’t exist) or when we’re missing one argument (because
method call()
doesn’t exist).
Early conclusions
This is still an early prototype but I believe that it demonstrates satisfyingly that we can extend Rust with named arguments, at least in simple cases, without modifying either the type system or the semantics of the language in depth.
It feels like we could extend the Rust type system to improve error messages (when we use the same argument twice or forget one argument) and likely compile-time performance of this use case without it having it cascade in too many places.
Future works
As mentioned previously, the current prototype only works for module-level function declarations. Making it work for impl
-level functions and trait
-level functions should be possible but will require a little more effort to store the type-level finite
state machine.
Links and credits
- An early prototype is available on github 1.
- This prototype is based on the (great!) crate typed-builder by Chris Morgan and IdanArye. They did all the heavy lifting :)
-
Disclaimer: The syntax in the latest prototype is actually slightly more verbose than the snippet at the start of this blog entry. Real-life complications have prevented me from finishing the prototype in which we adopt this cleaner syntax but as far as I can tell, there doesn’t seem to be any Rust-level blockers. ↩︎ ↩︎