Testing the waters: UMCS (Unified Method Call Syntax)

So there's this old post by @CAD97, which observes that . acts as a kind of pipe operator: it “pipes” methods via x.method(params) and callable fields via (x.field)(params). The proposal post suggested a third form of piping, x.(foo), defined as partial application of foo with x as the first argument, so that x.(foo)(params) evaluates to foo(x, params).

The appeal of this proposal is that it addresses several ergonomic pain points. For example, it resolves postfix method-call issues when there are conflicting trait implementations. It also fixes the type inference problem with Into, and provides a way to do postfix Ok wrapping (see the original thread for more examples).

I think the two concerns are that (1) making chaining more ergonomic may significantly change coding style, and (2) it looks kinda weird.

Regarding (2), I’m not sure what to say other than that I do think it grows on you, maybe since it is consistent, minimal, and arguably very natural in hindsight. As for (1), I was curious how significantly it affects coding style, so I simulated it with a Pipe trait (built with unsound nightly features). You’re welcome to play around with it.

I’m mainly curious whether there is interest in pursuing this idea, and whether it’s realistic for something like this to ever be accepted.

5 Likes

This is very interesting functionality-wise. I think having this ability would make Rust better, making disambiguation easier, and possibly macro-generated code more robust.

Specifying the full path to the method in one place seems more general than the recently proposed x.<Trait>::method() syntax, and looks closer to the existing UFCS syntax, which is a plus.


One thing I'm concerned about: is this currying? Can x.(foo) exist as a value, rather than just being part of a method call syntax? If so, what type does it have, with what lifetimes? Rust currently has a magic for two-phase borrows where x.foo(x.bar) is special. I'm afraid that currying wouldn't support that (at least not without new magic in the borrow checker and the type system), so changing x.foo(x.bar) to x.(foo)(x.bar) would fix one thing and break another.

4 Likes

Good question! @CAD97?

I'm still fond of this idea. Imho, it does feel a bit weird, but so did postfix .await initially; I think that this style of unified method call syntax would feel similarly natural in retrospect, but will be a difficult sell until people start

receiver.(function)() should not be currying, exactly because receiver.method() is not curried. The behavior of $place.($fn)($args) is exactly to call ($fn)($place, $args) while applying the same receiver autoref coercions that apply to $place.$method($args). The only difference is that the traditional method syntax looks in the associated method namespace and in the unified method call syntax $fn is an arbitrary expression evaluating to some impl Fn* (eg a function item or closure).

The only involvement of currying is that you can get close to faking the syntax with place.p(func)(args) by currying, but you lose out on mut-generic autoref and two-phase borrows.

1 Like

I think I prefer x.(foo, param1, param2, param3) for foo(x, param1, param2, param3). One advantage is that it resolves the issue of what to do with an unapplied function - it doesn't look like currying:

Another advantage is that it has less parenthesis.

In any case I just hope Rust gets something like it!

2 Likes

This is a pretty interesting one. I immediately like that you can teach it by de-sugaring "normal" method call syntax into it, and that also would be a neat segue into teaching that you can call methods via UFCS.

Also, here's some recent-ish conversation from zulip about a pipeline operator for completeness

I just realized that in combination with allowing contextual .Item lookup (e.g. io::Error::new(.Unsupported, error)), this becomes potentially even nicer of a uniform syntax:

  • $ident($args): look up $ident in the contextual namespace and invoke it with $args.
  • ($expr)($args): evaluate $expr and invoke it with $args.
  • .$ident($args): look up $ident in the contextual expression result type's associated item namespace, assert that it's a function returning the contextual expression result type, and invoke it with $args.
  • .($expr)($args): evaluate $expr, assert that it's a function returning the contextual expression result type, and invoke it with $args[1].
  • $self.$ident($args): look up $ident in $self's type's method namespace[2], assert that it's a method with compatible self type, and invoke it with $self, $args, applying receiver coercions to $self as needed.
  • $self.($expr)($args): evaluate $expr, assert that it is a function with a compatible self type as the first argument type, and invoke it with $self, $args, applying receiver coercions to $self as needed.

The immediate potential problem here is that the difference from x(foo, param1, param2, param3) is almost invisible, or if the developer meant to call a method that takes a callback first instead. Maybe formatting convention saves it? I'm unsure at best.

self.chain()
    .(foo, param1, param2, param3)
    .(foo)(param1, param2, param3)

  1. .($expr)($args) is essentially just a more restricted version of ($expr)($args), so should probably not be allowed in practice; it is shown here simply to further illustrate the logically consistent parallels between these (combinations of) syntaxes. ↩︎

  2. The method namespace is a shorthand for the associated item namespace plus fallback to the associated item namespaces additionally visible via deref coercions. ↩︎

8 Likes

Partial application fells quite un-rusty. Is this meant to implicitly create a closure? That's what partial application usually means in a functional language.

