Method-cascading and pipe-forward operators proposal

I won't agree that the EDSLs introduced by macros are strictly more general than custom operators. That is your view, but is not objective truth.

Macros essentially invent new localized languages in Rust. Meanwhile, custom operators are much less invasive and apply generally in the language. Thus, it is much easier to invent EDSLs with a set of custom operators and you don't have to opt in to them with my_macro! { .. }. The EDSLs you get with custom operators are closer to just libraries while the EDSLs you get with macros are closer to full blown dedicated DSL.

Sure, for example clap does it.

There is not enough documentation to convince me that particular DSL doesn’t have serious drawbacks in general and the same drawbacks like with construct has in particular, and it might well have, considering that the semantics are entirely up to the author of the macro library. At the very least, the macros have the drawback of being much more difficult to implement in comparison with Kotlin-style builders. Even just the macro syntax is very arcane and difficult to parse. E.g. can you tell what this does and what pitfalls does it introduce? I can’t.

@app ($builder:expr) (@subcommand $name:ident => $($tail:tt)*) $($tt:tt)*) => {
        clap_app!{ @app
            ($builder.subcommand(
                clap_app!{ @app ($crate::SubCommand::with_name(stringify!($name))) $($tail)* }
            ))
            $($tt)*
        }
};

Skimming the examples at the top, I can’t say they seem more readable to me. With the classical method chaining with . only, I know the next method is called on whatever the previous one returned. Keeping track on what the .~ is called and what it returns seems non-obvious, it has somewhat tree-like structure. The . is linear. What happens to the result of the intermediate method? Is it thrown away? When is the drop of that thing called?

How will a beginner react when seeing code like this? It’s quite natural with . chaining, there’s just about one thing that could reasonable happen. The behaviour of the .~ operator seems surprising and looks visually scary.

I know this is somewhat inspired by haskell. However, I can’t say haskell is easy to read ‒ I often wished for a language that would have similar semantics (the pure functions by default, lazy, etc, etc), but with somewhat more readable syntax.

I know this might be just my own impression, but this is not a code I would want to read on a Monday morning before coffee.

4 Likes

My personal opinion is that the meaning of .~ is not obvious at all, and Rust is already notorious for its weird sigils. Rust code is already perforated with *, &, ::<>, 'a. This will not help to improve its reputation in this regard, or reduce the steepness of its learning curve. I don't think it buys you much productivity or legibility either.
Look, how hard is it to write/read the following instead?

let hmap = HashMap::new();
hmap.insert("key1", val1);
hmap.insert("key2", val2);
3 Likes

I find Haskell very readable and noise free. For me, personally, it is syntactic nirvana. I think it all depends on where you are coming from. If you are used to Haskell / Idris / Agda, you will find that syntax readable; if you are used to C / Java / C++ (braces, semicolons, etc.) you will find Haskell utterly weird.

Harder. You are repeating hmap two times more than necessary. Also, as a mostly functional programmer, every time I drop into imperative control flow like that, it feels less good ^,- The best part of the standard library for me is std::iter because chaining iterators feels very functional.

@Centril, I don’t mind chaining functions when the actual output of one fn is used as input to the next. Here however, we’re doing some weird and spooky discarding and replacing of the order of things. It gives me the creeps!

2 Likes

Well; The unfortunate nature of some of Rust's interfaces is that you have &mut instead of passing the hashmap back to the caller in .insert(..) so that you can't write map.insert(..).insert(..). Given this, it seems appropriate to have some sort of "mutate with this function, and pass self back". If you use a sigil for "pass back self" that is somewhat leglible then I think you boost ergonomics for functional programmers a lot. I'm not sure how I feel about the specific syntax proposed in this post, but the general principle of a light weight way to "pass back self" seems nice to me.

If I look at it again, I guess my biggest problem is that the .<something> syntax is somewhat suggestive and the reality is actually different than what it suggests.

I think something like:

let map = HashMap::new()
  .with(|m| m.insert(42, "a"))
  .with(|m| m.insert(24, "b"))

Would be much more obvious and easy to read. I know it kind of lacks on consciseness, but maybe there could be some middle-ground solution that would not be misleading but still reasonable to write.

Maybe we could try to come up with some nicer ways to create lambdas, eg:

.with(_.insert(42, "a"))

8 Likes

I think the central question w.r.t special operators is: How far do we want to go in Rust in supporting embedded domain specific languages (eDSLs)?

