Idea: universal pipelining (a.k.a. making @await generic)

The . operator use here would be too inconsistent with the rest of the language, since postfix macros would neither be “methods” nor “fields”

2 Likes

Lets assume I take your word on that. With out a "how" to any of these points, it still just means "I don't like this symbol" and that is it. I could just as easily say "This symbol is destructive" and one could ask what that means, and I could reply:

It means the symbol destroys code readability, demolishes maintainability, and detonates a bomb on quality control as a whole

Right, okay, nice story, now how does it do any of these things? That comment didn't move the discussion anywhere, someone still needs to explain why things are "destructive", and now you've just wasted minutes of peoples time reading a comment that didn't need to be made in the first place.

And what is more, even if we assumed it meant something beyond "i don't like it" there's no way everyone would read a whole short story into the phrase "it adds a lot of noise". I've seen people use it the same way, but they mean "it emphasizes something that isn't important" ie "it makes metaphorical noise/brings attention away", which is the way I used the word noise to refer to the comment, and is consistent with, well, the dictionary:

irregular fluctuations that accompany a transmitted electrical signal but are not part of it and tend to obscure it.

  • random fluctuations that obscure or do not contain meaningful data or other information.

and

talk about or make known publicly.

"you've discovered something that should not be noised about"

  • LITERARY

make much noise.

Regardless it would probably serve conversation better to use more concrete language with less subtext, and explain why we believe something, than simply state whether we like it or not.

4 Likes

I think the same argument can be flipped into support for .: today there are two possible namespaces after a dot, methods and fields. This would extend the feature to also support the other two namespaces, macros and keywords.

If it can already do two different things -- see how you need to write (foo.f)() to call a Fn field because of the ambiguity -- then extending it in a way that wouldn't be ambiguous (because keywords or the !) doesn't seem like it's necessary a problem.

4 Likes

The difference here is that methods and fields are “tied” to a type, while keywords and macros are “independent”. Thus extending . in such fashion will significantly alter its semantics, this is why I believe it’s better to introduce an explicit pipelining which will be visually different from dot operator (as a bonus we also will get pipelinable functions for “free”).

I feel the same way about this that @Cazadorro does about "adding a lot of noise". Both here and in the other threads about async, people have been objecting over and over again to future.await because it's "not really a field access", to future.await() because it's "not really a method call", and even to future.await!() because it's "not really a macro." But from where I'm sitting, each of those only moves the question over a space. I, with with most of my programming experience in C (not C++) and Python, have no bellyfeel at all for why it would be bad for a syntactic construct that looks like a method call (or a field access, or a macro) to be something else instead.

This is particularly true for future.await(), where you can argue that the operation you're performing is a method call that happens to have unusual implementation details. But the argument generalizes to universal pipelining. Why does match not being a field of Option mean that it is bad to be able to write this?

fn blah(d: Option<Thing>) -> Option<OtherThing> {
    d.match {
        None => None, 
        Some(thing) => Some(OtherThing(do_stuff_with(thing)))
    }
}