Later comments indicate that the only legal way to write this is to immediately call the function, i.e. the only new syntax is x.(foo)(args...) and let f = x.(foo); is not allowed. If that's what you mean, the description here is very confusing. (This comment does not answer that question; it does not clarify whether x.(foo) exists as a syntactic form of its own.)

I also don't understand how this helps solve any of the problems you mention. Could you give some examples?

1 Like

It helps solve the syntactical problem of wanting to use method chaining, but some of the operations aren't actually methods.

There are other solutions in the ecosystem for this, like tap, but for some reason none is popular. I think that getting something like this in the Rust language (where it can be standardized and be something one can generally use in any codebase) would be great

1 Like

I have seen tap before, but it felt like one of those small dependencies where the cost of adding a new dependency outweighs the added benefit. If something like it was in std, or in a larger utility crate that I needed anyway, then I would definitely make use of it.

2 Likes

I can only repeat what I already wrote here, since you didn't supply any new information: "I also don't understand how this helps solve any of the problems you mention. Could you give some examples?"

Thank you for pointing this out. First, notice the edit. Second, as you mentioned, we already discussed this and concluded that x.(foo); should not be allowed. Maybe one can suggest an extension where x.(foo) will be allowed as curry (I for one think that partial application is nice, regardless to whether it is “rusty” or not. and no, i'm not an fp person, I just think its a nice tool), but it is not part of the core idea and would probably be suggested as a future extension in a possible future RFC if at all.

First, I’ve cited all the claims I made, and I chose specific threads in which there were explicit examples of this idea. Please refer to these threads (or the original post) for explicit examples. But to reiterate, when there is conflict trait implementation you have to refactor and change from postfix chaining to prefix “function like” call, which with this extension wont be needed and the refactoring would be much easier. The second example which I cited is about into, where you provide explicit generic parameters when required, meaning that you again need to refactor to use prefix from instead. Again, with this extension you would be able to use from postfix, making the refactoring less annoying and code more readable. The ok wrapping is similar. Hope this is a sufficient explanation.

tap is extreamly popolar, it has over 150 million downloads in cargo io (with 25m recently). For reference, tokio has 500m downloads with 85 recently. This shows, I think, that there is a lot of appetite for this type of idea. To get perspective, tap is so popular even though, compare to this proposal, its `pipe` trait is much less ergonomic. Not only you cannot do two phase borrowing, but you also need to use closures for multi-argument functions, which adds clatter and makes error handling a pain, especially when refactoring.

Exactly. Though i’m not sure that this can be faked in stable.

In general I think that there is a real problem that postfix “chaining” code is much harder to refactor relative to prefix code. This helps relieve that, which is important, I think.

You proposed a new syntax. The existing threads describe problems you claim to solve, but obviously they don't contain examples of how the solution to the problem looks like with your syntax. I am not sure if I am speaking the wrong language or so, but this is an entirely normal thing to ask for syntax proposals: please show us, by means of concrete representative examples, how your syntax looks like when it solves some of these pre-existing problems. That's an important part of getting a "feel" for a new syntax. I don't understand why I have to repeat such a simple request multiple times.

EDIT: Ah, the very first of these links actually has examples. I hadn't clicked through all of them apparently. It wasn't clear to me that you are just re-surfacing an old proposal, I thought you were making a new proposal. (In your second sentence, when you say "the proposal", I misinterpreted that as being about your new proposal.)

Sorry for the confusion.

1 Like

No worries, glad we're on the same page. Are the examples there sufficient? If you have any questions or concerns (including if you think it's "nice but unnecessary") please let me know. The whole reason for this thread is that the idea keeps being suggested as a general solution to many different pain points people raise — often in threads with proposals that include less general, much worse syntax — so I think it's reasonably well liked and does relieve many pain points people care about, but it's hard to be sure. Also, for sugar like this to be accepted and stabilized it needs clear backing from lang people, so that's essentially what this post is for.

I think for the common case of calling a method with a disambiguated trait/type, I'd rather leave out the parentheses: x.Trait::method(args). I don't think that's more clear if written as x.(Trait::method)(args).

Once that worked, I'd then want to know if we still need the more general x.(...)(args) syntax for anything else.

I think this proposal includes allowing free functions to be called like this. That would obviously require parens to disambiguate from field/method access. I want to proposition that having slight verbosity of parens being always required for this, has benefits over spooky action at a distance affecting whether you need them or not.

1 Like

Calling functions without a self parameter as methods seems very strange to me. Maybe it's something one can get used to, but I do quite like our current model of "the self parameter turns a function into a method that can be called with . syntax".

1 Like

Inspired by this comment, if import_trait_associated_functions is available, this example can be written as:

use ArithmeticallyAddable::add as a_add;
use Concatenatable::add as c_add;

println!("{}", anb.(a_add)());
println!("{}", anb.(c_add)());

Just looking from the result:

  • Much shorter
  • But not immediately clear which trait the method comes from (which I think is not uncommon in current Rust anyway?)

I agree, that's why I prefer the idea of reciever.(non-method)(args) with the parens mandatory. But I do semi-frequently find myself longing for some form of method-like UFCS, or a piping operator.