Pre-RFC: Cascadable method call

Maybe "paradigm shift" was a bold word from me, or at least I've misestimated which effect it might cause.

Automatizable modifications like I propose aren't bad: the change will be akin to running autoformatter on a whole code base — someone will need to review the diff and ensure that the code looks properly. That's all. We would skip some editions before propagating suggested compiler warnings to macros and before that it will be assumed that #[allow(uniform_method_call)] is inserted on top of them, or we may even make it a part of macro 2.0 so macro_rules! wouldn't be affected.

The change is either fully backward compatible: (self, ..) -> Self and (&mut self, ..) -> &mut Self methods will work as previously, compiler warnings are opt-out.

Not every code base will be affected equally: recently I've reviewed a small ~1000 LOC code base and there was very few places that would be changed. Moreover, I predict that this proposal will mostly affect fields that in Rust aren't established yet e.g. scientific computing and GUI.

Thus, I don't understand the issue.

Doing nothing is always an option

Adding #[interior_mut] might be considered as merely a good practice, so it would be up to programmers whether to follow it or not. So, things like cache.get() wouldn't be instantly considered as a legacy code.

This simply isn't true: the .. cascading operator doesn't add complexity to Dart code, and I as well as many other people feel oppositely. Syntactic junk like mut during data initialization, temporary variables, and method chains composed without care in contrast adds much more complexity.

Can we take some steps back? I think what's frustrating here is that we are just at a different stage of discussion.

You are at the stage of proposing your solution, while I (and I think many others here) don't see the need for it. Because we don't think the problem exists. I don't think any of the "original Rust" snippets you posted are particular unclear or problematic.

12 Likes

You are greatly underestimating the disruption this will cause on a large, frequently modified codebase. The GitHub - rust-lang/rust: Empowering everyone to build reliable and efficient software. repository has done something like this twice - once when enabling rustfmt by default, and once when reorganizing internal compiler crates into a different directory structure. Both times required a fair amount of planning, as well as blocking the merge of all other pull requests while the changes were landing. After the PR was merged, virtually every single open PR needed to be rebased. From personal experience, this was non-trivial when your PR modified existing code that had been reformatted.

On top of this, running git blame today (at least the version integrated into GitHub) will sometimes show one of the auto format/ restructure commits' when investigating the history of a line of code. This requires additional manual effort (navigating to the parent commit and re-blaming) in order to find the 'real' origin of a line of code.

Both of these large-scale changes were worth it for rustc, and improved the overall quality and consistency of the codebase. However, this does not mean that those kinds of large-scale changes should be taken lightly, especially if we're.going to be recommending (if not requiring) them for almost every single Rust project.

Additionally, rustfmt changes have the advantage of being 'just' formatting - to someone who's already familiar with Rust code, the changes are usually easy to skim through. Your proposal would require large-scale changes for a newly-addes feature, which is going to be unfamiliar to everyone at first, and then to many people for quite some time after. This is going to make it much more difficult to read through the (large) diff and ensure that the code is really doing the same thing as before.

12 Likes

That may be true in Dart, but you're proposing the addition of multiple new ways of calling methods into Rust. Method calling in Rust is already complicated due to things like deref coercions, inherent vs trait methods, and mutability. I think it's pretty much guaranteed that your proposal is going to make method calling more complex in general (particularly when reading through an unfamiliar codebase), regardless of whether or not that complexity is balanced by other readability benefits.

3 Likes

An interesting possibility for us to explore might be limiting autoformattings and fixes to diffs e.g. with cargo fix --only-changes=master which first obtains every diff between current let suppose feature branch and master then expands it to nearest expression with something like tree-sitter and applies formatting/fixes exclusively to this region. With that we could commit result of cargo fix on master and commit result of cargo fix --only-changes=master on feature and there would be less differences between branches, so it increases the chance for a successful merge; important is that here --only-changes should operate not directly on commit under master but on merge base between feature and master in order to exclude from this diff any new functionality merged into master after the feature branch was forked from it

