gleam-lang / gleam

⭐️ A friendly language for building type-safe, scalable systems!
https://gleam.run
Apache License 2.0
17.95k stars 748 forks source link

Early return operator for Result #589

Closed sporto closed 4 years ago

sporto commented 4 years ago

Just like ? in Rust, this make the code a lot easier to understand when you need to deal with multiple results / options.

From

get_one(...)
   |> result.then(_, fn(one) {
      get_two(...)
         |> result.map(_, fn(two) {
             use_one_and_two(one, two)
         })
})

To

let one = get_one(...)?
let two = get_two(...)?
Ok(use_one_and_two(one, two))
lpil commented 4 years ago

Gleam doesn't currently have traits so ? would be a lot more restrictive than in Rust (no into trait).

It would be good to also investigate OCaml's rebindable syntax which is used to achieve this and more.

OvermindDL1 commented 4 years ago

If you add an implicit modules system then you have your trait-like things (though even more significantly powerful if first-class functors are added), then you'd be able to have something desugar as you want.

However, ? fits in languages like Rust fine, because early returns, however I wouldn't recommend it for something like Gleam, which doesn't. OCaml already has a construct for this.

OCaml lets you add an unbound amount of +'s or *'s at the end of a let or and, so you can do this:

let open Result in
let+ one = get_one(...) in
let+ two = get_two(...) in
...

And this just desugars to:

let open Result in
(let+) get_one(...) @@ fn one ->
(let+) get_two(...) @@ fn two ->
...

The let+ in this case is defined in the Result module like:

let (let+) res monad =
  match res with
  | Err _ = e -> e
  | Some s -> monad s

But in general the let* <match> = <expr> in <body> gets mapped to (let*) <expr> @@ fn <match> -> <body>

This ability let's you open a module into the local space as normal, then use them like let's. On the forums I built a whole Elixir-style for comprehension with them and described these in more detail especially how they work in regards to the whole patterns.

In General, for consistency, the OCaml community uses +'s as meaning a monad call (where it takes something, operates on the innerds, and returns that same type), and where *'s mean an applicative call. It is considered that they may open up other operators to be used on them as well, but already the OCaml community is making great use of these with not only monad/applicative style handling for things like Result handling, but also for comprehension systems, parsers, etc... etc... It makes a lot of code a lot more readable, and it handles the ? case just fine.

