Expat - composable, reusable pattern matching

Lol, I saw it. ^.^

Have you thought about adding a defguard thing too like in mine so someone can add a guard to a def that can add multiple guards and/or matchers like mine? (Mine is purposefully incomplete because I overrode def to do it, which is not shibby, but it was purely an example anyway).

If this were more widely used, couldn’t Elixir having all patterns defined be able to add in more compile time checks to catch pattern matching errors?

Also, I do see some use in the ability to use guards in plain patterns. The match = operator in particular it becomes useful in doing something like %{data: data} when is_binary(data) = request_data()

Yes, in fact that was part of my original defguard proposal, by baking it into elixir that a guard can handle matching, binding, and guard tests then that opens a lot of capabilities.

I just got an idea, what about using ? for match and ! for creation as disambiguation (or just ! and no-prefix for matchers)?

I think I don’t understand what you mean with multiple matchers, could you provide an example of what syntax you use in yours and what would you want it to expand into?

Let me ellaborate a bit on how expat expands maybe could be useful for other people following this thread:

When you define defpat foo(1), the ast inside foo (here 1) is the code that will be placed at call-site. eg. foo() = 1 expands to 1 = 1.

That means it’s actually possible to place any elixir code in there, and the foo macro will just expand it when called.

Now, since expat’s purpose in life is to help with pattern matching,
the ast inside foo is treated specially in the following cases:

  • if it contains a variable like defpat foo(x) then x is boundable by the caller of foo. The caller can bind it by name, like:
    foo(x: 1) = 1 => 1 = 1
    If x is not bound by the caller, like foo(), x will be replaced with an _ , so foo() = 1 is _ = 1

  • if it contains a guard like defpat bar(y) when y > 10 then, the code of the guard will also be expanded, for example:
    bar(y: 2) will expand to y = 2 when y > 10.

    Note however that since we have a guard to check, and y is being used in it, the variable y is preserved in expansion, however this y is higenic (elixir’s counter distingishes it from others) and will not bind any other y in your own scope.

    To bind in your scope you do something like
    bar(y: u) expands to u = y when y > 10 and u is a variable you provided from your scope.

    So, you could bind bar’s y with any expression, even other pattern expansions (just regular function calls)

    bar(y: z = foo(x: 20)) will expand to y = z = 20 when y > 20
    this will also work: bar(z = foo(20)) since expat now supports positional arguments (variables get bound in the order they appear on the pattern)

  • If it contains a nested pattern expansion. For example, if you had
    defpat t2({x, y}) when x > y and later did
    defpat teens(t2(bar(a), bar(b))) when a < 20 and b < 20

    Then teens has two bindable names, :a and :b and it will get expanded into a pattern like:
    {a, b} when a < 20 and b < 20 and a > b and a > 10 and b > 10
    That means inner guards get propagated into the calling expansion.

Now since defpat just captures the code inside the pattern for expanding it later, defpat named(%{"name" => name}) allows you to expand named anywhere you can place a pattern in elixir, like on the left hand side of =

named(x) = %{"name" => "vic"} will expand to
%{"name" => x} = %{"name" => "vic"}, that’s why you can use it on a function definition like:

def index(conn, params = named(name)), do: ...

However, for those containing guards,

def lalala(teens(m, n)) would by itself expand into:
def lalala({m, n} when m < 20 and n < 20 and m > n and m > 10 and n > 10)

Of course having a when in that context fails.
as it would do if you try:

iex(14)> {m, n} when m > n and m > 10 and n > 10 = {30, 20}
** (CompileError) iex:14: undefined function when/2

So, having guards was what introduced the expat def syntax:

expat def lalala(teens(m, n)) expands correctly into:
def lalala({m, n}) when m < 20 and n < 20 and m > n and m > 10 and n > 10.

Finally, that’s why expat is different from defguard both elixir’s and @OvermindDL1’s because for expat, expansion is not limited to be used as part of a when. Named expat patterns can be used anywhere it’s valid to expand it’s containing expression.

So, hope that helps anyone.

If someone is interested, you can read more examples in the tests, and the docs, or use the source.

Oh btw, generated macros now get documented.

2 Likes

What my defguard did was you could define something like:

defguard is_exception(%{__struct__: struct_name, __exception__: true}) when is_atom(struct_name)

This could then by used like:

  def blorp(_exc) when is_exception(_exc), do: "exceptioned"
  def blorp(val), do: "No-exception:  #{inspect val}"

And it does expand as expected. Specifically since _exc is passed into the is_exception ‘guard’ then the _exc in the argument list gets a matcher added to it, while it also added the guard in the is_exception(_exc) position. You could even match ‘out’ something by passing in more arguments in some custom defguard, but essentially the above example turned into:

  def blorp(_exc = %{__struct__: struct_name$1, __exception__: true}) when is_atom(struct_name$1), do: "exceptioned"
  def blorp(val), do: "No-exception:  #{inspect val}"