Moreover, we can add something like cargo fix --except-changes=feature or even cargo fix --except-changes=glob which does the same job with obtaining every diff between current let suppose master branch and feature and expanding it to nearest expression, but instead excludes this region from being formatted/fixed — result of this command then should be committed on master and in this way it would be possible to rebase feature without conflicts and either further code on it would be based on actual as well as code on every new branch based on master would be based on actual.

And we may go further with flag that makes only changed code a subject for compiler warnings, which means cargo build --only-changes=master would warn only about newly written code so everything currently on master wouldn't become instantly legacy code which should be fixed on occasion.

Anyway, this proposal indeed may require a lot of reformatting of existed PRs but it still affects only method calls reformatting which despite monotonous is easy work. And as you've said changes like that happened in the past and we've survived, so it's not something which might ruin this proposal.

ignoreRevsFile may help with that

So, they learn it and then migrate. I don't believe that change of

  • .foo(bar) to foo (bar)
  • foo.bar() to (foo) bar, foo~bar(), or foo bar
  • removing some levels of indentations and adding whitespaces
  • adding , ) instead of )
  • etc.

is any complicated diff.

It's supposed to simplify method calling in Rust: instead of a single operator which implies everything but still don't allows obvious things like cascading and updating in place e.g. x = x.y() we will have a few operators that implies only 2/3 things and allows to express everything we may want. It's anywhere the same as e.g. the multitude of ways to call functions in Haskell since fixity and order in which operators could be arranged is always the same

Sorry, I still haven't managed to write a proper and full response, but I'd like to note one meta point that is quite important in the current state of Rust. It seems like when you notice some unexpected interaction between your proposal and something else, your first reaction is usually to add some feature fixing that interaction. Unfortunately, this approach does not scale and especially does not scale for a language that's already being actively used. Not only does it blow up the complexity budget for the language, but even just understanding the effect of all these changes taken together becomes really difficult when one looks at the language as a whole and not just the parts you've traveled to arrive to the suggested set of features.

7 Likes

Unfortunately, this doesn't work for the web-based Github version of git blame, which is otherwise very convenient.

I'm not trying to be rude, but that's almost exactly the opposite of the conclusion that I reached later in my post:

For this kind of disruptive large-scale formatting change, the bar is much higher than 'the project will survive it'. Also, I was describing the experience of a single project (rustc) - the experience could easily be much worse for other projects, especially when you consider the number of crates that will need to apply this formatting change.

7 Likes

I feel like this is discussion is stuck. There are two conflicts:

  1. People disagree about how big the benefits and the drawbacks of this proposal are.

  2. There's no exact (reference-level) explanation of the proposed changes, so there's still a lot of confusion about how the feature works or is supposed to work.

@160R I'd like to direct you to the RFC template. Please read it carefully, and fill out the sections. When you consider alternatives, the most important one is doing nothing (which I think most people in this thread lean towards).

8 Likes

There is nothing to excuse about. I really don't like being bombarded with rapid responses that people compose without thinking about what was said earlier and gives me either no time to think what to respond.

If talking about applying fixes/warnings/formatting only to VCS diffs, I really think that it might be useful even outside of scope of this proposal e.g. to make updates like adding autoformatting easier and I certainly wanted something like that in the past. Therefore, I see it as very logical and useful addition which isn't any complex.

Perhaps it's very poorly explained and there's a lot of things for people to guess, so that's from where all complexity arguments originates — sorry, I cannot explain everything at once in a comprehensive manner, there's just no time for it.


