Stream: compiler development

Topic: Record mapping and ?


view this post on Zulip Brendan Hansknecht (Aug 14 2024 at 04:35):

Sam Mohr said:

I think with the map2-based record builders landed, there's no more need for map3 and mapN beyond map2 for Result or Task

I don't think those are related to record builder at all. I think they are just convenience functions. If you have n results from separate paths you can pass them all to a single function (assuming they all end up being Ok). The most likely alternative is using Result.try n times and then passing the values into the function.

That said, I still don't think they are needed. I think pushing for Result.try and eventually ? will be a much nicer API.

view this post on Zulip Sam Mohr (Aug 14 2024 at 04:47):

@Brendan Hansknecht I agree that we don't need record builders, they're just an alternative way to manage multiple Results to the ? desugaring. For example,

{ a, b, c } =
    { Result.map2 <-
        a: resultA,
        b: resultB,
        c: resultC,
    }?

should be the same as

a = resultA?
b = resultB?
c = resultC?

But the first can be used to create a Result without the return type of the function being a Result.

view this post on Zulip Brendan Hansknecht (Aug 14 2024 at 04:47):

Sure, but that still is different than the mapN functions listed in the issue above.

view this post on Zulip Sam Mohr (Aug 14 2024 at 04:49):

Sure, you're right

view this post on Zulip Brendan Hansknecht (Aug 14 2024 at 04:49):

I wasn't trying to compare ? to record builder. More trying to mention what the goal of the mapN functions was for.

view this post on Zulip Brendan Hansknecht (Aug 14 2024 at 04:49):

Also, never thought of using record builder like that

view this post on Zulip Sam Mohr (Aug 14 2024 at 04:50):

Yeah, I was thinking of what you'd want to do that would use a mapN function, not something analagous to a mapN's literal usage, which isn't the same thing

view this post on Zulip Sam Mohr (Aug 14 2024 at 04:51):

And yes, that's the glory of @Agus Zubiaga 's design, it's really awesome that so much can be done with so little

view this post on Zulip Brendan Hansknecht (Aug 14 2024 at 04:51):

I guess it is the same as:

{a, b, c} =
    {
        a: resultA?,
        b: resultB?,
        c: resultC?,
    }?

view this post on Zulip Sam Mohr (Aug 14 2024 at 04:53):

I'd say you shouldn't need that last ? around the record literal, but yes

view this post on Zulip Brendan Hansknecht (Aug 14 2024 at 04:59):

It is actually important to require the final ? in my opinion. Enables aggregating the error and then acting on it instead of propagating it.

mergedRes =
    {
        a: resultA?,
        b: resultB?,
        c: resultC?,
    }
when mergedRes is
    Ok {a, b, c} -> ...
    Err _ -> ...

That said, definitely depends on exactly how we decide to desugar.

Which of these two is it equivalent to:

x = resultA?
y = resultB?
z = resultC?
{a, b, c} =
    {
        a: x,
        b: y,
        c: z,
    } # a `?` here would be a type error with this desugaring
{a, b, c} =
    x = resultA?
    y = resultB?
    z = resultC?
    {
        a: x,
        b: y,
        c: z,
    }?

I think the second desugaring is more useful.

view this post on Zulip Sam Mohr (Aug 14 2024 at 05:03):

@Brendan Hansknecht can you move this to another thread? This is useful discussion, but I don't want to distract from this thread's purpose. I'd do it myself, but I don't have the right perms

view this post on Zulip Sam Mohr (Aug 14 2024 at 05:06):

So the reason why I think the second proposed behavior is wrong is because it doesn't map onto my understanding of how ? works in Rust, which is how I assume Roc intends to work. In Rust, ? returns errors to the top of the function no matter how many blocks nested you're in. The only thing that breaks this is nested closures, which are their own functions, anyway

view this post on Zulip Brendan Hansknecht (Aug 14 2024 at 05:08):

For sure, it definitely differs from rust. That said, I think it matches ! today. Which is probably why I expect some differences.

view this post on Zulip Sam Mohr (Aug 14 2024 at 05:08):

Meaning that

{a, b, c} =
    x = resultA?
    y = resultB?
    z = resultC?
    {
        a: x,
        b: y,
        c: z,
    }?

would already have x, y, and z extracted from their results, not only within the scope of the record literal's block

view this post on Zulip Sam Mohr (Aug 14 2024 at 05:08):

Okay, if this is how ! works today, I'm good with that

view this post on Zulip Sam Mohr (Aug 14 2024 at 05:08):

! and ? should work identically IMO anyway

view this post on Zulip Sam Mohr (Aug 14 2024 at 05:09):

I'll have to double check the impl for !, then

view this post on Zulip Brendan Hansknecht (Aug 14 2024 at 05:10):

