[Pre-RFC] Extended dot operator (as possible syntax for `await` chaining)

It don't matter is bar() global function or not. What really matter here is that it takes result of foo() when is placed inside of square braces. There's no other variants.

All proposals with explicit receiver reminds me of anchored paths feature that was proposed instead of uniform paths for Rust 2018. I really don't see why obvious things should be so explicit. And I don't see any long term benefit from that

Do we need any constrains on this at all?

Closures could be useful for instant destructuring of taken value. But I don't think that they would be easier to work with because they reduces scope of ?, break, continue

I don't consider the rules you set out obvious.

If there is both a function bar() and a method Foo::bar(self), what does this desugar into? Does it use the function, or the method? I consider both interpretations of the following example statement equally plausible, and I'm not keen on cracking open the language specification to figure out which it is.

let confusing = foo.[await bar().something_else(it)].baz();

And does the meaning change if there's only the function, or only the method? I'm pretty sure that's bound to cause confusion.

4 Likes

Desugaring would be the following:

let confusing = { let mut _it = foo; { await _it.bar().something_else(_it) }}.baz();

Always the first method call in .[] scope is associated with receiver. This rule never changes

So how would I specify that I want to use the function, rather than the method? What happens if only the function is known, and thereā€™s no Foo::bar method?

Just provide an explicit receiver point, please, and insist the method calls are attached to it. Itā€™s less confusing that way.

1 Like

It depends if function you want to specify takes foo() result or not. If it takes - you could use bar(it) syntax. Otherwise it wouldnā€™t be allowed, since thatā€™s a code smell and in this case itā€™s better to proceed with temporary binding instead.

If you want to call associated method when it donā€™t exist - error would be produced. No magic here.

So hereā€™s another one, that might be closer to what someone might type:

let thing = foo(bar).[await this_should_not_be_a_method(quux, it)].eggs();

How would you explain the compiler error that this_should_not_be_a_method is not a method of the return type of foo(bar) to someone that previously tried

let thing = foo(bar).[await it.this_is_a_method(quux)].eggs();

and succeeded?

1 Like

This shouldnā€™t be an error, since this_should_not_be_a_method takes it as parameter. Probably the simplest rule here would be: only associated items and expressions that uses it in top level scope are valid in extended dot context.

This discards my response to @Centril about foo.[bar] resolving bar to external binding

And the effort trying to make sure that it is used seems to be overzealous, when you can do stuff like this:

trait Footgun {
   fn blast(&mut self);
}

impl Footgun for i64 {
    fn blast(&mut self) {
        *self = 0;
    }
}

fn main() {
    let mut intermediate = 2_i64;
    intermediate.blast();
    println!("{}", intermediate + 2);
}
1 Like

In this case itā€™s harder to write such code instead of just starting with zero. In case with scope for extended dot syntax that would allow you to use it as you want it would be easier to write bad code instead.

Shouldn't that be the job of compiler warnings? To throw up an notice if it appears that it is not being used, or that part of the expression can and should be moved to a separate binding?

Rust has safeguards, and I value it for leaning towards safety and sanity, but I'm pretty sure it doesn't bend backwards and have ad hoc semantic cases just to make sure that programmers don't do bad things with the power it gives.

EDIT: I think I'll stop arguing here until other people can bring in new points, as at this point, this looks like a conflict in programming language design philosophy, and I'm not interested in kicking up such an issue further.

1 Like

Anyway, amount of work either in case with error and in case with warning seems to be the same. But if we can prohibit bad code and provide less verbose syntax, why should we go with warnings and provide more verbose syntax instead?

1 Like

Perhaps it is just me, but I do not like the it name at all. It results me unclear and is also a common variable name for iterators. If there must be a keyword for this it could be something as conclusion. But I am also unclear if there must be a keyword. These construction seem rather similar to the let name=expr1 in expr2_depending_on_name construction of some languages.

A first possible example would be