If any inlining is performed at all then the function call can get entirely optimized out (as can be done in OCaml), but function calls are very cheap on the BEAM (since it's optimized for precisely this), though still not as cheap as an inline case.

lpil commented 4 years ago

Some syntax ideas for something like OCaml's rebindable syntax:

import gleam/result.{then}

pub fn main() -> Result(Thing, Err) {
  let(then) value = may_fail()
  let(then) next = also_may_fail(value)
  Ok(next)
}

pub fn qualified_main() -> Result(Thing, Err) {
  let(result.then) value = may_fail()
  let(result.then) next = also_may_fail(value)
  Ok(next)
}
import gleam/result.{then}

pub fn main() -> Result(Thing, Err) {
  let let+ = then
  let+ value = may_fail()
  let+ next = also_may_fail(value)
  Ok(next)
}

pub fn qualified_main() -> Result(Thing, Err) {
  let let+ = result.then
  let+ value = may_fail()
  let+ next = also_may_fail(value)
  Ok(next)
}
lpil commented 4 years ago

What other approaches do languages take here?

Do seems comparable to OCaml's rebindable let syntax, the others seem less expressive.

c-cube commented 4 years ago

Something that generalizes exceptions pretty well, in a typed setting, is algebraic effects. Monads are painful to use if they're not done in a pure setting like Haskell (and even then, transformers are still a pain); they don't compose well with list.map or other monads (like error handling and option handling when also using futures or whatnot). let+ is just sugar on top of monads, you still get a ton of continuations and you still have the problems of composing let+ from list with result or option.

See koka (and slides), the plans for OCaml, etc. Also a good talk about some work done on the jvm that emphasizes the importance of using native control flow instead of monads.

CrowdHailer commented 4 years ago

My opinion is you don't need something that generalises, or that you can discover the generalisation in later versions.

If result is going to get special status (as the comment on this issue suggests to me, https://github.com/gleam-lang/gleam/issues/531) Then you could have something that worked just for results.

I like the rust approach, and even without traits It would be useful. The only thing I don't like about the rust approach is ? very much suggests boolean to me. I would have chose ! instead.

I also think a syntax like below would work,

let value != try_get_value()
lpil commented 4 years ago

To me that reads as "let value not equal to try_get_value".

If we specialise for Result I think I would prefer this syntax:

try value = try_get_value()

I quite like the idea of having a syntax specific to Result from an approachability point of view- it is much easier to explain something concrete and it is harder to misuse than a syntax where the exact behaviour can be supplied by the user.

What use cases would we be losing out on if we made it specific to Result?

I would be interested in how frequently people use ? with types other than Result in Rust. By default it is implemented for Result, Option (which are Results in Gleam) and Poll (which we don't need because we don't have Future).

lpil commented 4 years ago

@tomwhatmore @gamebox @QuinnWilton @chouzar @kyle-sammons Do you have any thoughts on the above?

gamebox commented 4 years ago

I've let it be known before that I think a comprehension is as readable and more powerful than one off syntax like this. It can be specialized to the built-in monad-likes we have (List and Result), and can be generalized with whatever polymorphism device we develop later(traits, first class modules, etc).

tomwhatmore commented 4 years ago

I'm a big fan of ? in Rust and would very much like such a thing to exist in Gleam. As to the specifics...

This seems like an important point that didn't get any discussion:

However, ? fits in languages like Rust fine, because early returns, however I wouldn't recommend it for something like Gleam, which doesn't.

Is there a specific rationale for not having early returns in Gleam? Would supporting them have any other ramifications?

lpil commented 4 years ago

Is there a specific rationale for not having early returns in Gleam? Would supporting them have any other ramifications?

I think that early returns are a lot more useful in statement based languages than expression based languages as they don't pllay well with higher order functions. I can't say I've ever wanted them in Erlang/Elixir/Haskell etc, so I don't feel a reason to add them to Gleam at present.

kyle-sammons commented 4 years ago

Chiming a bit late here, but I agree with most things that @tomwhatmore said. I also prefer the syntax of try value = may_fail_func() as it seems much more clear-cut and readable. That said, as a Haskell fan I'm also always a fan of the do syntax, but that may be overkill for this specific use-case.

lpil commented 4 years ago

If we were to have a try binding syntax would it make more sense to have to work on patterns rather than to have it be specialised to Result?

// result based version
try form = read_form(request)

// expands to
case read_form(request) {
  Ok(form) -> ...
  Error(e) -> Error(e)
}
// pattern based version
try Ok(form) = read_form(request)

// expands to the same, but doesn't wrap `Ok` around the pattern for the user
case read_form(request) {
  Ok(form) -> ...
  Error(e) -> Error(e)
}

This would allow it to be used with types other than Result, and would match assert and let more, though it would be slightly more verbose.

CrowdHailer commented 4 years ago

I would lean to result based version, because other matches might not make as much sense, also you need a consistent type of the failure case for the early return. The expanded code needs to return in the error case.

// result based version
try form = read_form(request)

// expands to
case read_form(request) {
  Ok(form) -> ...
  Error(e) -> return Error(e)
}

I guess you example might actually already include this if you assume the ... on the Ok branch includes all the lines of code after try in the function.

lpil commented 4 years ago

Yes, all the lines after the try statement go in place of ... as there is no return statement in Gleam.

lpil commented 4 years ago

I'm trying out the Result specialised try here https://github.com/gleam-lang/stdlib/commit/4787386533f909a46d9f9854af846a2d398d2586