Arguing about arguments

42 points by abhin4v 17 hours ago on lobsters | 31 comments

cceckman | 9 hours ago

I wonder how much of the "improved readability" would go away if review interfaces supported LSP inlay hints as something you could toggle on.

I write Rust in my day job. When writing code, or reviewing in my editor, I am rarely confused by "which argument is this" because rust-analyzer tells me; in fact, it uses the same parameter: syntax as in this and the other proposal.

When reviewing code (GitHub) I don't have that visibility. I often check out a change to load it in to my editor to see the types and params, to make sure I understand what it's doing.

(I guess this would also improve things for humans without moving the needle for LLMs; take that as you will.)

spillybones | 4 hours ago

I wonder how much of the "improved readability" would go away if review interfaces supported LSP inlay hints as something you could toggle on.

Inlay hints are not a good substitute for having readable source code. For example, they don't work on github or codeberg or git diff or grep or stock vim. Your code becomes only selectively readable.

cceckman | 3 hours ago

Agreed, not a substitute for good code. "More readable" is relative and contextual, though; an argument name may be sometimes-useful and sometimes-noisy, depending on my state of mind when I'm reading it.

As an analogy: I don't necessarily want to litter my code with type annotations that could be inferred, because sometimes it's obvious / doesn't matter what the exact type is, and having the type listed out would just add noise to the source. But I do sometimes want to see them, if I'm reviewing or debugging something.

And sometimes I do want to write an explicit type annotation in the code, because I want to test that an expression has a specific type, or I think it is going to be a useful landmark to future readers.

I find the same is true of parameters. I do sometimes want to see them; sometimes I don't. And I definitely see value in offering named parameters as an option, in the same way that sometimes I want to write an explicit type annotation even when it's redundant with the inferred type; it creates a contract that the compiler will enforce. That would be great to have for parameters!

My suggestion is that "inlay hints doesn't work on <review interface>" could also be solved by better review interfaces! It doesn't obviate the other benefits; but it would help close the gap between "what is visible to the author" and "what is visible to the reviewer".

mitsuhiko | 15 hours ago

IMO the optimal way would be for Rust to have optional structural types and then an anonymous struct literal with .. to mean filling in defaults:

let cropped = image::imageops::crop_imm({
    image: &img,
    x: 10,
    y: 20,
    width: 200,
    height: 100,
    .. // brings in the rest
});

//EDIT: I know that this precise syntax is probably impossible to parse, but I want to just outline the idea

pushcx | 9 hours ago

Or newtypes. I haven't done graphics programming for a long while, but looking at this call I remember how easy it was to introduce bugs when function calls had positional arguments that were all the same integer type, even though one pair is position and another is dimension.

If this wasn't in a generic graphics library but a desktop GUI library where arbitrary rotations and transformations are exceptional, I'd probably even want to see separate types for x vs y, width vs height.

senekor | 14 hours ago

I often think anonymous structs / records would be nice in Rust. nitpick: the last line should probably be ..Default::default(), to stay consistent with named structs.

But I think a lot of the convenience here could already be achieved if the struct's name could be inferred at instantiation site. Imagine something like this:

fn main() {
    foo(_ {
        a: 42,
        ..Default::default()
    });
}

#[derive(Default)]
struct FooArgs {
    a: u32,
    b: u32,
}

fn foo(args: FooArgs) {}

The only new thing here is the underscore in foo(_ { /**/ }). This would avoid having to type (and import!) the name FooArgs. While it's not quite as ergonomic as some other proposals, I think it's a really good "bang for the buck". It adds very little complexity both for compiler implementers and language learners.

mitsuhiko | 14 hours ago

nitpick: the last line should probably be ..Default::default(), to stay consistent with named structs.

No, that would be intentional and have Default::default() be automatically added by the compiler if no filler is provided. Default is already in a few APIs anyways (eg: mem::take or unwrap_or_default). I don't think it would be completely ridiculous to just allow it to be inferred.

senekor | 9 hours ago

I see. It's not an unreasonable feature to ask for, although I don't think it's necessary. Maybe a middle ground would be to default-import Default::default in the prelude, such that ..default() is enough.

Would you agree that if this inferred default .. works on anonymous structs, it should also work on named structs for consistency?

mitsuhiko | 8 hours ago

Would you agree that if this inferred default .. works on anonymous structs, it should also work on named structs for consistency?

Yes, I think that should be the general behavior.

ekuber | 6 hours ago

