Заметки о 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})