[Pre-RFC] Named .arguments

If argument labels are treated as part of the identifier, then optional vs. mandatory isn't as big of a design concern. They can be mandatory, but an alias can be published without argument labels, and deprecation can be applied as appropriate for APIs transitioning between the two.

This would require the ability to alias trait members to be fully general, which I feel like has been discussed before but I can't recall how it might have been resolved.

The question of what to do in std would still be there, but it would mean that the pathways would be available to move forward with whatever strategy is deemed to be desirable.

It would be more challenging to do something like this with structural records, or any approach which makes argument labels part of the type of a function.

I would like to see this section expanded a bit. Why is the status quo not good enough? It's not quite obvious to me why IDE features (specifically inline argument name hints) are not a substitute for named arguments. A few possibilities come to mind:

  • Is it that not enough IDEs and editors support this feature?
  • Is it that IDEs support this feature, but it's buggy or broken in some way?
  • Is it that some development environments (like vim maybe) can't support inline hints at all?

If so, does it make sense to instead spend development effort enhancing the IDE experience?

Or is it that proponents of named arguments specifically want to not rely on IDE features. For example, maybe they want enhanced readability when cating rust source code?

Once IDE support has matured in vscode/vim/emacs/intellij/etc so any developer can toggle inline parameter name hints, why is named arguments still an important thing for the core language?

Expanding this section (along with the motivation section) will help explain things to those of us who prefer an IDE solution over a change to the language specification.

1 Like

Thanks for the feedback!

Yes. Code is often read and written online (e.g. on GitHub, stackoverflow, this forum, etc.).

I'm currently in the process of updating some parts of the RFC; I decided that argument names should not be mandatory at the call site, so I'll update the post shortly. I'm also expanding the "Rationale and alternatives" section to answer your questions in more detail.

6 Likes

The RFC is now submitted, view it here. Note that I updated quite a few sections; argument names are no longer mandatory in function calls.

4 Likes

Thanks a lot for the hard work

1 Like

In the named arguments aren't mandatory - remedy section.

I believe that there is two distinct lint that we want to individually turn on and off:

  • adding named arguments when the name don't match (ie calling foo(a) instead of foo(.arg = a) => this should be an optional warning, probably in clippy::all, but not activated by default
  • adding named arguments when the name match the name of another positional argument (see below) => this should probably be a warning activated by default since it's most likely a bug
fn foo(.bar: i32, .baz: i32);

let baz = 32;
let grob = 10;
foo(baz, grob);

// 5 | foo(baz, grob);
//   |    ^^^ baz is used positionnaly but a positional argument using
//   | this name exists for the function foo.
//   = note: declared here
// 1 | fn foo(.bar: i32, .baz: i32);
//   |                   ^^^^^
//   = note: please use named arguments if this is intentional
//   |    foo(.bar = baz, .baz = grob)
//   | or fix the argument order
1 Like

I'm commenting here since I don't want the github discussion to derive too much. I think thing it could be a good addition to the github thread, please tell me, or link this post.

I would definitively like to have named arguments, whatever the syntax used. The following comments is just an alternative proposition, but I think yours is just as good.


About structural records. A few addition of syntactic suggar could make them much more appealing:

  • being able to call a function using curly braces if the only argument is a structural record ¹
  • using parenthesis for such function (without the nested curly braces), and thus using positional arguments.
  • elide the binding when declaring them
// declaration
fn free_fall({ z0, v0, g }: { z0: f64, v0: f64, g: f64 }); // full form
fn free_fall({ z0: f64, v0: f64, g: f64 }); // automatic binding

// Then you could call it with either of the following syntax:
free_fall({ z0: 5.0, v0: 0.0, g: 9.81 });
free_fall{ z0: 5.0, v0: 0.0, g: 9.81 }; // parenthesis elision
free_fall(5.0, 0.0, 9.81); // using positional arguments
  • named arguments are no longer mandatory
  • converting positional arguments to a structural record is backwards compatible
  • structural record emulating named arguments isn't more verbose (thanks to the automatic binding)

However using structural binding, either all arguments are named or none (if you want to use the short form). I personally don't think that it's a problem.

fn some_args_are_named(zo: f64, {v0: f64, g: f64});

// can only be called using this syntax:
some_args_are_named(5.0, {v0: 0.0, g: 9.81});

¹ It's already possible to use types "like" a function with tuple struct. Calling functions with curly braces would just be the symmetric operation:

struct MyStruct(i32, i32);
fn my_function{x: i32, y: i32} { x + y}