Although, you've noticed it right: I've discovered method x y (z) method cascading notation which makes sense the most, then I've added , ) formatting to make it working in basically any method chain, then I've conjoined it with &mut self/self -> Self to make it working inside of Rust ownership/borrowing model, then added (x) y (z) notation to make it working inside of copy/move semantics, then added super to make it working even with control flow constructs and functions, then x~y(z) to make it working with command/query separation, then #[interior_mut] to make it working with interior mutability, and finally suggested --only-changes/--except-changes to make applying it to large code bases easier. This is a lot.

But I will never accept the number of additions itself as a constructive criticism.


At first, it doesn't seem that there's something more to add. Everything works perfectly together and a chance that any unexpected interaction will happen is very low. People may say that proposed changes can't work everywhere in Rust reliably — this isn't true. If someone has a good example of code where it doesn't work or where integration may be extremely difficult or even impossible I'm really interested in it, moreover, it's the best way to convince me that my proposal is a bad idea!

At second, so many features were added with ease because ownership/borrowing, copy/move and command/query provides a good semantic foundation that makes it obvious what's needed; then coincidentally we have exactly enough syntax space to make every proposed change possible or at least I don't feel that proposed notation is in any particular weird, noisy, complicated or hard to remember. Again, to convince me that Rust would be worse with it someone need to find a good example which demonstrates that — so far every argument against was that it's just different and I already agreed and I know the cost.

At third, request and command notations are the biggest additions and after understanding them understanding the rest should be easy. ITT I've approached the problem from a wrong side, dumped everything at once in a very dense format, and used a very obscure language. With a proper explanation and in a proper order I believe all complexity will gone — request/command/query is a stupid simple conception.

At fourth, there still are significantly more people who don't know Rust than people who know Rust and must transition their current knowledge. I have a strong belief that the proposed mental model makes it easier to learn Rust because it makes its internal mechanics more explicit, useful and gives many visual clues that makes it easier to remember how it works. That's why I feel that this proposal is worth the churn and everyone else doesn't.


Lastly, the number of additions is lower than the number of simplifications they give. We remove doubts of when to return Self or (), we remove doubts of when to incapsulate mut or not, we remove doubts of when to depend on tap crate or not, we remove doubts of when to return Self or &mut Self, we remove doubts of when to use macros for DSLs or or builders, we remove doubts on when to chain methods or not, we remove doubts on when mut is a useful annotation or not, we remove doubts on when &mut self is that complicated to be included in beginning of Book or not, we remove doubts on when to indent method chain or not, and it's possible to search further and further like that — this big number of questions without a sufficient answer that increases the chance on analysis paralysis is perhaps the big problem which I'm trying to solve.

Through, it may not be the problem for everyone in this thread — it remains the problem for me. Most likely I have ADHD disorder and struggle with problems that other people don't see e.g. it's easier to get distracted with unnecessary mut and it's harder to accept why it should be like that and not differently, then it's easier to start analyzing the situation and forget about something critical. So, for me learning some language features and spending some time on code base migration is absolutely worth it as well as it should be worth it for everyone with the same struggles. That's just a mystery why it's not worth it for everyone else because of the same reason, as literally everyone may get distracted and waste the same amount of time on resolving unsolvable problems...

Maybe I just miss access to some variable which allows to reach the same conclusion. TBH, such outcome gives quite depressing feelings, so I'll try to complete "a better explanation" path first

You've not provided any evidence to support this claim. And even if your syntax were to be used, I still expect a book introducing the language to start the way The Book starts today, since introducing all of the concepts at once is a surefire way to overload people.

I kind of hate to say it, but if you want to convince people that your Rust++ is easier to teach than current Rust, the best way to do so might be to bullet point a new edition of The Book that teaches Rust++ the way you think will make it easier to teach.

There's honestly no such removal of doubt possible. Formatting is (and should remain) a property of the source code that has no impact on semantics. As such, people will disagree with you on how source code should be formatted.

Just as an example, , ) on a line, and generally how you've formatted code in general, doesn't really line up with the general guidelines of the rustfmt style, and I find it personally uncomfortable, compared to rustfmt bracket/indent style.

