[Pre-RFC] named arguments

FWIW, named keyword arguments aren’t necesarily “free” when it comes to library maintenance. Keyword names are set in stone, even if newer versions of a library would like to make even backwards-compatible changes. For this reason, an entirely new syntax was recently accepted for Python PEP 570 to again limit their use in call sites (see link for details).

2 Likes

Keyword names are set in stone, even if newer versions of a library would like to make even backwards-compatible changes.

Personally, I find this argument entirely unconvincing. The same could be said about method names or any other names in the API. Why should field names be any different. How often (really?) would you need or want to change the field names in a stable API? I think this sort of objection is finding a problem that doesn’t exist in reality. YMMV.

Correct me if I am wrong, but it seems the issue mentioned/addressed in pythom was that positional arguments were callable through named argument syntax. That’s very much a footgun as I expect that library maintainers often didn’t realize that the name of positional parameters were part of their public API.

I think making named parameters opt in solves it neatly enough. And other things, like adding internal vs public names gives further protection.

I can see people changing argument names during refactoring or bug fixing.

7 Likes

Well you obviously never need to, in the same way that it’s valid to just name everything with guids.

But the names show in documentation, in assert messages, etc, so here’s an example where I renamed one:

That’s also a case where I struggle to understand why anyone would want to name the parameter in the call site.

3 Likes

One alternative is to provide an annotation that let’s library authors deprecate and replace a parameter name with a new one, like @deprecatedName.

In that sense, I would prefer having all parameters be publicly available, and let library authors sort out unfortunate names using that mechanism.

Compared to other approaches, this one has a track-record of having been used successfully in practice without further increasing the footprint of the language.

It seems like the footprint increase of this is at least as large as saying that a foo parameter isn’t publicly-named but a @foo parameter (or pub foo or whatever) is publicly-named.

How about:

  • arguments are always positional
  • all arguments have always to be supplied
  • arguments have to come in the correct order

but

  • a name may optionally be supplied with an argument
  • it is then a compilation error not to match argument name in function definition/declaration
  • if a trait and its impl use different names for the same argument some rules decide which is “more visible” at the call site

Here argument names serve to prevent confusion and improve readability. Argument names are not part of the function type and are obliterated in Fn traits. Possibly Fn traits are assumed to have non-descriptive argument names like a0, a1, a2..

6 Likes

That sounds like a recipe for disaster. If mismatching argument names in other places is disallowed, why should it be allowed between the declaration and the impl of a trait method?

1 Like

Hi H2CO3, so you like the idea otherwise? :slight_smile: I just wanted to make sure we’re on the same page

fn foo(arg: i32)  {...}
foo(42); // ok
foo(arg : 42); // ok
foo(anotherName : 42); // compilation error

trait Xyz { fn bar(&self, arg: i32); }
impl Xyz for i32 { fn bar(&self, superArg: i32) {...} } // compiles now
                         // so we can't possibly declare this an error?

14.bar(42); // ok
14.bar(arg : 42); 14.bar(superArg : 42); // one of these should be ok
                               // and the other should be an error, right?
                               // since this is a new feature we probably can
                               // define a rule to our liking which one is which?
1 Like

I was only reacting to this particular aspect of the suggestion.

Otherwise, I’m not keen on named arguments at all. In my experience they are a massive pain in the neck in a statically-typed language, especially in the context of a dynamically-called function (e.g. fn pointer or a closure). There, various rules need to be bent in some way, in order to allow either functions with named arguments to be called without names, or vice versa, or to make argument names part of the function type, which has its own set of problems and inconveniences.

For my taste, the order of 2 arguments is easy to remember and they can be easily differentiated via newtypes anyway. 3 arguments is already too much and should be refactored with a struct having proper named fields. The currently-stale structural records proposal would make this even more convenient without the need of having to define a named struct in these cases, but it’s a neat solution even without anonymous structs.

11 Likes

I highly agree on this. structural records would eliminate most of the problems i can agree on for “missing” named arguments – they’re neat in some niche “genre” of programming at least for my taste, but i really don’t need them. Its nice to have in 3D programming (that’s just my personal taste) but i don’t miss them in any other aspect i use Rust for.

