Ruby
 Computer >> コンピューター >  >> プログラミング >> Ruby

Rubyのパターンマッチング入門:case/in構文でコードを読みやすくする

Rubyにおけるパターンマッチングについて、その仕組みとコードの可読性向上への活用方法を簡単に見ていきましょう。

数年前の私のように、これを正規表現(Regex)のパターンマッチングと混同している方もいるかもしれません。「pattern matching」だけでGoogle検索すると、正規表現に近い定義の記事が上位に出てくることさえあります。

正式には、パターンマッチングとは、あるデータ(文字列、トークンの列、タプルなど何でも)を別のデータと照合して検証するプロセスのことです。

プログラミングにおいては、言語の能力に応じて、以下のようなことが可能になります。

  1. 期待されるデータ型とのマッチング
  2. 期待されるハッシュ構造とのマッチング(特定のキーの存在確認など)
  3. 期待される配列の長さとのマッチング
  4. マッチした値(またはその一部)を変数に代入する

私が初めてパターンマッチングに触れたのはElixirでした。Elixirはパターンマッチングを第一級でサポートしており、=演算子は単純な代入ではなく、実際にはmatch(マッチ)演算子として動作します。

つまり、Elixirでは次のようなコードが実際に有効です。

iex> x = 1
iex> 1 = x

それを踏まえて、Ruby 2.7以降で導入されたパターンマッチングのサポートを見て、今日からコードをより読みやすくするためにどう活用できるかを探っていきましょう。

Rubyのcase/inによるパターンマッチング

Rubyでは、特別なcase/in式を使ってパターンマッチングを行います。構文は次のとおりです。

case <expression>
in <pattern1>
  # ...
in <pattern2>
  # ...
else
  # ...
end

これはcase/when式と混同しないように注意してください。wheninの分岐を同じcase内で混在させることはできません。

また、else節を省略した場合、どのパターンにもマッチしないとNoMatchingPatternErrorが発生します。

Rubyでの配列のパターンマッチング

パターンマッチングを使うと、配列をデータ型・長さ・値といった事前の構造要件に対して照合できます。

たとえば、以下はすべてマッチします(最初にマッチした時点でcaseは評価を止めるため、実際に評価されるのは最初のinだけであることに注意してください)。

case [1, 2, "Three"]
in [Integer, Integer, String]
  "matches"
in [1, 2, "Three"]
  "matches"
in [Integer, *]
  "matches" # * は任意の要素にマッチするスプレッド演算子
in [a, *]
  "matches" # 変数 a の値は 1 になる
end

この種のパターンマッチングは、メソッド呼び出しから複数のシグナルを返したい場合に非常に便利です。

Elixirの世界では、データベースへの挿入など、:ok:errorの両方の結果があり得る操作を行う際によく使われています。

これを使って可読性を高める例がこちらです。

def create
  case save(model_params)
  in [:ok, model]
    render :json => model
  in [:error, errors]
    render :json => errors
  end
end
 
# コードのどこか、例えばグローバルヘルパーやモデルの基底クラス内(名前は変える)
def save(attrs)
  model = Model.new(attrs)
  model.save ? [:ok, model] : [:error, model.errors]
end

Rubyでのオブジェクトのパターンマッチング

Rubyではオブジェクトもマッチングでき、特定の構造を強制することができます。

case {a: 1, b: 2}
in {a: Integer}
  "matches" # デフォルトではオブジェクトのマッチは部分一致
in {a: Integer, **}
  "matches" # {a: Integer} と同じ意味
in {a: a}
  "matches" # 変数 a の値は 1 になる
in {a: Integer => a}
  "matches" # 変数 a の値は 1 になる
in {a: 1, b: b}
  "matches" # 変数 b の値は 2 になる
in {a: Integer, **nil}
  "does not match" # キー a のみを持ち他のキーがない場合のみマッチ
end

これは、paramsなどに対して厳格なマッチングルールを課したい場合に威力を発揮します。

たとえば、少し凝った挨拶プログラムを書くなら、次のような(かなり独断的な)構造にできます。

def greet(hash = {})
  case hash
  in {greeting: greeting, first_name: first_name, last_name: last_name}
    greet(greeting: greeting, name: "#{first_name} #{last_name}")
  in {greeting: greeting, name: name}
    puts "#{greeting}, #{name}"
  in {name: name}
    greet(greeting: "Hello", name: name)
  in {greeting: greeting}
    greet(greeting: greeting, name: "Anonymous")
  else
    greet(greeting: "Hello", name: "Anonymous")
  end
end
 
greet # Hello, Anonymous
greet(name: "John") # Hello, John
greet(first_name: "John", last_name: "Doe") # Hello, John Doe
greet(greeting: "Bonjour", first_name: "John", last_name: "Doe") # Bonjour, John Doe
greet(greeting: "Bonjour") # Bonjour, Anonymous

Rubyにおける変数のバインディングとピン留め

上記の例で見てきたように、パターンマッチングはパターンの一部を任意の変数に代入するのにとても便利です。これは「変数バインディング」と呼ばれ、いくつかの方法があります。

  1. 型指定つきでバインドする。例:in [Integer => a]in {a: Integer => a}
  2. 型指定なしでバインドする。例:in [a, 1, 2]in {a: a}
  3. 変数名を省略するとキー名がそのまま使われる。例:in {a:} はキー a の値を持つ変数 a を定義する
  4. 残りをまとめてバインドする。例:in [Integer, *rest]in {a: Integer, **rest}

では、既存の変数をサブパターンとして使いたい場合はどうすればよいのでしょうか? そんなときは^(pin)演算子による変数のピン留めを使います。

a = 1
case {a: 1, b: 2}
in {a: ^a}
  "matches"
end