It basically just allows treating a guard as something that can both match and guard at the same time. This is more powerful than just what expat does now because currently expat can only add a single matcher and N guards, where with my defguard pattern above it allows for N matchers and N guards, like in this convoluted example:

  defguard can_vote?(%Human{birthdate: utc_bd}, now) when (utc_bd + 60*60*24*365*18) >= now

...

def vote(human, now) when can_vote?(human, now), do: ...
def vote(_, _), do: ...

Or whatever, the thing is that you can pass in multiple things, and notice that you can pass ‘out’ as well:

defguard bloop({a, b}, b) when is_atom(a)

...

def blorp(c) when bloop(c, d), do: d

So here you can see that it passes ‘out’ something as well, and you just put a binding there. Notice that you can put a specific value there too like:

def blorp(c) when bloop(c, :ok), do: ...

So now that will only match {a, :ok} where a is an atom. The thing is that instead of just being 1-matcher/N-guards, it is N-matchers/N-guards, and since it is in the guard position then you can use in_guard?/1 as usual to do the right action if in a guard or make it an expression, which means you could use it in statement positions properly like:

a = {:ok, :blah}
b = 42
if foo(a, b, c) do ... else ... end ...

And notice that you can match on yet ‘more’ things as well. I could easily imagine adding the capability of having it support static KWLists so you could do things like this too if you want:

def blorp(c) when bloop(c, out: d), do: d
# or
if foo(the_tuple: a, an_arg: b, out: c) do ... else ... end ...

Or whatever as well. :slight_smile:

Yep, same with the defguard style too.

Instead of just supporting single matchers, what about supporting multiple?

I’m not saying replace the existing functionality with this, but rather ‘add’ the defguard stuff to expat as well, then it would have both the ability for N-Matchers/N-Guards via a ‘guard’ and for easy-inline 1-Matcher/N-Guards via the matcher style too. :slight_smile:

Expansion is not limited to being used as part of when, it can be used as an expression with defguard as well as it essentially just appears as any normal function call. :slight_smile:

v1.0.4 features Union Patterns. Patterns composed into a single one, this lets you emulate things like:

2 Likes

Oh now those are really cool! Full matching and so forth without enforcing a structure, even the Maybe is just natural in elixir (still not big on the nil word, but eh, Elixir’isms). :slight_smile:

EDIT1: In the maybe test, you have this test:

  test "just can be pattern matched" do
    assert just() = :jordan
  end

What about having this too:

  test "just can be pattern matched and extract" do
    assert just(j) = :jordan
    assert j == :jordan  
  end

That shows you can extract as well. :slight_smile:

EDIT2: How would you implement a Result type that accepted an :ok, {:ok, value}, :error, and {:error, reason} as an example? That would show a variety of usages of using native types and no wrapping and so forth, preferably using ok/0/error/0/ok/1/error/1 and such constructors. :slight_smile:

1 Like

Yep, added your additional Maybe example, thanks. Also added a
Result one.

1 Like

I saw! I also left a comment/question on that commit. :slight_smile:

Hi @vic,

I really enjoy the continuous work you have been doing in expat!

I wonder if we should support when clauses inside patterns and automatically move them out. So if you do:

def foo(x when is_integer(x)), do: x

It becomes:

def foo(x) when is_integer(x), do: x

while nobody would write the first in practice, it is very powerful for extending the language. It means you would be able to drop the expat prefix in many cases.

Similarly, regarding the union types, I wonder if instead of the expat prefix, you could use the prefix of the union, so this example becomes:

  test "nil cannot be pattern matched with just" do
    maybe case Keyword.get([], :foo) do
      just() -> raise "Should not happen"
      nothing() -> assert :ok
    end
  end

Or even:

  test "nil cannot be pattern matched with just" do
    maybe Keyword.get([], :foo) do
      just() -> raise "Should not happen"
      nothing() -> assert :ok
    end
  end

Although I am not sure how that would play with nesting. For example, if you have a maybe with a nat inside, how would you handle it? Do you need two separate cases? I guess that would be the case, as a nat with a nothing doesn’t make sense. I wonder if other languages (OCaml/Haskell/F#) allow case to be “folded” in such cases or do they require an explicit case per pattern? /cc @OvermindDL1

Thoughts?

3 Likes

Follow-up question: how do you know that in the natural number case you need to wrap each subpattern inside {:nat, val} but you don’t have to in the maybe case? Given one says zero(0) and the other says nothing(nil), I was expecting zero() to generate 0 and not {:nat, 0}.

I also assume that when using unions you can no longer do something like def value(maybe())? I guess not doing that makes sense though, as the different subpatterns may have different shapes/number of arguments.

4 posts were split to a new topic: Internal Guards

I’m thinking that expat can be a simple way to improve readability and also it allows a way to do a sort of contract check at function boundaries. Is this the way to follow or the Elixir community are agreeing on other approaches?