Pre-RFC: Cascadable method call

Not everyone shares the same experiences with you. The RFC process is there to help you explain your reasoning and motivations in as clear as possible way so that others can get to the same place you are. Remember that any proposed feature is eventually going to be the first time someone runs into the concepts involved. It helps a lot if the docs explain things from the basics. Experienced devs (usually) know what they can skip; newer ones need more hand-holding. And where prior experiences differ from whatever gets decided, experienced devs can go back and clear things up.

5 Likes

Simple or not, it seems uncommon, as Wikipedia says:

Method cascading is much less common than method chaining – it is found only in a handful of object-oriented languages, while chaining is very common.

5 Likes

Indeed the concept is simple and my personal take on it is that having the ".." syntax makes it more obvious than relying on "nothingness" (i.e. spaces). The Dart language-tour explanation of Cascade notation is also informative and shows the "null-shorting cascade operator (?..)". Obviously Rust would need something like this to handle Option and Result return values. Personally, I like the explicit nature of using visible characters rather than spaces, I'd suspect it would make IDE support easier too.

1 Like

Then you may want to change the example in Pre-RFC: Cascadable method call - #9 by 160R because it clearly shows calling bar and qux, which take self on a &mut X.

Is there a need to distinguish this case? Can't you just make it so that it's always propagated? I feel like this would simplify a bunch of the desugaring rules.

1 Like

This doesn't seem to match the semantics in the wikipedia article on method cascading:

Given a method call a.b() , after executing the call, method cascading evaluates this expression to the left object a (with its new value, if mutated), while method chaining evaluates this expression to the right object.

The problem is that I don't know what others know. Another problem is that I cannot explain everything — method cascading was taken as an axiom. And yet another is that talking too much about method cascading would be either misleading, since my feature differs in many critical aspects. Moreover, method cascading in its previous form has no sense in Rust: there is quite old proposal for it which resulted in cascade! macro which is rather unpopular and reuses weird .. operator that's ambiguous with range syntax — I didn't wanted dragging all of that in this thread for being criticised again. Also, in the past I've tried to open discussion about a similar feature almost in the way as it "ought to be" and, honestly, the current thread feels more productive.

Prior art

The fluent interface pattern in the current Rust as well as in many other languages was probably the biggest source of inspiration and the attempt to improve it in Kotlin was influential as well. Then some criticisms of fluent interfaces and method chaining [1, 2, 3] were helpful in validating prototypes and establishing the further design direction — it seems that many people have an intuition that programming languages misses here an important part.

It's hard to say that a regular infix method notation that could be known from languages like R, Kotlin, Haskell and Scala contributed anything besides the fact that its shape can be readable enough and compose well with the rest of the code. This thread also seems to confirm importance of well-defined associativity which cascadable method call has.

Up to this date probably the most successful implementation of method cascading as a dedicated construct happened in Dart language — the general lack of a negative response about that feature was very motivating during writing this RFC. And their new formatting rules seems to provide a bit similar aesthetics.

Many other languages have something similar e.g. Smalltalk, Clojure, Pascal, Nim and there were proposals to add it to C#, Python, Swift, CoffeeScript and who knows to what else language missing in this list. Even Rust has it implemented in cascade! crate which people recommend to use [1, 2], moreover, the tap crate seems to be closely related here. That's enough to demonstrate the demand and how many use cases for cascading there could be.

What's also relevant, there was proposal to rename &mut so it'll denote uniqueness instead of mutability and with that remove mut annotations (this was named "mutpocalypse" by the community). And recently another proposal appeared to just relax missing mut from hard error to suppressable warnings. That said, people really think that mutability isn't as useful concept as it's supposed to be — hopefully the current RFC will transform it into "shared state" and that will become an acceptable compromise for everyone.




Right, initially Copy was derived for X and the example was valid, but after some time I've reviewed it and for some reason removed that derive. This is good catch — fixed it.

Consider this pattern:

let mut x = ...;
if ... {
    x y
}
x.z();

