Proposal to add pattern matching to Ruby

https://bugs.ruby-lang.org/issues/14709

Still early, based on experimentation on a forked Ruby interpreter and this presentation from RubyKaigi.

Proposed syntax:

res = [:ng, 500]
case res
when %p([:ng, status])
  p status
end

Looks… familiar :grin:

I’m curious to hear what the community thinks of this

4 Likes

That would be awesome. I’m a huge fan of pattern matching in erlang/elixir and the flows it enables you to build as a result.

2 Likes

I suspect I would find it frustrating, matching in function heads is the game changer.

4 Likes

I’m not sure this:

def f(pattern1) do
  # ...
end

def f(pattern2) do
  # ...
end

def f(pattern3) do
  # ...
end

def f(pattern4) do
  # ...
end

is much better than:

def f(x) do
  case x do
    pattern1 ->
      # ...
    pattern2 ->
      # ...
    pattern3 ->
      # ...
    pattern4 ->
      # ...
  end
end

The first version probably plays better with tracing, but that’s an artifact of how the tracer is implemented, and not a universal property of matching in function heads.

For what it’s worth, both versions emit exactly the same bytecode. At the core erlang level (one of the intermediate languages in the compiler right below Erlang AST) functions are lowered to a single big case inside one function and there are no more “multiple heads”.

4 Likes

On the source level I’ll take

def f(pattern1) do
  # ...
end

def f(pattern2) do
  # ...
end

def f(pattern3) do
  # ...
end

def f(pattern4) do
  # ...
end

over

def f(x) do
  case x do
    pattern1 ->
      f1(params1)
    pattern2 ->
      f2(params2)
    pattern3 ->
      f3(params3)
    pattern4 ->
      f4(params4)
  end
end

… in the end you have to work with what you have got.

Yes, and many people agree with you that multiple function heads are better. I was just trying to show that ot was very subjective and most benefit comes from pattern matching :slight_smile:, whether in a case statement or in multiple function heads

There is also the proposal to add pattern matching to javascript.

I’ve always preferred the case statement pattern matching to the function head pattern matching - it’s much clearer to me. The community seems to have the opposite preference though, so I just go with the flow.

I am quite sure that the first is much better than the second. That’s why I would be frustrated.

I think the feature that is quite different between the two ( at least in Elixir) is variable scope. I know exactly where and when the variable is in scope vs having to keep in my head the languages internal rules about variable scope in complex logical structures.

In a language where = is an assignment operator, I’m not sure that pattern matching buys you all that much particularly in languages like Python and Ruby with their relatively complex variable scoping rules.

1 Like

Yes but why would you not use multi-function clauses if you have access to them?

My bias comes from being in the small function camp.

  • case/2 is a perfect fit for a small number of clauses each of which have tiny (one or two line) bodies - i.e. the resulting function isn’t going to be that large
  • if there are too many clauses, the function grows too large
  • if too many clauses have large bodies, the function grows too large
  • reorganizing a large case statement into multiple function clauses:
    • gives you the illusion of smaller functions (as it is actually one big function anyway)
    • while at the same time still having the relevant pattern colocated with the body

The biggest drawback is that there are instances where having all patterns tightly in one place is beneficial to

  • assess proper ordering
  • detect unintentional pattern overlap
  • detect unintentional pattern gaps

But even with case the patterns are spread all over the place if the clause bodies are large enough.

So it’s not that case is worse - multi-clause functions are simply an opportunity to use a different style of code organization.

For me in OCaml/Bucklescript/ReasonML match/switch expressions often turn into a list of pattern -> function_invocation pairs.

Sounds like the age of code formatters could become a constant source of frustration… :grin:

For example refmt turns

let result = switch (isMorning) {
| true => "Good morning!"
| false => "Hello!"
};

into

let result = isMorning ? "Good morning!" : "Hello!";

and soon enough into

let result = if (isMorning) {"Good morning!"} else {"Hello!"};

… there is always opportunity to get “frustrated” with somebody else’s coding standard.

I’m not sure that pattern matching buys you all that much

Once you de-emphasize dot notation in favor of destructuring, pattern matching seems to be the next logical step.

3 Likes

As just another design, OCaml went a kind of combination route.

Basically OCaml has single function heads, and it has a case-like statement called match, so the above things would be like:

let f(x) =
  match x with
  | pattern1 ->
    f1 (params1)
  | pattern2 ->
    f2 (params2)
  | pattern3 ->
    f3 (params3)

However that was such a common pattern that they made the function keyword that combines fun/let and match, so the above can be just:

let f = function
| pattern1 ->
  f1 (params1)
| pattern2 ->
  f2 (params2)
| pattern3 ->
  f3 (params3)

And of course you can put whatever spacing you want, like:

let f = function

| pattern1 ->
  f1 (params1)

| pattern2 ->
  f2 (params2)

| pattern3 ->
  f3 (params3)

Which is similar to elixir/erlang functions (with an initial elixir ‘head’ function). ^.^;

Unsure which overall I like though to be honest, I’m ‘meh’ about it as long as it exists in some easy form. :slight_smile:

2 Likes

Alo, author of Qo here and writer of massive issue comments on aforementioned bug.

I actually suggested something later in the thread that amounts to the multi-function clause for an individual match. I could very likely make it into a method creation / match as well with some ideas I came up with in Qo::Evil:

Basically compiling dynamic conditions into the most basic way to express the boolean statement possible (&& joins more or less) using binding.eval and injecting non-string-coercible types as local variables set on a hash during “compilation”.

Why? Because that runs at 10-20% off optimal vanilla ruby speeds in a lot of cases for sets of a decent size. Still experimenting on that one though.

1 Like