Would you agree that if this inferred default .. works on anonymous structs, it should also work on named structs for consistency?

https://doc.rust-lang.org/unstable-book/language-features/default-field-values.html

Structs will be able to be constructed with Foo { .. } if struct Foo { field: i32 = 42 }. It makes sense that structural structs would too.

senekor | 5 hours ago

Ah, I didn't know about this feature being worked on, thanks. I like that it requires the default field values to be const, this means that SomeStruct { .. } won't execute arbitrary code, IIUC.

ekuber | 3 hours ago

Yes, that was a conscious constraint, although there are many people that (rightly!) are annoyed by that restriction. Bevy for example would love to have arbitrary expressions be auto-inserted. I am considering restricting the default values to always be surrounded by const { ... } before we stabilize, as a way to keep the syntax open for allowing non-const expressions in that position in the future.

academician | 6 hours ago

Edit: I missed that you said "anonymous". Honestly the code below doesn't bother me, even if it's more verbose. I do think that being able to omit the struct name would be fine though.

Doesn't Rust have this? It's just more verbose.

    let cropped = crop_imm(CropImg {
        x: 10,
        y: 20,
        width: 200,
        height: 100,
        ..Default::default() // brings in the rest
    });

https://play.rust-lang.org/?version=stable&mode=debug&edition=2024&gist=af50c7d617a2c0d2ec84101d25c52372

untitaker | 12 hours ago

Comment removed by author

dlisboa | 7 hours ago

For the simple cost of “write two different names for your functions,” you get to keep the rules very simple: there is one function definition, and you invoke it by name. Done.

There's a hidden cost to these things: name confusion and discoverability.

Rust has this explosion of functions that do slightly different things with also slightly different defaults and naming conventions. Let's take a look at String:

  • get
  • get_mut
  • get_unchecked
  • get_unchecked_mut

Then you look at some other methods of String:

  • split_at
  • split_at_checked
  • split_at_mut_checked

The defaults for these are different: get is checked, split_at is unchecked. The naming convention is ..._unchecked_mut for one and ..._mut_checked for the other. String.get returns an Option but String.split_at does not.

The Option type also has many methods to do slightly different versions of the same thing:

  • unwrap
  • unwrap_or
  • unwrap_or_default
  • unwrap_or_else
  • unwrap_unchecked

It's not at all clear what each of them do from the name alone without reading the documentation, which is a similar issue to a single function doing too much without being obvious. unwrap_or and unwrap_or_default both return defaults, just different types of them. Clearly what I want here is a version of unwrap but I need to figure out which one, similar to if there was just one unwrap function for which I needed to figure out the parameters for.

Also notice how get with no suffix is the "nice" version for String but unwrap with no suffix is the "not nice" version for Option, as it panics. So if I do some_string.get(1..).unwrap() I'm actually putting myself in a position that is not at all clear from the naming convention being used.

In general these things won't affect seasoned Rust developers, but it leads to an accumulation of quirks in the language that are impossible to undo and get worse with time.

chrismorgan | 6 hours ago

The defaults for these are different: get is checked, split_at is unchecked.

split_at is not unchecked, and “checked” and “unchecked” aren’t opposites here, because there are two types of checked. The three types are: checked non-panicking (returns Option<T>), checked panicking (returns T or panics), and unchecked (returns T and is unsafe).

str.get(range) is checked non-panicking; str.get_unchecked(range) is unchecked; str[range] is checked panicking.

split_at is checked panicking; split_at_checked is checked non-panicking; and there is no unsafe version, but if there was a compelling case for it (which I can’t imagine), I could genuinely see split_at_unchecked happening too.

I haven’t decided if these details make it better or worse. As one of your seasoned Rust developers, I can explain why most of them actually make sense if you tilt your head in the correct direction, adhering to a specific other convention, and the history behind most of the rest. But as you say, they’re quirks and accumulate.

munificent | 4 hours ago

Excellent post. Some remarks:

But it can be common to break truly complex examples down into variables that we end up forwarding to the function, and then it becomes verbose and redundant. As an example from inside FastAPI where get is invoked directly with all of those options:

self.router.get(
    path,
    response_model=response_model,
    status_code=status_code,
    tags=tags,
    dependencies=dependencies,
    summary=summary,
    // ...
)

This part is annoying. A little syntax sugar you can add is to allow omitting the argument name if the value is a identifier expression with the same name, like:

self.router.get(
    path,
    =response_model,
    =status_code,
    =tags,
    =dependencies,
    =summary,
    // ...
)