(Yes, I know there are several better ways to do that, it's meant to be a trivial example.)

(Personally, for universal pipelining, I would want a novel arrow-like operator instead of ., but my reason is because the precedence of . is too high; based on experience with dplyr over in statistics land, I think pipelining should have a precedence only slightly higher than assignment. And I prefer |> or ~> to @ because arrows have more mnemonic value; it'll be easier to remember what they do. That's the kind of argument I would like to see people making.)

7 Likes

Please be try to be more specific about why this particular significant alteration to the semantics of . is a bad significant alteration. I don't understand why the magnitude of the change would in itself make it bad.

2 Likes

It actually isn't a method call, await()! can't be implemented as a method, and can be implemented as a macro. In the other thread centril linked to another discussion on git hub and claimed that you couldn't implement it as a macro. However, on further inspection, others realized the issue was you couldn't give good compiler error messages by implementing it as a macro. It wasn't the functionality you couldn't implement in other words.

Why does this matter? Well because macros and functions aren't the same thing and don't do the same thing. A function runs code. A macro replaces code. Await doesn't just "do an await function" it replaces code in a special way, we would be defying expectations for no good reason. It also matters because, in the argument of the language team, it hurts the credibility of the language to have too much "weirdness/inconsistencies". If you were to make something that is effectively a macro (it replaces code) look like a function, now everyone has this mental burden of "everything with foo() syntax is a function, except when it isn't". And while that may be not the worst thing in the world for just one keyword, they may plan to do the same with yield, and then many others, and this will be the precedent that sets what all the other modifications to the language can do. It will create a long lasting inconsistency. If you read the article withoutboats posted in the other thread you can see that they absolutely use past syntax for current justification of new syntax. There is no doubt that any change will have an effect on future syntax.

This circles back around to why we have the universal pipeline syntax discussion in the first place. If we are going to have to change syntax somehow why not figure out what the potential scope of that change is and limit it? If we make this a new feature, we can, in effect limit what we can do with this change in the future. If the feature is universal pipeline syntax with a sigil, it doesn't make a lot of sense to shoe horn that into const generics, or some advance type feature, or another keyword. We can stop it from being used with future orthogonal syntax used with similar parsing.

I would either universal function call syntax be implemented, so that both macros and functions could be used in chaining even if not members, or universal pipelining was implemented, so that everything could be chained, and we wouldn't even need UFCS. I don't care on the particular symbols we use universal pipelining, as long as it isn't just the dot operator, ie it is somehow special (|>, ~>, @, #, etc..). The dot has parsing issues potentially stop certain members from being defined, and creates cognitive overhead (the scope of the . operator becomes enormous, was this a keyword or a field or statement?..). UPS would also mean that I would stop caring if we keep await using macro or keyword syntax.

2 Likes

I am totally in favour of this proposal. Ever since postfix syntax for await was first suggested I thought about a generalisation in the form of some postfix keyword syntax, i.e. future@await, failible@try, etc. The ? operator was in retrospect an incredibly good idea and building on that with a more general mechanism that is open for future extensions seemed natural to me.

For a current discussion of possible interactions between a general pipelining operator (in this case called |>) with await syntax, see also this TC39 proposal where they, arguably coming from a slightly different viewpoint, explore the design space maybe even a bit further than what has happened here so far.

1 Like

When I used it here

I meant that the @ symbol didn't really convey what was going on. So it had a low signal to noise ratio, hence it is noisy. Something like an arrow, like ~> or |>, while longer, better convey what is going on so they aren't noisy.

4 Likes

Small clarifications:

UFCS is calling a method like a function (unified function call syntax), e.g. Vec::into_iter(list). We have UFCS today. UMCS (unified method call syntax) is the idea that basically turns . into the pipeline operator properly, allowing you to do list.Vec::into_iter.

. already is the “pipelining” operator in Rust, just one that has minimal access to type-dispatched names. Thus, using it for more general pipelining (UMCS) makes some sense. That said, using some other symbol such as the |> pipe makes sense as well, to separate that pipelining from the type-based lookup, if it weren’t for the macro compatibility hazard.

That said, the treatment of await as a function in terms of pipelining is telling. It’s an operation applied to a value, not a transformation of code.

await actually cannot be properly implemented simply as a macro, even if you ignore the keyword/identifier/name-resolution/stability issues. This is not just because of diagnostics, though those are a part of the reason. The real reason is that yield isn’t (shouldn’t) be valid in an async context, only “await” is.

async fn(⟨args⟩) -> ⟨ret⟩ {
    ⟨body⟩
}

is defined to (roughly) expand to

fn(⟨args⟩) -> ⟨ret⟩ {
    async {
        let ⟨args⟩ = ⟨args⟩;
        ⟨body⟩
    }
}

and an async block is defined to create an anonymous impl Future that can use await to drive child futures. The fact that async uses generators is an implementation detail.

Being a macro means that writing the macro is exactly (modulo hygeine) the same as writing the internal code. There is no equivalent code that can be written to drive a Future from an async context.

It could be a macro-like syntactic structure. (asm! is an (unstable) one today.) But it cannot be a macro.

Also, saying await isn’t a function is also not fully correct. It doesn’t effect control flow the way ? does, nor does it do manipulation of the input code like a macro. await is best described as a method of Future with a really bizarre calling convention. All it does semantically is a potentially long-running operation and parking the current “thread” of execution while waiting.

Arbitrary code can run while you’re “blocked” on the “subroutine” to finish, whether you’re “synchronous” or “asynchronous”. It’s just that in a “synchronous” environment the level of parallelism is OS threads, and in an “asynchronous” environment, the level of parallelism is tasks decoupled from OS threads. Rust’s safety (mutability xor aliasing & Send/Sync) protect you from problems here today.

(For the record, my personal favorite option is to make await a member of Future, either as a “extern "rust-async" fn” or as a member macro (like syntactic structure), and have it go through regular name resolution to be used (i.e. not a keyword). I realize that this is unrealistic, however. My next favorite option is just any of the various postfix options, no preference within.)

4 Likes

I am not sure if I understood you correctly, but in this proposal await is a keyword. And "novelty" of the proposal is to allow UPS to be used with keywords.

Yes, await can be viewed as an operation applied to a value, but I think it's a shallow, even wrong understanding which we should not promote, even though it can be used to some extent for reasoning about async code. In any form await does perform code transformation, it's undeniable fact of Rust take on async programming, and because of Rust values I believe we should not sweep this "implementation detail" under the rug.

The point is that semantically it isn’t a code transformation. At least, not a local one. async is what drives the transformation into the Future::poll-driven state machine, await is just how you mark where that state machine delegates to sub state machines.

I would actually be on team implicit-await if it weren’t for the complexities of then integrating that with a Future-based view from non-async code making that basically an impossibility. Personally, I don’t see what makes awaitting a Future that different semantically from calling a Fn. They’re both “just” delayed computation, just with different calling conventions and (potentially) greener threads.

4 Likes

We have universal function calling syntax, so: instance.method() is equivalent to Type::method(instance). The D language (and to a degree, JS and Python too) goes as far as making all functions equally usable with both syntaxes. IMHO that's pretty elegant.

So I see . not as related to the type, but as a syntax sugar that moves the first function argument to the prefix position. Macros already pretend to be function-like. Therefore, making macro!(expr) and expr.macro!() equal would seem like a logical extension of the function<>method relationship.

17 Likes

We have universal method calling syntax. foo(bar) is not equivalent to bar.foo(). . only works with fields and methods. The fact that methods have an explicit self parameter doesn’t in my opinion make . a pipelining operator. In addition, I don’t think we should pollute member namespaces (accessible using .) with non-field-nor-method identifiers.

One of the things I find hard about function pipelines in rust is that the use of closures means that abnormal control-flow becomes hard. For example, you can unwrap in a .map closure, but you can’t return an error from the outer function (without using things like impl FromIterator<Item=Result<T, E>> for Result<Vec<T>, E>). Does this idea interact with that problem?

1 Like

if I understood you correctly you mean something like this?

fn foo(val: T1) -> Result<T2, E> { ... }
fn bar(val: T2) -> Result<T3, E> { ... }

let res = val@foo?@bar?;

With generators we can do stuff like this:

items
    // let's assume Iterator = Generator<Return=()>
    .iter()
    // transforms Generator<Item=T1, Return=T2> to
    // Generator<Item=T3, Return=Result<T2, E>>
    // generator returns immediately on a first error
    .map_err(|x: T1| -> Result<T3, E> { ... })
    .consume();

Which I believe can be made more transparent than current impl FromIterator<Item=Result<T, E>> for Result<Vec<T>, E>.

1 Like

A realization I just had: this idea would make match basically the pipelining operator that lets anything be put into a chain:

z.match { x => x + 1 }.match { y => y * 2 }
12 Likes

Yes, but not a really ergonomic one...

3 Likes

But not terribly unergonomic either, at least if you want to do something other than pass the value through a function with no other arguments (…which admittedly is a common case).

Even with arguments dedicated pipelining will be significantly better. Compare:

z@f(42)@bar(v)@|x| baz(9, x)
z.match { x => f(x, 42) }.match { x => bar(x, v) }.match { x => baz(9, x) }
1 Like