Just for other people entering the conversation here is the rfc for structural records (i hope that’s the one you’re referring to)

2 Likes

Would it alternatively be possible to make this case easier by allowing the type of a struct be omitted but not adopting structural record types? This would avoid extending the type system but make calling methods with a struct arguments for the sake of naming its arguments somewhat easier.

struct Dog { … }

impl Dog {
    pub fn woof(&self, woof: Woof) -> Sound;
}

/// Only exists to provide names to `woof` arguments
struct Woof {
    pitch: f32,
    loudness: f32,
    repeats: u32,
}

good_boy
    // Type is uniquely deductible. Being able to omit it here also
    // means we don't need to `use` it.
    .woof(_ {
        repeats: 5,
        pitch: 440.,
        loudness: 80.,
    })
2 Likes

Yes, I think this (possibly combined with anonymous struct types) would be better than structural records.

This came up in

Quite a good approach IMHO is to not change anything but use meaningful names for variables and put argument names in comments if needed. i.e.

let mut disp = Display::new(X, Y, W, H, /*obscure_param*/ 666.999);
        disp.set_background(/*r,g,b,a*/ &[0.1, 0.1, 0.2, 1.0]);
        disp.set_title("Example Window");
        disp.set_target_fps(24.0);

let label = String::new(&font, "Hello World", /*justify*/ 0.0);

an advantage of this approach is that the /*name*/ in the fn argument call could be more meaningful than just the actual name.

Also this DIY builder approach makes it clear that the Display instance is now mut. If that's not wanted then there would be a clear line following it..

    ...
            disp.set_target_fps(24.0);
    let disp = disp; // i.e. no longer mut

Note that it doesn't actually use more lines than the chaining dots system, with the advantage that you don't have to look at the source code for Display to figure out what's happening.

What if argument labels were part of the name of the function? This would make them mandatory, and also allow using them to differentiate functions.

impl Rectangle{
  pub fn new(width:f64, height:f64)->Rectangle{...}
  pub fn new(area area: f64, width width: f64)->Rectangle{...}
}

let r1 = Rectangle::new(3.0,4.0);
let r2 = Rectangle::new(area:12.0, width: 3.0);

This is not overloading, because the name of the second function is new(area:width:), while the first is new, and to get a reference to the second, you would have to use Rectangle::new(area:width:), while for the first, Rectangle::new. Also, syntax for the same inner and outer names could be improved. Also, the inner and outer names of the functions can be different, as the best internal names of arguments are not always the best at the callsite.

This would turn using named arguments into an api design decision, and would be opt in, so no backwards compatibility issues.

4 Likes

This is how Swift does it, modulo some source compatibility problems from when parameter labels were part of the type, not the name, discussed here.

(Namely, that any function can be referenced, when not called directly, by its 'base name', e.g. without argument names.)

2 Likes

Yes. However Swift has overloading, so it's a little more complicated, and also, the syntax is a bit better because it was designed from the beginning. Also, swift's apis have been designed with this in mind, so size_of in rust would be size(of:) in swift. As Rust's style has already sort of solidified, it would feel a little different.

1 Like

I really believe that Rust should get named arguments eventually. And it's not about convenience or being more friendly to people coming from dynamic languages, but about helping to prevent bugs and thus making software more reliable (i.e. to be closer to Ada, not Python). And I did encounter bugs which could've been solved by named arguments, for example flat_projection uses lon/lat order instead of a more common lat/lon order (motivation being that lon/lat mirrors X/Y), which I've used automatically out of habit. It took a fair amount of time to find the reason why my program was misbehaving. In RustCrypto we also have encountered this problem when designing AEAD traits. Initially we have used cipher.encrypt(nonce, plaintext, ad), but since authenticated data and plaintext both have type &[u8], there was a real danger that users will use the wrong order, which may have serious implications. We "solved" it with an additional Payload type, enhanced with a cute trick to improve ergonomics.

I've read most of this thread and I think I have a variant which was not proposed yet.

Initially I was in favor of somehow utilizing the struct symmetry for named arguments, but after a fair amount of thinking I've come to a conclusion that it's probably a dead end. There is simply too many issues with generic code and conflicts with type ascription, and attempting to solve them will make feature too big and scary to accept.