Note, I'm not actually sure ! works with records at all today. But it definitely works that way if you do something like:

x =
   y = task1!
   z = task2!
   {y, z}

In this case, x will be a Task. The outer function is not required to be a task.

view this post on Zulip Sam Mohr (Aug 14 2024 at 05:10):

Hmm

view this post on Zulip Brendan Hansknecht (Aug 14 2024 at 05:11):

Of course with task, in essentially all cases, you will end up merging all task together and aggregating into a single error type.

view this post on Zulip Sam Mohr (Aug 14 2024 at 05:12):

I'd posit that this is not desired, but I think having it return to the top-level function block isn't ideal because then we break how platforms that expect a main : Task I32 _ work, since they aren't functions

view this post on Zulip Sam Mohr (Aug 14 2024 at 05:13):

Yeah, okay, I can see the logic with your above example

view this post on Zulip Brendan Hansknecht (Aug 14 2024 at 05:13):

Note, the reason it works this way is cause it is super simple sugar:

x =
   Task.await task1 \y ->
       Task.await task2 \z ->
           {y, z}

view this post on Zulip Sam Mohr (Aug 14 2024 at 05:14):

But ideally, you'd not have to write

{a, b, c} =
    {
        a: resultA?,
        b: resultB?,
        c: resultC?,
    }?

and could instead write

{ a, b, c } =
    { Result.map2? <-
        a: resultA,
        b: resultB,
        c: resultC,
    }

and avoid all the duplication of ?

view this post on Zulip Brendan Hansknecht (Aug 14 2024 at 05:15):

I think it matches the roc viewpoint of:

  1. everything is an expression
  2. there are no early returns (in other words, no ifs without elses)

view this post on Zulip Sam Mohr (Aug 14 2024 at 05:15):

Okay, so that last point...

view this post on Zulip Sam Mohr (Aug 14 2024 at 05:15):

Doesn't ! work like an early return? Is "no early returns" a core principle?

view this post on Zulip Brendan Hansknecht (Aug 14 2024 at 05:16):

I'm trying to think of the best wording here. Fundamentally, the real principle is there are no ifs without elses.

view this post on Zulip Brendan Hansknecht (Aug 14 2024 at 05:17):

So maybe that is still just

  1. everything is an expression

view this post on Zulip Brendan Hansknecht (Aug 14 2024 at 05:18):

so you have an if expression that always includes an else. The expression as a whole returns a value, so there is no early return technically.

view this post on Zulip Sam Mohr (Aug 14 2024 at 05:18):

I think allowing early returns is preferable for writing clean code. AFAIK we have a way to use ! in when and if statements so that you don't have to pull out intermediate vars

view this post on Zulip Sam Mohr (Aug 14 2024 at 05:18):

I'd say that early returns are exactly an if-else statement with sugar

view this post on Zulip Brendan Hansknecht (Aug 14 2024 at 05:19):

Fair enough. As long as you have an else with a block that includes everything else, that is an early return kinda.

view this post on Zulip Sam Mohr (Aug 14 2024 at 05:19):

If I write

fn parse_data(s: &str) -> Result<Data, Error> {
    if s.is_empty() {
        return Err(Error);
    }

    s.parse()
}

There's an implicit else

view this post on Zulip Brendan Hansknecht (Aug 14 2024 at 05:20):

But I think the core distinction is that you can't skip any blocks. You have to return through each layer of the expression tree

view this post on Zulip Brendan Hansknecht (Aug 14 2024 at 05:21):

So ! inside of an if is not early returning. It is creating a value passed as the result of the if. That value could then be returned, but that is not guaranteed

view this post on Zulip Brendan Hansknecht (Aug 14 2024 at 05:21):

So everything is guaranteed to be isolated to some extent.

view this post on Zulip Brendan Hansknecht (Aug 14 2024 at 05:24):

If I see

x =
    # tons of code
    finalFn a q

z =
    # something with x I guess

I know that z is guaranteed to be run. x is capturing the entire expression with all of that tons of code. There are no early returns. If the tons of code includes !, x is a Task. If the tons of code includes ?, x is a Result. x may be some other type, but fundamentally, x is the result of some expression and then we are continuing forward.

view this post on Zulip Brendan Hansknecht (Aug 14 2024 at 05:24):

That's what I mean by no early returns.

view this post on Zulip Sam Mohr (Aug 14 2024 at 05:25):

I can understand wanting isolation as a principle, but I think this is valid under the "everything is an expression" rule:

main =
    firstHalf =
        input = Stdin.line! {}
       Str.withPrefix input "first half: "
    secondHalf =
        input = Stdin.line! {}
       Str.withPrefix input "second half: "

    Stdout.line! "$(firstHalf) $(secondHalf)"

