A proposal for structural pattern matching

I am proposing runtime structural pattern matching to Crystal, akin to Ruby 3’s => and in operators. Ideally, such a construct would let us write code like below:

# Before
ary = [1, 2, 3, 4, 5, 6, 7]
if ary.size >= 2
  a, b = ary
  rest = ary[2..]
else
  raise "not enough elements"
end

# After
[1, 2, 3, 4, 5, 6, 7] ~> [a, b, *rest]
# Before
if ary.size == 3
  first, x, y = ary
end
if first.is_a?(Circle) && x.is_a?(Int32) && y.is_a?(Int32)
  first.move_to(x, y)
end

# After
if ary ~>? {first = Circle, x = Int32, y = Int32}
  first.move_to(x, y)
end
# Before
anniversary = events.find do |ev|
  ev.has_key?("start") && ev["start"].in?("2020-01-01".."2020-12-31") &&
    ev.has_key?("id") && ev["id"] =~ /^ANNIV.*/
end["start"]
events.select! &.[]?("start").== anniversary

# After
events ~> {*_, {"start" => anniversary = "2020-01-01".."2020-12-31", "id" => /^ANNIV.*/}, *_}
events.select! &.~>?({"start" => ^anniversary})

This library contains a proof-of-concept implementation in the macro language that is good enough for toying with and defining the matching semantics. These pattern matching operators will be type-safe; the reference macros leverage flow typing as much as possible to achieve this.

The actual proposal is here. This is a first draft and I’d like to gather some feedback here before submitting an actual RFC issue.

2 Likes

The examples are not very fair.

For the first, no need to have the if/else conditions. It will already raise if the array size is 0 or 1.

ary = [1]
# raises
a, b = ary
rest = ary[2..]

For the second, this is another way to do it:

struct Circle
  def move_to(x, y)
    puts "hi"
  end
end

ary = [Circle.new, 2, 3]

if ary.size == 3
  first, x, y = ary
end

# this
case {first, x, y}
when {Circle, Int32, Int32} then first.move_to(x, y)
end

# or this
if {first, x, y}.is_a? {Circle, Int32, Int32}
  first.move_to(x, y)
end

And in the third, you can use &.try? to reduce the code:

anniversary = events.find do |ev|
  ev["start"]?.try(&.in? "2020-01-01".."2020-12-31") && ev["id"]?.try &.=~ /^ANNIV.*/
end["start"]
events.select! &.[]?("start").== anniversary

For me, this above examples are not enough to justify the added complexity for only saving few lines, and having another feature to learn and master. You’ll have to find other cases :wink:

1 Like

Honestly, I would have no clue what this code is supposed to mean:

Okay, the Before code isn’t very readable either. But at least it’s relatively easy to figure out.
And @j8r’s improvement makes it much better than the pattern matching variant IMO.

For the first example, I’d prefer the syntax proposed in https://github.com/crystal-lang/crystal/pull/10410 which would conceptually even be simpler than the pattern matching:

a, b, *rest = [1, 2, 3, 4, 5, 6, 7]

I think that fits very well with Crystal’s idioms.

I don’t have a clear opinion on the second example, but I would question why you have an array with types Circle, Int32, Int32 in the first place.

Overall, I’m sceptical. But not necessarily opposed to the proposal. Might just be missing the right arguments.
Sure, it’s nice to have such a feature. But I’m in doubt about the practical relevance (for now).

6 Likes

I’d be inclined to believe that pattern matching would be more useful in instances where classes/structs have nilable types. As an example from an actual project I’m working on:

abstract class ParamType
  # body omitted for brevity
end

class FlagsParam < ParamType
  # no params
  # body omitted for brevity
end

class NormalParam < ParamType
  getter type : Type
  getter flag : Flag?
  
  # remainder omitted for brevity
end

Here we have 2 cases which generally require a case statement or an if/else statement. 1) We need to know which ParamType we’re working with since both have different attributes, and 2) when working with a NormalParam we have an optional parameter flag. Right now I have to do something like this:

# example function
def parse_params(params : Array(ParamType))
  params.each do |param|
    case param
    when FlagsParam
      # do something
    when NormalParam
      if flag = param.flag
        # do something
      else
        # do something else
      end
    end
  end
end

And this works fine. I have few complaints. However I do believe case statements could be extended with additional pattern matching capabilities so as to make things less verbose. Maybe something like the following:

# example function
def parse_params(params : Array(ParamType))
  params.each do |param|
    case param
    when FlagsParam
      # do something
    when NormalParam { flag: !it.nil? }
      # do something with flag
    end
  end
end

The real power would come from nested values though:

# example function
def parse_params(params : Array(ParamType))
  params.each do |param|
    case param
    when FlagsParam
      # do something
    when NormalParam { flag: nil, type: { a, b: 32 } }
      # do something with a and b (knowing that b == 32)
    end
  end
end

Admittedly this idea is 100% from the way Rust does things, but the idea is that the curly braces would act somewhat as a less verbose case statement where the left-hand arguments (if matched) are passed to the block.

Just my two cents. I feel like there are cases where the proposed pattern matching could be useful, but for the most part I think we’d be fine just extending case to handle more patterns.