Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Ruby: "uselessシンタックスシュガー"シリーズ「パターンマッチング(1/3)」(翻訳)

概要

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

日本語タイトルは内容に即したものにしました。訳文の一部に強調を加えています。

Ruby: "uselessシンタックスシュガー"シリーズ「パターンマッチング(1/3)」(翻訳)

本記事は、最近のRubyで出現した"無用な"(さもなければ物議を醸す)構文要素を扱うシリーズ記事の一環です。本シリーズの目的は、そうした機能を擁護することでも批判することでもなく、その機能が導入された理由、設計、そして新構文を使うコードに与える影響を分析するための一種の「思考のフレームワーク」を皆さんと共有することです。本シリーズのあらまし記事と、前回のnumbered block parameter記事もご覧ください。

本日のお題はパターンマッチング(pattern matching)です。このテーマを適切に扱うために3本の記事を要しました。

🔗 パターンマッチングとは何か

Ruby 2.7で実験的機能として登場したパターンマッチングは、3.03.13.2と進むに連れて数々の改良を経て対象範囲を拡大していきました。

パターンマッチングは、ネストしたデータ構造を宣言的にマッチ可能にするとともに、その一部をローカル変数にバインドできるようにするものです。

case point
in [Integer => x, Integer => y]
  # `point`は整数の座標ペアだったが、
  # それらがローカル変数`x`と`y`に入ってくる
in Point[x, y]
  # これはフィールドを2つ持つPointクラスの構造だったが、
  # それらのフィールドが`x`と`y`に入ってくる
in lat:, lng:
  # これは:latと:lngというキーを持つハッシュだったが、 
  # それらに対応する値が`lat`と`lng`に入ってくる
in {coord: {latitude:, longitude:}}
  # これはネステッドハッシュ形式だが、
  # ネストの中から`latitude`と`longitude`が取り出される

# ...などなど

このパターンはさまざまな言語構造で利用できます。

  • case ... inによる分岐(上述の例の通り)
  • 単独の値 => パターンを照合して、構造がパターンに対応しない場合はraiseする(バリデーションして既知の構造にバインドする)
  • 値 in パターンによるブーリアンチェック

パターンには巧妙かつ非常に自然な構文があります。これについてはこの後の段落で説明します。

この機能の重要性と、Rubyコードに与える影響は、人々の見解が大きく分かれる元となります。
これを「単なるシンタックスシュガー」扱いする人もいます(これが本シリーズのあるパートがそういう結論になった経緯です...この機能について議論すべきことは山ほどあるので早くも後悔し始めていますが)。
それとまったく逆に、パターンマッチングは独立したパラダイムであり、「パターンマッチングが欲しければ、それを使える言語に乗り換えるべき」と考える人もいます。

本記事執筆時点では、パターンマッチングは確実にある程度受容されています。私が今言えるのは、パターンマッチングはRubyのスタイルを(まだ)大きく変えていないということです。全般的に見た場合、「構造のチェック」だけに注目するのであれば、is_a?==の気の利いたショートハンドだと主張するのはたやすいことです。しかし、「照合してからバインドする」手法がコードのスタイルにもたらすあらゆるバリエーションについては、もっと多層的な評価が必要です。

本記事を読み進めれば、あなたにもできるかもしれません。

🔗 パターンマッチングが導入された理由

パターンマッチングの強みを説明する方法はいろいろありますが、「そのようなもの」への欲求を刺激する主な直感は、すなわちシンメトリーです。

現代の高級言語では、ネステッドデータ構造を任意の深さ、幅、複雑さで宣言的に構築するのは実に簡単です。欲しいデータ構造をそのまま書き、そこに変数や定数や計算を存分に書き込めばいいのです。

{
  events: [
    {
      timestamp: Time.now,
      kind: 'created',
      tags: [*DEFAULT_TAGS, 'created'],
      # ...などなど ...
    }
  ]
}

データ構造をこのように宣言的に構築することは、もう当然のものとして広く受け入れられています。("配列を作成せよ、これを第1要素に入れよ、次にこれを第2要素に入れよ..."という調子で)相も変わらずデータ構造を1つ1つ読み上げて処理する命令的な方法を要求するプログラミング言語やAPIは、時代に逆行しているように見えます。