I think it's good to be cautious about layering on too much syntactic sugar, but this is one I believe is worth it.

What happens when functions become values? I can write this today:

fn resize(width: u32, height: u32) {
    // body elided
}

fn offset(dx: u32, dy: u32) {
    // body elided
}

let f: fn(u32, u32) = if resizing { resize } else { offset };

What name do we use for f’s parameters? They can be named different things. Does this become an error? What names can you use when invoking f?

The way this works in most languages I know is that argument names are only a property of direct invocations of named functions/methods. When you invoke a function through a callback, you can only use positional arguments. That way, the parameter name isn't part of the function type in the static type system and functions are freely assignable regardless of their parameter names.

This is not how it works in Dart. In Dart, named parameters a very real, distinct part of the function's type and are visible to the static type system. You have to pass arguments by name for a named parameter. Functions are not assignable unless their named parameters have the same names.

In practice, almost no one ever uses named parameters when working with closures/callbacks/whatever, so either approach works out fine. I like that Dart's approach is sort of "pure" and truly integrated in the language. But it is also cumbersome in some ways. You need a distinct syntax to declare positional versus named parameters.

The same goes for trait definitions, actually:

trait Writer {
    fn write(&mut self, data: &[u8]);
}

struct Sink;

impl Writer for Sink {
    fn write(&mut self, bytes: &[u8]) {
        // body elided
    }
}

Is this an error? Is it okay?

In C#, the analogous code using interfaces is allowed. Named arguments are associated with the static type of the member being called. So if you call write() on a value whose type is Writer, then you would use data. If the static type is Sink, then you would use bytes.

It's an ugly corner of the language but in practice, I don't think many people ever notice.

Dart doesn't have this issue because parameter names are part of the function type. You can't rename a parameter like this.

Here’s another problem: evaluation order.

I think the only right answer here is to keep left-to-right evaluation order even if that means the compiler has to take the resulting values and shuffle them around on the stack before jumping to the called function. Anything else is too confusing.

Another small thing is a compatibility hazard: if we start naming parameters, renaming them breaks callers. Is that okay?

This is something that has always worried me. It's an advantage of Dart's approach because the API author knows that every named parameter is a committed part of the function's public API. Conversely, every positional parameter is an implementation detail they are free to change.

However, C# added named arguments in a later release. That means they retroactively made it so that every parameter in every public function was now part of its public API. And, as far as I can tell, the world did not come crashing down.


There is another approach to named arguments I've always found fascinating: Smalltalk-style keyword sends. In Smalltalk's approach, what look like named arguments are actually part of the method's own name. This gives you the readability of named arguments, some of the readability of overloading, but with very little semantic complexity. (Smalltalk doesn't even have static types!)

In Smalltalk, a method call like:

dictionary update: key to: value ifAbsent: defaultValue

Works semantically as if you had written:

dictionary.update_to_ifAbsent(key, value, defaultValue)

So the update:, to:, and ifAbsent: are all part of the method's name. If you omit any of those, you're calling a totally different method with a different name.

If you want to use this to make parameters optional, then it can be very verbose to define all of the methods for each combination of parameters, but otherwise it's very simple and explicit about what's going on.

ekuber | 3 hours ago

self.router.get(
    path,
    =response_model,
    =status_code,
    // ...
)

The problem with syntax like that is that there's nothing else in Rust that uses that syntax, so it is confusing when seen for the first time and won't be discoverable by someone extrapolating what they already know of the language's syntax and semantics.

munificent | 2 hours ago

This is fair. At the same time, every syntax was novel at one point in time, but people can and do learn it.

dlisboa | 3 hours ago

Inclusive ranges use that syntax (1..=5). That was one-of-one at the time it was introduced.

I'd say syntax consistency and discoverability is not really Rust's strong suite though. Maintaining simple syntax doesn't strike me as a core value.

A little syntax sugar you can add is to allow omitting the argument name if the value is a identifier expression with the same name, like:

I love this feature in ocaml and would really like to see it added to python some day.

ptrettner | 13 hours ago

function overloading has tons of variations but I want to call out one axis I'm experiment with in my own (as-of-yet private) language:

"function overloading by type" vs "function overloading by shape".

