Expat - composable, reusable pattern matching

Expat is a tiny experiment I did for extracting patterns and being able to reuse them (compose and share patterns between elixir libraries). Look for zombies in the README.

It was born as some of my first attempts at creating composable things like the just released data specification library Spec.

Hope anyone finds it interesting at least.

18 Likes

Huh, looks to save a lot on readability. Is it mostly just for maps?

1 Like

not just maps, anything that can be a pattern is valid.

1 Like

Wow. I find it very interesting to say the least.

I notice in your readme (and in your tests) that you are defining the pattern with the string key but matching using the pattern(key: value) which uses the atom keyword syntax. I looked at your macro code, but I’m just not versed enough with macros to quite pin it down…are you somehow converting the keys to atoms in order to match in this way?

Also, am I reading it correctly that the keywords are flattening the keys, e.g. the lat/lng vars in the readme?

And just to “pinch myself” before using this to refactor my controller code, I would be able to do something like the following?:

Before (difficult to read with more form fields):

def fork(conn, %{"fork_form_data" => %{"dest_ib" => dest_ib, "src_ib_gib" => src_ib_gib} = form_data} = params)

to After (composable, and readable):

# Reused patterns
defpat src_ib_gib_p(%{"src_ib_gib" => src_ib_gib})
defpat dest_ib_p(%{"dest_ib" => dest_ib})

# Pattern for just this one function
defpat fork_params_p(%{"fork_form_data" => dest_ib_p(...) = src_ib_gib_p(...)})

def fork(conn, fork_params_p(fork_form_data: form_data, dest_ib: dest_ib, src_ib_gib: src_ib_gib) = params)

Also, I am planning in the future on switching to structs for changeset validation. Would using expat preclude me from using structs?

2 Likes

There aint any key conversion, when you have

defpat foo %{"bar" => baz}

# and then

def something(foo(baz: qux)), do: qux
# Actually you are just replacing one pattern `baz` for another `qux`
# in this example turns out both patterns variables (since assignment is matching an unbound name pattern)
# so instead of a variable `baz` you get a variable `qux`

# (lets use some other kind of things besides maps for examples)

# And because It's just pattern replacement you could place any other pattern instead of `baz`, say:
def something(foo(baz: [first | _tail] )), do: first

# Actually that's how composition works in `expat`,
# if you have something that looks like a function call (its expat-expanded) since anyways function calls
# are not allowed in an elixir pattern.

defpat batman({:bruno, sidekick})
def something(foo(baz: batman(sidekick: robin))) when robin in ["demian"], do: "oh son"
# batman(sidekick: robin) gets expanded to {:batman, robin} you replaced pattern `sidekick` by `robin`

# If you notice, the previous example had batman expanded with an explicit keyword `sidekick: robin`
# that means, that of all `variable` patterns inside `batman` you only care for replacing `sidekick` all others
# we dont care and get replaced by `_` placeholders

# composition takes advantage of this, for example:
defpat latlng {lat, lng}
defpat trip {origin = latlng(), destination = latlng()}

# remember you are just replacing variable-patterns
trip(destination: {0, 0}) # would expand to:  { _ = {_, _}, {0, 0} = {_, _} } notice only `destination` got replaced.


Hope this can explain a bit better how expat works.
Also if you want to help by improving the README that would be great, as my english is almost always a bit weird. :smiley:

Cheers :slight_smile:

2 Likes

Oh and one distadvantage of how things are right now, is that

defpat latlng {lat, lng}
defpat trip {origin = latlng(), destination = latlng()}

# if you replace lat from trip directly  it will be replace in both
trip(lat: 99) # exapnds to { _ = {99, _}, _ = {99, _} }

but you could then define trip like

defpat trip {origin = latlng(lat: orig_lat, lng: orig_lng), destination = latlng(lat: dest_lat, lng: dest_lng)}

but that doesnt seems to have much advantage over a normal pattern tho

1 Like

oh btw, there’s defpatp for private patterns (wont be visible from other modules it’s defmacropd)

3 Likes

haha sorry for the response by pieces, ^^ last one: using structs, no problem, as previously shown expact doesnt care, if you can pattern match it you can use it.

2 Likes