Specifically, things like

SquaresGrid::new()
    child (
    Label::new(...)
        .also(...)
    , )
    ...

don't indicate nesting with indentation, and in rustfmt style would be written

SquaresGrid::new()
    child (
        Label::new(...)
            .also(...),
    )
    ...

Also, wait, , ) is semantic? That feels really bad. It's one thing to have (item,) for one-tuples (which, I may add, are basically never used) and optional trailing commas for lists, and another for ,) to mean something different from ) generally.[1]

Just generally, though you claim that the style of code is self obvious (to you), I find the nesting structure hard to follow and especially to scan.


Generally, what seems to be the main difference (actually studying your example for a bit) is that what you're envisioning is a fairly decent declarative format.

But Rust isn't a declarative language, and attempts to make it so aren't going to be well received. Rust is strongly multi paradigm, and while it supports and uses functional and object modeling concepts, it's procedural at its core.

I think the strong principle disagreement is that you're trying to turn what people conceptualize as an imperative/procedural language and turn it into a declararive language implemented via imperative state change. This is a giant paradigm shift. It describes a fundamentally different language. It could have a shared backbone to Rust, but it's fundamentally a different thing.


To be blunt: this is too large of a change to be Rust. There is no way that Rust 20XX is going to be the imperative language we have today, and Rust 20XY is your declarative syntax.

What you have is a new language sharing the core concepts of Rust. Like Kotlin is a new language that (originally) is a new coat of paint over Java with (mostly) seamless interoperability, you have in your mind a design for a language which is not Rust, but shares Rust's model of how programs behave.

I understand you don't have the time or knowledge required to implement your language the way you see it in your head. But you're also asking that of other people, so you owe it to them to understand when your vision doesn't match the existing vision of what the project's identity is.


  1. A function call isn't $path $tuple-expr, it's $path ( $($expr),* $(,)? ). ↩ī¸Ž

14 Likes

We won't teach people all implications given by &mut self at once — uniqueness certainly should be explained later in The Book. But we will teach mutability aspect of methods, since mutability of variables is already introduced in 3.1: Variables. That said:

impl Rectangle {
    fn scale(&mut self, ratio: f64) {
        self.width *= ratio;
        self.height *= ratio;
    }

    fn resize(&mut self, units: i64) {
        self.width += units;
        self.height += units;
    }
}

fn main() {
    let mut rect = Rectangle {
        width: 30,
        height: 50,
    };
    rect resize (100) // Methods mutate variables with this notation
         scale (0.5); // It's by default chainable
    ...
}

Here we taught mutability in methods as well as method chaining, and what's most important how they work together so it's more or less obvious how builders works. Nobody will be confused with &mut Self anymore.

I don't think that the change is so big that it deserves to be called Rust++ or deserves a new edition of The Book. We add the above example, briefly mention ~ notation, and perhaps (x) y shortcut in a section where copy/move semantics is introduced. And maybe there should be added eDSL section somewhere at the end.

The interesting thing about the proposed syntax is that here formatting has no impact on semantics but it matches the semantics. If someone don't agree on that — it's bad for them and for everyone else who will read their code

But SquaresGrid is placed on a first level and Label is placed on a second... ?