It's expected to be very common — exactly as mutating values with methods in if expressions. An important point (which also seems to be missed by everyone ITT and I must figure out a proper way to clarify it) is that instead of x y you won't be able to use x = x.y() or x.y() which mutates — compiler will complain about that because cascadable notation is more appropriate and must be always used for mutating. So, if x here was propagated then it must be moved into cascadable y call, but then x.z() wouldn't be possible and that isn't really any useful behavior of the code — there's either mut which is rather useless then cascade which steals values without an obvious reason, and a compiler error instead of what user most likely will expect.

That's interesting, didn't know about null-shorting cascade operator in Dart. However, I don't see what we should implement here — cascadable method call would work fine with bubble/questionmark/try operator, for example there's nothing that will prevent foo? bar baz qux to work similarly to Dart's version.

Or did you mean that Rust should support something like foo bar? baz? qux? instead where bubble/questionmark/try operator is applied to result of cascadable method calls instead of foo ? That intentionally wouldn't be supported in order to promote a proper error handling in builder pattern. This is taken from future possibilities section in my RFC: "a proper error handling in builder APIs would be to delay validation to the .build() call while setters shouldn't return Result [1, 2]; it isn't common anyway to have setters that may fail and if having one would be so necessary then it can be possible to return Result<Self, _> and proceed with method chaining in the old-school way."

So, with cascadable method call we should write something like this:

let foo = Foo::builder()
    bar (Bar::new())
    baz (Baz::new())
    .build()?;

Instead of:

let foo = Foo::builder()
    bar (Bar::new())?
    baz (Baz::new())?
    .build();

That’s true

But given that it’s what you are trying to propose, you cannot skip this explanation. Even a link is already too much for the core of what you are talking about.

I am personally familiar with the proposal for a pipe operator |>, apparently similar to the same thing in F#, as well as pipe in shell scripting, but I never heard of cascadable method calls. Because of that I had to read multiple paragraphs, and play find-the-diff in the examples just to know what you were talking about. And because of that, I would have had to re-read everything to understand the details, but I didn’t had the courage to do it. And I’m not even sure a pipe operator is what you are proposing.

1 Like

It looks like this is also trying to change how people would use builder APIs?

To me, I'm not convinced by the value of this change from the OP:

     let input = ops::Placeholder::new()
-        .dtype(DataType::Float)
+        dtype (DataType::Float)
-        .shape([1u64, 2])
+        shape ([1u64, 2])
        .build(&mut scope.with_op_name("input"))?;

A new feature to have a space instead of a period seems like a ton of churn.

It seems more interesting for the places where there's not already a builder API, like the motivation in the Dart article you linked. But even there, Rust having block expressions makes the motivation weaker since you can use a short ident in the small scope:

    let some_long_name = {
        let mut v = Vec::with_capacity(100);
        v.push(2);
        v.push(3);
        v.push(5);
        v.push(7);
        v
    };

If I understand the proposal right, in the new way that'd be

    let some_long_name =
        Vec::with_capacity(100)
        push (2)
        push (3)
        push (5)
        push (7);

instead? But that seems actually worse by some metrics -- for example, it's harder to add push (11) because of the semicolon positioning.

4 Likes

@160R, I think I understand cascading methods. Now I'd like to understand the &mut issue and your syntax proposal. Here you say:

Consider this pattern:

let mut x = ...;
if ... {
    x y
}
x.z();

I look at this and I'm unsure of what is actually happening as I'm a relative neophyte to rust so it's hard for me to fill in the missing code. So, to be somewhat pedantic, below is a slightly more complex example that executes:

fn main() {
    struct P {
        x: i32,
        y: i32,
    }
    impl P {
        fn inc(&mut self) {
            self.x += 1;
            self.y += 1;
        }
    }
    let mut p = P { x: 123, y: 456 };
    if p.y != 0 {
        p.x = 0;
        p.y = 0;
    }
    p.inc();
    println!("x:{}, y:{}", p.x, p.y);

And below is the corresponding code you are proposing:

fn main() {
    struct P {
        x: i32,
        y: i32,
    }
    impl P {
        fn inc(&mut self) {
            self.x += 1;
            self.y += 1;
        }
    }
    let mut p = P { x: 123, y: 456 };
    if p.y != 0 {
        p x (0)
          y (0);
    }
    p.inc();
    println!("x:{}, y:{}", p.x, p.y);
}

With the diff being:

$ diff p-rust.rs p-160r.rs
14,15c14,15
<         p.x = 0;
<         p.y = 0;
---
>         p x (0)
>           y (0);

I'm sure I've got something totally wrong, please provide corrections?

Disagree. The ambiguity created by the non-uniformity of the method call syntax (sometimes you need . and (), sometimes you don't, and needing () even depends on the arity of the function!) is far worse than the "noise" it removes.

Meh. When I skim through code, I don't start by locating "changes of return types" and "shared state of non-sequentially arranged expressions". I just want to be able to read the thing first, and let my uncoscious figure out what the immediate, low-level meaning of the syntax is. Then I can start reasoning about types and state, but if the first, low-level step is disrupted, that makes everything down the line much, much more difficult.

Oh no, no paradigm shifts please.

6 Likes

Ok, I've admitted that and now working on ground-up explanation of the feature. This isn't easy as it seems because there's a lot of extra concepts to explain, however, I'm already close to finishing it

Something very similar to pipe is a future possibility of this syntax. To point exactly, if there was pipe operator (verbatim) in Rust then we would have the following code:

    .body(
        TabBarView::new()
            .controller(self.tab_controller)
            |> |view| {
                for tab in tabs {
                    view.child(Center::new().child(Text::from(tab)))
                }
            }
    )

In current Rust without extra syntax an equivalent would be:

    .body(
        TabBarView::new()
            .controller(self.tab_controller)
            .tap_mut(|view| {
                for tab in tabs {
                    view.child(Center::new().child(Text::from(tab)))
                }
            })
    )

And with my proposal that could become:

    body (
    TabBarView::new()
        controller (self.tab_controller)
        also (
        for tab in tabs {
            super child (Center::new() child (Text::from(tab)))
        } )
    , )

Or even simpler example:

foo |> bar;            // <pipeline operator
foo.pipe(bar)          // <current Rust with `tap` crate
foo also (bar(super))  // <a future possibility of this proposal

This is inspired by it keyword in Kotlin and their scope functions with the biggest difference and advantage being that my syntax relies on scopes and not on closures, so it's much simpler.

The following might be also possible:

foo.also(bar(super)) // < `super` points to receiver of `also` which is `foo`

Here also might return () or its input — that's irrelevant. What matters is that it combines very nicely with casadable notation and something like that would be unpractical with a different one


Almost. The completely corresponding code would be the following:

fn main() {
    struct P {
        x: i32,
        y: i32,
    }
    impl P {
        fn inc(&mut self) {
            self.x += 1;
            self.y += 1;
        }
        fn x(&mut self, x: i32) { self.x = x }    // ++
        fn y(&mut self, y: i32) { self.y = y }    // ++
    }
    let mut p = P { x: 123, y: 456 };
    if p.y != 0 {
        p x (0)
          y (0);
    }
    p inc;                                        // ~~
    println!("x:{}, y:{}", p.x, p.y);
}

Dart allows you to call cascading on fields, but in Rust you would always need methods, thus I've added x and y to P implementation (notice that there's no need in returning Self).

Also you must call p inc with this syntax as well, since it mutates self (notice that &mut self is taken by every method on P). With p.inc() instead you will get a compiler warning.




Perhaps I know where exactly it get wrong: nobody did understood what the "non-optional" property means, and that was crucial. I've really explained is very poorly, sorry about that and let me clarify.

I propose to distinguish through a different notation all method calls that mutates their receiver. They have different behavior and very different properties — because of that the current uniform .call() notation makes them less useful and complicates the language in various subtle ways!


This proposal should have been started from the following point:

There's a principle in API design called command-query separation which postulates that a method should either perform an action (command) or return (query) some data, but not both. And this isn't some buzzword from OOP world — it's already the common reason which presumably every Rust API follows, maybe not 100% but enough to make a statement. That said, a method with signature (&self, ..) -> T is a query while a method with signature (&mut self, ..) -> () is an action. Of course, there's a bigger variety and there are hybrid methods like (&mut self, ..) -> T but either they're not common and anyway often we use them as an action through ignoring the result (T). So, if these aren't the same then why we call them in the same way?

And that exactly what I'm trying to achieve. Then cascading, pipeline operator, DSLs, builders, wrapping operators ergonomics, etc. are mostly side-effects of command-query separation on a syntax level.




There's no "sometimes" — where you need . as well as () is very predictable. From command-query separation perspective you always need . when performing a query (as it ought to be) and you may remove () when performing an action if it makes code look bad. It's not that complicated.

Ambiguous is the current syntax because x.get(y) and x.set(y) looks the same while doing different things. This is familiar, uniformly looking, simple, but nevertheless wrong abstraction. You don't see where is query and where is action. You're never certain what types goes in and what types goes out. You need a decent variety of design patterns consisting mostly from syntax noise to maintain all of that. It also makes mut annotations less useful because they're everywhere and either could be hidden under method chains

In most of cases it will look like "space+word+space+paren" and this is extremely easy to recognize. In the rest of cases the surrounding context you must know is anyway smaller than we must know with current syntax to read the same thing. So, maybe it's just the matter of habit?

It might be a bit more disruptive, but every disruption gives you an information that makes the reasoning on a next step much simpler. At least that's how I see it

2 Likes

First off, fn(&mut self) -> Value is a lot less uncommon than you're implying. Plus, shared mutability completely breaks the dichotomy that you're trying to make more visible.

Also, since this is just a large syntax change with high cost and high likelihood of dialecting the language, its chance of passing RFC is very low. (Again, I'll encourage you to pursue other avenues for exploring the syntax, but asking Rust developers to do it on a production language isn't likely to get you far.)

Existing edition changes did require some changes, and requiring dyn Trait for trait objects was probably the most disruptive one of them. If your change is more onerous to existing codebases than dyn Trait was, then it's probably never making it into rustc.

Your vision is interesting, don't get me wrong! But it isn't Rust; it's a very different language (potentially on the same abstract machine) with different ideals on the frontend. For better or for worse, Rust cares about being relatively approachable syntax-wise to people familiar with C family languages. If I'm not mistaken, identifier adjacency is only ever used in any semi-mainstream C family language where one token is a keyword (or positional keyword).

And this is actually a very valuable property! It means that keyword adjacency is "negative space" in the language design that does two things: it makes it stick out as significant and meaningful, and it gives us the possibility of adding new positional keywords.


I want to note that x.get() and x.set() use the same syntax, because they are the same thing – sugar for X::get(#autoref x) and X::set(#autoref x). Method syntax is a bit special since it's the only way to get autoref behavior where a place is automatically referenced exactly as necessary, but otherwise it's just a normal function call. There's no conceptual difference between an "action" and a "query".

Rust isn't OOP, and the "method" isn't special. It's all just functions, to the point that while we currently have Unified Function Call Syntax (UFCS) that allows calling methods as the functions they are (Type::method(&receiver)), there's a repeating and loosely positive push towards eventually accepting Unified Method Call Syntax (UMCS) that allows calling arbitrary potentially free functions as methods (receiver.crate::function()).

8 Likes

Interesting. This explanation certainly makes your proposals way more easy to understand. I agree with others pointing out that "no operator at all" would probably not he the right syntax though.

Note that we don't necessarily "call them the same way". When you call a method that returns something, then you'll usually use the return value. I. e.

a statement

foo.bar();

vs an expression

foo.bar()

the latter being part of some larger expression or a let statement, does kind-of distinguish the two. Of course, this distinction is not enforced, but there are useful warnings:

  • if you assign the result of a ()-returning function, there's not much you can do with the returned value. And if you do nothing with it, you get warnings about unused return values.
    • maybe one could even go further and increase the number of warnings here, or perhaps at least offer a Clippy lint: arguably one never needs to assign a known-to-be-() value to a variable. If you want e. g. look at the the return value to double-check that it really is a (), then you could match against a () pattern instead
  • if you call a value-returning function as a foo.bar(); statement, i. e. ignore the return value, then often a #[must_use] warning will tell you not to ignore the value. There's an increasing number of functions in the standard library getting #[must_use] and arguably every function that is - in your mentioned "command vs query" distinction - not at least also a command, i. e. everything that's exclusively a query, should be getting a #[must_use] attribute
    • this #[must_use] is also used for error-handling. This blurs the line a bit. Is foo.bar()?; rather syntax for command or query, i. e. is potentially-returning and checking for an error already a "query call" because return values were technically involved? Is there perhaps need for a stronger #[must_use] where you're also forced to explicitly handle the okay-value inside of a Result?

Given this discussion, I claim that introducing new syntex for the sake of distinguishing "command vs query" might not even be necessary, even following the argument that the two things shall be distinguished. Nonetheless, a new, optional, syntax for chaining (or should I say "cascading"..) &mut self -> () access to the same thing might have good value. It should be something else than "no operator"/juxtaposition, and it should not necessarily try too hard to replace any existing syntax, then I think there's room for a reasonable proposal. Basically a straightforward proposal for a cascading operator; which should probably also be compared to what syntax would be possible with some kine of postfix-macros syntax alone.

2 Likes

But these will work fine with the syntax I'm propose. That's basically the whole purpose of method cascading to support methods like that (at least in Dart).

Do you mean types like Cell/RefCell/Mutex? I've tried to rewrite some codebases with cascadable syntax and didn't found them problematic, maybe because I've not seen them it there. Perhaps it would be possible to add attribute like #[cascadable] to allow any method being embedded into casade and this will resolve the problem?

The syntax is still backward compatible. If somebody won't like it or won't have time on refactoring then flag to disable warnings might be a sufficient solution — we either may rely on it in macros. And Rust is already full of features like that e.g. not everyone uses impl in argument position and ? operator despite benefits.

It is a bit different language than we have currently, but IMO it describes better how Rust's mutability/borrowing system works thus should make it easier to approach for anyone.

Well, return x will be ambiguous with foo bar but as long as return is colored differently and as long as I know what it means I won't care. Also, relatively recently .await was introduced as ambiguous with .field and everyone agrees that it was a great success. So, I don't understand what's the exact problem here

The distinction between commands and queries is more high level than that, so it's not a particularly good perspective to represent it.

But would it eventually happen? This seems to be very prone to abuse syntax, e.g. where should I prefer foo(bar) over bar.foo()? And wouldn't it be surprising that bar.foo() call is unavailable in a different module if I would assume that foo is associated method?

In this regard the following seems to be much better:

foo also (bar(super))

At least people would think where to use it, and function call still looks like a function call

As I've said, there's no better possibility left. If you or anyone else want to convince me that this isn't the right syntax then the best way to succeed would be to show me the right syntax that will at least work on examples from OP. Who knows, maybe together we will really achieve something...

I am aware of CQRS and I think there is some value in it, as a pattern. However, I think pursuing a purist 100% CQRS style is a lot less useful and adds more burden than clarity. There are many cases in which it's not the better/clearer solution, and trying to force it would be uglier or plainly incorrect.

  • An example for the first situation comes straight from the standard library: Vec::pop() has signature (&mut Vec<T>) -> Option<T>, so it mutates and returns the top value. Splitting this up into two methods, .last() followed by a pure-command Vec::pop: (&mut Vec<T>) -> () would require two function calls (and potentially a block when used in expression position), as well as a superfluous clone (if you want the top by value) and two length checks instead of one. Not to mention it has the potential to introduce subtle bugs due to a decision having to be made about the order of getting vs. popping.
  • An example for the second case is DB operations that need to atomically return data that was altered. For example, if you want to return a list of comments that were just deleted, you can't query them first and then delete, because that would not be atomic (and some DBs don't support value-returning transactions). You would need a DELETE FROM … RETURNING clause instead.

All in all, CQRS seems like something that needs to be a domain-specific pattern or idiom when applicable. What it definitely shouldn't be is an artificially enforced, cargo cult "everyone knows CQRS is good" obligation baked deeply into the language, because it introduces more friction than value in the grand scheme of things.

It's the same as with OOP. Languages have endlessly tried to implement "pure OOP" by forcing a very specific low-level style on programmers – and we now know it didn't turn out well. It's annoying and it makes code verbose, without delivering much actual value in terms of higher-level program organization.

9 Likes

There's no strive to achieve a pure CQRS, exactly because of this burden.

So it doesn't require splitting Vec::pop into two methods:

let x = vec.pop();  //< called as a query
vec pop;            //< called as a command

Technically there's nothing wrong with that.


Although this is where I've missed a critical flaw in the whole theory: vec.pop() looks like a query but it either acts as command — this hides mutation and makes every other query look suspicious!

Because of mutation it ought to look like a command but command syntax also designed in a way that doesn't allow it to return anything... Hence, I see no way to preserve consistency of commands other than introducing a third method call syntax for exactly this command+query kind of methods. Let's call them "requests":

let x = vec~pop(); //< called as a request (either command and query)
vec pop;           //< called as a command only 

The obvious downside of this is that a new sigil worsens the "line noise" problem. On the other hand side such unpleasant syntax may either force users to extract "requests" out of complicated method chains, which really has sense even currently since otherwise it makes it really easy to miss the mutating side-effect.

Requests either aren't that common and using them extensively should be discouraged, so a distinct syntax isn't that bad as it might seem. Of course, it will have the price of introducing a new sigil — but that's the single really significant drawback I can think of. Perhaps also the suggested ~ may not fit on that role perfectly although currently I don't see enough evidence to confirm that.

So far I've tried it on a few examples and the idea seems to work well:

fn event(&mut self, ctx: &mut EventCtx, event: &Event, data: &mut T, env: &Env) {
    for widget in self.widgets~iter_mut() {
        widget event (ctx, event, data, env);
    }
}

// in current syntax
fn event(&mut self, ctx: &mut EventCtx, event: &Event, data: &mut T, env: &Env) {
    for widget in self.widgets.iter_mut() {
        widget.event(ctx, event, data, env);
    }
}

// example was taken from:
// github.com/linebender/druid/blob/9bfdaf81/druid/examples/widget_gallery.rs#L311

This addition to my proposal requires "rebranding" of it, since there's nothing common with method cascading. Furthermore, I either didn't liked it being named "cascadable" and everyone found the name confusing.

"Non-uniform method call" may be much better.


P.S. I'm still working on a better explanation of what do I really want to achieve here. That was just the point where I've been stuck for a while so it became delayed.

I'd definitely encourage you to focus down on one thing as the most important part. For example, at first I'd understood this as wanting something like a pipeline operator, but now it's seeming to focus more on CQRS or visual mutability or something.

The more you can isolate the one thing you want and sell the motivation for that, the better. Then the syntax specifics are a relatively minor point.

1 Like

My new explanation is built on premise that .call() on top of borrow checker and move/copy semantics is a very crude abstraction that makes the current Rust complicated to learn and often confusing to work with, so I want to split it in smaller blocks that are easier to grasp separately and that better stacks with the rest of the language and on top of each other. I wouldn't be able to motivate them independently because order really matters: if I would start from the bottomest nobody will see the potential of all other blocks and nobody will be interested; otherwise if I would start from the very top (as I've tried here) the foundation wouldn't look solid and their purpose would be perceived as merely cosmetic improvements.

Anyway you gave a good advice. If I would be able to assemble the complete picture and convince everyone that all evidence holds together then we would be able to review the proposal in smaller parts

My summary of the feature:

  • The technical difference between normal and cascading method calls is that cascading method calls evaluate to self or &mut self, even when the method returns nothing, to support chaining.
  • The semantic difference is that cascading method calls are for commands and normal method calls are for queries.
  • The objective is that, eventually, cascading method calls will used by all crates, wherever possible, and clearly indicate whether a function call is a query or a command.

My concern is that commands may still return a value, so you can use either a cascading method call or the return value, but not both. Examples include Iterator::next, Vec::iter_mut and any function that returns Result<(), E>.

This means that the syntax of a method call can't clearly indicate whether it is a query or a command semantically, which defeats the purpose of enforcing this separation. If another syntax is added for this purpose, e.g. vec~pop, this makes the RFC even more complex. And it still doesn't address the problem that mutation can be hidden anywhere using interior mutability.

You said that this would make Rust easier to learn, but my instincts say the opposite: It's yet another concept to learn and to keep in mind when writing code, or when reading old code that hasn't been updated yet to use cascadable method calls.

13 Likes