Rubyのパターンマッチング構文を考えてみた(翻訳)

概要

原著者の許諾を得て翻訳・公開いたします。

なお、本記事で言及されている#14792そのものはMatzによって2018/06/21にcloseされています。その後#14912に関連付けられました(こちらは2019/03/26時点でopenです)。

Rubyのパターンマッチング構文を考えてみた(翻訳)

このブログを始めてからほぼ3年の月日が流れましたが、その最初を飾ったこの記事でパターンマッチングをRubyのライブラリとしてそれらしく作るという練習を行いました。楽しい作業でしたが、最後に悲しい現実に突き当たりました。

やはり、強力なパターンマッチングは言語のコア機能として実現する必要がある

その後3年の間に、Ruby言語のコア機能でパターンマッチングを行うためのissue #14709を投げ、その最後を締め括ったのはMatzの以下の一言でした。

もしRubyにパターンマッチングを追加するのであれば、もっとよい構文を備えたパターンマッチングを追加すべきです。

というわけで本記事は、皆さまを今後のRubyバージョンで実現できそうなパターンマッチング(やその要素)や、Ruby言語に導入可能な最小限かつ理に適った変更についての議論にご招待するためのものです。

メモ: 本記事はRubyメンテナーからの「公式な」(議論への)招待のようなものではありません。本記事には私自身しか登場いたしません。しかし最近の(訳注: Ruby関連の)イベントでおわかりのように、Rubyの新機能のための受け入れ口は現在かなりオープンになっており、Rubyメンテナーもコミュニティからの提案に注意深く耳を傾けてくださっているように思われます。

簡単なまとめ

Wikipediaのパターンマッチング英語版)の項は定義的というより記述的ですが、たとえば実用のために、マッチした構造の部分に対する「何らかのパターンに対応するデータ構造のチェック」および「構造の分解(ローカル変数への束縛)」としてパターンマッチングを定義できるとしたら、これをポリモーフィックなメソッドとして定義するのに使えるかもしれません(し、できないかもしれません)。これが思いつきそのものであることも、対応する記述が教科書にないことも重々承知していますが、(私には)十分明確です

そうした要素のいくつかはRubyのあちこちに散らばっているものの、存在しているのは確かです。これについてはもう少し後で追うことにしましょう。

パターンマッチングをライブラリとして実装する試みは何度となく繰り返され、その一部については私の前回の記事でも参照しましたが、今回は以下も追加できます。

  • Qo: Brandon Weaver(@keystonelemur)の手になるライブラリで、いくつかの興味深い実験やこの主題に関する理論上の議論も含まれています。
  • prototype: Yuki ToriiによるRubyKaigi 2017のプレゼンで発表された、Rubyコアでの実装がどのようになりそうかというプロトタイプです。

実装上の選択肢とアイデア

Rubyでパターンマッチング機能の導入または拡張を行ううえで3つの選択肢があります。

1. メソッド定義とポリモーフィックメソッドのための新しい構文

# たとえばこんな感じ
defm method(Integer, nil) { |i| ... }
defm method(0..20, Hash) { |i, h| ... }

# またはこんな感じ
defm method(Integer i, nil)
  # ...
end
defm method(0..20 i, Hash h)
  # ...
end

# あるいはこれ: こちらも複数のメソッド本体にコンパイルされ、呼び出し時に引数で選択される
defm method
  match(Integer, nil) { |i| ... }
  match(0..20, Hash) { |i, h| ... }
end

訳注: 以下、原文の「construct」が訳出困難と判断し、英ママで用います。

2. メソッドのコード内部で次のような何らかの新しいconstructを用いる

def method(*args)
  match args do
 on Integer, nil ...
  end
end

3. 既存のRubyのconstructやコーディング慣習に何らかの装飾を施して、さらに強力な構成を漸進的に達成する


3つの方法のうち、2.は面白みも何もないと信じています(言語に新しい構文を糊付けすることはいつでもできますが、それで言語が改善されることはほぼありません)し、1.はいくらなんでも過激です(Rubyのオブジェクト指向におけるメッセージ受け渡しやメソッド呼び出し全体の再設計が必要で、近い将来それが行われるとは思えません)。というわけで「漸進的な変更」という選択肢が残されます。