let m = MyStruct(1, 2); // this may looks like a function, but it's a constructor call
let m = my_function{x: 1, y: 2}; // this may looks like a constructor call but it's a function

// Currently valid Rust.
let using_tuple_struct   = [(1,2), (3,4)].iter().map(MyStruct   ).collect::<Vec<_>>(); // The tuple struct constructor is used like a function
let using_structural_rec = [(1,2), (3,4)].iter().map(my_function).collect::<Vec<_>>();

assert_eq!(using_tuple_struct, vec![ MyStruct(1, 2), MyStruct(3, 4) ]);
assert_eq!(using_structural_rec, vec![ 3, 7 ]);
1 Like

I think I understand the logic behind this argument (users use the path of least resistance, and if named args aren't mandatory, then they'll be underused), but I'm certain that it focuses way too much on philosophical purity (eg "this is the right way and everyone does the same") over practical trade-offs.

To quote the RFC:

Instead of looking at how code could be written in carefully crafted APIs, we should look at how code is being written in reality . Programmers don't always have time to rack their brains over how to create the most beautiful API. They want to get things done.

Named arguments allow iterating quickly without sacrificing readability, because they are dead simple. There's no need to create new types or make up long function names.

In languages like C++ where structs and the builder pattern exist, but named arguments don't, the vast majority of developers just use positional arguments and don't look any further.

What's important is that library writers can use named arguments, without breaking backwards compatibility. From there, making sure people actually use them can be achieved with optional lints, good documentation where named args are used in code samples, etc.

3 Likes

FWIW, I'm still unconvinced that named arguments are a solution that's right for Rust.

I'd much rather see newtypes become much more ergonomic in their declaration and use. Then you could write:

http.get(Url::from_str("https://www.rust-lang.org/"), Timeout(None));
Window::new(Title("Hello"), Rect::tlbr(20, 50, 520, 300));
free_fall(Length(100.0), Speed(0.0), Acceleration(9.81));

This would also get the meaning across, but have the additional benefits of:

  • Url::from_str supposedly would panic if what you pass in is not, in fact, a URL.
  • You can choose how to create the window rect: from corners, or top/left and width/height, ...
  • You can no longer accidentally mix up parameters in free_fall, as now the types won't match

If the above is too verbose, I'm sure we could come up with something that looks even closer to named arguments, e.g.

free_fall(100.0 as Length, 0.0 as Speed, 9.81 as Acceleration);
3 Likes

Let me just present yet another

that also uses macros and unstable language features, this time however not fn_traits but instead const_generics. The syntax can look like this:

fn free_fall(z0: __!(z0: f32), v0: __!(v0: f32), g: __!(g: f32)) {
    unpack!(z0, v0, g);
    
    println!("Got z0: {}, v0: {}, g: {}", z0, v0, g);
}

fn main() {
    free_fall(1.0, 2.0, 3.0);
    let g = 9.81;
    free_fall(__!(z0 = 1.0), __!(v0 = 2.0), __!(g));
    let f = 123.0;
    // free_fall(1.0, 2.0, __!(f));
    /* yields:
    error[E0308]: mismatched types
      --> src/main.rs:37:5
       |
    37 |     free_fall(1.0, 2.0, __!(f));
       |     ^^^^^^^^^ expected `"g"`, found `"f"`
       |
       = note: expected type `"g"`
                  found type `"f"`
    */
}

(playground)

2 Likes

This looks good at first sight, except:

  • Title need to be created and use Title'd. ¹
  • I have no idea what tlbr means
  • You can mess-up the order of 20, 50, 520, 300

¹ Named arguments vs strong typing when the type is used only once is the same kind of ergonomic between closures and functors (struct that implements an Fn(...) -> ... trait). Functors are more powerful than closure (you can have associated functions and methods, different constructors, …), but if you use a Functor in a single place, using a closure is much more ergonomic.

This was also the reason to add the new syntax fn foo() impl SomeTrait. You could make the concrete type returned by foo public, but it would only add noise.

Thanks I hate it :crazy_face: But it's definitively interesting.

I suppose it means “top-left, bottom-right”.

1 Like

I could have guess it myself, but the point was that the proposed alternative to named arguments is definitively less readable, while being more work.

1 Like

While risking of getting repetitive, let’s still do

because I think this one has potential, lots of flexibility, needs no unstable features and allows for libraries to easily explore a bunch of variants in syntax and practicality.

The idea is to create a proc-macro like

#[named(z0, v0, g)]
fn free_fall(z0: f32, v0: f32, g: f32) {
    println!("Got z0: {}, v0: {}, g: {}", z0, v0, g);
}