さらに、パターン内で定義された変数に対してもピン留めを使えるため、次のような強力なパターンを書くことも可能です。

case order
in {billing_address: {city:}, shipping_address: {city: ^city}}
  puts "both billing and shipping are to the same city"
else
  raise "both billing and shipping must be to the same city"
end

変数バインディングに関する重要な注意点として、パターン全体がマッチしなかった場合でも、変数自体はバインドされてしまうという癖があります。

これは時に役立つこともありますが、多くの場合は微妙なバグの原因になり得ます。マッチ処理の中で使われたシャドウされた変数の値に依存しないよう注意しましょう。

たとえば次のコードでは、cityは「Amsterdam」になると期待しがちですが、実際には「Berlin」となります。

city = "Amsterdam"
order = {billing_address: {city: "Berlin"}, shipping_address: {city: "Zurich"}}
case order
in {billing_address: {city:}, shipping_address: {city: ^city}}
  puts "both billing and shipping are to the same city"
else
  puts "both billing and shipping must be to the same city"
end
puts city # Amsterdam ではなく Berlin

Rubyのカスタムクラスをマッチングさせる

特殊なメソッドを実装することで、独自クラスでもパターンマッチングに対応させられます。

たとえば、ユーザーをfirst_namelast_nameでパターンマッチしたい場合は、クラスにdeconstruct_keysを定義します。

class User
  def deconstruct_keys(keys)
    {first_name: first_name, last_name: last_name}
  end
end
 
case user
in {first_name: "John"}
  puts "Hey, John"
end

deconstruct_keyskeys引数には、パターン側で要求されたキーが渡されます。すべてのキーの計算コストが高い場合に、必要なキーだけを返せるようになっているのです。

同じようにdeconstructを実装すると、オブジェクトを配列としてパターンマッチできるようになります。たとえば緯度経度を持つLocationクラスがあるとしましょう。deconstruct_keysでlatitudeとlongitudeのキーを提供するのに加え、[latitude, longitude]形式の配列も公開できます。

class Location
  def deconstruct
    [latitude, longitude]
  end
end
 
case location
in [Float => latitude, Float => longitude]
  puts "#{latitude}, #{longitude}"
end

ガード条件で複雑なパターンに対応する

通常のパターンマッチ演算子では表現できない複雑なパターンがある場合は、if(またはunless)文でガード条件を追加できます。

case [1, 2]
in [a, b] if b == a * 2
  "matches"
else
  "no match"
end

caseなしの=>/inによるパターンマッチング

Ruby 3以降を使っているなら、さらに強力なパターンマッチング機能が利用できます。Ruby 3からは、case文なしで1行のパターンマッチングが書けるようになりました。

[1, 2, "Three"] => [Integer => one, two, String => three]
puts one # 1
puts two # 2
puts three # Three
 
# 上と同じ意味
[1, 2, "Three"] in [Integer => one, two, String => three]

この構文にはelse節がないため、データ構造が事前にわかっている場合に最も有用です。

たとえば、管理者ユーザーのみを許可する基底コントローラーには、このパターンがぴったりはまるでしょう。

class AdminController < AuthenticatedController
  before_action :verify_admin
 
  private
 
  def verify_admin
    Current.user => {role: :admin}
  rescue NoMatchingPatternError
    raise NotAllowedError
  end
end

Rubyのパターンマッチング:今後の展開に注目

最初はパターンマッチングにとっつきにくさを感じるかもしれません。華麗になったオブジェクト/配列の分解にすぎないと感じる人もいるでしょう。

しかし、Elixirの人気が示すとおり、パターンマッチングは武器庫に入れておきたい優れたツールです。Elixirで実際に使った経験から断言できますが、一度慣れてしまうともう手放せなくなります。

Ruby 2.7では、パターンマッチング(case/in)はまだ実験的機能です。Ruby 3ではcase/inは安定版に移行し、新しく導入された1行パターンマッチング式が実験的扱いとなっています。警告はコード内でWarning[:experimental] = falseを設定するか、コマンドラインオプション-W:no-experimentalで無効化できます。

Rubyのパターンマッチングはまだ発展途上ですが、この入門記事が役に立ち、今後の進化に対して私と同じくらいワクワクしてもらえたら嬉しいです!

P.S. Ruby Magicの最新記事をいち早く読みたい方は、Ruby Magicニュースレターを購読して、投稿を見逃さないようにしましょう!

  1. Ruby 2.6の新機能9選|コード例でわかる注目ポイントを徹底解説

    Ruby 2.6には、開発者の生産性を高める新しい機能やパフォーマンス改善が多数盛り込まれています。 本記事では、Ruby 2.6で導入された9つの注目新機能を、実際のコード例とともにわかりやすく紹介します。最新のRuby動向をキャッチアップしたい方は、ぜひ最後までご覧ください。 1. 無限Range(Endless Range) Ruby 2.5以前でもFloat::INFINITYを使えば終端のない範囲を表現できましたが、Ruby 2.6ではさらに直感的な記法が使えるようになりました。 新しい無限Rangeは次のように書きます。 (1..) 通常のRangeが(1..10)のように終端

  2. 【Ruby】デコレータデザインパターンでクラスを変更せずにオブジェクトを拡張する方法

    デコレータ(Decorator)デザインパターンとは、どのようなものなのでしょうか? また、このパターンをRubyのプロジェクトではどのように活用すればよいのでしょうか? デコレータデザインパターンを利用すると、クラスそのものを変更することなく、オブジェクトに新しい機能を追加して拡張することができます。 それでは、具体的な例を見ていきましょう! ログ出力とパフォーマンス計測の実装例 この例では、rest-clientのようなGemを使ってHTTPリクエストを送信します。 コードは次のようになります。 require restclient data = RestClient.get(www.