Skip to content

Explore computational expressions in rust (do notation) #2034

Open

Description

@mdinger

F# includes support for what they call computational expressions which seems to be an extremely useful and powerful abstraction which doesn't require a large number of extra syntax to support it. There is a gentle introduction here, an official documentation page, and a paper discussing the topic.

I'm not an expert and am actually quite new to the idea but I'm going to try to present the idea briefly below. See the links above for more thorough discussion of these topics. I think the idea is very interesting and if the rust developers haven't seen the idea, I should think it would be a useful concept in the future development of rust.

Also, I think it can be understood as an approach to make error handing in the functional style more agreeable and some people find that very appealing. I'm not going to try to explain in detail how it works because the article linked does a much better job that I would.


I view computational expressions somewhat as an inversion of implementing the iterator trait in rust. In rust, if you have a type which you implement the iterator trait for, you immediately gain access to all of the methods iterators provide and more importantly, the ability to use the for loop construct directly.

struct X;

// We implement this iterator trait
impl Iterator for X {}

let x = X;

// and in the *context of a for loop*, you gain the benefit of *clean iteration*
for i in x {}

In a computational expression, the situation is reversed: you implement the functionality, let's assume for the moment it was an iterator, and in the context of the type, you gain the benefit of a for loop construct.

Now, I'm not sure the previous analogy is fully accurate in F# for a for loop but for let, it is more precise. Consider the following testable example where a construct maybe_worker is defined which modifies how binding occurs to allow options have simple mathematical equations applied to them. This works correctly regardless of whether any of the options are None or otherwise.

maybe_worker {
    let! x = Some 3
    let! y = Some 4
    let! z = Some 5

    return x * y + z
}

This type of construct is quite general and as such, allows you a lot of flexibility to apply these bindings to do various types of extra work (such as logging) however you define it. Another interesting aspect is they don't create extra operators, as can be seen here. They have reused many of the normal keywords of the language in these constructs as a kind of extension seeming to add a lot of flexibility.

Also interestingly, since these types holding these operation variants are essentially adding side effects
to and slightly modifying operations, they have had great success using them with types such as async and seq among others.

