[eeps] EEP 049: Value-Based Error Handling Mechanisms

Kenneth Lundin kenneth@REDACTED
Thu Oct 22 16:31:00 CEST 2020


I tend to agree that a construct like begin *expressions1* else _ ->
*expression* end is attractive and copies the essence of the with construct
in Elixir.
The use of '<-' makes sense as well.
I know we had an objection in the OTP team for introducing an operator '<~'
that was not usable everywhere but in this case we are reusing '<-' that is
already used in list comprehensions (as the only place).

In that case LHS '<-' RHS should only be allowed in the begin else _ -> ...
end construct.

I don't know if we have a problem with introducing 'else' as a keyword.
Will that potentially break old existing code?


On Thu, Oct 22, 2020 at 2:56 PM José Valim <jose.valim@REDACTED> wrote:

> My initial thoughts about an approach where <~ is not lexically bound by
> any construct is that it may make moving code around harder.
>
> For example, if you have this function:
>
> foo() ->
>   {ok, Value} <~ file:read_file("somefile.erl"),
>   Another = do_something_with(Value),
>   do_something_else(Another).
>
> If you are going to move the first two lines to a private function then
> the automatic extraction does not work:
>
> foo() ->
>   Another = bar(),
>   do_something_else(Another).
>
> bar() ->
>   {ok, Value} <~ file:read_file("somefile.erl"),
>   do_something_with(Value).
>
> You have to do this:
>
> foo() ->
>   {ok, Another} <~ bar(),
>   do_something_else(Another).
>
> bar() ->
>   {ok, Value} <~ file:read_file("somefile.erl"),
>   do_something_with(Value).
>
> Which is doable but not obvious because the scope <~ applies to is not
> immediately clear. I think something like this:
>
> foo() ->
>   begin
>     {ok, Value} <~ file:read_file("somefile.erl"),
>     Another = do_something_with(Value),
>     do_something_else(Another)
>   end.
>
> provides a clearer indicator of the scope and more clues that moving code
> around requires extra work. The other benefit of having a delimiter is that
> you can include else clauses that Fred mentioned, and I believe you will
> quickly find out they are a must have. Imagine you want to perform many
> operations that may fail and, if they do, you want to raise an error.
> Without else, you have to do this:
>
> change_and_backup(File) ->
>   Res =
>     begin
>       {ok, Value} <~ file:read_file(File),
>       NewValue = do_something_with_value(Value),
>       ok <~ file:write_file(File ++ ".backup", Value),
>       ok <~ file:write_file(File, NewValue),
>       {ok, NewValue}
>     end,
>
>   case Res of
>     {ok, NewValue} -> NewValue;
>     {error, Reason} -> erlang:error({backup_error, Reason}, [File])
>   end.
>
> With else, you do this:
>
> change_and_backup(File) ->
>   begin
>     {ok, Value} <~ file:read_file(File),
>     NewValue = do_something_with_value(Value),
>     ok <~ file:write_file(File ++ ".backup", Value),
>     ok <~ file:write_file(File, NewValue),
>     NewValue
>   else
>     {error, Reason} -> erlang:error({backup_error, Reason}, [File])
>   end.
>
> Note the compiler can raise if else is given but no <~ is used.
>
> If I remember correctly, Elixir's with originally started without else,
> but else was added in the next release because it was seen as a very clear
> extension of the original mechanism. I have included some examples from the
> Elixir codebase where we use "with". I think the first example is really
> clear on the benefit of usng "with" to perform validation, compared to
> something like using try/catch:
>
> *
> https://github.com/elixir-lang/elixir/blob/272303e558286dff35ace00eada6d2030431874d/lib/elixir/lib/dynamic_supervisor.ex#L355-L361
> *
> https://github.com/elixir-lang/elixir/blob/272303e558286dff35ace00eada6d2030431874d/lib/elixir/lib/file.ex#L560-L574
> *
> https://github.com/elixir-lang/elixir/blob/272303e558286dff35ace00eada6d2030431874d/lib/elixir/diff.exs#L83-L104
> *
> https://github.com/elixir-lang/elixir/blob/272303e558286dff35ace00eada6d2030431874d/lib/elixir/lib/version.ex#L536-L546
>
> Finally, another potential benefit of having a explicit delimiter is that
> you don't need a new operator as you can re-use <- if you want to.
> Especially because <- inside a comprehesion already has a "soft match"
> semantics (i.e. it doesn't raise if it doesn't match).
>
> On Thu, Oct 22, 2020 at 12:53 PM Kenneth Lundin <kenneth@REDACTED>
> wrote:
>
>> See embedded comments
>>
>> On Thu, Oct 15, 2020 at 4:58 PM Fred Hebert <mononcqc@REDACTED> wrote:
>>
>>>
>>> On Thu, Oct 15, 2020 at 3:31 AM Kenneth Lundin <kenneth@REDACTED>
>>> wrote:
>>>
>>>> We welcome initiatives like this and are positive to revisit this.
>>>> A proposal for something closer to *with* in Elixir looks interesting.
>>>>
>>>>
>>>
>>> Alright. So before I get into the big details, here are a few things /
>>> variables I'm considering if we are to redesign this.
>>>
>>> I'm labelling them as below into proposal rewrites 1 through 3, some
>>> with variants. Let me know if some of them sound more interesting.
>>>
>>>
>>>
>>> First, dropping the normative return values of ok | {ok, T} | {error, R}
>>> :
>>>
>>> begin
>>>     {ok, A} <~ exp(),
>>>     {ok, B} <~ exp(),
>>>     {ok, A+B}
>>> end.
>>>
>>> This has interesting impacts in some of the examples given in the EEP,
>>> specifically in that _ <~ RHS now means as much as _ = RHS, but also
>>> that it allows rewriting some forms. The RFC looked at expressions such as:
>>>
>>> backup_releases(Dir, NewReleases, Masters, Backup, Change, RelFile) ->
>>>     case  at_all_masters(Masters, ?MODULE, do_copy_files, [RefFile,
>>> [Backup, Change]]) of
>>>         ok ->
>>>             ok;
>>>         {error, {Master, R}} ->
>>>             remove_files(Master, [Backup, Change], Masters)
>>>     end.
>>>
>>> Could now be written the following way:
>>>
>>> backup_releases(Dir, NewReleases, Masters, Backup, Change, RelFile) ->
>>>     begin
>>>         {error, {Master, R}} <~ at_all_masters(Masters, ?MODULE,
>>> do_copy_files, [RefFile, [Backup, Change]]),
>>>         remove_files(Master, [Backup, Change], Masters)
>>>     end.
>>>
>>> Which looks a bit funny because the main path is now the error path and
>>> the happy-path is fully removed from the situation.
>>>
>>> In any case, this is the most minimal rework required, has some edge
>>> cases pointed out by the EEP already (a match for {ok, [_|_]=L} <~ RHS
>>> can end up doing a short return for {ok, Tuple} for example, and
>>> interfere with expected values). I'll call this *proposal rewrite 1. *
>>>
>>> We can't avoid the above escaping mechanism without normalizing over
>>> what is an acceptable or unacceptable good match value, and proposal
>>> rewrite 1 makes this impossible. This requires adding something akin to the
>>> else construct in the elixir with:
>>>
>>> %% because of `f()', this returns `{ok, "hello"}' instead of `{ok,
>>> <<"Hello">>}'
>>> %% or a badmatch error.
>>> f() -> {ok, "hello"}.
>>> validate(IoData) -> size(IoData) > 0.
>>> sanitize(IoData) -> string:uppercase(IoData).
>>>
>>> fetch() ->
>>>     begin
>>>         {ok, B = <<_/binary>>} <~ f(),
>>>         ok <~ validate(B),
>>>         {ok, sanitize(B)}
>>>     end.
>>>
>>> %% Only workaround:
>>> fetch_workaround() ->
>>>     begin
>>>         {ok, B = <<_/binary>>} <~ f(),
>>>         ok <~ validate(B),
>>>         {ok, sanitize(B)}
>>>     else
>>>         {ok, [_|_]} -> ...
>>>     end.
>>>
>>> This format might work, but requires introducing new extensions to the begin
>>> ... end form (or new things like maybe ... end), regardless of terms.
>>> In terms of semantics, a catch, of, or after block might reuse existing
>>> keywords but wouldn't be as clear in terms of meaning. Specifically
>>> addressing this requirement that comes from relaxing semantics for proposal
>>> 1 is *proposal rewrite 2*. *Variant A* would be to keep it as described
>>> above, and *Variant B* would include the potential options with other
>>> alternative keywords and blocks.
>>>
>>> Either way, dropping the pattern and changing constructs maintains the
>>> overall form and patterns described in the EEP. They however still keep LHS
>>> <~ RHS as a special expression type that is always contextual, which
>>> was pointed out to be a thing the OTP team did not like. Making it apply
>>> everywhere is a particularly tricky bit, but I think it might be possible.
>>>
>>> First, we need to define where a free-standing LHS <~ RHS is going to
>>> return. If it's free-standing it can't be a sort of macro trick for a case
>>> expression, and it can't also be based on a throw, since throws can't
>>> clearly disambiguate the control flow required for this construct vs.
>>> random exceptions people could be handling at lower levels.I've seen in
>>> EEP-52 that there is a core-erlang construct as a letrec_goto, and using it
>>> we might be able to work with that.
>>>
>>> We'd first have to choose which scope nested expressions would need to
>>> return to:
>>>
>>>         a() ->
>>>             V = case f() of
>>>                 true ->
>>>                     ok <~ g(),
>>>                     h();
>>>                 false ->
>>>                     {ok, X} <~ i(),
>>>                     k(element(2, {ok, Y} <~ j(X)))
>>>             end,
>>>             handle(V).
>>>
>>> This is an interesting test bed for some possible execution locations
>>> where the new operator could be bound. We could pick:
>>>
>>>    - shortcut the lexical scope: since case expressions and any other
>>>    construct share the parent lexical scope and can export variables, we would
>>>    have to expect that {ok, X} <~ i() implies that a() itself can
>>>    return i()'s value directly if it doesn't strictly match the form,
>>>    regardless of how deeply nested we are in the conditional. This is unlikely
>>>    to be practical or expected to people, but would nest appropriately within
>>>    funs. It may have very funny effects on list comprehensions when used as
>>>    part of generators and that likely will need special treatment.
>>>    - shortcut to the parent control flow construct / end of current
>>>    sequence of expression: I don't know how to word this properly, but the
>>>    idea would be to limit the short-circuit return to the prior branching or
>>>    return point in the language. This means that {ok, X} <~ i() failing
>>>    implies that V gets bound to the return value of i(), and similarly
>>>    for the return value of j() if it were to fail. Upon seeing a LHS <~
>>>    RHS expression, the compiler would need to insert a label at the end
>>>    of the current sequence of expressions (which may conveniently going to be
>>>    explained as "all of the current expressions separated by a comma [,]),
>>>    and do a conditional jump to it if the expression fails to match. If it
>>>    works it keeps chugging along, and the last expression in the sequence can
>>>    just jump to the same label with the identified return value. To my
>>>    understanding, this wouldn't interfere with LCO nor require more
>>>    stackframes than any other conditional would ever require.
>>>
>>> I think the middle bullet (above this) is the most interesting. The LHS
>> <~ RHS expression should be thought of as MATCH_OR_BREAK or MATCH_OR_RETURN
>> a conditional return as I think you mention somewhere.
>> The scope to break out from is function clause, begin/end, case clause
>> and probably something else which I have not thought of yet.
>> If we introduce this maybe it is strange to not introduce an
>> unconditional return as well.
>> Will take a closer look into the 'with' construct in Elixir and see if
>> there is anything more we could copy.
>> Note, I have discussed this briefly with some of the OTP team members,
>> see this as an initial view point not written in stone.
>>
>>>
>>>    - Something else I haven't thought of
>>>
>>> I also assume that none of these expressions would ever be valid in
>>> guards since they can't do assignment today. I'm also unsure of whether
>>> letrec_goto can use a label with arguments in what to execute (which would
>>> let it carry/return a variable), but I'm waiting to do research on that on
>>> whether this idea looks good or not to the OTP team. I think the lexical
>>> scope option is unacceptable. I call the sequence of expressions approach *proposal
>>> rewrite 3*. This is much more ambitious and could have a ton weirder
>>> unexpected effects, but it drops all pretense and introduces a new
>>> operation type/control flow mechanism (which is comparable to a conditional
>>> return somewhat scoped like a continue in an imperative language)
>>> rather than a new operator within a bound construct.
>>>
>>> An interesting *Variant B* for this one would be that since we make the
>>> expression general, we could change the LHS <~ RHS expression to
>>> instead be LHS <- RHS expression; after all, there is no unwrap
>>> involved anymore, and the logical handling of this thing is now much closer
>>> to what you'd see in a list comprehension such as [handle(X) || {ok, X}
>>> <- [i()]].
>>>
>>> Let me know what you think about these.
>>>
>>
>> /Kenneth, Erlang/OTP Ericsson
>>
>>>
>>> /Kenneth, Erlang/OTP Ericsson
-------------- next part --------------
An HTML attachment was scrubbed...
URL: <http://erlang.org/pipermail/eeps/attachments/20201022/f56ccfb8/attachment-0001.htm>


More information about the eeps mailing list