The problem if this works is that we lose isolation, but we get the convenience of not needing to await firstHalf and secondHalf while running things.

view this post on Zulip Sam Mohr (Aug 14 2024 at 05:26):

So yes, in our documentation of ! and ?, I think we need to emphasize that we break the mental model of Rust for the sake of maintaining isolation

view this post on Zulip Brendan Hansknecht (Aug 14 2024 at 05:27):

I would not expect that code to work in Roc. Would need to be:

main =
    firstHalf =
        input = Stdin.line! {}
        Str.withPrefix input "first half: "
    secondHalf =
        input = Stdin.line! {}
        Str.withPrefix input "second half: "

    Stdout.line! "$(firstHalf!) $(secondHalf!)"

view this post on Zulip Sam Mohr (Aug 14 2024 at 05:27):

Unless the thing we both seem to agree on (the current behavior is good and should be kept) is actually bad and needs to be made to follow Rust

view this post on Zulip Sam Mohr (Aug 14 2024 at 05:27):

Brendan Hansknecht said:

I would not expect that code to work in Roc. Would need to be:

main =
    firstHalf =
        input = Stdin.line! {}
       Str.withPrefix input "first half: "
    secondHalf =
        input = Stdin.line! {}
       Str.withPrefix input "second half: "

    Stdout.line! "$(firstHalf!) $(secondHalf!)"

Yes, it wouldn't work with current rules, only if we changed Roc to work like Rust

view this post on Zulip Brendan Hansknecht (Aug 14 2024 at 05:27):

yep

view this post on Zulip Brendan Hansknecht (Aug 14 2024 at 05:29):

But yeah, we definitely could change ! desugaring to make that work.

view this post on Zulip Brendan Hansknecht (Aug 14 2024 at 05:30):

It is kinda jumping up an extra scope, but that might be ok

view this post on Zulip Sam Mohr (Aug 14 2024 at 05:32):

No, unless it's really annoying to people, I prefer the isolation we get to the scope level. I think it will be really annoying to have it work the other way

view this post on Zulip Sam Mohr (Aug 14 2024 at 05:32):

I think this just requires documentation in the tutorial

view this post on Zulip Brendan Hansknecht (Aug 14 2024 at 05:33):

This is definitely one of those things where working through the journey of how roc got to ! makes the isolation feel totally normal, but obvious that is now how new users will feel. From nested closures to backpassing to !.

view this post on Zulip Brendan Hansknecht (Aug 14 2024 at 05:34):

I wonder what the best way to teach this is.

view this post on Zulip Brendan Hansknecht (Aug 14 2024 at 05:35):

Makes me also think of:

input = Stdin.line {}
    |> Task.mapErr! StdinErr

When I first saw that I was super confused. ! felt like it was in a nonsensical place

view this post on Zulip Sam Mohr (Aug 14 2024 at 05:35):

Yeah, great similar example to bring up

view this post on Zulip Sam Mohr (Aug 14 2024 at 05:36):

Makes perfect sense as a simple design choice once you get it

view this post on Zulip Sam Mohr (Aug 14 2024 at 05:36):

It feels like watching Arrival haha

view this post on Zulip Richard Feldman (Aug 14 2024 at 11:46):

Sam Mohr said:

I'd say that early returns are exactly an if-else statement with sugar

in Roc this is true (although in other languages it's more complicated because of things like finally) - we've never talked about return as an idea! Do you want to start a thread in #ideas about it?

view this post on Zulip Richard Feldman (Aug 14 2024 at 12:53):

Brendan Hansknecht said:

I guess it is the same as:

{a, b, c} =
    {
        a: resultA?,
        b: resultB?,
        c: resultC?,
    }?

an argument for not requiring the }? is that (as Sam noted earlier) you can already get that behavior using record builders:

{ a, b, c } =
    { Result.map2 <-
        a: resultA,
        b: resultB,
        c: resultC,
    }?

view this post on Zulip Richard Feldman (Aug 14 2024 at 12:54):

and I definitely agree that ! and ? should have the same rules

view this post on Zulip Richard Feldman (Aug 14 2024 at 12:55):

using ! with records seems much more common, and there I think we wouldn't want }! - e.g.

user = {
    username: readUsernameFromFile!,
    posts: getPostsFromUrl! url,
}

view this post on Zulip Richard Feldman (Aug 14 2024 at 12:55):

I don't think anyone would expect to have to write that as:

user = {
    username: readUsernameFromFile!,
    posts: getPostsFromUrl! url,
}!

view this post on Zulip Brendan Hansknecht (Aug 14 2024 at 15:33):

Fair. This is mostly a question of what counts as a nested expression.

