Support guards in Erlang `maybe`

We have finally started playing with maybe expression EEP 49 and we have noticed that they do not support guards. For example, I may want to write:

begin
  {ok, Int} when Int > 0 ?= expr(),
  Int
end.

One could argue that the above could be rewritten as:

begin
  {ok, Int} ?= expr(),
  true ?= Int > 0,
  Int
end.

But remember guards have specific semantics in relation to errors that would be annoying for the developer to manually replicate.

EEP 49 does not mention guards in this context, so it is not possible to know if this aspect was considered (and then discarded) or not. I can think of two concerns about supporting guards here.

First, the AST currently does not have a place to put guards:

{'maybe',1,
         [{maybe_match,1,
                       {cons,1,{var,1,'X'},{nil,1}},
                       {cons,1,{integer,1,1},{nil,1}}},
          {var,1,'X'}]}

Unless guards are expressed as an operator, which is not done anywhere else in the AST.

The punctuation for multiple guards in Erlang can be confusing. Take this example:

begin
  {ok, Int} when Int > 0, Int < 0 ?= expr(),
  Int
end.

One option is to require parenthesis in such cases:

begin
  ({ok, Int} when Int > 0, Int < 0) ?= expr(),
  Int
end.

Any thoughts, concerns, or alternatives?

9 Likes

For one, I think guards could be useful there. But



 looks pretty ugly to me, parentheses or not :fearful: AFAIK, this would be the first thing where guards appear before the expression that actually produces them, everywhere else where we can have guards (function heads, if, case, try, 
) they appears afterwards.

So, in a nutshell, I’m for guards here, but please find a better style. (And no, no suggestions from me :sweat:)

2 Likes

I agree.

Given we use ?=, which looks like =, it could also be:

begin
  {ok, Int} ?= expr() when (Int > 0, Int < 0),
  Int
end.

But that would also 100% require parentheses and I am not sure it is better.

1 Like

I think Maria was pointing out that you’re using the result of the expression in the guard. Unless I’ve missed something this is not possible anywhere else in the language.

That aside, moving the ‘when’ to the right would match the rest of the language, and since the punctuation-based operators are the problem, there are two options;
a) require parentheses, or
b) don’t allow the punctuation operators (only or/and/
 etc)

2 Likes

Yeah guards were fully out of scope there, mostly because I wanted the smallest disruption to syntax as possible. The grammar is maybe Exprs | MatchOrReturnExprs end where MatchOrReturnExprs is defined as Pattern ?= Expr.

Guard expressions generally contain the operators ; (“or”) and , (“and”), the latter of which could likely be tricky to read in cases such as:

begin
  %% confusing on the assignment, but only one interpretation
  %% since A > B can't be a pattern
  {ok, Int} when expr(), A > B ?= expr(),
  ...
  %% should this be legal? Only if Val is bound or also when unbound?
  _ when Val ?= expr(), Val =:= 3,
  %% Or do you need to flip the clause to ensure the `expr()` is always last?
  %% this would mess some binding rules' expectations at least? 
 _ when Val =:= 3, Val ?= expr(),
 %% That's of course a trap, because `_ [Guards] ?= expr()` is the key there so
 %% the following two lines are equivalent:
 _ when Val =:= 3, Val ?= expr(),
 _ when Val, Val =:= 3 ?= expr(),
 %% So this tells only pre-bound value should be allowed there, so we
 %% instead need the following to get the other intent, which also
 %% risks looking confusing:
 BoundPat when Val =:= 3, BoundPat =:= Val ?= expr(),
 %% Then there's just confusing cases like the following, where it's unclear
 %% if you should error on "A when is_list(A)" being an illegal expression
 %% or on "A = 3" being an illegal guard, depending on how you parse it
 A when is_list(A), A = 3, X ?= expr()
end.

Then if you decide that hey, using , and ; in there is not great for readability so you only use and, or, andalso, and orelse, then you’re defining a whole new guard type (or a type of possibly side-effectful validation expressions like in list comprehensions) and the whole spec’s scope grows and grows.

Overall this felt like it’d bring in so much more confusion than it would bring clarity (especially when my initial intent was to make {ok, Val} | {error, Val} more of a structural language-supported pattern than a convention, which did not happen) that it simply felt not worth even considering.

4 Likes

I wonder if solution would be to introduce “single-expression guards” - guards that explicitly don’t allow , or ; operators and instead require using andalso and orelse.
In particular this would allow such guards to be used easily in this context, or for that matter, any other context where a pattern is supported with no ambiguity.

8 Likes

A regular expression match in a guard would be nice. But then a Reg exp pattern match on the mailbox queue could be fun also.

1 Like

I wold personally go for

{ok,X} when X < 1, is_float(X) ?= expr()

as it keeps the pattern and the guard together.

7 Likes

Up to this topic with this PR.
The PR example:

-module(maybe_guard).
-feature(maybe_expr, enable).
-export([t/1]).

t(X) ->
    maybe 
        Zero when Zero =:= 0 ?= X,
    	One when One > 0, One < 2 ?= one(),
    	ok
    else
    	X -> {nonzero, X}
    end.

one() -> 1.
1> maybe_guard:t(0).
ok
2> maybe_guard:t(1).
{nonzero,1}
2 Likes

I often wish I had guards that I could use during a normal assignment to allow me to fail fast and let it crash, e.g.

B when is_binary(B) = read_some_data(F),
5 Likes

Do we have any updates on this topic?

Just to be clear: I personally have made absolutely no plans nor do I have any intent of adding guards there; if anyone wants this feature, they’ll have to drive the EEP process and early implementations likely on their own.

(Sorry if stating the obvious.) I believe this particular one can be solved with something like this:

<<Binary/binary>> = read_some_data(F),

but yeah, I totally agree with the sentiment. For example, can’t do the same for checking for integers.

1 Like

Or something like this:

-module(foo).
-export([bar/1]).

bar(F) ->
    case read_some_data(F) of B when is_binary(B) -> B end, % Here is the guard and B got exposed
    <<B/binary, "bar">>. % This is just extra code

read_some_data(F) ->
    F.

Shell:

1> foo:bar(<<"foo">>).
<<"foobar">>
2> foo:bar(1).
** exception error: no case clause matching 1
     in function  foo:bar/1