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

Extended dot operator provides a more flexible way of invoking associated methods, functions, macros, and even control flow constructs, which in some cases allows to avoid extra bindings, parentesis, identifiers, and unnecessary mut state.

Originally it was evolved in resolve await syntax discussion as proposal for the following syntax:

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

Last RFC version is published on HackMD. It’s open for modifications for registered users only, but Github authorization is very easy through, and I hope that in this way it would be visible who contributes. That’s also not a problem to allow modifications for everyone if it would be required.

Current highlights:

  • I feel that some things could be explained better
  • There’s a lot of typos and wrong idioms
  • Probably syntax and naming could be changed to better

I get 403 Forbidden, is there any way to view it without registering?

Yes, I’ve changed read permission to “Guest”. Can you try again?

Works now :+1:

1 Like

After looking at the “feature matrix”, I’m not sure about:

  1. what does this line mean?

    Explicitly `x.[y(it)]` `y()` method don’t belongs to `x` `y(val)`
    

What is it? What is val? Where do they come from?

  1. Why would I want to use anything from section 4, specifically x.[+y()] instead of +x.y(), x.[it+y()] instead of x+y(), or x.[it as i32] in place of x as i32? All three uses seem to be only more convoluted and heavier on syntax (specifically, sigils). The same is true of section 2’s “Inner” style: why would it be better to write x.[y().z()] if it means the same as x.y().z()?

Overall I feel that this additional syntax doesn’t help understanding at all: it’s complicated, not really evocative of its semantics, and in general just way too magical for little return.

2 Likes
  1. y(val) is changed to y(x), previous snippet was invalid
  2. x.[+y()] is changed to x.[await y()], it should better describe intention

I think that real-world examples would answer “why a over b?” question. Currently some are provided in “motivation” section, but right, that’s not sufficient.

Also, it should be explicitly said that some features really makes sense only when combined with another. I’ll fix that

I don’t see any expansion in the reference that supports foo.[await bar] expanding to await (foo.bar).

1 Like

The current examples look inconsistent to me. How about unified _ as the input value placeholder in the inline block?

let result = api.method().{ await _.returns_future() };
let cond = long.method().chain().{ !_.is_empty() };

let sorted_vec = iter
    .collect::<Vec<_>>()
    .{ _.sort(); _ };

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

let x = long().method().{ dbg!(_) }.chain();
5 Likes

@160R I am broadly sympathetic to this proposal (although I think it need some serious justification over the more obvious x.macro!() postfix macro syntax), but I’m strongly against the “implicit” variants that don’t use the “it” contextual keyword.

For example:

foo().bar().[await it].baz() evaluating to (await foo().bar()).baz()

Seems pretty nice. And it is obvious what is going on. Whereas I believe that:

foo().bar().[await].baz()

Is too implicit/involves too much magic, and should be a syntax error.

@tanriol’s suggestion to use _ instead of “it” could also work, although I’d be a little wary of it clashing with other uses of _

such as (value) inference (in a possible future).

4 Likes

Do we actually have value inference outside the miraculous realm of const generics?

On closer examination, I would clarify that I only support the syntax exhibited in use cases 2 and 6. It seems to me that the other use cases can fairly easily be implemented with the more explicit syntax in 2 and 6, and that there is no need for the implicit syntax in 1, 3, 4 and 5.

Under my revision of your proposal, it would work as follows:

1. Deferred prefix operator

let result = api.method().[await it.returns_future()];
let cond = long.method().chain().[!it.is_empty()];
let val = something.[*it.returns_ref()];

It not only allows to move await into a method call chain, but allows that for the rest of prefix operators as well.

2. Pipeline operator

let deserialized: DataType =
    Path::new("path/to/file.json")
        .[File::open(&it)].expect("file not found")
        .[serde_json::from_reader(it)].expect("error while reading json");

It looks completely different than regular |> operator, however it plays well with Rust move/borrow semantics, don’t clashes with scoping and precedence, and is way more flexible.

3. Side effect function

let sorted_vec = iter
    .collect::<Vec<_>>()
    .[{sort(it); it}];

It allows to ignore result of method and instead return initial value on which that method was called. This could be useful when interacting with APIs that don’t supports method call chaining for some reasons.

4. Wither scope

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

It allows to not introduce temporary mut bindings and to not provide macros/builders for values that would be used very rarely

5. Method chain split

let sf = surface().[{
    it.draw_circle(ci_dimens).draw_rectangle(rect_dimens).finish()?;
    it.draw_something_custom().finish()?;
    it
}];

It could be used for error handling in separate detached chain, and some DSLs could adopt this syntax instead of macros to integrate better with IDE autocompletion.

6. Postfix macros

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

I think this is much simpler than your original proposal, as there is only one new construct to learn, while still bringing most of the benefits.

5 Likes

Prior art for this: https://kotlinlang.org/api/latest/jvm/stdlib/kotlin/let.html, which I’m willing to believe is the author’s inspiration. While something like this can be implemented in Rust, it can’t be used for control flow (control flow can’t cross function boundaries). This could be done with a combination of postfix macros and “unhygenization”, but I think that’s just inviting people to write even more unreadable single-expression functions.

(Frankly, I’m not a huge fan of trying to contort await into being chainable, but that’s neither here nor there.)

3 Likes

@Centril your point about foo.[await bar] case was significant. After thinking about it, I decided to slightly change semantics to desugar this snippet to { let _it = foo; { await bar } }.