As mentioned by Sam above, we could also make this work if we wanted:

main =
    firstHalf =
        input = Stdin.line! {}
        Str.withPrefix input "first half: "
    secondHalf =
        input = Stdin.line! {}
        Str.withPrefix input "second half: "

    Stdout.line! "$(firstHalf) $(secondHalf)"

view this post on Zulip Brendan Hansknecht (Aug 14 2024 at 15:34):

We just have to make the rules consistent enough that we don't confuse users too much

view this post on Zulip Brendan Hansknecht (Aug 14 2024 at 15:37):

Also, going a step farther, what about this:

tasks = {
    userTask: {
        username: readUsernameFromFile!,
        posts: getPostsFromUrl! url,
    },
    adminTask: {
        username: readUsernameFromFile!,
        posts: getPostsFromUrl! url,
        secretStuff: readSecretAdminOnlyStuff!,
    },
}

if user.isAdmin ... then
    {username, posts, secretStuff} = tasks.adminTask!
    ...
else
    {username, posts} = tasks.userTask!
    ...

view this post on Zulip Anton (Aug 14 2024 at 15:49):

we could also make this work if we wanted:

Yeah, I've hit that one a couple times as well. I think if that does not work it will be confusing for users who don't deeply understand ! desugaring

view this post on Zulip Richard Feldman (Aug 14 2024 at 17:00):

so the idea there would be that we automatically insert Task.ok at the end of the expression?

view this post on Zulip Richard Feldman (Aug 14 2024 at 17:02):

if so, I can see that being useful but also potentially confusing in conditionals, e.g. this wouldn't work:

foo =
    if blah then
        input = Stdin.line! {}
        Str.withPrefix input "first half: "
    else
        "stuff"

view this post on Zulip Richard Feldman (Aug 14 2024 at 17:02):

because it would have to be Task.ok "stuff" since the sugar wouldn't expand to the else branch

view this post on Zulip Richard Feldman (Aug 14 2024 at 17:03):

we could potentially make the rule be that if there's a ! in one branch, we treat all the other branches as having a ! too :thinking:

view this post on Zulip Richard Feldman (Aug 14 2024 at 17:03):

and on those grounds apply the transformation to all of them

view this post on Zulip Anton (Aug 14 2024 at 17:16):

That sounds reasonable

view this post on Zulip Richard Feldman (Aug 15 2024 at 00:18):

Richard Feldman said:

so the idea there would be that we automatically insert Task.ok at the end of the expression?

I just realized a cool thing about this idea:

view this post on Zulip Richard Feldman (Aug 15 2024 at 00:18):

so it would eliminate the unnecessary (and unintentional, but currently unavoidable) stylistic option there

view this post on Zulip Richard Feldman (Aug 15 2024 at 00:18):

and make the style more consistent!

view this post on Zulip Brendan Hansknecht (Aug 15 2024 at 00:33):

Richard Feldman said:

so the idea there would be that we automatically insert Task.ok at the end of the expression?

I don't think that was the idea. I think the idea was that ! would desugar to the outer scope.

view this post on Zulip Richard Feldman (Aug 15 2024 at 01:51):

hm, I don't follow :thinking:

view this post on Zulip Brendan Hansknecht (Aug 15 2024 at 02:11):

Essentially, the question was, should this code:

main =
    firstHalf =
        input = Stdin.line! {}
        Str.withPrefix input "first half: "
    secondHalf =
        input = Stdin.line! {}
        Str.withPrefix input "second half: "

    Stdout.line! "$(firstHalf) $(secondHalf)"

do the same as this code?

main =
    input0 = Stdin.line! {}
    firstHalf =
        Str.withPrefix input0 "first half: "
    input1 = Stdin.line! {}
    secondHalf =
        Str.withPrefix input1 "second half: "

    Stdout.line! "$(firstHalf) $(secondHalf)"

view this post on Zulip Brendan Hansknecht (Aug 15 2024 at 02:11):

Many beginners expect those two pieces of code to be equivalent. It also is more similar to how ? works in rust.

view this post on Zulip Richard Feldman (Aug 15 2024 at 02:50):

hm, I don't think beginners would write the first thing though :big_smile:

view this post on Zulip Richard Feldman (Aug 15 2024 at 02:50):

so that seems moot in this case, although maybe there's another case where it might come up?

view this post on Zulip Brendan Hansknecht (Aug 15 2024 at 04:09):

I would assume similar cases exist. That said, I think where a beginner is most likely to trip up would be related to conditionals and the trailing Task.ok. so your idea above seems helpful

view this post on Zulip Richard Feldman (Aug 15 2024 at 11:37):

yeah I think that idea is worth exploring!


Last updated: Jul 06 2025 at 12:14 UTC