The reason this is important is that all special operators, from + and - to ^*^ and ++ are in fact all domain specific operators. It’s just that most people happen to be intuitively familiar with the domain of basic integer/decimal arithmetic, thus they can readily understand that.

Those same people likely have no exposure to symbols from other domains, and thus find them relatively unreadable. If they used them daily for a while it would become second nature soon enough, but they don’t, so it doesn’t.

Therefore my argument is that Special Syntax for Domain-Specific Operators should be viewed as a primitive to construct eDSLs with, and nothing else. And even then it should be viewed with suspicion: syntax soup code (think perl, brainfuck, SBT’s configuration etc) burdens the user with a steeper learning curve than regular names.

As a bit of a tangent, rust’s macro_rules! facility is a strikingly poor tool to create eDSLs with, which leaves boilerplate-cutting as the primary (not unimportant!) use case. I haven’t mucked around with procedural macros yet enough to know for sure, but I expect those to be powerful enough to express any random eDSL that uses token sequences supported by the Rust parser. Relative to macro_rules! this comes at the cost of imperative rather than declarative code, rejection of the stable Rust compiler, and additional complexity.

3 Likes

Couldn't agree more.

1 Like

For now, but, not indefinitely, right?

1 Like

I absolutely disagree with this proposal. It would make rust even more alien and confusing. This proposal has no equivalent in other language.

A possible alternative sigil could be:

let hmap = HashMap::new()
    .< insert("key1", val1)
    .< insert("key2", val2);

This design is proposed because it has a visual directionality pointing to the left with <. The added spacing is intentional to make it more scrutable.


Meanwhile foo.(bar) as syntax for bar(foo) can simply be solved with foo.bar(). A new rule can be added to the dot syntax that if all other resolution mechanisms of x.f(y, ..) fails, then f(x, y, ..) will be tried. The new mechanism also extends to unary functions as x.f(). If f is not immediately in scope, then you can also write x.foo::bar::f(y)

This way, every free function can magically start using dot syntax. Of course, this will require some mental retraining to reconsider what a method is.

1 Like

Procedural macro stabilization would require that the language provides a few guarantees about things that are (used to be?) direct compiler internals. Those guarantees would have to take a similar path as proc_macros itself: Some stable kind of interface that needs to be decoupled from the internal state. I don’t see why that won’t be done at some point, but I haven’t a clue when.

I think this whole table is too complicated and everything could be much easier:

value.(function) should just "convert" function into a method, with all the special rules that apply to methods, like auto-dereferencing, auto-borrowing, etc. This would make all the special ownership syntax superfluous.

The natural combination with ~ would then be val.~(function), not val.(~function)

1 Like

Sorry for late responses.
It’s hard to respond when discussion is moving forward and your own views changes.
But that’s only for good, and therefore it involved into something better than was initially.

I’ve reworked it. And there will be second version, completely different

Look at my comment where I've analyzed Kotlin's way of doing things and why it's not suitable for Rust. However second version will be close enough aesthetically and it will also avoid lot of problems I've listed.

Thank you. Your criticism was wague and it inspired me to rethink current design.
I understand your position, however still exists some points I don't agree:

I think that there always will be programmers that feels itself unproductive and uncomfortable (like me) with reading or writing such code:

    let open_options = OpenOptions {
        write: false,
        read: true,
        ..Default::default()
    };
    let file = open_options.open("location")?;
    let mut collection = file.to_some_collection();
    collection.sort();
    return collection;

My thoughts about it:

  • Syntax for OpenOptions is worst, since it cumbersome and additionaly requires Default implementation; you also creates two OpenOptions instances.
  • Every binding here just holds temporary state, and don't serves any documenting purposes.
  • That code does exactly the same that its "fluent" alternative does and nothing more.
  • It sacrifices in ergonomics and gains nothing (except simplified debugging) from that.

Is it only your own preference?
Are there "technical" reasons to write code this way?
If that's only because "fluent interfaces" are redundant - I agree (see my next response):

I'm too.
But "fluent interfaces" are already in core language, everywhere. They are convenient way to do things.
And there are reasons for hate them:

  • Two kinds of APIs: that allows them and that don't allows them and it's not fun when you have both in codebase
  • It's not clear which is better when you writes your own API
  • Incompatibility with functions that don't returns self - that's annoying
  • Returning self feels like simple hack when proper solution might exist
  • It's additional boilerplate that defaces APIs and function signatures

And just because of that I've proposed this change


About method piping: it was scary and now it's also reworked from scratch