Instead I would like to suggest "pub argument blocks" (PAB):

fn foo(a: u32, pub { b: u32, c: u32 }) { .. }
fn bar(pub { a: u32, b: u32 }) { .. }
fn baz(a: u32, pub { b: u32 = 1, c: u32 = SOME_CONST }) { .. }

const fn f() -> u32 { .. }
fn zoo(&self, pub { a: u32 = f()}) { .. }

// foo has type Fn(u32, u32, u32), so
foo(1, 10, 30);
// is equivalent to
foo(1, b = 10, c = 30);
// with type ascriptions
foo(a: u32, c = 1: u32, b = my_b: u32);
// arguments with a default value can be omitted
baz(1);
baz(1, c = 10);
z.zoo();

Why introduce the block instead of using pub before the each argument? First, it's less duplication, and second it will highlight that PAB has some additional constraints and capabilities.

Allowing positional usage of named arguments will allow to add them into existing codebases in a backward compatible fashion (ofc with a MSRV bump). Plus it will limit the scope of the proposal, since for type system function will look as a plain old Fn(u32, u32, u32), without any named type arguments or complications arising from anonymous structs.

PAB will be the only place inside of which default values for arguments will be allowed. Note that only constants can be used as default values. Some may argue that it may be useful to be able to call any function for initializing a default value during runtime (e.g. to allocate some space on the heap for a value), but I think it's a needless complication which does not pull it's weight, plus it may be unintuitive, since AFAIK other languages do not allow such functionality.

PAB will be different from the usual arguments, you will not be able to use patterns, only identifiers, i.e. the following code will be illegal:

fn foo(pub { (a, b): (u8, u32) }) { .. }

Of course positional arguments can not be defined or used after named arguments:

// both lines will cause a compilation error
fn f(pub {a: u32}, b: u32) { .. }
foo(b = 10, c = 30, 1);

Now about the calling syntax, as was mentioned many times in this thread, this code is legal right now:

let mut a = 0;
f(a = 1); // `a = 1` evaluates to `()`

I think it should be possible to solve this issue in the next edition (either way, I don't think "named arguments" have a chance to be stabilized before the next edition). In other words, in the next edition inside function call parser will prioritize parsing <ident> = <expr> over parsing expression, while for 2015/2018 edition crates it will work as it does currently.

If for some reason it will not be possible to do it, then there are a bit more ugly options like :=. But I really hope we will be able to use =.

Why not try to work around parsing issues for : instead of =? Since PAB do not use any symmetry with structs, I don't think we should use : for named arguments and in context of function calls leave it for type ascription only.

In future we may even somehow extend PAB with variadic arguments.

Because PAB do allow positional usage of named arguments, some may say that this proposal does not help with bug prevention, since lazy users will omit argument names or they may delete them (which may cause lat/lon issues raised by @graydon). I think a good way to solve this issue will be to mirror #[must_use] and introduce an attribute, which will emit warning if named arguments are used in positional form:

fn foo(pub { lat: f32, lon: f32}) { .. }
#[check_pub_args]
fn bar(pub { lat: f32, lon: f32}) { .. }

// no warnings
foo(1.0, 2.0);
bar(lat: 0.0, lon: 0.0);
// warning: use function argument names
bar(0.0, 0.0);
// for convenience sake we probably should omit warning
// if variable names match argument names, i.e.
// this line will not result in a warning
bar(lat, lon);
// but this one will emit a warning
bar(lon, lat);

Sorry for a relatively rough description of the PAB idea, but I hope was able to explain it well enough for you to understand the general idea and its properties. I think PAB with the attribute solves most of the issues raised in this thread, ergonomic to use and has a very limited scope, which should prevent weird corner cases when used in combination with other Rust features.

13 Likes

@newpavlov: :+1: ... -defaults +mandatory order = an even simpler design

Personally I'd prefer the opposite: require named arguments, for the precise reason to remove ambiguities as a safe(r) default.

For those cases where you require it for backwards-compatibility, or if you simply want default parameter values, add an attribute #[allow_positional]