Jakiś czas temu napisałam artykuł o podstawach pattern matchingu w Elixirze. Bardzo spodobała mi się idea dopasowywania do wzorca. Teraz możemy się nią cieszyć również w najnowszej wersji Ruby 2.7!!! Nie jest to co prawda ten sam pattern matching co w Elixirze, ale jest to ciekawa funkcjonalność. Trzeba też pamiętać, że pattern matching w Ruby jest funkcjonalnością eksperymentalną, więc może się zmieniać dynamicznie w następnych wersjach Rubiego. Nie zmienia to faktu, że już teraz możemy ją przetestować.
Zanim jednak zaczniemy, przypomnijmy sobie Co to jest pattern matching? Pattern matching, czyli inaczej dopasowanie do wzorca jest to sposób na przygotowywanie pewnych wzorców dla naszych danych, dzięki którym, jeżeli dane do tych wzorców pasują, możemy rozebrać je na czynniki pierwsze bazując właśnie na podanych wzorcach. Innymi słowy pattern matching służy nam do wyciągania wybranych elementów ze skomplikowanych struktur danych, na podstawie pewnych reguł przez nas zdefiniowanych. Możemy też powiedzieć, że pattern matching jest jak wyrażenie regularne z wielokrotnym przypisaniem wykorzystywane nie tylko dla łańcuchów znaków (string).
Na początku artykułu Pattern matching w Elixirze - podstawy opisałam operator match w Elikxirze. Ponieważ Ruby jako język powstał na podstawie trochę innych koncepcji niż Elixir (choć trzeba tu pamiętać o ich powiązaniu), to nie zobaczymy w Ruby czegoś podobnego do operatora match. Ruby operuje na przypisaniu. Podczas gdy w Elixirze możemy zrobić:
iex> x = 4
4
iex> 4 = x
4
to w języku Ruby jest to niemożliwe:
irb> x = 4
=> 4
irb> 4 = x
Traceback (most recent call last):
3: from /home/agnieszka/.rvm/rubies/ruby-2.7.0-preview1/bin/irb:23:in `<main>'
2: from /home/agnieszka/.rvm/rubies/ruby-2.7.0-preview1/bin/irb:23:in `load'
1: from /home/agnieszka/.rvm/rubies/ruby-2.7.0-preview1/lib/ruby/gems/2.7.0/gems/irb-1.0.0/exe/irb:11:in `<top (required)>'
SyntaxError ((irb):2: syntax error, unexpected '=', expecting end-of-input)
4 = x
^
Nie pokazuje tego by udowodnić, że Elixir jest lepszym językiem niż Ruby. Mówię o tym, by zaznaczyć, jak trudne zadanie stało przed twórcami języka Ruby, gdy postanowili wprowadzić pattern matching do języka. Tak jak wspominałam na początku, jest to funkcjonalność eksperymentalna, więc potrzeba czasu na jej dopracowanie, odpowiedzenie na wszystkie pojawiające się pytania i podjęcie decyzji w jakim kierunku dalej rozwijać pattern matching. Na tę chwilę możemy już teraz wypróbować to, co nam oferuje pattern maching w Ruby.
Od Ruby 3.0 istnieje nowy sposób na skorzystanie z powyższego dopasowania do wzorca:
3.0.0 > 4 => x
=> nil
3.0.0 > x
=> 4
Pattern matching w Ruby - podstawy
Na początku zapoznajmy się z nową składnią dla case
jaką mamy dostępną:
case expression
in pattern [if|unless condition]
...
in pattern [if|unless condition]
...
else
...
end
Pattern matching ma tą samą kolejność wykonywania działań co normalny case
. Szukamy więc pierwszego dopasowania do wzorca. Jeżeli takie dopasowanie nie zostanie znalezione, wywołany będzie kod znajdujący się w else
. Gdyby jednak tego else
nie było i nie byłoby również dopasowania do żadnego zadeklarowanym przypadku, to zostałby wywołany wyjątek NoMatchingPatternError
. Możesz to zobaczyć w poniższych przykładach.
Pattern matching dla klasy Array w Ruby
Jednym z najprostszych przykładów dopasowania do wzorca dla tablic jest:
case [1, 2]
in [2, a]
:no_match
in [1, a]
:match
end
=> :match
irb> a
=> 2
Pierwszy wzorzec nie został dopasowany do tablicy [1, 2]
, więc wywołany został drugi przypadek, w którym dopasowanie zostało znalezione. Dodatkowo otrzymałyśmy przypisanie a = 2
.
Jeżeli nie znasz rozmiaru tablicy lub po prostu chcesz dostać dostęp do jej części możesz skorzystać z operatora splat.
case [1, 2, 3, 4]
in [1, *a]
end
=> nil
irb> a
=> [2, 3, 4]
Pamiętaj jednak o różnicy między zmienną a
a splat operatorem *a
:
# normal variable
case [1, 2, 3]
in [1, a, 3]
end
=> nil
irb> a
=> 2
# splat operator
case [1, 2, 3]
in [1, *a, 3]
end
=> nil
irb> a
=> [2]
Splat operator zawsze zwróci Ci tablicę. Możesz też użyć _
do pominięcia części danych we wzorcu:
case [1, 2, 3]
in [_, a, 3]
end
=> nil
irb> a
=> 2
Istnieje też możliwość pominięcia nawiasów:
case [1, 2, 3]
in 1, a, 3
end
=> nil
irb> a
=> 2
Pattern matching może być użyteczny dla tablic z bardziej skomplikowaną strukturą:
case [1, [2, 3, 4]]
in [a, [b, *c]]
end
=> nil
irb> a
=> 1
irb> b
=> 2
irb> c
=> [3, 4]
Pattern matching dla klasy Hash w Ruby
Kiedy mówimy o dopasowaniu do wzorca dla klasy Hash
musimy pamiętać, że obecnie istnieje tylko wsparcie dla tablic słownikowych o kluczach w postaci symboli. Klucze w postaci łańcuchów znaków lub bardziej skomplikowanych obiektów nie są jeszcze wspierane. Jeżeli chciałabyś wiedzieć więcej na temat problemów dotyczących klasy Hash
odsyłam Cię do prezentacji Kazuki Tsujimoto na temat Pattern matching w Ruby.
Zacznijmy od prostego przykładu:
case { foo: 1, bar: 2 }
in { foo: 1, baz: 3 }
:no_match
in { foo: 1, bar: b }
:match
end
=> :match
irb> b
=> 2
Podobnie jak w przypadku tablic gdzie mamy operator splat w tablicy słownikowej też możemy dostać się do części naszego obiektu, tym razem jednak za pomocą podwójnego operatora splat:
case { foo: 1, bar: 2, baz: 3 }
in { foo: 1, **rest }
end
=> nil
irb> rest
=> {:bar=>2, :baz=>3}
Tak samo jak dla tablic możemy opuścić nawiasy:
case { foo: 1, bar: 2 }
in foo: foo, bar: bar
end
=> nil
irb> foo
=> 1
irb> bar
=> 2
Dzięki cukrowi składniowemu (syntactic sugar) możemy również opuścić zmienne i zostać przy samych symbolach:
case { foo: 1, bar: 2 }
in foo:, bar:
end
=> nil
irb> foo
=> 1
irb> bar
=> 2
Chciałabym też zwrócić uwagę na jeszcze jedną rzecz. Dokładne dopasowanie (exact matching) zachowuje się inaczej w przypadku tablic, a inaczej w przypadku tablic słownikowych (Hash
). Popatrz na przykład poniżej. Dla zwykłej tablicy dostaniemy wyjątek NoMatchingPatternError
:
case [1, 2]
in [1]
:no_match
end
Traceback (most recent call last):
4: from /home/agnieszka/.rvm/rubies/ruby-2.7.0-preview1/bin/irb:23:in `<main>'
3: from /home/agnieszka/.rvm/rubies/ruby-2.7.0-preview1/bin/irb:23:in `load'
2: from /home/agnieszka/.rvm/rubies/ruby-2.7.0-preview1/lib/ruby/gems/2.7.0/gems/irb-1.0.0/exe/irb:11:in `<top (required)>'
1: from (irb):33
NoMatchingPatternError ([1, 2])
Natomiast dla tablicy słownikowej mamy:
case { foo: 1, bar: 2 }
in foo:
:match
end
=> :match
irb> foo
=> 1
Na początku może być to mylące. Dla obiektu typu Hash
dopasowanie nie odbywa się dokładnie. Jednak muszę przyznać, że sama z tego korzystałam wielokrotnie podczas moich testów. Gdyby jednak, to zachowanie miało się zmienić nie powinnam mieć problemu z dostosowaniem się do składni typu:
case { foo: 1, bar: 2 }
in foo:, **_
end
Żeby jednak osiągnąć ten sam efekt, jaki otrzymałyśmy dla zwykłych tablic, potrzebujemy zrobić:
case { foo: 1, bar: 2 }
in foo:, **rest if rest.empty?
:no_match
end
Traceback (most recent call last):
4: from /home/agnieszka/.rvm/rubies/ruby-2.7.0-preview1/bin/irb:23:in `<main>'
3: from /home/agnieszka/.rvm/rubies/ruby-2.7.0-preview1/bin/irb:23:in `load'
2: from /home/agnieszka/.rvm/rubies/ruby-2.7.0-preview1/lib/ruby/gems/2.7.0/gems/irb-1.0.0/exe/irb:11:in `<top (required)>'
1: from (irb):37
NoMatchingPatternError ({:foo=>1, :bar=>2})
Jest to bardzo istotne by pamiętać o różnicy w dopasowaniu dla obiektów typy Hash
, ponieważ w niektórych momentach możemy się zdziwić odnośnie dopasowania do wzorca:
case { foo: 1, bar: 2 }
in { foo: 1 }
:it_will_match_here
in { foo: 1, bar: b }
:no_match
end
=> :it_will_match_here
Mogłybyśmy oczekiwać dopasowania do drugiego wzorca, a w tym przypadku dostaniemy dopasowanie już dla pierwszego z nich.
Pattern matching - strażnicy (guards)
Mogłaś zauważyć wykorzystanie strażników w poprzednim przykładzie. Do naszego wzorca możemy dodać jeszcze dodatkowy warunek logiczny (guard), który operuje na zmiennych zadeklarowanych we wzorcu:
case [1, 2, 3]
in [a, *c] if a != 1
:no_match
in [a, *c] if a == 1
:match
end
=> :match
irb> a
=> 1
irb> c
=> [2, 3]
Czego możemy używać w dopasowaniu do wzorca?
Literały
Pattern matching dla języka Ruby może używać literałów czyli: wartości logicznych (Booleans), nil
, liczb (Numbers), łańcuchów znaków (Strings), symboli (Symbols), tablic (Arrays), tablic słownikowych (Hashes), zakresów (Ranges), wyrażeń regularnych (Regular Expressions) i Procs.
case 2
in (1..3)
:match
in Integer
:too_late_for_match
end
=> :match
Zmienne
Możemy też używać zmiennych, co pokazywałam już w poprzednich przykładach. Chciałabym tylko zwrócić uwagę na jedną rzecz. Podczas sprawdzania wzorca zawsze następuje przypisanie:
irb> array = [1, 2, 3]
=> [1, 2, 3]
case [1, 2, 4]
in array
:match
end
irb> array
=> [1, 2, 4]
Gdy chcemy sprawdzić, czy nasze dane pasują do wzorca wpisanego już wcześniej do zmiennej musimy użyć operatora ^
:
irb> array
=> [1, 2, 4]
case [1, 2, 3]
in ^array
:no_match
else
:match
end
irb> array
=> [1, 2, 4]
Wzór alternatywny
Kolejną rzeczą jaką możemy użyć jest wzór alternatywny (alternative pattern):
case 5
in 6
:no_match
in 2 | 3 | 5
:match
end
=> :match
As pattern
Ten typ wzorca nazwałabym po prostu przypisaniem. As pattern pozwala nam na przypisanie do zmiennej bardziej skomplikowanego wyrażenia:
case [1, 2, [3, 4]]
in [1, 2, [3, b] => a]
end
=> nil
irb> a
=> [3, 4]
irb> b
=> 4
Pattern matching dla innych obiektów
Do tej pory pokazałam Ci jak wygląda dopasowanie do wzorca dla tablicy i dla tablicy słownikowej. Na tą chwilę Ruby obsługuje pattern matching tylko dla kilku obiektów. Do tej grupy zalicza się również Struct
.
Point = Struct.new(:latitude, :longitude)
point = Point[50.29543618146685, 18.666200637817383]
case point
in latitude, longitude
end
=> nil
irb> latitude
=> 50.29543618146685
irb> longitude
=> 18.666200637817383
Jeżeli chciałabyś użyć dopasowania do wzorca dla innych obiektów, to należy do tego celu użyć metody deconstruct
lub deconstruct_keys
. W zależności, którą z nich użyjesz, Twoje obiekty podczas dopasowywania będą się zachowywać odpowiednio jak Array
lub jak Hash
. W przypadku powyżej Struct
zachowuje się jak Array
. Poniżej zamieściłam bardzo prosty przykład, gdzie obiekt klasy Date
będzie zachowywał się podczas dopasowywania do wzorca jak Hash
:
class Date
def deconstruct_keys(keys)
{ year: year, month: month, day: day }
end
end
date = Date.new(2019, 9, 21)
case date
in year:
end
=> nil
irb> year
=> 2019
Dane w formacie JSON
Moim zdaniem bardzo ładnie prezentują się zalety stosowania pattern matching w przypadku danych w formacie JSON:
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"properties": {},
"geometry": {
"type": "Point",
"coordinates": [
18.666200637817383,
50.29543618146685
]
}
}
]
}
W tym przypadku dopasowanie do wzorca może wyglądać następująco:
case JSON.parse(json, symbolize_names: true)
in { type: "FeatureCollection", features: [{type: "Feature", geometry: { type: "Point", coordinates: [longitude, latitude]}}]}
end
irb> longitude
=> 18.666200637817383
irb> latitude
=> 50.29543618146685
Jeżeli użyłybyśmy do tego celu warunków logicznych kod wyglądałby następująco:
point = JSON.parse(json, symbolize_names: true)
if point[:type] == "FeatureCollection"
features = point[:features]
if features.size == 1 && features[0][:type] == "Feature"
geometry = features[0][:geometry]
if geometry[:type] == "Point" && geometry["coordinates"].size == 2
longitude, latitude = geometry["coordinates"]
end
end
end
irb> longitude
=> 18.666200637817383
irb> latitude
=> 50.29543618146685
Myślę że widać różnicę w czytelności.
Zakresy - dziwne zachowanie
W obecnej wersji pattern matching w Ruby 2.7 ma pewne dziwne zachowanie, o którym wiadomo i które będzie zmienione w następnych wersjach języka Ruby. Chodzi o problem przypisywania wartości do zmiennych, nawet jeżeli warunek (nasz wzorzec) nie został dopasowany. Oto przykład:
case[1, 2]
in x, y if y > 3
:no_match
in x, z if z < 3
:match
end
=> :match
irb> x
=> 1
irb> z
=> 2
# unexpected assignment for y when pattern matching failed
irb> y
=> 2
Moje propozycje
Kiedy testowałam sobie pattern matching w Ruby, przyszły mi do głowy pewne dodatkowe funkcjonalności, które moim zdaniem mogłyby być przydatne. Oto one:
- pattern matching w jednej linii - Mamy
each
zapisywany w jednej linijce. Dlaczego nie mieć czegoś podobnego dla dopasowania do wzorca? Mogłoby to wyglądać następująco:case [1, 2, [3, 4]] { [1, 2, [3, b] => a] }
i służyć do przypisywania wartości do zmiennych. - obliczenia we wzorcach - Czasami chciałoby się zrobić szybkie obliczenia, których wynik zostanie potraktowany jako wzorzec. Tak jak w przypadku
in (1..3).to_a
. Na ta chwilę nie jest to jednak możliwe. Ten problem da się obejść wpisując wynik działania do zmiennejarray = (1..3).to_a
a później używając go jakoin ^array
. - pozwolić na używanie zmiennych w wzorcach alternatywnych - byłoby fajnie móc zrobić
[1, 2] | [1, 2, c]
.
Wiem że moje pomysły mogą być trudne do osiągnięcia lub może nawet niemożliwe, ale jest to moja taka mała lista życzeń. ;]
Od Ruby 3.0 mamy dostęp do większej liczby funkcjonalności związanej z dopasowaniem do wzorca. Jeżeli chcesz wiedzieć więcej na ten temat, zapraszam na mój artykuł: Drugie spojrzenie na pattern matching w Ruby.
Podsumowanie
Bardzo podoba mi się się funkcjonalność dopasowania do wzorca w Ruby. Cieszę się, że mogłam się nią pobawić. Wiem, że to rozwiązanie nie jest jeszcze gotowe do używania produkcyjnie, ale podoba mi się jego czytelność i potencjał jaki ma w sobie.
A Ty co myślisz o pattern matchingu w Ruby? Podziel się swoimi przemyśleniami w komentarzach.
Potrzebujesz pomocy?
Jeśli szukasz doświadczonej programistki Ruby z ponad dziesięcioletnim stażem, śmiało skontaktuj się ze mną.
Mam doświadczenie w różnych domenach, a szczególną wagę przykładam do szybkiej reakcji na opinie użytkowników i pracy zespołowej. Pomogę Ci stworzyć świetny produkt.