となると、Ruby言語のコアに何らかの新しい要素を導入したいのであれば、次の2つの重要な制限事項を守る必要があります。

  1. ライブラリによるソリューションと異なり、DSLやサブ言語を大量に導入するわけにはいきません。既存のconstructをできるかぎり再利用すべきです。
  2. 新しいconstructや既存のconstructの新しい用法は、1つ前のバージョンのRubyから緩やかに非互換になるようにすべきです。つまり、そうした変更は過去のバージョンのRubyにおいては「誤ったコード」と認識されるべきであり、 過去のバージョンで意味の異なる「正しいコード」として認識されてはならないということです。

それでは、Ruby言語仕様の別の領域に、どんなものが既にあるかを見ていきましょう。これらはパターンマッチングの2つの目的(複雑な値に対するdeepかつ機能豊富なチェックと、それらの値の分解(deconstruction))という点で類似しています。

# 明らかな例: #caseや#grepと#===演算子
case value
when 1..10
  # ...
when Regexp
  # ...
when ->(x) { (x % 10).zero? }
  # ...
end

[1, 'test', 1/3r].grep(Numeric) # => [1, (1/3)]

# 別のケース: proc呼び出し時にdeconstruct
{a: 'foo', b: 'bar'}.map.with_index { |(k, v), i| [k, v, i] }
#                                      ^^^^^^^^^
# => [[:a, "foo", 0], [:b, "bar", 1]]

# 上はメソッドでも使える
def test((k, v), i)
  [k, v, i]
end
{a: 'foo', b: 'bar'}.map.with_index(&method(:test))
# => [[:a, "foo", 0], [:b, "bar", 1]]

# 実は(引数constructの)deconstructはかなり自由にできる
def test((a, b), c, *d, e:, **f)
  puts "a=#{a}, b=#{b}, c=#{c}, d=#{d}, e=#{e}, f=#{f}"
end

data = [[1, 2], 3, 4, 5, {e: 6, g: 7, h: 8}]
test(*data)
# a=1, b=2, c=3, d=[4, 5], e=6, f={:g=>7, :h=>8}

# 最終的なRubyのケース: テスト「と」ローカル変数代入が同時に発生
rescue ArgumentError => e
#      ^^^^^^^^^^^^^    ^^
#      チェックする種類    マッチに使う値が置かれるローカル変数

上を念頭に置いて、これらを壊さずに私たちの習慣や直感をどれだけ拡大できるかを見てみることにしましょう。

メモ: 以下の例では、パターンマッチングの最も有用なケースを「ある種の入力に対する動的なディスパッチ」と仮定しています。この入力は、外の世界(パーサーや柔軟なフォームハンドラなど)からのものか、または何らかのサービスクラスの単なるポリモーフィックメソッド(カスタムコレクションなど)です。
さらに追伸: 以下のコードサンプルには多種多様なリファクタリング方法が考えられますが、本記事ではRubyの既存のパターンマッチの機能をより豊かにする「穏やかな」手法を考えることのみを主眼に置いています。

まずはシンプルな例で考えてみましょう。以下のようなパターンはときどき目にします。

訳注: latとlngは、それぞれ緯度(latitude)と経度(longitude)を指します

def process_coord(lat, lng = nil)
  case lat
  when String
    Geo::Coord.parse(lat)
  when Geo::Coord
    lat.dup
  when Numeric
    case lng
    when Numeric
      Geo::Coord.new(lat, lng)
    when nil
      Geo::Coord.new(lat, lat)
    when Array
      if lng.last.is_a?(Hash) # キーワード引数のオプションハッシュはほぼ不要ですが... 
        options = lng.pop
        ....
      end
    else
      fail ArgumentError
    end
  else
    fail ArgumentError
  end

たとえば「最初の引数をcaseで、その内部では次の引数をcaseで(たぶん他にもcaseがあるかも)」という場合、以下のように書けるんじゃないの?と私なら常々思うわけです。

case (lat, *lng)
when (String) # 引数が1つだけ渡された場合
  Geo::Coord.parse(lat)
when (Numeric, Numeric)
  Geo::Coord.new(lat, lng)
when (Numeric, Numeric, Hash)
  # オプションハッシュが渡された...

これはこんな感じに思えます

  • かなり明示的である
  • 現時点では誤った構文である(上述の「緩やかな非互換性」を参照)
  • 既存の構文と衝突していない(1つのwhenの中で複数の条件がいずれかのかっこの外でOR実行され、その内部のリストの1つとマッチする)
  • メソッドやprocの引数定義における「deconstruction」と似ている