And the experiment is how far you can get by only overload "by shape". Which means you overload by number of args and/or by the name of the named arguments. So basically each call has a "shape", with "foo(1, 2, a: 7)" being "2 positional args, named arg 'a'" (it's like a mixture of a tuple and an anon struct). And then you resolve "foo" against that shape only, disregarding any argument type.

This is a lot less messy than overloading by type because you overload syntactically, no typing pass needed at all. It makes the overloading resolution a lot more "surface-level" and easy to do in your head.

The "bet" (or experiment) here is to see if this suffices for 90%+ of cases where you want to overload (with genuinely different bodies - if you want to overload with effectively the same function body, use traits and make it generic).

gavinmorrow | an hour ago

Erlang (and Elixir) have a similar thing, overloading by arity (number of arguments). (Erlang at least afaik does not have named args but sometimes uses maps/lists of options.)

So for example, there is io:read_file/1 that takes a single Filename, and io:read_file/2 which takes Filename, Opts.

I don’t write a ton of Erlang but it seems to work pretty well.

[labelled arguments do] really increase readability, no question about it. But for me, typing all of that out just isn’t really super worth the squeeze. But when Claude is gonna write it? I care a lot less.

AI aside, language servers can handle labels for you. Gleam has labelled arguments, and I use them often, but I rarely write them. Instead I run the "fill labels" code action, which for me in Neovim is just a few keypresses that are thoroughly burned into my muscle memory and take no brain cycles at all.

In my opinion, the only fully orthogonal, consistent, way to implement default arguments would be to introduce an args type for every fn type, so that fn foo(a: i32) introduces a struct { a: i32, b: f32}, that could be named via <foo as Fn>::Args.

Then the second part would be to allow implementing traits through type aliases: impl Default for <foo as Fn>::Args.

And finally, implement a proposal for allowing to skip naming type in initializers when it is obvious from content: foo.call(.{ a: 42, ..default() }

junon | 12 hours ago

Agreed on +named -optional, personally. I've wished for named a few times. Never needed optional, and personally hope they don't land.

ralfj | 10 hours ago

I think Rust could be okay with named parameters, but not optional or default ones.

Interesting position. That would certainly prevent the dreaded "god functions" like Python's process spawning, or the get example from the post.

OTOH, omitting a sequence of pointless None would be very useful. Maybe optional arguments should be required to have Option type? It's kind of in the name already. ;)

koala | 9 hours ago

Comment removed by author

schneems | 8 hours ago

but get can take 23 different keyword arguments, and so this could get quite, quite long.

I noticed that only the named argument side with 23 parameters was shown. The unnamed version might be more concise but is less understandable.

You could argue that IDEs will annotate your code, at which point “concise” is no longer a virtue.

But what you gain for that verbosity is clarity.

Oh dang. Guess we agree. Not gonna lie, he had me in the first half.

Also coming from Ruby: I miss named arguments less than I thought I would at this point. A lot of safety in dynamic programming comes from "Hungarian notation" where you name things instead of using types, which is typing with less safety. And rust has us covered there. Overall I would prefer named arguments exist than not, pending specific syntax ergonomics.

hyperpape | 7 hours ago

I think one implicit assumption is that no one will create such a monstrosity with required/positional parameters (I say, an hour or two after seeing an MR where someone added a 20th parameter to a Java method).

schneems | 5 hours ago

lol. Good point. Though. In this case we are assuming someone needed all 23 arguments. So even if you wouldn't make this API as a one shot with 23 positional args, I think showing the alternatives like a builder with 23 functions etc. would be a more apples to apples comparison.

As a side note. One thing not mentioned: for a library author wanting to extend an API, optional arguments are amazing. You can add them without a major version bump. And while rust generally prefers composition of multiple functions there are plenty of times when a fully contained API (with configurable behavior) is more ergonomic.

hyperpape | 4 hours ago

Yeah, I think the piece doesn’t do enough to acknowledge that some form of builders/optional/default arguments ends up being a requirement for certain types of libraries where “just upgrade and change every call site” is a bad idea.

travisgriggs | 4 hours ago

Having done all these over the years with Python, C, Fortran, Kotlin, Swift, Elixir, I still like the Smalltalk keyword syntax best (https://book.gtoolkit.com/understanding-smalltalk-message-syntax-w9fc37am75ozp0rrdb5xftjo). It communicated the best (imo), because it weaves the parameter<->argument binding without the overhead of a function name as well. Swift's attempt to have its cake and eat it too, ironically, is probably my least favorite, it's like you're giving service to all the different call syntaxes at once. Somewhere in all of Elixir's matching goodness, I think there's something similar. If the literal dictionary syntax were a little lighter in Elixir, I could see a keyword like syntax where all of your functions were just matching on maps.