Named arguments increase readability a lot

The problem is that if an API uses named arguments, it should always be used with named arguments. For example, using a bool as a named argument is totally fine, but when the API is used from the 2018 edition where named arguments are not available, you might have code that looks like foo(1, 5, true, false), which is incomprehensible. This would not be a problem if named arguments could be added to all editions backwards compatibly.

As people who actually get results in politics or standards have learned, compromise is the means of achieving objectives that are weighted more highly by their advocates than by the community at large.

Back in Swift 1, having a named first argument was spelled func a(#arg: Type), Rust might be able to use that.


Syntax idea:

fn a(# arg: Type) : internal/external name are the same, necessary to use the name when calling.

fn b(out in: Type) : possibly different names for the caller and the implementation ( out for the caller, in for the implementation), necessary to use the external (caller) name when calling.

fn c(_ in: Type) : no external name, in is the name used for the implementation.

fn d(in: Type) : (by edition of defining crate)

  • 2015/2018: no external name, in is the name used for the implementation.
  • 202x: internal/external name are the same, necessary to use the name when calling.

I think this syntax would allow a crate to compile with all editions without warnings by rejecting the simple spelling while transitioning, if this syntax is otherwise acceptable (I have no idea of that).

1 Like

note that fn f(out in: Type) is ambiguous with pattern matching in function arguments.

fn f(S (a, b): S) {}
//  is it an `in: Type` variant where `in` is `S(a, b)`,
// or an `out in: Type` variant where `out` is `S` and `in` is `(a, b)`?
9 Likes

Indeed, in that regard we'd have to use either the aforemention 'out in or the currently syntactically legal out @ in, which in practice is not yet usable due to it "double binding" (unless in is _; I don't think I've ever seen Rust code using arg @ _: ty syntax out there).

I would envision something like:

fn foo(_ { bar: usize, baz: usize = 42 }) {}
fn main() {
    foo(_ { bar: 3, ..default });
}

This is a combination of two/three changes: "anonymous structs" (which wouldn't be strictly necessary), "struct name inference" where you don't have to name the struct and use only a pattern instead, and "default as keyword" where we bless default as a context dependent keyword that expands to Default::default().

This approach has in my eyes the following benefits:

  • it fits the rest of the language more naturally
  • blesses the builder pattern into the language
  • introduce features that can be used in other contexts instead of being its own thing exclusive to method calling
  • it has a natural way for it being backwards and forward compatible with previous and new editions

On the last point, older compilers could call new methods the following way

// edition:2021
fn foo(_ { bar: usize, baz: usize = 42 }) {}
fn main() {
    foo(_ { bar: 3, ..default });
}

// edition:2021 definition used in 2018
foo(foo_args { bar: 3, ..Default::default() });
// it wouldn't need to be `foo_args`, but it has to be *some* ident

// edition:2018 definition used in 2021
struct FooArgs {
    bar: usize,
    baz: usize,
}
impl Derive for FooArgs { /**/ }

fn foo(FooArgs { bar, baz }) {}

fn main() {
    foo(_ { bar: 3, ..default });
}

If we were to go down this route we could/would need to also have:

struct FooArgs {
    bar: usize,
    baz: usize = 42,
}

I am waving my hands at the fact that bar doesn't have a default value yet I am relying on Default being implemented for the whole thing (this could be not a problem if the field's type is already Default) or have some kind of PartialDefault trait (that would need to be blessed into the language to actually work).

4 Likes

I think the last part about a special syntax for Default is not really necessary. Since we can just have a derive default macro with a override like the following:

#[derive(Default)]
struct FooArgs {
    bar: usize,
    #[default=42]
    baz: usize,
}
3 Likes

One more thing: this is already a pattern you can use today, you just don't have syntactic sugar for any of it beyond infallible pattern destructuring.

1 Like

I don't think anonymous structs would be a satisfactory alternative to keyword arguments, I have two concerns with that approach; both of which are around API stability.

  1. You cannot later introduce an optional named argument to a function that currently has no named attributes without it being a breaking change. To use the example above I couldn't start with fn foo(bar:usize) which is called like foo(3) and then later introduce baz as an Option<usize> without breaking all previous foo calls. This is a common occurrence from my experience so not having it really reduces the utility of keyword arguments in my opinion.
  2. It doesn't allow for having different argument names in the internal API from the external API so you can't distinguish between how you want to refer to it in your internal code and how you want to present your API to the user. Swift was the first language I've seen do this and I think it works much better for designing APIs than to requiring them to be linked.
2 Likes

Those I think are good points so should be commented on.

  1. I think that this is an orthogonal issue because it is related to optional parameters and not named parameters.
  2. This is an odd request since when looking at a function definition as the writer you always see the external signature. Now I have never written any Swift code, but I don't see what is preventing someone from doing a simple let x = y; binding...
2 Likes

I agree optional arguments are orthogonal.

I don’t agree with your solution of let internal = argname though. It shouldn’t be needed for just clearly accessing the arguments with clear names.

See this function (taken from the Swift standard library):

// For non-swift dev:
// `inout` behaves like `&mut` here
public func hash(into hasher: inout Hasher) {
    hasher.combine((self ? 1 : 0) as UInt8)
 }

Looking at the usage site of the function you would see something like my_bool.hash(into: my_hasher) which is very clear and easy to understand. But reading the code on the implementation side is just as clear.

The current situation in Rust means the implementation side is often quite clear, less so for the usage side.

In languages that have keyword arguments that are the same on both sides, like Python, the situation is better but still imperfect:

# The names are quite clear on the implementation side,
# but do not read as easily on the usage side
range(start, stop, step)

Whereas the Swift equivalent is clear on both sides:

public func stride<T>(
  from start: T, to end: T, by stride: T.Stride
) -> StrideTo<T>

Good arguments were raised to explain why simply adopting the Swift model without changes will not work though, and they should be considered so that a workable solution can be found for Rust.

1 Like

Sorry but I am not convinced that a let binding in the few cases where a good enough middle ground name doesn't exist wouldn't be good enough.

Because it’s boiler plate that can be avoided with a well thought out design, just like we now write async instead of impl Future<Something...>.

Promoting good API design and readable code through good language design is something Rust does well and I think this would only improve this behavior.

2 Likes

As I alluded to before in my statement about "compromise", the best (perfection) is the enemy of the good. I wish you luck on convincing everyone with opposing views that they should just abandon their objections and do what you want.

2 Likes

I’m sorry if I wrote my posts in a way that feels agressive, I’m not a native speaker and that was not my intention.

I freely admit I prefer the « Swift version » of named arguments (more precisely the differentiation between external and internal names) but my objective is not to browbeat people into liking them too, it’s to argument in their favor.

Defending them (or structural records, or not having such a feature in Rust, or something else, depending on the poster) by providing arguments is the point of this thread no ?

I don't think it's orthogonal in that I don't find named arguments a compelling addition without having optional named arguments. the benefits of required named arguments are easily provided by structs and you lose a lot of expressivity in named arguments if you can't have alternative arguments.

I don't find this to be a good argument because at the end of the day there's nothing preventing anyone from having this API if they write enough code, the point is more that the level of boilerplate to express it in Rust is currently too much, in fact I have written this boilerplate many times over the years.

To provide a concrete example where having optional named parameters would dramatically reduce the amount of boilerplate would be octocrab which is a HTTP client library for GitHub's API. GitHub's API has a lot of optional parameters for everything, a lot of methods have at least two, and there can be up to five or six optional parameters for some. To be able to use GitHub's API you have to have named parameters because there's just so many, and Octocrab uses the builder pattern for any method with two or more optional arguments, so you can write something like the following.

let page = octocrab.issues("octocrab", "repo").list()
    // Optional Parameters
    .creator("octocrab")
    .state(params::State::All)
    .per_page(50)
    .send()
    .await?;

This is really nice and provides everything I expressed wanting from named arguments however it's a tonne of boilerplate. Octocrab is already at ~4k lines of code most of which is builder and function definitions and still has a lot of GitHub's API left to add. It would be a whole lot nicer if all of these builders could be condensed into the function header.

5 Likes

Currently when you write Foo { bar: baz } in a pattern, then bar is a field name, and baz is a variable binding. In your proposal, however, after the : comes a type. This would be confusing to many people.

I would like to have named/optional arguments in Rust, but struct literals are not the best syntax for this I believe.

1 Like

What you're saying is correct (and I came up with the example quickly without putting too much thought into it, but if you remove my made up sugar, this is what I was envisioning it would desugar to, which is possible to do today:

struct FooArgs {
    bar: usize,
    baz: usize,
}

impl Default for FooArgs { /* .. */ }

fn foo(FooArgs { bar, baz }: FooArgs) {}

fn main() {
    foo(FooArgs { bar: 3, ..Default::default() });
}

We would have to figure out what the anonymous struct syntax would be before using them for this use case.

I proposed this idea in another thread, but it was suggested to bring it here. The idea is to allow both positional and named arguments for any function based on the syntax used to call the function. That is, if you call a function with parens, it will be positional, but if you call the function with curly braces the same rules used for struct initialization apply.

Here's an example:

fn do_something( arg1: i32, arg2: i32 ) -> i32 {
    arg1 + arg2
}

// Both are valid: 
do_something{ arg1: 18, arg2: 24 }
do_something(18, 24)

There are some issues with this, the primary one being that functions and structs live in the same namespace. Collisions are likely to be small, but it's not stricly backwards compatible. However, this resolves ABI compatibility issues and doesn't introduce any new syntax.

It has been argued that this makes function calls and struct initializations look too similar to each other. However, structs can already be initialized in a syntax exactly similar to function calls if they are tuple structs, and in fact the constructors can be coerced into functions. For example:

struct Example(u32);

let x: Vec<Example> = vec![ 1_u32, 2, 3, 4 ].drain(..).map(Example).collect();

is valid. Therefore, it seems that the distinction between struct initialization and function calls is already somewhat blurred.

3 Likes

Stupid thing that just came to my mind and that works today (using unstable features):

Just implement FnOnce<()> for a struct!

Looks like this (or even nicer with a procedural macro):

named_args!{
    fn do_something( arg1: i32, arg2: i32 ) -> i32 {
        arg1 + arg2
    }
}

fn main() {
    // Both are valid: 
    println!("{}",
        do_something{ arg1: 18, arg2: 24 }()
    );
    println!("{}",
        do_something(18, 24)
    );
}

(playground)

my macro does somewhat support generics, for example:

named_args!{
    fn my_clone<'a, T>(source: &'a T) -> T
    where T: Clone
    {
        source.clone()
    }
}

fn foo() {
    let x = 1;
    let y = my_clone{source: &x}();
}
8 Likes