しかし逆の操作についてはどうでしょうか?巨大な(巨大でなくてもネストした)データ構造から宣言的な方法でデータを取り出したい場合はどうすればよいのでしょうか?「この構造が欲しい、ならばそうした部分を改善すべきだ」。

答えはいろいろ考えられます(Swiftのlensはかなりいいですよ!)が、プログラミング言語の全般的な進化プロセスにおいて、さまざまなアイデアの合せ技や、舞台が学術から日常へ移り変わってきたことで、構造的パターンマッチングに対する共感が一般的になってきたように見えます。構造的パターンマッチングは、ある種の宣言的な「照合してからバインドする」構文のことであり、そこではデータ構造の構築をシンメトリックな視点で見ることが好まれます。

Rustのような新しい言語は最初からこのシンメトリックな構成方法を取り入れて登場しましたし、古参の高級言語(PythonからC#まで)でも業界のあちこちでこの構成方法が取り入れられました。

何十年も前のことですが、正規表現という概念の普及についても同じことが起こりました。かつては難解なしろものとされ、よく言っても特殊なライブラリツールとされていたものが、今ではあらゆる場所で使われるようになったのです。

「他のデータ構造についても宣言的に照合可能だろうか?」という問いは、論理上は次のステップに属するものであり、Rubyもまた例外ではありません1

特に、既にRuby言語にはデコンストラクトチェックや構造チェックがある程度備わっていることを考慮すれば、このことはRubyにとって幸運だったように思えます。というのも、汎用的なデータ構造とマッチングするためには、正規表現よりもずっと深い形で言語と一体化することが求められるからです。

🔗 パターンマッチング登場前

Rubyには構造のデコンストラクト(取り出し)が既に備わっています。ただし対象は配列のみですが。

a, b = [1, 2]
p(a:, b:) #=> {:a=>1, :b=>2}

head, *tail = [1, 2, 3, 4]
p(head:, tail:) #=> {:head=>1, :tail=>[2, 3, 4]}

原注

最近のRubyの動きを知らず、このp(a:, b:)という記法で首をひねっている方に申し上げておくと、ここで使っているのはRuby 3.1で導入されたキーワード引数における値の省略です。
p(a:, b:)は基本的にp(a: a, b: b)と完全に同じであり、特にデバッグで大変有用です。この構文については、今後の"uselessシンタックスシュガー"シリーズ記事で必ずや取り上げるつもりでいます。

構造のデコンストラクトは当初私たちが考えていたよりも強力で、ネストした構造からの取り出しも可能です。

(top_left, *top), *middle, (*bottom, bottom_right) =
  [[1, 2, 3], [4, 5, 6], [7, 8, 9], [10, 11, 12]]

p(top_left:, top:, middle:, bottom:, bottom_right:)
#=> {:top_left=>1, :top=>[2, 3],
#    :middle=>[[4, 5, 6], [7, 8, 9]],
#    :bottom=>[10, 11], :bottom_right=>12}

また、メソッドやブロック引数への暗黙の引数渡しでも利用できます。

# ハッシュのイテレーションで[key, value]ペアを生成し、
# with_indexで別の[element, index]ペアにラップする
# これをprocで取り出せる
{a: 1, b: 2}.each.with_index { |(key, val), idx| p(key:, val:, idx:) }
#=> {:key=>:a, :val=>1, :idx=>0}
#=> {:key=>:b, :val=>2, :idx=>1}

def smart_method((head, *middle, (left_tail, right_tail)))
  p(head:, middle:, left_tail:, right_tail:)
end

data = [1, 2, 3, 4, [5, 6]]

# ここでは*dataではなく1個の値だけを渡しており、
# メソッド宣言の2番目の()で配列の取り出しを処理していることに注目
smart_method(data)
#=> {:head=>1, :middle=>[2, 3, 4], :left_tail=>5, :right_tail=>6}

これと同じデコンストラクトが、ハッシュや、配列とハッシュの任意の組み合わせでも可能になったらどうだろうと夢見た人がいるかもしれません。

a:, b: [left, right] = {a: 1, b: [2, 3]}
# こう書いたら`a:`に1が入り、
# `b:`内の`left`と`right`に2と3がそれぞれ入るだろうか?

残念、これは構文エラーになります。

この構文はあくまでバインディング(デストラクト)のみを提供するものであり、マッチング(形状のチェック)を提供するものではありません。意図した形に合わないデータを渡すと、ほとんどの場合エラーを発生せず2、マッチしなかった箇所で大量のnilや空配列が発生することになります。

(top_left, *top), *middle, (*bottom, bottom_right) = 1
#=> {:top_left=>1, :top=>[], :middle=>[], :bottom=>[], :bottom_right=>nil}

従来のRubyにおけるマッチングは、===演算子(case等号演算子)で実装されます。===はデフォルトでは==と同じですが、多くのクラスで再定義されているので、以下のように振る舞います。

"b" === "b"             #=> true(シンプルな等号)
String === "b"          #=> true(Classで`#===`を再定義してこのクラスのオブジェクトとマッチさせる)
("a"..."z") === "b"     #=> true(Rangeで`#===`を再定義して範囲内のオブジェクトとマッチさせる)
/\w/ === "b"            #=> true
# 以下のようなことも可能
proc { _1.length < 3 } === "b" #=> true

===演算子の最もよく知られている用法はcase内分岐での暗黙の呼び出しです(これがこの演算子の名前の由来です)。以下のコードでは、分岐ごとに===が暗黙で呼び出され、そこでargumentを渡し、分岐がtrueを返すまで進みます。

# 訳注: 途中からシンタックスハイライトが乱れています
case argument
when nil
  # 処理その1...
when 1..10
  # 処理その2(1〜10の数値)
when /user:.*/
  # 処理その3(この正規表現にマッチする文字列)
when User
  # ...などなど...
end

(明らかに、caseで選べる方法はさほど多彩ではありません。「nilの場合」「いずれかのクラスの場合」「明らかに文字列だが正規表現で分岐する場合」といった具合です)

case等号演算子」は、その名前にもかかわらず、言語のコア構造で他にも有用な使い道がいくつかあるのです(grepの一般化など)。

# #grepは内部で`#===`を呼び出すので、
# 以下のような昔ながらのUnix的grep利用法以外にも...
lines.grep(/^user:/)
# ...こんな書き方が可能
numbers.grep(0..20)
results.grep(Success)

# 一部の述語メソッドでも使える
lines.any?(/^user:/)
numbers.all?(0..20)
results.none?(Success)

しかし===の威力が通用するのはここまでです。ネストした構造に対する再帰的なマッチングを行う方法はありません。また、マッチが成功した場合にいくつかの変数を代入(束縛)する方法もありません。

Rubyのcase文には、パターンの網羅が不十分だった場合に備えた「脱出ハッチ」形式があります。マッチするオブジェクトがない場合は単なるifelsifのように機能しますが、以下の2つの分岐はよく見ると同等なのです。

case
when args.size == 2 && args.first.is_a?(String)
  # 何かする
when args.size == 1 && (arg = args.first).is_a?(Number) # 条件内で変数に代入することも可能!
  # 何か他のことをする(`arg`には代入済み)

私がこれまで業務で扱ったコードベースの中には、この「処理を分岐の(本文ではなく)条件部に書く」スタイルが推奨されているものがいくつもありました(もちろんチェックの複雑さは適切でした)。このスタイルは「スタイル上のエラー」でありcaseの誤用であると考える人もいます(Rubocopのデフォルトスタイルもそうなっています)。いずれにしろ、このスタイルは比較表現を強化するというほどのものではなく、分岐を少しばかり統合するに過ぎません。

構造があやふやなデータを扱う機会が多い開発者たちは、「期待するものをRubyらしく記述できる表現力豊かな記法がきっとあるはずだ」という感覚(直感と呼びたければどうぞ!)を日常的に感じていました。そういうわけで、Rubyではパターンマッチングの追求とは、すなわち「===をさらに強力にすること」であると受け取られることが多かったのです3

こぼれ話

私がRubyブログを始めたきっかけは、ライブラリ内で===ベースのパターンマッチング実装の実験や考察結果を共有したかったからでした。当時の記事の結論は「つまるところ、強力なパターンマッチングは言語のコア機能に組み込まれる必要がある」というものでした。

その後、Ruby 3.0に向けたアプローチを受けて、「いよいよパターンマッチングを導入すべき」という圧がコミュニティで高まってきました。たとえば、RubyConf 2017ではパターンの%p()構文に関するプロトタイプを実際に動かしました。当時のMatzは、このようなプロトタイプについて以下のように述べていました。

Rubyにパターンマッチングを追加するのであれば、構文を優れたものにすべきだ。(中略)問題は、パターンマッチングに適した優秀な構文が思いつかないことだ。

私も当時、「パターンマッチングに適した良い構文」はどんなものになるかを過去記事で検討したことがあります。ご興味がおありでしたら、当時の記事で私が書いたアイデアと、Rubyで最終的に採用されたパターンマッチングを比較してみると楽しめると思います。

🔗 パターンマッチングが採用されるまでの経緯

2019年のクリスマス、ついにRuby 2.7にパターンマッチングが導入されました。少々意外だったのは、導入されたタイミングが最終リリース目前だったことと、Changelogでリンクされていたドキュメントがカンファレンスのスライドのみだったことです。しかしMatzは最終的に受け入れた(そして私の理解が正しければ、設計もある程度手がけた)パターンマッチングはこれだったのです。

そして、パターンマッチングはcase文と統合されていることが判明したのですが、多くの人が予想していたような「===をサポートする何らかの新しい強力なオブジェクト」を利用する形ではありませんでした。選ばれたソリューションは、inという新しいキーワード/演算子4を利用するという方法でした。

case args
in [Integer, Integer]
  # Integerのペアとマッチさせる
in [String => first, String => last]
  # Stringのペアとマッチさせ、ローカル変数`first`と`last`に代入する
in [foo, bar]
  # 任意の値ペアとマッチさせ、ローカル変数`foo`と`bar`に代入する
# ...などなど...

このソリューションで注目すべき点は、パターン構文そのものがRuby言語ユーザーが慣れ親しんできた習慣(直感と呼んでもいいですよ!)と信じられないほど見事に調和していることが判明したことです。

  • [1, 2]という値の配列は、[pattern, pattern]のようにパターンの配列で照合できる
  • {x: 1, y: 2}というハッシュも、同様に{x: pattern, y: pattern}で照合できる
  • 値の一部だけを変数に入れたい場合は、Rubyのエラーハンドリング(rescue ErrorType => error_var)でお馴染みの構造を用いてpattern => variableとするだけでよい
  • ただし、追加のチェックを行わずに変数に値を入れたい場合はもっと素直な[x, y](つまりxyの場所に値を入れる)という方法があります。ハッシュの場合は{x:, y:}と書きます。
  • 他にも覚える必要のある方法がいくつかありますが、中には覚えやすい書き方もあります。pattern | patternで多くのパターンとマッチさせる方法、^xによる「変数ピニング(variable pinning)」(ピニングした変数は比較用であり、そこに値を置かないことを示す)などです。

明確なパターンマッチング構文が導入されたことと引き換えに、小さいとは言えない代償が伴いました。それはパターン構文が言語の他の部分から完全に切り離されていることです。パターンを変数や定数に代入することはできませんし、パターンをメソッドに渡すこともできません。パターンはあくまでinに続けて書くことで機能する特殊な構文であり、「パターンオブジェクト」のようなものは存在しません。

公平のために申し上げると、円熟期を迎えた表現力の豊かな言語にパターンマッチングを適切に導入する方法は、おそらくこれしかありませんでした。上述した[Integer, Integer]などの「自然な」パターン構文は、いずれもRubyの(意味の異なる)文法構造として元々有効なものなのです。

Ruby 2.7のパターンマッチングは、慎重を期して"experimental"(実験的)と銘打たれていましたが、このときに構築された基礎が合理的であったことが証明されました。

その次のバージョンでは、パターンマッチングが整理されて磨きがかけられました。最も注目すべきは、1行パターンマッチ構文が2種類確立されたことです。

  # 単独の`in`は、パターンがマッチしない場合`false`を返す(`if`文で有用)
if point in [x, y]
  # これは2要素の配列であり、これをチェックして値を取り出す
  # ...
end

# 単独の`=>`は「宣言的」マッチであり、「マッチ」「エラーをraise」のいずれかになる
kwargs => db: {user:, password:, **}, logger:
# => 構造が正しい場合はここで`user`と`password`と`logger`に代入される
# ...正しくない場合はNoMatchingPatternになる

当初、Ruby 2.7にはinの形式しかなく、マッチしない場合はraiseしていました。この点をどうするかについて激しい議論が繰り広げられ、引数の順序についても同様でした(多くの言語ではvalues, to, assign = patternの順となります)が、これが最終的に現在の2つの演算子に結実したのです。

この議論(および他の言語との比較)で得られた結果の1つは、1行記法が「ステートメント全体を1個の変数にマッチさせる」という最もシンプルな形でもうまくいくことが強調されたことです。inはこの場合ほとんど無力ですが、=>演算子は「右代入(rightward assignment)」と呼ばれる新しい形式の代入となりました。

1 => x
# 単にxに1を代入する。これはさすがにキモい...

# しかしこんな場合なら`=>`記法が光り輝く!
File.read('data.txt')
  .split("\n")
  .map { |ln| some_processing(ln) }
  .select { |ln| some_filter(ln) }
  .and_so_on => result

# ここで計算の末尾に`=>`を置くことで
# 結果がどこから来たかがわかりやすくなる
pass_further(result)

# また、結果が複雑になった場合にも書き換えが楽
File.read('data.txt')
  .some_complex_processing
  .and_so_on => users: [admin, *rest], transactions:

最近のRubyでは、パターンマッチングでさらに多くのことが起きています。「パターン検索」の導入、ピニングの強化、いくつかのコアクラスにおけるデコンストラクトのサポート追加などがあります。

やはりパターンマッチングは偉大な機能です。言語仕様においてそれなりに大きな妥協が必要だったものの、広範囲に渡る興味深い結果がいくつかもたらされました。しかし、まだこれですべてではないと信じています。

パターンマッチングは大きな機能なので、これについての議論はやっと半ばまで進んだところです。続きは来週をお楽しみに。なお、numbered parameters機能については、以下のような「裏付けのある批評と分析」を行いました。

  • ナンパラのむずかゆい点
  • ナンパラ導入後どうなったか
  • 他の言語ではどうやっているか
  • この先どうなるか
  • まとめ

更新情報: その後パターンマッチング記事のパート2パート3(最終パート)を公開しました。パターンマッチングについてはこれでおしまいですが、本シリーズそのものはまだ続きます。

今後の記事をフォローしたい方は、Substack に登録いただくか、Twitterでフォローをお願いします。


お読みいただきありがとうございます。ウクライナへの軍事および人道支援のための寄付およびロビー活動による支援をお願いいたします。このリンクから、総合的な情報源および寄付を受け付けている国や民間基金への多数のリンクを参照いただけます。

すべてに参加するお時間が取れない場合は、Come Back Aliveへの寄付が常に良い選択となります。

本記事(あるいは過去の私の仕事)が有用だと思えたら、Buy Me A Coffeeサイトにある私のアカウントまでお心づけをお願いします。戦争が終わるまでの間、ここへのお支払いは(可能な場合)私や私の戦友たちが必要とする装備または上述のいずれかの基金に100%充てられます。

関連記事

Ruby: "uselessシンタックスシュガー"シリーズ記事のあらましと予告(翻訳)

Ruby: "uselessシンタックスシュガー"シリーズ: numbered block parameters(翻訳)


  1. 仮にRubyが現代の状況で設計されるとすれば、「典型的な構造マッチとは何か」が定まり次第、直ちに言語のコアに組み込まれることでしょう(Rubyが誕生した当時最先端だったクラスメソッドやイテレータなどの概念を、大変な努力の末に取り入れたときと同じような感じで)。 
  2. method(*args)呼び出しで渡そうとする引数が足りない場合を除きます。ただしこれが機能するのは、位置引数の1つのレイヤにおける限られた事例だけです。 
  3. もし、このときの議論が「値の取り出し(unpacking)」に集中して、この方面から目標に近づいていったとしたら歴史はどう変わっただろうと思うことがときどきありますが、歴史のifは知りようがありません。 
  4. inは古いRubyでfor el in array構文をサポートするための予約キーワードでしたが、ほぼまったく使われていなかったので安全に再利用できました。ローカル名としてのinは常に無効だったので、仮に誰かがinという名前を使ったとしてもパーサーでfor ... in ...と衝突する心配はありませんでした。 

CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。