Pre-RFC: Named arguments

Yes, this is effective overloading. But current track is not to ever stabilize this ability.

This falls under trait-dispatched parametric polymorphism.


Ultimately: I agree that the difference between type-driven adhoc overload sets and the current method lookup and trait dispatch is slight, but what I'm really arguing is that there is a well-defined difference.

The difference is pretty much exclusively in that overload sets are adhoc. Trait lookup is parametric, and method resolution is constrained. (E.g. a difference is that due to how Java namespacing works, all items in an overload set must be declared in the same file. Rust's module system would allow constructing an adhoc overload set by importing the name from multiple files/modules, as well as parts of the overload set having different visibility, etc. There are real differences between adhoc overload sets and method lookup.)

I would like to explicitly disclaim any opinion, expressed or implied, on whether this side of the line is "better."

Yes, that is a fair point. I personally find that named arguments, even though they increase formal complexity, actually reduce perceived complexity because code using them often places reduced cognitive demands on the reader (and writer). It feels less complex. So I don't fully buy the complexity argument. But you're absolutely right that it does affect everyone. There's no getting around it. And there's a good argument to make that not complexity per se but "I've got this but just barely--don't make me change anything!" is a very valid reason to not make any changes unless they really pay for themselves.

So, yes, you're right: the two arguments are not equivalent.

5 Likes

I very much hope we do stabilize it. To the best of my knowledge, the main blocker has been the "rust-call" calling convention, which we could either stabilize, or replace with type-level variadics.

6 Likes

Okay, but this is like saying that we have different rules for chick peas and garbanzo beans. Nothing prevents adoption of exactly the same rules for static dispatch for cases that are distinguished only by a trivial syntactic rewriting. Every foo(x, y) is equivalent to a x.foo(y) and (x,y).foo().

It's all just dispatch to overloaded names. It's totally reasonable for type inference to step down a notch when you use overloaded names. (Would be good to have convenient syntax for type ascription, though.)

I agree that there can be all kinds of gnarly problems if you want to solve the most generic case possible. Same deal if your "method receiver" (chick pea) has complex types that would only be disambiguated by the method called. Maybe method receiver position is a good way to signal different expectations about type inference as compared to first argument (garbanzo bean). But what we shouldn't maintain is that Rust has no overloading. We might argue that it already has exactly the right amount (I'm skeptical, but hey, it's a coherent position), but not that it doesn't have it because we chose to call the exact same thing by a different name.

(There are gotchas with Java regarding generics, if you forget that overloading is not ad-hoc polymorphism, so in the generic context you will statically dispatch to whichever overloaded method is most specific for the root class in the hierarchy of all allowed generics. In contrast, the multimethod approach (or dynamic dispatch) that Julia uses will use the actual type.)

(Cool macro by the way! I might actually something like that if it were idiomatic. As it is, I think it'd really raise the difficulty of someone else who needed to understand my code, e.g. me in a few months/years.)

3 Likes

Sure, an evolution in this direction could fulfill the need for named arguments adequately. Not sure this is quite enough, but it's a lot better than the existing case. I'd have to use it for a while to know whether it scratched enough of the itch.

I'm all for minimal evolution of existing features to meet the need rather than importing wholesale the implementations chosen by other languages.

2 Likes

It's not the same, because you have to decide when you're resolving those names. See Two-phase Lookup, Koenig Lookup in C++ for the kinds of horrible things that end up happening with ad-hoc overloading.

Putting the traits in the middle makes a huge difference. See Justification for Rust not Supporting Function Overloading (directly) - #3 by scottmcm

2 Likes

It's entirely possible to make the same decision in both cases.

You just can't win an argument that two situations isomorphic up to a trivial syntactic rearrangement have any huge showstoppers in one case vs. the other.

You might be able to argue that the different syntax sufficiently strongly suggests different expectations that it is unwise to use the same rules for both. But you can't argue that it's necessarily fundamentally different. It's isomorphic!

1 Like

Painting people who disagree with you as stupid doesn't earn your argument any points, not does it help to disregard valid criticisms since you yourself can't see them personally.

While in your own personal project you could dismiss this as inconsequential, in a large code base worked on by multiple people over time even the smallest duplication of features becomes a sink in productivity over time and a source of complexity and pain. If we want rust to become the language for the next 50 years we need to cater for such code bases! Google has over a billion LOC. What kind of code style would you reckon they prefer? They specifically disallowed a large chunk of advanced features of C++ which "reduce boilerplate" because they preferred to have (more) code that is easier to reason about and that allows to onboard new engineers faster.

Every feature added to Rust must satisfy the condition that it really pays for itself. That ought to be an obvious fundamental requirement for that same objective.

Edit: As an example of this, at my $job we maintain a large code base in C++. All the engineers are fully capable and understand C++, yet we still waste time on endless debates because different people have differing preferences. So yes, the complexity of C++ is a major problem even for experts and Rust should do better.

6 Likes

While it depends on some other features in the pipeline, it seems reasonable to me that rust could add more overloading later along the lines of

trait A {}
trait B {}
impl !B for A;
impl !A for B;

// Now this is fine:
fn foo(a: impl A) { ... }
fn foo(b: impl B) { ... }

But you're arguing against a feature that is universally used to simplify and clarify code, making it easier for people to understand each others' code especially in large projects. Unless people who have learned the language are already overwhelmed and can't take any more, adding simplifying and clarifying features decreases complexity and pain.

Rust isn't an easy language to master. "I've got this but don't make me change" isn't any more of a charge of stupidity than is your "easier to reason about" argument.

But code with named arguments is easier to reason about!

There may be reasons why it's not possible to get there in a practical way (or why getting partway there is better because e.g. it fits better with the rest of the language). That happens a lot with languages--feature X is great but it just doesn't mesh with language Y's other syntax/features.

If you disagree that named arguments makes code easier to reason about, then what is your opinion on Rust's existing struct syntax requiring names?

4 Likes

Counterargument: Swift. Literally all of Apple SDK APIs use named arguments.

Named arguments from the beginning (and crucially, ones that are required) are objectively a useful tool for API design. There are edge cases to consider that do catch people out (e.g. function types and closures) but combined with a helpful compiler and an API design language that universally uses function argument labels, they serve as an enormous boon for quickly digesting an API surface.

Function argument labels are so important that even in languages that don't use them on calls, documentation typically shows them anyway. Rust trait functions used to be declarable without argument labels, but that usage is deprecated now. Public visible function argument labels are objectively a good thing.

Adding argument labels to an existing language is problematic because using argument labels leads to a drastically different API design language. Implicitly optional argument labels are a terrible idea, especially when argument names weren't an API guarantee previously, because they're so easy to accidentally make a poor name part of the stable API.

But there is nothing intrinsically bad about function calls using argument labels in a static language, (so long as they are a controlled part of a library's API,) because having them is no different API/ABI-wise than putting them in the function stem.

11 Likes

Swift is not a good counterargument at all: Swift has to be compatible to the prexisting platform APIs from Objective-C which was essentially a reimplantation of Smalltalk on top of C syntax. Smalltalk is a dynamically typed language and Objective-C leaned heavily on the same design and semantics.

I also did not say that naming parameters properly is a bad thing or that named arguments are always absolutely evil :person_facepalming:. I'm agreeing with most everything you said - Rust has already a naming scheme and we should not have two. Named arguments simply do not fit with the tradeoffs already made by Rust. They fit Swift due to its history. They fit python and other dynamic languages very well.

Lastly, while I agree there is a minor edge case where named arguments results in better API design (e.g having a non commutative function with two parameters of the same type) I still stand by my previous assertion that more generally, named arguments are somewhat inferior overall compared to relying on types because the ordering of parameters becomes more of an issue. Smalltalk has somewhat redundant APIs due to this. IfTrue:IfFalse: vs. IfFalse:IfTrue: (the concatenated names of the parameters form the function name in Smalltalk)

I've done some coding with Smalltalk-80 in the past and this was my experience. I still prefer to have traditional functions with few parameters (say 3-4 tops) and at $job we have a linter that checks exactly that. When adding overloading to the mix, the costs of named arguments greatly outweigh the benefits. Though even without it, you still have other costs such as having multiple ways to name the same thing. Also as I said before, making this a bit salty encourages splitting up functions so they won't have many parameters which results in better designed code overall.

We're literally going in circles here as I'm repeating myself from up-thread..

2 Likes

This is demonstrably false.

Swift needs to be able to call preexisting platform APIs. It does not need to and in fact doesn't call them by their existing names. It can and does include glue to rename every single preexisting API from the existing API design language into the new one.

That Apple was able to do this alongside all of the other impossible things Swift does (e.g. a stable ABI in the face of library evolution) is a marvel of the amount of developer time they threw at the problem.

Swift/ObjC is a bindgen+annotations problem. Swift could have abandoned named argument labels in the transition (and would have; Apple was not afraid to make sweeping breaking changes early on, and did make fundamental changes both to how they worked and how ObjC was mapped) if they were a poor fit for a static language.

5 Likes

It does so using a consistent pattern though AFAIK. If swift didn't support named arguments, the function names themself would need to include the argument names to avoid conflicts between for example -[NSString initWithFormat:arguments:], -[NSString initWithFormats:locale:] and -[NSString initWithFormats:locale:arguments:].

2 Likes

Other people disagree with your claim that it is isomorphic, and they give arguments for that counterclaim. You can't win an argument by just repeating what you said before.

Concretely: if this is isomorphic, then there should be something like this and this in Rust, after being transported through the isomorphism. So, where in Rust do we have the equivalent of these rules?

3 Likes

There's been several reports from both sides of the issues here. I'd like to encourage everyone to take a few steps back.

For @Ichoran and @yigal100 in particular, please try to focus on how this RFC pertains to Rust. RFCs can be supported by existing art, but it's not productive in the pre-RFC phase to argue against the proposal unless there is hard, concrete, and unavoidable reasons that the RFC shouldn't be considered. It's the point of the RFC phase for review for acceptance. Pre-RFCs should be focused on the drafting, making sure the RFC as a proposal is well written, even if you disagree with the proposal.

20 Likes

Swift Developer Here.

It's unfair to call the syntax unintuitive and boilerplate-y if you don't even understand it. Please read the documentation for functions in Swift here. The syntax is very simple:

func foo(param: Int) { }
foo(param: 5)
func foo(_ param: Int) { }
foo(5)

As this example shows, the underscore simply indicates that the parameter has no argument label (the identifier used by the caller). It's quite a stretch to claim that the addition of TWO characters (the underscore and the space) is boilerplate-y.

5 Likes

Calling it unintuitive is fair, IMO, since it is not something one will have seen in other languages. So it makes Swift code less readable for people coming from other languages. Of course all languages have things like that, but doing it for something as common as argument types is not necessarily a good idea.

6 Likes

Just a note: boilerplate doesn't need to be long. If you write the same thing every time (and @digama0 apparently had no recall of ever seeing anything but), then it's boilerplate.

Mostly, boilerplate is communicating information that you're comfortable assuming, but the other systems involved aren't, because they're supporting things that you aren't considering. Thus, you consider it boilerplate ("I just have to write this for no reason") while from a different perspective it's justified.

mut for bindings in Rust is boilerplate. It would be completely possible for the compiler to just make all bindings mut and nothing would change. Except things would change... but only if you're familiar with what leaving off the mut means.

What really makes something boilerplate-y is if the normal case requires writing more than the uncommon case. If the common case is unnamed arguments (as in the code @diagama0 had been exposed to), then specifying that the arguments should be unnamed is boilerplate. If the common case is that arguments are named (as is the main Swift naming conventions), then writing arg arg is boilerplate. This is IIRC a change that was made in Swift development, in that the first function argument used to be unnamed by default, and this was changed to match other arguments to avoid the need to write arg arg to get a named first argument.

It's still the case as I recall that most first arguments (of free functions) are still unnamed[1], so you could argue that requiring them to be _ arg is boilerplate, where the language could default to the common case, or you can argue that consistency is better in this case. Both are supportable positions.


  1. verb(_ object: Type, preposition object: Type)... though this is perhaps better written as (object: Type).verb(preposition object: Type)? ↩ī¸Ž

12 Likes

This is one component, but this alone is a terrible definition. Boilerplate refers to something much more specific than mere repetition. Based on this, every single construct in the language is boilerplate, assuming it occurs more than once in your program. Every keyword is boilerplate; types are boilerplate; enums are boilerplate; variables are boilerplate; control flow is boilerplate; ownership is boilerplate; references are boilerplate. And it doesn't matter how these constructs are used; the mere fact that they occur more than once in your program makes them boilerplate, according to this definition.

I mostly agree that this principle is part of the definition.

However, this principle does NOT apply to mut. Consider the following example:

let mut y = 10;

mut is not "communicating information that you're comfortable assuming". mut communicates that y is mutable; there's no way to assume that y is mutable without adding mut. And there is a reason you have to write this: it's to tell the compiler that the variable is mutable ("I just have to write this for no reason").

Now consider this example:

let point: Point = Point::new();

In this example the explicit type annotation does "[communicate] information that you're comfortable assuming". A human can already assume that the type of the expression Point::new() is Point, so the the explicit type annotation is boilerplate in this case, and exemplifies the aforementioned principle.

Yes. And in that case, explicitly adding mut would be boilerplate. But the compiler doesn't do this, so I fail to see your point.

This makes no sense. If the compiler made all bindings mut, then the program will change whether you're familiar with what leaving mut off means or not.

No, this has nothing to do with whether or not something is boilerplate-y. I don't know where you got this from. Can you cite references for this claim?

What Is Boilerplate Code

Wikipedia defines it best:

In computer programming, boilerplate code, or simply boilerplate, are sections of code that are repeated in multiple places with little to no variation. When using languages that are considered verbose, the programmer must write a lot of boilerplate code to accomplish only minor functionality (emphasis added).

I encourage you to read the full article. In particular, read about the history of the term.

The example on java sums it up well:

In Java programs, DTO classes are often provided with methods for getting and setting instance variables. The definitions of these methods can frequently be regarded as boilerplate. Although the code will vary from one class to another, it is sufficiently stereotypical in structure that it would be better generated automatically than written by hand. For example, in the following Java class representing a pet, almost all the code is boilerplate except for the declarations of Pet, name, and owner:

Java

public class Pet {
    private String name;
    private Person owner;

    public Pet(String name, Person owner) {
        this.name = name;
        this.owner = owner;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public Person getOwner() {
        return owner;
    }

    public void setOwner(Person owner) {
        this.owner = owner;
    }
}

Most of the boilerplate in this example exists to fulfill requirements of JavaBeans. If the variables name and owner were declared as public, the accessor and mutator methods would not be needed.

To reduce the amount of boilerplate, many frameworks have been developed, e.g. Lombok for Java. The same code as above is auto-generated by Lombok using Java annotations, which is a form of metaprogramming:

@AllArgsConstructor
@Getter
@Setter
public class Pet {
    private String name;
    private Person owner;
}
2 Likes