let it=consume(&HashMap::new() in {
    it.insert("key1", val1);
    it.insert("key2", val2);
};

Although it is awkward to chain.

Another option would be

consume(&HashMap::new() as it in {
    it.insert("key1", val1);
    it.insert("key2", val2);
};

Although as already means type change instead of name change.

That part always bothered me as well.

There is already the @ sigil for bindings. I like this versus the closure-like syntax suggested by @eaglgenes101 since it doesn't suggest a closure when there isn't one.

For example:

consume(&HashMap::new().[ it @
   it.insert("key1", val1);
   it.insert("key2", val2);
];

Or more like your example.

consume(&HashMap::new() in it @ {
    it.insert("key1", val1);
    it.insert("key2", val2);
};

The problem I see is the current usage for '@' has the value being bound after the sigil, but I couldn't think if a syntax I like to work that way in this case.

1 Like

On first glance using @ makes a lot of sense here. But if there would be any syntax with @ Iā€™d like it to be the following instead:

consume(&HashMap::new().@m {
   m.insert("key1", val1);
   m.insert("key2", val2);
   m
};

However, in this case we should make m mutable as well because it would be surprising to have @ behaving differently in this context than in patterns:

consume(&HashMap::new().@mut m {
   m.insert("key1", val1);
   m.insert("key2", val2);
   m
};

Well, and this should be scaled on simple use cases:

let result = client.get("url") 
    .@mut q { await q.send()? }
    .@mut r { await r.json()? };

Just compare it with original example:

let result = client.get("url")
    .[await send()]?
    .[await json()]?;

And with syntax you propose:

let result = client.get("url")
    .{ mut q@ await q.send() }?
    .{ mut r@ await r.json() }?;

So, this as well as any other explicit receiver syntax donā€™t really makes things simpler IMO. We anyway would need to explain users how .{} works, how @ works, how itā€™s different from pattern context, how to use this syntax to not grow code horizontally, etc.

And Rust already has reputation of extremely verbose language. Do we need to make it yet worse?

I do not think that being verbose is being worse. However, verbose or not, the language should be clear. Which I am mostly opposing is the use of the it keyword. Your examples without that keyword, such as

are fine in my opinion.

I find the problem with things like this:

let x = long().method().[dbg!(it)].chain();

Here it is very unclear what it is. For comparison, even the self is made explicitly clear what means by being at the beginning of methods. Hence I think the above code should be rewritten with some binding, either with as, @ or something else.

let x = long().method().as x.{dbg!(x)}.chain();

We already have precedent of as being used to rename things:

use std::io::Result as IoResult;

And also seems natural. I have some doubts about using braces or brackets and about allowing to mix the two notations. I would say that brackets for the implicit notation and braces for the explicit and hence not to mix the two options.

It would be a keyword, and I think it would be a bit cleaner with syntax highlighting:

let x = long().method().[dbg!(in)].chain();

I can't agree with analogy with self. It's taken as method parameter not for beginner-clarity. Instead, that has a more important purpose: to indicate that self is moved, mutable, or borrowed. That said, I don't see any purpose to explicitly introduce it, which anyway always would be available in [] and always would have the same properties.

Also, I can't agree with your argument that it is a common name for iterators made in your previous comment. I've done search over alacritty and ripgrep repositories and found that all occurrences of it are in comment context, while all iterators are always named iter. Never before I've not seen any iterator named it. I'm not sure if naming iterators it is a good practice at all.


Overall, that's most likely just a matter of habit. Personally, I've used it in Kotlin and it was always fine when applied properly, therefore it looks natural for me. And probably you never seen it in code before, so it looks for you as something alien.

The question is: how hard it would be for you to become comfortable with it?

If anyone interested: here is the link to thread about Kotlinā€™s it

Summary could be:

  • people love it for simple use cases
  • people hate it when itā€™s used in nested lambdas

Meanwhile: would it be a good idea to adopt it for closures to simplify things like |x|x.to_string()?

I get from that thread that they have not removed it because they want backwards compatibility. Some people may love it but to me seems a mistake. It does not simplify anything, you just write a few less characters, i.e., the binding. And to people which has not read about this usage, it certainly appears alien.

I understand its meaning and I can glimpse on why some people could like it. But I would never be comfortable with it.

For the same as above I think it is a very bad idea. |x|x.to_string() is perfectly fine.

I do not know how much my opinion counts, but my vote is to keep explicit variable bindings.

Naming iterator as it is a thing at least in C++, as you can see in almost any example online. Surely, there are C++ programmers converted to Rust that have carried along this convention. I am not claiming that it is a good practice.

2 Likes

As a side note (I just feel I have to mention this because it doesn't seem to be that well-known): closures of this type can already be written by just designating the inherent or trait method (scoped by the type or trait name, respectively); and I'd argue this is better style because it avoids the creation of an unnecessary closure at all. For example, I always prefer to write

let strs: Vec<_> = [1, 2, 3].iter()
    .map(ToString::to_string)
    .collect();

over

let strs: Vec<_> = [1, 2, 3].iter()
    .map(|num| num.to_string())
    .collect();
3 Likes