Rationale: if we would have foo.[await bar] resolving to await (foo.bar) then it will shadow local variables and we wouldn’t be able to access them on the same place; moreover, foo.[await it.bar] can produce exactly the same result. New syntax either provides access to local variables and don’t has this ambiguity. Even I think that it’s easier to understand, since only methods now requires a special support for it.

And there’s use case for that syntax: it allows to change subject of method call chain.

Edit: however this makes the purpose of extended dot syntax less specific. The following rule would be simpler: only associated items and expressions that uses it in top level scope are valid in extended dot context .

@tanriol, @nicoburns,

I understand rationale behind alternative syntax you proposed, it’s really simpler. But for me verbosity of this syntax probably in most of cases would be the reason to use temporary bindings instead. Either with _ or with it or with any other placeholder it would be cumbersome just for very little benefit in learning experience.

Another thing I worry about is that (if I understand correctly) your proposals enhances method chains with full featured scope. I see the following drawbacks with that approach:

  1. Syntax would be more easier to abuse, since nothing will restrict growing code inside of extended dot scope. I’ve ran multiple times into similar problems when programming in Kotlin and it probably was the worst my experience with Kotlin.
  2. It would be harder to explain what is it and why it’s not available e.g. inside of closures or another scopes. It also would be harder to understand from where it comes from.
  3. It would be harder to distinguish this syntax from regular closure or any other scope and this would make harder to understand intention of code.
  4. Because of hygiene it probably wouldn’t be possible to introduce fluent with!() macro, it would require to take it explicitly.

And when talking in terms of examples:

/// 1
#[inline]
async fn request_user(self, user_id: String) -> Result<User> {
    let url = format!("users/{}/profile", user_id);
    let user = self.{ await _.request(url, Method::GET, None, true) }?
        .map(|x| {
            match x {
                Ok(y) => y.do_some_important_stuff(),
                _ => fallback(),
            }
        })
        .res.{ await _.json::<UserResponse>() }?
        .user
        .into();

    Ok(user)
}

/// 2
#[inline]
async fn request_user(self, user_id: String) -> Result<User> {
    let url = format!("users/{}/profile", user_id);
    let user = self.{ await it.request(url, Method::GET, None, true) }?
        .map(|x| {
            match x {
                Ok(y) => y.do_some_important_stuff(),
                _ => fallback(),
            }
        })
        .res.{ await it.json::<UserResponse>() }?
        .user
        .into();

    Ok(user)
}

/// 3
#[inline]
async fn request_user(self, user_id: String) -> Result<User> {
    let url = format!("users/{}/profile", user_id);
    let user = self.[await it.request(url, Method::GET, None, true)]?
        .map(|x| {
            match x {
                Ok(y) => y.do_some_important_stuff(),
                _ => fallback(),
            }
        })
        .res.[await it.json::<UserResponse>()]?
        .user
        .into();

    Ok(user)
}

/// 4
#[inline]
async fn request_user(self, user_id: String) -> Result<User> {
    let url = format!("users/{}/profile", user_id);
    let user = self.[await request(url, Method::GET, None, true)]?
        .map(|x| {
            match x {
                Ok(y) => y.do_some_important_stuff(),
                _ => fallback(),
            }
        })
        .res.[await json::<UserResponse>()]?
        .user
        .into();

    Ok(user)
}

1. Is the hardest to eye-parse because all things on it looks the same.

2. Brings some kind of context with it, although it’s a bit hard to say from where it comes from.

3. Makes more sense because it uses special scope that’s easy to identify and looks like some kind of metaprogramming (which we actually do), but anyway we can’t read it fluently because of it that creates context that requires interruption.

4. is the simplest here because it reads as in plain declarative English, however you should understand how it works first (which IMO even could be done intuitively)

@nicoburns it will be impossible to write

foo().bar().[await].baz()

because it will desugar to

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

which is invalid. However, the following code will be possible

foo().bar().[await it].baz()  

but why not write it like the next:

 foo().[await bar()].baz()

?

@mcy right, Kotlin was my source of inspiration, however not the only. I wanted to make it like in Kotlin, but without closures, and to substitute postfix macros not to utilize them. Updated desugaring section should proide a better picture how it works under the hood.

Meanwhile, taking into account many advices from current thread I’ve updated RFC. Thank you all for supporting!

Personally, I think that it’d be cleaner and simpler to resolve if there is always an explicitly referred object (it in the proposal). I.e., in the following it’s not immediately clear if bar() is a global function or a method:

let a = foo().[await bar()].baz();

Secondly, I think we could re-use self rather than a new it to refer to the local object. It seems unlikely that in this position we would want to refer the outer self. Lastly, {} seems more Rusty to me than []. Put together, I’d suggest:

let a = foo().{await self.bar()}?.baz();

Although there’d still be the question what constructs are allowed to cross this boundary (whether delimited by [] or {}), and which don’t. async seems to need to cross it, but should break? continue? Should the ? operator?

1 Like

Personally, this looks an awful lot like a pipeline operator. Rust sort of has one in the period, but with the restriction that the function must be a method which is a part of an declared impl that applies to the type of the value in question. (Compare with D, which allows you to chain any function taking one or more arguments onto any value as long as everything typechecks out.)

So here’s a thought: transient method closures, where closures that are labeled with impl (or some other keyword, impl seemed to be a natural fit for the idea) are treated like anonymous methods for the purposes of method chaining. I wouldn’t advise allowing unlabelled closures to be used like this, as that would be just plain confusing why you could chain up closures but not random functions.

let result = client
    .get("url")
    .(impl |it| await it.send())()?
    .(impl |it| await it.json())()?;
1 Like