Activity

  1. arielb1 commented on Jun 16, 2017

    @arielb1
    Contributor

    To anyone who missed this, computational expressions = do-notation.

  2. mdinger commented on Jun 16, 2017

    @mdinger
    ContributorAuthor

    I didn't know that. Then this form is probably less unknown than I was afraid of. I was afraid people would see my bad explanation and gloss the issue.

  3. changed the title [-]Explore computational expressions in rust[/-] [+]Explore computational expressions in rust (do notation)[/+] on Jun 16, 2017
  4. mdinger commented on Jun 16, 2017

    @mdinger
    ContributorAuthor

    For reference, the paper linked above distinguishes between this type of abstraction and the do-notation Haskell utilizes and so therefore assuming them equivalent is not necessarily accurate. Those of you well versed in do-notation might find it interesting and helpful.

  5. clarfonthey commented on Jun 18, 2017

    @clarfonthey
    Contributor

    Isn't this just catch or am I missing something?

  6. mdinger commented on Jun 19, 2017

    @mdinger
    ContributorAuthor

    I don't think so. I looked for the most recent RFC on try and didn't see a catch so I'm not sure specifically what you're referring to (though it is possible I am incorrect). The following snippet in F# in this instance quite literally translates to something like this:

    // F#
    maybe_worker {
        let! x = Some 3
        let! y = Some 4
        let! z = Some 5
    
        return x * y + z
    }
    // Translated into a rust direct equivalent
    fn maybe_worker() -> Option<i32> {
        Some(3).and_then(|x|
            Some(4).and_then(|y|
                Some(5).and_then(|z|
                    Some(x * y * z)
                )
            )
        )
    }

    However, those comparisons don't really do it justice because implemented differently, that same F# snippet would be equivalent to this:

    // Rust equivalent when logging is desired
    fn maybe_worker() -> Option<i32> {
        Some(3).and_then(|x| {
            println!("Got `{}`", x);
            Some(4).and_then(|y| {
                println!("Got `{}`", y);
                Some(5).and_then(|z| {
                    println!("Got `{}`", z);
                    println!("Returning `Some({})`", x * y * z);
                    Some(x * y * z)
                })
            })
        })
    }

    Now let! is only one of their operators that this supports. Using this type of binding, they added support for for loop concepts and yield operations. Many others exist to yield code which might match our conceptual models easier than otherwise.

  7. clarfonthey commented on Jun 19, 2017

    @clarfonthey
    Contributor

    @mdinger with catch that F# snippet is

    do catch {
        let x = Some(3)?;
        println!("Got `{}`", x);
        let y = Some(4)?;
        println!("Got `{}`", y);
        let z = Some(5)?;
        println!("Got `{}`", z);
        println!("Returning `Some({})`", x * y * z);
        z * y * z
    }
  8. mdinger commented on Jun 19, 2017

    @mdinger
    ContributorAuthor

    Do you mean catch from rfc #243? Is it implemented out of curiousity?

  9. nagisa commented on Jun 19, 2017

    @nagisa
    Member
  10. burdges commented on Jun 19, 2017

    @burdges

    @clarcharr Yes, catch and ? gives do notation for the Result and Option monads. Also, the coroutine eRFC proposes creating do notation tied to function bodies for Futures using procedural macros, aka #[async] and await!. I think the question should be what else would benefits from a do notation, like say transactional data structures or anything listed in the links above.

  11. mdinger commented on Jun 21, 2017

    @mdinger
    ContributorAuthor

    I was not aware of catch though it seems less flexible than the example I included (probably because it is hardcoded). I'm glad to see they're pursuing a lightweight and flexible approach to that RFC as opposed to using new keywords at every juncture.

    BTW, I thought the F# approach was very interesting and different from the standard rust "macro" approach and I thought others might as well. Maybe it will be useful when devising future designs.

  12. Centril commented on Oct 23, 2017

    @Centril
    Contributor

    Before a proposal like this is relevant, I think we need to be able to reason generally regarding and_then and return for any monad, which requires at least generic associated types and possibly HKTs.

  13. kspeakman commented on Oct 24, 2017

    @kspeakman

    F#er here who is interested in Rust. I most commonly use the built-in computation expressions for async and sequences.

    let dbQuery =
        async {
    
            // the ! means that the expression returns an async, so unwrap it
            let! dbData = runDbQuery ()
            let! apiData = runApiCall ()
    
            let saveQuery = makeSaveQuery dbData apiData
            return! save saveQuery
        }
    let validate customer =
        seq {
            if String.IsNullOrWhiteSpace customer.Name then
                yield CustomerNameInvalid
    
            // the ! means the expression returns a sequence, so flatten it
            yield! List.map validateContacts customer.Contacts
        }
  14. added
    T-langRelevant to the language team, which will review and decide on the RFC.
    on Dec 6, 2017
  15. CosminSontu commented on Nov 29, 2023

    @CosminSontu

    F#er here who is interested in Rust. I most commonly use the built-in computation expressions for async and sequences.

    let dbQuery =
        async {
    
            // the ! means that the expression returns an async, so unwrap it
            let! dbData = runDbQuery ()
            let! apiData = runApiCall ()
    
            let saveQuery = makeSaveQuery dbData apiData
            return! save saveQuery
        }
    let validate customer =
        seq {
            if String.IsNullOrWhiteSpace customer.Name then
                yield CustomerNameInvalid
    
            // the ! means the expression returns a sequence, so flatten it
            yield! List.map validateContacts customer.Contacts
        }

    This is the beauty of computation expressions, you basically extend the language without modifying the compiler.

    What is achieved in other languages with special keywords and container types (IEnumerable and yield ; Task and async/await; ) in F# you just use computation expressions ( seq{} async{} task{} ...)

    Another usecase is the Bolero (fsbolero.io) wasm Frontend lib which uses computation expressions to define a dsl for its html templating. With the ability of F# compiler perform inlining when compiling the expression the resulting code is performant.

    Computation expressions in this form are one of the superpowers of F#.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    T-langRelevant to the language team, which will review and decide on the RFC.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions