Заметки о pattern matching в Ruby 2.7

В недавно вышедшей Ruby 2.7 кроме регулярных обновлений библиотек внесли несколько интересных изменений в сам синтаксис языка. На фоне минорных расширений, таких как нумерованные аргументы (_1, _2, …), beginless range (..n) и изменение семантики splat-операторов (* и **), pattern matching выделяется очень заметно. Это, вероятно, последняя фича взрослых функциональных языков, которой не хватало в Ruby.

Не буду описывать здесь весь синтаксис - для знакомста рекомендую почитать статью из The Ruby Reference, которая в будущем может стать официальной документацией. В этом посте я лишь дополню ее, опишу граничные случаи и нюансы.

Официальной документации до сих пор нет, но в сети есть множество статей посвященных новому синтаксису. Большинство появилось еще до официального релиза Ruby 2.7 25-го декабря 2019 года, поэтому внимательно смотрите на дату публикации. Они писались на основе черновой реализации и содержат неточности и неактуальную информацию.

Деструкция

Начну я с акцента на том, что кроме расширения синтаксиса case оператора, появился и мощный механизм деструкции с использованием оператора in:

config = {
  connection: {
    timeout: 10, url: 'http://example.com', method: :get
  },
  mode: :test
}

# Destruction !!!
config in {connection: {url:}, mode:} # => nil

url # => "http://example.com"
mode # => :test

Оператор in может создавать новые локальные переменные (url и mode в данном случае) и сохранять в них данные из глубин массивов или хешей.

Оператор then

Новый синтаксис оператора case/in выглядит так:

case 0
  in 0
    true
end

Но, как и с оператором case/when, можно использовать ключевое слово then и записывать шаблон и соответствующую ветку в одной строке:

case [0, 1]
  in [0]    then :foo
  in [0, 1] then :bar
end

Синтаксис для массивов и хешей

Идем дальше. Для массивов и хешей обычно используют синтаксис с [] и {}:

case [0, 1, 2]
  in [0, 1, 2] # <==
    true
end

case {a: 0, b: 1}
  in {a: 0, b: 1} # <==
    true
end

Но существует и полный синтаксис:

  • Constant(pat, ..., *var, pat, ...) для массивов,
  • Constant[id:, id: pat, "id": pat, ..., **var] для хешей
case [0, 1, 2]
  in Array[0, 1, 2] # <=
    true
end

case {a: 0, b: 1}
  in Hash[a: 0, b: 1] # <=
    true
end

Также можно использовать () вместо []:

case [0, 1, 2]
  in Array(0, 1, 2) # <=
    true
end

case {a: 0, b: 1}
  in Hash(a: 0, b: 1) # <=
    true
end

Примеры для value pattern

Value pattern использует метод === для сопоставления шаблона и значения. Приведу примеры для стандартных классов, которые реализуют ===:

case 0
  in 0
  in (-1..1)
  in Integer
  in Enumerable
  in /[0-9]/
  in ->(s) { s == "0" }
  in Prime.method(:prime?)
end

Для любых скалярных типов вроде Integer, TrueClass, … метод === работает как простое сравнение. Для String значение приводится к строке вызовом to_str. Для Method и Proc === означает вызов самого метода/proc‘а и передачу значения аргументом. Класс и модуль проверяют является ли значение экземпляром класса или включает ли модуль.

Частичное соответствие

Для хеша шаблон матчится всегда частично, т.е. шаблон сматчен если соответствует подмножеству хеша. Массив же, напротив, матчится всегда полностью. Но есть нюанс. Используя синтаксис [0,] можно сматчить массив частично начиная с первых элементов:

case [0, 1, 2, 3]
  in [0, 1,]
    true
end

if/unless guards

Несмотря на схожесть if/unless guard с модификаторами if/unless первые работают иначе:

case 0
  in 0 if false
    true
else
  false
end

Условие в if/unless вычисляется всегда после матчинга шаблона и только если шаблон сматчился успешно. Как следствие, в условии доступны переменные, которые выставились при матчинге:

case [0, 1]
  in [a, 1] if a >= 0
    true
end

Вычисления в value pattern

В шаблоне нельзя что-то вычислять или вызывать методы - можно использовать только литералы и константы:

case 0
  in 1 - 1
end

# SyntaxError: (eval):2: syntax error, unexpected '-', expecting `then' or ';' or '\n'
#   in 1 - 1
#       ^
# (eval):4: syntax error, unexpected `end', expecting end-of-input

Но есть исключение - разрешается интерполяция строк, где можно использовать любые выражения:

case "0"
  in "#{1 - 1}"
    true
end

Variable pattern

В шаблоне с binding‘ом переменных нельзя использовать одно и тоже имя дважды:

case [0, 0]
  in [a, a]
end

# SyntaxError ((irb):9: duplicated variable name)
#   in [a, a]
#          ^

Но по конвенции переменную _ и любую другую, которая начинается с символа _, можно использовать повторно. Переменной присвоится последнее значение:

case [0, 1]
  in [_a, _a]
    true
end

_a # => 1

Если мы хотим в шаблоне указать, что какое-то произвольный элемент встречается несколько раз, можно использовать оператор ^ и имя переменной:

case [1, 2, 3, 1]
  in [n, 2, 3, ^n]
    true
end

Так мы проверяем, что первый элемент равен последнему.

Надо учитывать, что выражение ^n использует переменную n, которая должна уже существовать. Поэтому n должна идти в шаблоне раньше оператора ^n:

case [1, 2, 3, 1]
  in [^n, 2, 3, n]
    true
end

# SyntaxError ((irb):20: n: no such local variable)

Ключи в hash pattern

Почему-то в шаблоне разрешаются только символьные ключи и повторение ключа не разрешено. Выражение "": тоже можно использовать, но строковая интерполяция уже запрещена:

case {a: 0}
  in {"a": 0}
    true
end

case {a: 1}
  in {"#{x}": 1}
    true
end

# SyntaxError ((irb):28: symbol literal with interpolation is not allowed)
#   in {"#{x}": 1}
#       ^~~~~~~

Как можно использовать pattern matching?

На самом деле pattern matching это очень мощный механизм. И если смотреть не на Scala с обрезанным pattern matching‘ом, а на реализации в функциональных языках вроде Haskell, можно найти красивые способы применения.

Пример метода, который вычисляет длину коллекции на Haskell:

length' :: (Num b) => [a] -> b
length' [] = 0
length' (_:xs) = 1 + length' xs

И теперь на StandardML:

fun len l =
  case l of
    [] => 0
    | (_::t) => 1 + len t;

А вот реализация на Ruby:

def length(seq)
  case seq
    in [] then 0
    in [_, *rest] then 1 + length(rest)
  end
end

Ruby-код так же лаконичен и прост и теперь можно писать в труЪ функциональном стиле и почти как на Haskell.

Хотя фича считается еще экспериментом и может меняться в будущем, уже сейчас это мощный и продуманный механизм. Я бы с удовольствием использовал его в production‘е.

Чего же еще не хватает?

Мне очень не хватало деструкции после знакомства с Clojure, а особенно деструкции аргументов методов/блоков. Сейчас это все еще невозможно - нельзя использовать оператор in в списке аргументов:

def foo(a, b in {x:, y:}, [z, _])
end

# SyntaxError: syntax error, unexpected `in', expecting ')'
#       def foo(a, b in {x:, y:}, [z, _])
#                    ^~
# syntax error, unexpected ')', expecting `end'
# ...f foo(a, b in {x:, y:}, [z, _])
# ...                              ^

Запрещено дублировать имя переменной в шаблоне и надо использовать для этого оператор ^. Но разве не естественней разрешить повторение переменной и трактовать это как тот же самый ^n? Например, вместо [n, 2, 3, ^n] можно было бы писать просто [n, 2, 3, n].

Если есть оператор |, то почему бы не разрешать и оператор &? Так можно было бы упростить сложный шаблон и разбить его на отдельные части:

case [[1, 2], {a: 3, b: 4}]
  in [[1, *], *] & [*, {a: 3}]
end

И как-то неуютно без отрицания - т.е. нельзя определить шаблон, который матчится, когда условие не выполняется. Например, используя “неработающий” оператор not, можно было бы сделать так:

case x
  in not Array
  in not /[0-9]/
  in not 0
end

Еще напрашивается само собой использовать шаблоны как предикаты в методах типа select/find… Только представьте, что можно было бы писать вот так:

[0, 'a', :a].select(%p{Integer})

Ссылки