Pre-RFC: Explicit overload sets for mixed-arity function calls

Pre-RFC: Explicit overload sets for mixed-arity function calls

Problem

Rust can express:

draw(point);
draw((x, y));

with traits, or:

draw!(point);
draw!(x, y);

with macros, but it cannot express:

draw(point);
draw(x, y);

as ordinary function calls.

This proposal explores explicit overload sets as a narrow language feature for that specific problem.

Proposed syntax

fn draw_point(point: Point) {
    // ...
}

fn draw_xy(x: i32, y: i32) {
    // ...
}

fn draw_vector(vec: Vec3) {}

overload draw {
    fn(point: Point) = draw_point;
    fn(vec: Vec3) = draw_vector;
    fn(x: i32, y: i32) = draw_xy;
}

Usage:

draw(point);
draw(x, y);

Non-goals

  • No implicit conversions.
  • No overload ranking.
  • No return-type-only overloading.
  • No duplicate fn items with the same name.
  • No open overload sets across crates, at least initially.

Questions

  • Is this problem large enough to justify a language feature?
  • Is an explicit overload set preferable to traits, tuples, or macros?
  • Is the proposed syntax too foreign for Rust?
  • Are mixed-arity calls important enough to solve directly?

I continue to be opposed to anything that just adds a need for overload resolution rules. We have trait resolution rules already; that's enough. I never want to have to think about both trait resolution and overload resolution.

Check zulip -- there are people talking about ways to have a single function delegate to a single trait via a tuple or similar, which seems fine.

In the mean time, you can always just do

draw((point));
draw((x, y));

as ordinary function calls and it's fine.

11 Likes

There is an ongoing experiment which is pretty much this.

2 Likes

Rust does not need yet another form of ad hoc polymorphism. We have the most systematic mechanism, type classes(i.e. traits). Adding overloading would seriously conflict with type inference and traits, undermine features we value, and risk breaking existing codebases. It is unacceptable.

The ongoing experiment is based on traits.

4 Likes

Right, I didn't focus on the mechanics but on the fact that the experiment also allows overloading based on arity.

What I didn't emphasized enough though was that the experiment is for the purposes of FFI only, although that won't be enough to stop users.

1 Like

And exactly that is why I think the FFI focused experiments with overloading are misguided. Making FFI slightly easier isn't worth the cost to devex for the rest of the language.

I have yet to see a convincing argument as to why you can't just rename the function at the language boarder. The mangled name in C++ differs anyway, so why not just expose it as foo_i32(a: i32) and foo_f32(a: f32) or such? That targets FFI, is doable with current language features and attributes, and doesn't pollute the rest of the language.

1 Like

Adding the types makes the name really ugly and long, and not adding them means the bindings cannot be autogenerated.

1 Like

I think a little pain at the FFI border is worth not messing up the rest of the language with terrible IDE experience etc (I have worked in large legacy C++ code bases, as well as supposedly modern ones, there are tons of overloads and the IDE tooltips never quite know which one you are calling).

Just take a look at the constructor for std::vector (std::vector<T,Allocator>::vector - cppreference.com). It's an unmitigated disaster in native C++. I'd much rather the FFI bindings spend a bit of manual effort and create more Rust native bindings with names like with_size etc.

In other words, I disagree with what you said being a good argument, and I reiterate that I have yet to see any good arguments.

3 Likes

This is exactly what I need. Traits looks a lot better than what I suggested. Thanks !

That is just an argument for bad API design of the STL. It is not an argument against this feature, unless you argue that it's not possible to create a good API with overloads.

Furthermore, even if this is what you argue, in large existing C++ projects that had a large API surface area overloads are just a fact, and coming with new names for every overloaded method is a huge burden. Then you also have to maintain those names when the API changes.

I actually do argue that the costs of ad-hoc overloads are always too high, especially when those ad-hoc overloads are used as extension points.

A variadic trait method that thus can take different arities? Sure, that's fine, because it's not ad-hoc.

2 Likes

Most nontrivial C++ code has many examples like that.

Indeed it is practically impossible, apart from very simple examples (like sin taking either a float or a double). And in any non-trivial code base you will end up with some of the awful overloads.

And I think that is not a problem that should burden Rust just to get the FFI a bit nicer. I would rather not have the FFI with C++ at all if that is a requirement. Overloading as done in C++ is just that bad.

And I write that as someone who writes C++ and Rust for work (but currently not linked into the same binary, the different services talk over gRPC). FFI would be nice. But not at the cost of overloading in Rust.

I think you argue that people can have good taste when it comes to overloads. The problem with good taste is that it can't be enforced by a linter (at least not yet) and it doesn't scale. And as soon as you have a project with hundreds of developers that becomes unenforceable both socially and from a review workload point of view.

The answer is thus to just not do overloading at all, it is just a feature too prone to be misused for bad API design, as we see time and again in C++. Rust should as far as possible nudge people in the direction of "good" code, and it does a pretty decent job at that currently. C++ as a language really doesn't. There are books and lists on all the parts of C++ you shouldn't use any more. Rust for the most part doesn't need that (yet?). Overloading would be a major step in the wrong direction for Rust.

1 Like

Almost every language feature can be abused. Generics, traits, operator overloading (famously Java does not have operator overloading for this reason). I don't think the solution is to not provide powerful features; rather, you should trust your users (and I agree that we should prefer features that are harder to misuse).

Rust doesn't need overloading on its own and so shouldn't have them; But interop with C++ does, and even if it's not important to you (or not that important to you), there are many important projects that it is vital for them. Basically, every legacy project that is written in C++ and wants to adopt Rust. I definitely wouldn't state that "I would rather not have the FFI with C++ at all if that is a requirement".

Also, while I agree that C++'s overloading rules are quite bad, we don't need to have them (and the current proposal doesn't propose them). We need to have some overloading (with possibility to differentiate by arity and types).

Some features have impact on the language even when not used, however. So it's important to avoid those things anyway.

For example, it's currently the case that you can implement inherent methods for arbitrary crate types in a block inside a const generic argument. Almost nobody uses that, because it's extremely rare that it's useful. But it has IDE responsiveness cost, so with prompting from R-A we're trying to find a way to remove some of the flexibility here because the compiler can't just "trust the users" and needs to support everything allowed.

1 Like

I would really love if functions like Fn((A, B)) could have been easily converted to Fn(A, B). Mapping with enum variant is very useful, but it loses a lot of benefits when you go to variants with two or more members.

Well I don't know about this kind of multi arity overload, but multi-arity generics (e.g., variadic generics) would be nice

1 Like

If this is purely for FFI, then to me, the obvious way to solve the problem is for functions to have different names on the two sides of the FFI boundary: on the Rust side of the boundary, all the overloads of a given C++ function would have their own individual names, but on the C++ side they would all have the same name, with the extern block specifying the mapping between them.

That would avoid any complication in Rust the language: the only additional complication from the overloads would be about writing the extern block, as it would need to specify both names.

1 Like