or whatever and have that macro just generate a macro called free_fall.

For example

macro_rules! free_fall {
    (.z0 = $z0:expr, .v0 = $v0:expr, .g = $g:expr $(,)?) => {free_fall($z0,$v0,$g)};
    ($z0:expr, .v0 = $v0:expr, .g = $g:expr $(,)?) => {free_fall($z0,$v0,$g)};
    ($z0:expr, $v0:expr, .g = $g:expr $(,)?) => {free_fall($z0,$v0,$g)};
    ($z0:expr, $v0:expr, $g:expr $(,)?) => {free_fall($z0,$v0,$g)};
}

fn main() {
    free_fall(1., 2., 3.);
    free_fall!(1., 2., 3.);
    free_fall!(1.5, 2.5, .g = 9.81);
    free_fall!(.z0 = 1.1, .v0 = 2.2, .g = 33.0);
}

However the possibilities of how exactly to do the syntax here are almost limitless.

Okay, to be realistic, I just found one caveat: this approach does not really support methods, however I’ve already seen ideas of how to do macros in method position in other threads.


My personal takeaway would be: It’s hard to pin down a single “best” way to do named arguments that Rust would need to pin town and support forever. Instead we could keep making features around Fn traits and const generics more flexible, and most notable improve macros (so that they can come up in method position or expand to whole function argument listings, or so that procedural macros can also accept invalid syntax, etc). Then a named arguments feature could instead become a named arguments library.

5 Likes

The inconvenient of using macro are

  • you need to import two objects: the function and the macro
  • it blows-up compile times
  • another big drawback of having named arguments as a library is that it's much harder to justify using it when doing a PR to a random crate where you have never participated before.

Btw, such macro exists today (disclaimer: I never used it).

3 Likes

Macros instead of functions don't work for inherent methods and for trait methods.

Your __! macro using const generics is very interesting! It is similar to tagged strings in other languages, but generalized to any type.

I could imagine a type Tagged<T: ?Sized, const Tag: &'static str> with a syntax sugar tag@T, that can be automatically coerced to T, so you could write:

fn answer(a: augend@i32, b: addend@i32) -> i32 {
    a + b
}
answer(augend@14, addend@28);

which desugars to

fn answer(a: Tagged<i32, "augend">, b: Tagged<i32, "addend">) -> i32 {
    a + b
}
answer(Tagged::<_, "augend">::new(14), Tagged::<_, "addend">::new(28));

The main differences to named arguments are:

  • The tag is part of the type:
    let _: hello@&str = hello@"world";
    
  • Because of this, argument names can be used multiple times within a function:
    fn foo(x: x@bool, y: x@i32, z: x@bool) {}
    foo(x@true, x@0, x@false);
    
  • Types with different tags are incompatible:
    fn foo(f: impl Fn(char) -> bool) {}
    fn bar(c: c@char) -> bool { true }
    foo(bar) // doesn't work
    
  • It is not possible to specify arguments in a different order, or omit arbitrary arguments. (This RFC also forbids that, but this restriction could be lifted in a future RFC)
1 Like

I totally forgot about this point :sweat_smile:

One thing I'd like to encourage everyone is to think how the feature will work, it's semantics and internal representation, instead of the syntax as the former will help inform the later. For example, if the named arguments are merely positional arguments where the compiler removes sugar at the call site and are otherwise are identical, that has certain implications for ffi. If they are a structural record with named fields, that gives a nice symmetry with thinking of positional arguments as a tuple (which funnily enough is how closure arguments are represented), but then you have to think of how positional and named arguments interact: do you have a single structural record for all named fields or have multiple if interleaved? Can you interleave named and positional arguments? Can you refer to a named parameter as if it were a positional parameter? How are optional parameters treated?

Trying to come up with semantics in terms of currently existing syntax helps pave the way for an implementation and helps anticipate what the new feature's interactions with the rest of the language would be.

12 Likes

If they're accessible at the same path, then one import will import both.

This naturally falls out of generalized type ascription, relaxed fn argument type ascription, and structural records, all on its own.

I don't have a link handy, but there's slow movement towards allowing e.g. fn apply(&mut self, Velocity(v)) where struct Velocity(Vec3<f32>), where the type is determined not by a type ascription at the highest level, but by types involved in the pattern and optionally inner type ascription. (e.g. the older nonzero design could do fn takes(NonZero(x: u32)) rather than fn takes(NonZero(x): NonZero<u32>).)