ここから始めれば、かなり簡単に「メソッドパラメータのdeconstruction」が適用されるほとんどの部分に対してこのアプローチを「拡大」できます。

when ((Numeric, Numeric), Hash) # ネストしたシーケンス
  # 呼び出しのシーケンスは `parse_coordinates([57.0, 32.0], strict: true)`となるだろう
when (:skip, _, _, Numeric)     # _はメソッド引数のときと同様「無視する」(=何にでもマッチ)の意味になる
when (*Numeric)                 # 任意のサイズの配列(ただしすべて数値)
when (*/\d+(\.\d+)?/)           # 数値的な文字列の配列(サイズは任意)
when (Numeric, Numeric, radius: Numeric, **) # やりすぎ?

この時点では「Ruby言語によい」と思われる手触りや感覚がだいぶ散り散りになってしまっていますが、これでも「私の目には」今後長きに渡ってRubyに苦もなく存在できるように思えます(なお、上の*はメソッド定義などにおける特殊な構文であり、他のあらゆる場所における#to_a呼び出しではありません: この世界のあらゆるオブジェクトに#to_aが定義されていてパターンっぽい何かを返したらそれは明らかにまずいでしょう)。

最後に、以下をじっくりご覧ください。

case (lat, *lng)
when (Numeric, Numeric, Hash)
  # オプションハッシュが渡された...

次に書くべきは、lngを経度(longtitude)自身とオプションハッシュにどうやって分割するかです。.popを再度使ってみたらどうなるでしょう?

case (lat, *lng)
when (Numeric, Numeric => lng, Hash => opts)
  # オプションハッシュが渡された...

見やすくなりましたよね?この構文は、rescueで型チェック「と」変数の束縛を同時に行っているのと同じです。その結果どうなるかはわかりません(やはりこのコンテキストでは普通のハッシュが欲しくなりそうです…)が、率直に言ってこのアイデアは驚くほど魅力的に思えるのではないでしょうか。

ひととおり遊んで議論しよう

私がこしらえたささやかななんちゃってgemをこちらに置いてあります(pattern-matching-prototypeと同様にインストール可能です)。これは上で提案した変更をできるだけ擬似的に再現するもので、そのためにいくつかのいやらしいトリックが用いられています。

  • 複数の項目を含むパターンを1つ作成する場合、()ではなくM()を使うこと
  • ローカル変数の束縛にはbinding_of_caller gemの力を借りており、変数は定義済みでなければなりません。
  • 上述の#to_aの説明とは異なり、#to_a*Numeric機能の擬似的再現に用いました。
  • グローバルな_メソッドは、おそらくいつも使っているのと同じ挙動にはならないでしょう。

このgemはあくまで新しい構文のアイデアを試すためだけのものですので、ご了承ください。

よろしければ、議論は以下でお願いします。

  • Twitter
  • Reddit
  • (コメントも当方でチェックしますが、コミュニティに気づいてもらいにくいかもしれません)

訳注: Redditの該当エントリは現在アーカイブされており、新規投稿はできません。

ぜひ皆さまのご意見をお寄せください!

関連記事

Rubyで関数型プログラミング#2: クロージャ(翻訳)

デザインも頼めるシステム開発会社をお探しならBPS株式会社までどうぞ 開発エンジニア積極採用中です! Ruby on Rails の開発なら実績豊富なBPS

この記事の著者

hachi8833

Twitter: @hachi8833、GitHub: @hachi8833 コボラー、ITコンサル、ローカライズ業界、Rails開発を経てTechRachoの編集・記事作成を担当。 これまでにRuby on Rails チュートリアル第2版の監修および半分程度を翻訳、Railsガイドの初期翻訳ではほぼすべてを翻訳。その後も折に触れて更新翻訳中。 かと思うと、正規表現の粋を尽くした日本語エラーチェックサービス enno.jpを運営。 実は最近Go言語が好き。 仕事に関係ないすっとこブログ「あけてくれ」は2000年頃から多少の中断をはさんで継続、現在はnote.muに移転。

hachi8833の書いた記事

週刊Railsウォッチ

インフラ

ActiveSupport探訪シリーズ

BPSアドベントカレンダー