Syntactic nesting isn't useful: it takes too much vertical space and makes cascaded methods very similar to functions which is really wrong kind of similarity. Then ) alone makes it ambiguous whether the previous expression returns T or self. Also, such formatting isn't good on x also (...super expressions.

I want to make it more declarative not strictly declarative

Does a few syntax sugar constructs plus formatting makes a new language?


BTW, you provided a very interesting analogy: Kotlin — almost everyone who knows Java can adopt it in a few days despite it changes almost everything except the core OOP model.

So, what's exactly the big deal with Rust being a slightly different language? That said, I understand that the bar of changes here is much higher, but I don't understand why exactly this change doesn't pass the bar

I really don't see where my vision doesn't match the vision of current project's identity:

IMHO, The main conflict might be that, sometimes we need a fn(self)->Self but only fn(self)->OtherType or fn(&mut self)->OtherType is implemented.

if we could modify the existing method with signature fn(&mut self)->OtherType to fn(self)->Self, a lot of work could be done very easy.

Suppose that . is an op of functions that change its signature from fn(self,...)->OtherType to fn(self,...)->Self (and perhaps, fn(self,...)->Result<OtherType,Errors> to fn(self,...)->Result<Self,Errors>), The Cascadable method call problem may be solved.

e.g.

let v=Vec::new()
        .push.(1)// notice the dot in `push.`, it change the signature of `Vec::push` from `fn(&mut self)->()` to `fn(self)->Self`
        .push.(2)
        .push.(3);

since dot after a function is invaild in the current version, no conflict may occurs. and since we have already using ! as a postfix of a macro, it is not difficult to understand the dot operator as understand the ! postfix.

what's more, if we define the function carefully, we could customize Cascadable trait for some special functions which works like:

fn return_result(&mut self)->Result<(),Error>{...}
fn return_result.(self)->Result<Self,Error>{
    // self.return_result().and(self)// don't know whether it could pass the borrow checker.
    let result=self.return_result()
    result.and(self)
}

The impl could be rewritten as:

default impl Cascadable for (all the functions){
// don't know how to write the remain parts...
// here we should define that the output of the modified function may not always `Self`
}
impl<F:MustUseCascadable> Cascadable for F{
//just call MustUseCascadable trait, 
}
default impl MustUseCascadable for(F with output Result<T,E>){
//don't know how to write the remain parts...
}

Is it possible?

Yeah, but also Label is on the same level as child, which makes no sense.

That's not possible, since one can't move out of a reference safely. If there is a panic, there's something you have to put back to the referenced place. So simply allowing this isn't enough, there must be something that the caller specifies to fill the hole with, at which point the whole syntactic "simplification" loses its point.

1 Like

Your argument is for why the opposite if impossible though. You can easily rewrite a fn(&mut self) function into an fn(self) -> Self function by doing this:

fn f(mut self) -> Self {
    self.original_f();
    self
}
1 Like

That's more than possible.

when we want a Cascadable call, we are either request an exclusive version of a variable, or we already own the variable.

when we are talking about things like

let a=Vec::new()
    .push.(1)
    .push.(2)
    .push.(etc);

things we need is Self, not &mut Self

we are not move out the signature

we are just make a new function, which takes Self as its first variable and return Self

fn Vec::push.(mut self:Vec<T>,val:T)->Vec<T>{
    self.push(T);
    self
}

If &mut Self is desire, we could deal with special changes (manually write impl Cascadable to override default impl Cascadable) like what we may do with Result<_,E>

If we really want both fn(self)->Self and fn(&mut self)->&mut Self, maybe another operator(like ..) is preferrable.

If only ONE new ugly symbol is allowed, we could re-use the current fish symbol:

let v=&mut vec;
v.push::<_/*generics for Vec::push, if exists.*/>.::<&mut _,_>(data).push(another_data)

Methods are always more than problems.


Since .( is not a legal pattern in current Rust version, we could define (up to infinitely many) dots before brackets are special unary operators of functions.

I enjoyed this parody on me until people started commenting it, since that bumps this thread to the top and sends email notifications that are nothing but annoying.

And what's exactly the problem? We can perceive it as annotation on top of Label and it wouldn't be wrong mental model, since this "anotation" describes the relation between Label and its parent. I also expect that cascaded methods would be highlighted differently than regular methods and functions, so this formatting would be less confusing.

You can rewrite it (the function implementation), but unless you are willing to have the call operator rewrite the function definition (which would be undesirable), it's not possible to call an fn(T) -> T given only a &mut T. If all you have is a vec: &mut Vec<_>, then there's no way to safely call push(Vec<_>) -> Vec<_>, which is exactly what is being proposed.

1 Like

But the syntax doesn't make that clear. Looking at the start of the line there are no differences.

Highlighting can help improve somthing, but you shouldn't rely on it to solve problems.

I don't think that's what was proposed, but rather the opposite. If you call .push.(elm) then the compiler will rewrite the fn(&mut self) into a fn(self) -> Self, which is the opposite of what you said and is possible.

Thank you for your enjoy.

I have to emphasize that, my propose is not a parody, it contains a solution of trait Cascadable.

suppose that you have

trait AnimalBehavior{
    fn eat(&mut self:T,food:Food)->Result<(),Error>;
}
trait DogBehavior:AnimalBehavior{
    fn eat(&mut self,food:Food)->Result<(),Error>;
}
struct Dog{...}
impl DogBehavior for Dog{...}
impl AnimalBehavior for Dog{...}
fn main()->Result<(),Error>{
    let dog=Dog::new() eat (Food::new());
}

which eat is choosen now?

what we should written to call different eat function?

dog as AnimalBehavior eat (food)?

My view is, define extra functions rather than grammars.

Then, the problem may be solved by existing methods:

AnimalBehavior::eat.(dog,food)

This could be done easily since dot is an unary operator of function/methods.

Besides, structs.call (functions, with_space_before_brackets) is legal from the oldest version to the latest version of Rust, your propose is just delete a dot, not adding a space.


consider the following statement:

impl Config{
    fn add_field<T>(&mut self,x:T) /*-> &mut Self*/{
        // do something with x...
        // place a `self` here could be ugly and efficientless.
    }
}
let config=Config::new();
// we couldn't wrote `config.add_field(x).add_field(y).add_field(z).build()` here
// since we do not add a useless self at the end of the method.
config.add_field(x);
config.add_field(y);
config.add_field(z);
let real_thing=config.build();

before Cascadable method call, people cannot both writting simple methods without return Self and using cascadable calls at the same time.

with the small modification, we could do both.

If you really think the signature is a problem, introduce more unary operators could solve it:

//for function(var:S,...)->U where U is either a single type or a Result<R,Error> and S=T,&T or &mut T
function.(mut var:T,...) -> return var as T
function&(var:&(mut) T,...) -> return &(mut) var as &(mut) T// compiler should pick &mut if possible, no matter what signature the function is used., since we could not upgrade &T to &mut T, and further calls may use `&mut T`
function?(var:T,...) -> return Result<var as T,Error>

what's more, we could easily infer that,

function&?(var:&(mut) T,...) -> return Result<&(mut) var as &(mut) T,Error>

since function& is a well-defined function.


what's more, if cascadable method call is acceptable with generating many functions with fn(self,...)->Self, we MAY add a new keyword, keep/pass_through/transparent/..., to our program:

may not, since the keyword is hard to understand.

fn method(keep mut self,args:Args)->Self{...}

the keep keyword have the following meanings:

  1. for fn f(keep x:T), f(x) never consume x
  2. for fn f(keep mut x:T), f(x) do not consume x if x is mutable, otherwise it consume x.
  3. . generate function with signature f.(keep mut x:T,...) when f is fn f(&mut x:&mut T,...), otherwise generate f.(keep x:T,...)

Note: for 1., f should not drop x. for 2., it is caller, not the function, who decided to drop x. for 3., it keeps most of the features of the original function. a cascadable method call is illegal only if the original chain is illegal.


cascadable with f(_)->Self generates a warning:

a.clone() // generate a clone of `a`
a.clone.() // generate a clone of `a`, discard it, the result now is `a`, not `a.clone()`, also generate an warning, since the second dot is not needed.
a.r#clone.() // tell Rust, I'm pretty sure I should using `.clone.` rather than `.clone`. Same to a.clone.(), but no warning is generated.