Overloadable functions with tuples - why can't compiler do that?

It's almost never a question of "can". It's almost always a question of "should".

Implicitly changing types and attempting conversions is bad for a number of reasons:

  • It's surprising and highly non-obvious, so it hinders debugging.
  • It plays poorly with type inference, as it creates additional choices for the type/trait solver engine to consider, and currently unambiguous cases would become ambigous. (For this reason, it would also break backward compatibility).

Swift used to do something like this, but the implicit tuple <-> multi-argument conversion was actually removed from the language, because it did a lot more harm than good.

This is a commonly perpetuated myth, but adding more syntactic sugar doesn't usually improve learnability/teachability. Adding more and more layers of implicitness will result in people not understanding why what they wrote happens to compile, and they will easily be led to write code that appears to work but then it doesn't actually do what they intended.

12 Likes

It's almost never a question of "can" . It's almost always a question of "should" .

Fair enough.

For this reason, it would also break backward compatibility

That's convincing argument — can you show an example of where and how this may happen? Note: if you have two traits which have functions with the same name and different number of arguments it's already rejected as ambiguous (which is strange to me, but AFAICS precisely that decision makes such change backward-compatible).

I guess if that usecase would be allowed then it would solve foo.bar(1, 2) case (but wouldn't solve Foo::new(1, 2) case), and yes, these two possibilities are mutually exclusive (except if you willing to go C++ way and introduce 20-steps algorithms in language specification).

But currently both are forbidden and that's why people complain about lack of overloading support.

Swift used to do something like this, but the implicit tuple <-> multi-argument conversion was actually removed from the language, because it did a lot more harm than good.

That's another pretty convincing argument, but devil is in details. If it caused ambiguity where it was solved by removal of such conversion then it was, perhaps, a good decision. But, again, reference to some justifications from developers of Swift would help a lot, because it's not always obvious why certain decision was made.

This is a commonly perpetuated myth, but adding more syntactic sugar doesn't usually improve learnability/teachability.

That's only half-truth: of course it makes it harder to learn language itself. But if it makes it easier to actually use that language then it may a good decision. Take to the extreme: you may say that C, C++ or Rust are just pile of “syntax sugar” of top of the machine code and if you look on the machine code… usually it's much simpler “language” than C++ or Rust.

But actually writing something in machine code is much harder than writing something in C++ or Rust — that's why such languages exist.

That's why all languages add syntax sugar over time. Not to make language easier to learn, but to make it easier to use.

You may say that Rust is already very hard to learn and we shouldn't make it harder, but, again, experience with C#, Java, Python, even C++… showed that old users become confused by new syntax sugar sometimes while newcomers usually accept it easily.

Adding more and more layers of implicitness will result in people not understanding why what they wrote happens to compile, and they will easily be led to write code that apepars to work but then it doesn't actually do what they intended.

Again: true. It makes it harder to fully master the language. But makes it easier to grasp for the beginners which is often an acceptable tradeoff.

There's no need for two methods for the existence of a new type to cause ambiguity. The canonical example is the AsRef problem, of which the equivalent with a custom trait is demonstrated here. If the compiler is allowed to pull new types out of thin air, then the same kind of problem can happen. (This is a bit hard to demonstrate as-is without the feature actually being implemented.)

I can't point to anything concrete because it was several years ago. I do remember reading that it was removed because it caused ambiguity in closure arguments, and I did experience a lot of grief while writing Swift, it was one of the most frequently encountered annoyances.

No, please don't. That causes reasonable arguments to become useless. Of course we do like and use high-level languages. But the border has to be drawn somewhere.

Optimizing for writing is the wrong objective function. Ease of reading and understanding is much more important than ease of writing, and this kind of feature, while it might make it easier to write code, would also make it harder to understand during reading, precisely because of the increased number of choices.

I don't think that Rust is "already very hard to learn". I actually think it's a pretty friendly language. I still don't think we can afford adding many small and unimportant features directed at marginal writing convenience, because cruft accumulates over time.


Furthermore, please note that feature requests are off-topic here. If you want to write an RFC, you should start at IRLO.

3 Likes

While I think Rust can be difficult to learn for many people, I would say the difficulties relate to the unusual ownership system, borrowing, references., and maybe type inference/iterators, generics. I don't think it's because functions cannot be overloaded.

There's no need for two methods for the existence of a new type to cause ambiguity.

But we are not talking about new type here. One-argument case is fine and there are no need to do anything with it. And if tuples are only considered for a case where number of arguments ≠ 1 then I don't see where ambiguity can come from.

This is a bit hard to demonstrate as-is without the feature actually being implemented.

Why? Just show me the code where both …((…))… compiles and have a meaning and the appropriate …(…)… compiles and have a meaning.

I can't point to anything concrete because it was several years ago. I do remember reading that it was removed because it caused ambiguity in closure arguments, and I did experience a lot of grief while writing Swift, it was one of the most frequently encountered annoyances.

Well… if you can reconstruct something like that in Rust (where change …((…))… to …(…)… changes the meaning of code) then we have a problem, obviously.

Optimizing for writing is the wrong objective function.

True. Yet we have tons of features which do that hard. Compared to what macro can do to the ability to find code defintion the simple …((…))……(…)… rule is pretty mild.

Furthermore, please note that feature requests are off-topic here. If you want to write an RFC, you should start at IRLO.

Well, I was mostly trying to understand why such an obvious and simple to implement feature doesn't exist already.

Especially since, as you have pointed out, it actually exist (well… existed, at least) in other languages.

I don't think it's because functions cannot be overloaded.

It becomes as issue when you try to use API that you already know and find out that instead of calling initMouseEvent you now have to call
init_mouse_event_with_can_bubble_arg_and_cancelable_arg_and_view_arg_and_detail_arg_and_screen_x_arg_and_screen_y_arg_and_client_x_arg_and_client_y_arg_and_ctrl_key_arg_and_alt_key_arg_and_shift_key_arg_and_meta_key_arg_and_button_arg_and_related_target_arg.

Actually it's a problem even if you want to use said API and you haven't used it before, from other languages where overloading is a thing.

In an ideal world where everything is written in Rust it may not be a big deal. In a real world… it's a problem.

Macros exist because there are some, mainly syntactical problems which can't be solved either at the value (function/expression) or the type (generic/trait) level. They are not exactly optimized for writing, either: finding the definition of a macro creates a lot less friction than writing even a rule-based one (not to mention proc-macros, which require a separate crate).

That's exactly why. If it doesn't compile currently, I can't produce for you the code that causes ambiguity. The implementation of features that interact with the type system is usually way more nuanced than that – I can't be sure that if I speculatively write an example, it will or will not work exactly in the form that I imagined.

Could you kindly rewrite your comment in every other language? Some people are not familiar with English, so it is an issue that they should have to learn it to understand your arguments. In the ideal world, we'd all speak English, but in the real world it's a problem that your comment doesn't account for the phrasings and constructions they are already familiar with.

Well now the problem has shifted somewhat. It's not really about people learning Rust, it's the difficulty of calling a rather strange API. I suspect the answer might be to provide a suitable wrapper for that API. Reading the documentation it actually states it is deprecated? A function with 15 parameters seems unwieldy to me. I am not familiar with this interface though.

2 Likes

Reading the documentation it actually states it is deprecated?

It's deprecated in JavaScript — because there are other, better ways to achieve the same result. In Rust you don't have a choice.

A function with 15 parameters seems unwieldy to me.

True. That's why there are move to use designated initializers instead in C++ world (then most parameters get the default values and you specify the remaining ones using names, not counting commas). In JavaScript you can pass dictionary.

In Rust that can be simulated with fluent interface, sometimes, but that relies on optimizer and very often it fails if there are are dozens of calls chained and, especially, if some of these are creating non-trivial objects.

I suspect the answer might be to provide a suitable wrapper for that API.

Before you can provide it you have to decide how it should look like. And that is what is currently not clear and what I'm talking about. That init_mouse_event_with_…_and_related_target_arg looks kinda ugly, but understandable. One, single, constructor (or function) with fixed name (new or some other) and between one and fifteen arguments would work just like it works in JavaScript or C++. But without automatic conversion between tuple and multiple args it's unimplementable thus we are kinda stuck, I guess.

And that's something made by Mozilla developers, I kinda-sorta assume they have developers who may help them develop nice bindings.

Yes, sometimes you can first create raw bindings and then provide nice rusty bindings on top of these. But realistically speaking when you have billions lines of C++ code and hundreds of thousands libraries that you may want, potentially, use and only a few Rust-users in your company then it's just not feasible and without passing that stage you would never reach the stage where Rust is widely used.

I'm going to go out on a limb and say that API is truly horrible. But it could easily be fixed half a dozen different ways, and none of them involve overloading or implicit type conversion of arguments (YUCK!). I hope Rust never adds anything like that.

foo(bool, bool, string, bool)
foo(bool, bool, bool, string)
foo(string, bool, bool, bool)

Nope. Just Nope.

3 Likes

I agree with the sentiment that this API naming scheme is a result of decisions made by the crate maintainers, more than it's a result of Rust's lack of function arity overloads.

Would supporting function arity overloads improve this specific situation? Yes. Is "let the compiler insert magical parentheses" the right way to implement function arity overloads? Hmmm, probably not.

However, OP is free to write a Rust preprocessor to try it out. (Only half-joking. I'd love to see someone actually attempt to fix all of their bikeshedding issues with Rust syntax by writing a preprocessor. Because that's always worked so well in the past.)

And indeed, there are already procedural macros, and they are basically allowed to generate arbitrary Rust code from almost arbitrary input (with the basic sanity constraint that parentheses must be balanced in the input). One of the most important raisons d'être for macros is this exact situation, so of course anyone is free to add whatever syntactic extensions they wish to use in their own codebase. With macros, one doesn't have to wait for a long time until an RFC is accepted, implemented, and stabilized, one could instead start using any desired new syntax right away, with the added benefit that no disadvantages will be forced on everyone else automatically.

3 Likes

With macros, one doesn't have to wait for a long time until an RFC is accepted, implemented, and stabilized, one could instead start using any desired new syntax right away, with the added benefit that no disadvantages will be forced on everyone else automatically.

This only works with “simple” extensions which only deal with source sequence of topics and have no need to know about the structure of the program.
Syntax sugar often requires looking on the semantic of the program outside of macro (like here: you need to know if trait accepts one argument or many, you couldn't just insert additional parens blindly).

Anyway: I wasn't looking for a way to spent crazy amount of time bikeshedding, was just trying to answer the question of my friend about why Rust almost-but-not-quite supports overloading.

Answer “it's because noone did enough arguing to push for one solution or another and Rust-development is concensus-based process” is enough for me.

After all you may always write double-parens for a single-argument functions and then add #![allow(unused_parens)].

Actually, in this case, it is possible to just start inserting additional parentheses, because it happens to work: (x) is the same as x, and not the same as (x,), so a macro that wraps everything in an additional set of parentheses is trivial:

macro_rules! call {
    ($fn:expr, $receiver:expr, $($args:expr),+ $(,)?) => {
        $fn($receiver, ($($args),+))
    };
}

trait Trait<T> {
    fn foo(self, args: T);
}

struct X;

impl Trait<i32> for X {
    fn foo(self, x: i32) {
        println!("x = {}", x);
    }
}

impl Trait<(i32, f64)> for X {
    fn foo(self, (x, y): (i32, f64)) {
        println!("x = {}; y = {}", x, y);
    }
}

fn main() {
    call!(Trait::foo, X, 42);
    call!(Trait::foo, X, 13, 3.7);
}

In other, more complicated cases, no global analysis is needed either. The usual practice is to generate syntactically uniform code that piggybacks on the trait system in order to gain access to type-level functionality and potentially behave differently. For this reason, "macro support" or "macro runtime" crates are a well-known design pattern in the Rust ecosystem.


By the way, syntax and parsing should not depend on the type system, because that's disproportionately harder to parse for the compiler as well as humans. The need for global analysis is also highly undesirable for the exact same reasons, while, in addition, slowing down compilation times significantly by preventing incremental compilation. So those requirements are then two other arguments against having a feature like this.

You have missed forest for the trees. Syntax sugar goal is not to make something more verbose and cryptic, but to make something easier to read and write. Macro which would implement auto-dereferincing in your fashion would be pretty useless since simple * would be replaced by much larger mostrosity.

a macro that wraps everything in an additional set of parentheses is trivial:

It's trivial and also completely useless. You are turning something ugly into something awfully ugly. Something like this:

   emitter.imul(cx);
   emitter.imul((cx, dx));
   emitter.imul((cx, dx, 5))

is much shorter and easier to read than

   call!(imul_trait::imul, emitter, cx);
   call!(imul_trait::imul, emitter, cx, dx);
   call!(imul_trait::imul, emitter, cx, dx, 5);

First one looks close enough to syntax which 8086 used for last 40 years, 2nd one looks like something very cryptic, hard to understand and, most importantly, not similar to any kind of assembler at all.

And larger macro which wraps not just one call but large portion of Rust source would need to know if a given function actually accepts tuple or few separate arguments.

Not possible with current rust compiler AFAIK (which, is perhaps, a good thing, as you note below).

For this reason, "macro support" or "macro runtime" crates are a well-known design pattern in the Rust ecosystem.

This works when you want to simplify something big. Macro call needs at least 4 characters (name of macro, exclamation mark and some braces) which makes no sense when offending form already have only 2 “extra” characters: one additional-brace in the beginning and one in the end.

It would have been possible to use something like foo.bar⦅1, 2, 3⦆ and do a simple search-end-replace in macro, but, unfortunately, Rust doesn't support characters like or (not even as argument of macro) thus such idea doesn't work either.

You probably can get away with passing a string and then turning it into a Rust source, but then you are, essentially, creating your own language in you macro and would need to add infrastructure to the rust-analayzer and other tools.

Not easier than changing the compiler, I'm afraid.

By the way, syntax and parsing should not depend on the type system, because that's disproportionately harder to parse for the compiler as well as humans. The need for global analysis is also highly undesirable for the exact same reasons, while, in addition, slowing down compilation times significantly by preventing incremental compilation. So those requirements are then two other arguments against having a feature like this.

I understand perfectly why macros are as limited as they are (hey, they are still more powerful than C/C++ macros!). I just point out that because of that limitation it becomes very hard to implement syntax sugar like autoderef or automatic tuples⇔multiple argument conversion as macro.

Not the least bit. I was merely pointing out that your assertion about what is and isn't possible was incorrect.

Agreed. I wouldn't recommend anyone to actually use that macro I wrote under any circumstances.

I'd say that's close, with a lot of hidden nuance like "the Rust teams aspire to forward-compatible and complete solutions on the language level." So part of the answer to your titular question is "90% is not good enough" (independent of any other criticisms).

In other words, there likely won't be a language level feature regarding arity until we get the full blown variadic generics you also mentioned.

1 Like

Which is not true, of course. Rust does precisely and exactly the same silent conversion which I'm talking about when you deal with Fn/FnMut/FnOnce. Only Rust does it in both directions there thus you don't even realize it's happening.

I guess there the need to have something nicely looking right now outweighted the need to have “forward-compatible and complete solution”. Because it's needed more often.

I would put it in the same category as nightly-only box syntax: yes, it's shorter and more efficient but it's kept out of stable because there are hope to make Box non-special at some point.

Similarly here: variadic generics may provide nice, clean solution closer to the middle of XXI century thus for today we are stuck with a bit ugly solution. Fair enough, I guess.

Function overloading has a million problems but this ain't one of them. That's much more about Rust not using argument labels at call sites. Esp. because, even if I know that there's only one overload foo(bool, bool, bool, string), I still have no idea how to call the darn thing. After touching Swift, I can't help but see this omission as an ancient relic of the past.