Named arguments increase readability a lot

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

I was wedded to the idea that Rust needs named arguments. This comment and exam ple makes me realize I was completely wrong. There could simply be a clippy-lint, or even compiler lint, that is warn in the next edition and deny by default in the one after that which simply triggers whenever someone defines a new function/method with more than 3 (to bikeshed) parameters besides the receiver (if any). The lint could even suggest making a struct for the additional parameters and even do so in a way that rustfix could automagically make the change.

1 Like

This is very clever, and nicely resolves the question as to how the developer is to signal that the argument names are now a part of semver.

If this were reified into an actual language construct, I think that the developer would need to specify that they are opting-in to this calling convention, via the method suggested in the other thread and in a manner orthogonal to how structs specify if they are tuples or not. For example, a function defined in the following way:

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

would do the following:

  • Makes declaring a struct with the same name as do_something a compiler error
  • Indicates that the argument names to this function are semantically versioned.
1 Like

I commented this possibility on [Pre-RFC] named arguments. That post is followed by a few comments. One issue is that prevents to use the same name for a normal struct a for a function. Although that already happen with tuple structs, so I am not sure if it is an actual issue. However, there were not much interest then.

1 Like

As someone who went to a 5-years coding school with this exact rule... meh.

Sometimes it helps you structure your code, sometimes you just really need a function that takes a lot of parameters with no semantic relation to each other whatsoever and the rule is just an added nuisance.

(it was in C code though, so the boilerplate was heavier than Rust)

4 Likes

I'm a bit lost here, I hoped there emerges a trend for a solution, but there were no new responses for 19 days. To summarize the majority (not all) of the previous responses:

  1. Named, fixed order arguments are a fantastic way to improve the readability of the code. They would have a really positive impact on the language.
  2. The Swift solution is the cleanest when it comes to readability and clearest when it comes to overlapping with existing related syntax. No big ambiguity conflicts there.
  3. Adding them Swift-Style is for many people out of question because of the big changes in the language.
  4. All suggested alternatives, circumventing the previous point, violate point (2.), from normal to big. They are harder to read, write and are ambiguous / conflicting with existing syntax.

I would add 2 new points:

  1. To point (3.): C++ went this way. Out of the respect for the holy animal "Backwards-Compability" they just attached constantly new language features without removing old ones so that it's now one of the most complex languages in the mainstream. Because of all the code which is mixed of all iterations of C, C++ of all different eras. Just think about medium to big projects, were programmers of all eras come together, using different libraries and language features, and they all write working C++ code. On the first glance that's all fine code. But when that code must be maintained it's super confusing, complicated and programmers which learned to use "the old ways" stick mostly to that, although C++ got now a bunch of new features which make it much easier to write good code.
  2. If the previous point isn't convincing enough, using different braces seems like the cleanest solution, but most of them are already in use: "{ }", "[ ]", "< >". Double lower / greater "<< >>" isn't used at all as braces, only for bit-shifting.
    1. So this syntax could be used: fn func<<start from: i32, ...>> { ... }.
    2. Only when this collides with generics it looks a bit messy: fn func<T: Copy><<start from: T>> { ... }. From the first impression this looks still quite readable.

On the other hand: I can already see how the next Rust novice will handle this if there's a solution to avoid writing double-braces: "Wait, I could write also code with only one normal round brace and I don't have to name the arguments in each call?! Well that's fantastic, wonder why they didn't recommend this in the tutorial. Guess they have to update that. Now, if they could finally add GarbageCollection / Reference-Counting and get rid of this stone-age, manual memory-management I would be happy.". With this example I mean: People tend to stick to the easiest way (if there is one!), not always the cleanest, even if it becomes a giant problem in the long run. And: Many people here didn't like warnings on that part. Plus: Many people ignore anyway anything which isn't an error.

Summary:

  • I would still prefer the Swift solution, because of the C++ example in 2nd point 1.
  • For still unconvinced people: Thoughts on double -lower/-greater braces (<< >>)?