Ah, so in foo(baz: qux), you’re actually accessing the variable assigned to the value of the key in the original map? In looking at your readme, this confusion arose I think because all of your examples have the same name for both the key/value, e.g. "iq" => iq and "email" => email. I had errantly latched onto the keys being converted. But now I see that you are pulling the variables (which are atoms) out of the pattern and using those for composition. That’s awesome! :+1:

Yes, this makes it much clearer to me. Thanks! :smile:

3 Likes

Thanks to you! Will definitely update the README with better examples. Thanks !

3 Likes

Possibly. It depends I would think on how much reuse you’re getting from the pattern. The pattern itself looks relatively complex, but you’re giving it a name and then composing it more granularly, and if you reuse it even once, I think it would be a win.

In the OO world (where I came from), I would use granular interfaces and gradually build more complex data structures with them. This looks like a lightweight way of implementing similar interfaces, which is also partly why I was asking about how they’d jive with structs.

I love helping with documentation, but I don’t think I could add anything to your readme as it stands - it is really entertaining. :laughing:

1 Like

Just updated the README to avoid possible confusion and added a note on how expat patterns could be used to construct data if you provide all bindings, I mean you’ve always had, but with expat you abstract the actual form and give it a name, like this example

defpat ok {:ok, value}

ok(22) # => {:ok, 22}
1 Like

Yeah, this is definitely my favorite new library. The primary gain from it for me is making things DRYer. Where before I would have map structures repeated in multiple function clauses, e.g.

def handle_cmd(%{"dest_ib" => dest_ib,
                 "context_ib_gib" => context_ib_gib,
                 "src_ib_gib" => src_ib_gib} = data, ..) when guard1 do
def handle_cmd(%{"dest_ib" => dest_ib,
                 "context_ib_gib" => context_ib_gib,
                 "src_ib_gib" => src_ib_gib} = data, ..) when guard2 do

which has redundancy in the "var_name" => var_name, as well in the function clause level. And dest_ib and src_ib_gib are used across multiple commands in many places. I’m now able to create a single file for the reused patterns:

defmodule WebGib.Patterns do
  @moduledoc """
  Reusable patterns using expat
  """
  
  import Expat
  
  defpat dest_ib_        %{"dest_ib" => dest_ib}
  defpat src_ib_gib_     %{"src_ib_gib" => src_ib_gib}
  defpat context_ib_gib_ %{"context_ib_gib" => context_ib_gib}
  # ..
end

(NB: I am tacitly going with a _ suffix to indicate a pattern vs the var name, but I’m not sure what other non-word characters are legal in elixir. I would rather prefix the pattern with a single character and would love any suggestions.)

And then I compose them above the function and consume them:

defpat fork_data_(
  dest_ib_() =
  context_ib_gib_() =
  src_ib_gib_()
)

def handle_cmd(fork_data_(...) = data, ..) when guard1 do
def handle_cmd(fork_data_(...) = data, ..) when guard1 do

EDIT: The ... inside the pattern is literal syntax, which helps enormously with DRY. The other .. just means other args.

This is ridiculously more DRY and readable. Definitely a powerful lib you made here! :smile:

Thank you! :thumbsup:

4 Likes

This is a really nice project. Since you are exploring things on the area, here are some challenges/questions you can consider:

  1. Is it possible to extend expat to be a generalization of records? defpat contains an exact subset of the records functionality, which is the pattern matching one. It is also easy to see how defpat could be used for updates, all you need to do is to match on the value and then build another new value of exactly the same shape with the same variables in place except the ones you are replacing, etc.

  2. Have you considered using another operator instead of = for mixing patterns? I believe your mixing of patterns is actually an intersection. If you assume that %{"id" => id} will match all maps with the "id" field, including %{"id" => "foo", "name" => "baz"}, and the pattern %{"name" => name} all maps with a "name" field, including the map mentioned above, when you specify id() = name() you are actually saying it should have BOTH patterns, id() AND name(). If you think of patterns as sets representing the structures they can match on, it is an intersection. Another reason to choose another operator is that the precedence will work in a way it won’t require parentheses, for example: defpat subject id() &&& name(). Here is a list of operators.

There is one feature we could add to Elixir that would allow the library to become more powerful which is to allow guards inside patterns:

def is_foo_or_bar(atom when atom in [:foo, :bar])

Of course nobody would write such in practice but supporting it would allow you to express patterns with guards:

def is_foo_or_bar(foo_or_bar())

Which the Elixir compiler would then rewrite internally as:

def is_foo_or_bar(atom) when atom in [:foo, :bar]

Anyway, this is very exciting and it is close to topics I am currently researching. :slight_smile: I could expand more on both points above if you are interested. For example, if you are able to generalize defpat to records, you should also be able to generalize it for map updates, and if you define a pattern such as:

defpat foo_bar_baz %{"foo" => {bar, baz}}

And then allow someone to update the nested tuple like this:

map = %{"foo" => {1, 2}, "hello" => "world"}
foo_bar_baz(map, bar: 3)
#=> %{"foo" => {3, 2}, "hello" => "world"}

Optimizations could allow you to compile foo_bar_baz to %{map | "foo" => Map.fetch!(map, "foo") |> put_elem(0, 3)}.

10 Likes

Hey José, thanks for your input!

haven’t explored this, but will try and report back how it goes.

Actually, I’m doing nothing with operators in expat. It’s just that if you define a pattern like

defpat foo %{"a" => a}
defpat bar %{"b" => b}
defpat baz(foo() = bar())

baz(...) = %{a: "missing b", c: "unused"} # this would expand to
%{"a" => a} = %{"b" => b} = %{a: "missing b", c: "unused"} 

so you are just using the standard = operator to match on two patterns at the same time. I just mentioned it on the README so people could know how to intersect two patterns (will s/mixing/intersect/ that part on the guide)

That would be a nice idea to explore, not sure if guards should be inside of patterns in Elixir tho, and not sure if we do need to extend Elixir for it. Actually when I was writing Expat I got tempted to allow guards but didnt for the sake of simplicity and time (just being lazy actually, got things to do at Spec). But I was thinking of things like:

defpat foo(%{"foo" => x, "bar" => y}) when x in [:foo, :bar] and is_binary(y)

# but the way Expat currenly works, doing
foo(y: 1) # would expand to
%{"foo" => _, "bar" => 1) when _ in [:foo, :bar] and is_binary(1)
# so when ignoring `x` like in the previous example, I'd need to also
# ignore any guard that uses it
# so just to KISS I went for not yet implementing guards
# (wanted to release early an gather feedback)

:slight_smile: would be really nice, will see how far I can get and report back to you.

Thanks for reading!

1 Like

Oh, I see! This is really clever.

My initial impression is that it is only possible with Elixir extensions since (x when ...) = ... is simply not supported today and if you expand a pattern with a guard inside a function signature, Elixir will fail to compile the code. But if you have found workarounds, please let me know!

1 Like

I thought this also. It was a bit confusing but I just black-boxed it as “this is how you do it”.

I will say, however, that my understanding of Erlang/Elixir patterns was much improved just from looking at this lib. I had thought of patterns as the entire function signature, and I didn’t realize that Erlang docs give each argument match as individual patterns…So I gotta say thanks just for that. Huge improvement on my misconception.

PS I have started to convert my command functions (command pattern) to use this lib. It’s great. :smile:

2 Likes

Hey, good news, I’ve just released v1.0, I did a major rewrite as I really wanted (and needed) it to support guards. Wrote much a much better README guide (I guess) and also documented it more.

The only thing I removed from v0 is the ... syntax as it introduced all variables in scope and it was mostly a pain since elixir 1.5. So now you have to be explicit on what you bind.

Hope the guide explains a bit better where this library fits and how it could be used.

<3

Edit. Forgot to mention, for @josevalim, in the example using guards from the readme, expat def just expands the inner patterns and collects (anding) any guards produced by them, and finally just ands those with any from the function definition. here’s the code

7 Likes

Since expat v1.0 it’s now possible to use guards on your pattern definitions, and they will be expanded at the call-site.

Whoo! This now entirely replaces my purposefully gimped (since it was a standards suggestion and not a real library) library of defguard. ^.^

Although I think I still like the syntax of mine better, I just think that it should be built into elixir instead of the highly gimped-in-comparison defguard that it recently got (though it could be expanded in the future…)

I like how you encourage your’s as a constructor too, do the guards apply on that properly? :slight_smile:

Not normally, but just for you I’ve added an experimental workaround for that case. See the last commit foo_test.exs. also might be interesting for you ast_test.exs

1 Like