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

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

概要

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

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

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

本記事は、Rubyのパターンマッチングに関する記事のパート3であり、この記事自体は最近のRubyで導入された機能を扱うシリーズの1つです。可能でしたらパート1パート2を先にお読みください。シリーズの目次については本シリーズのあらまし記事をどうぞ。

パート1とパート2では、最近他のいくつかの言語にも取り入れられているRubyのパターンマッチングについて見てきました。パターンマッチングがどのような経緯を経て実装されたか、パターンマッチングが言語の構文やセマンティクス(意味論)にどのような問題や可能性をもたらしたかについて議論しました。

パート3では、より広い文脈でこれらすべてを考えてみましょう。

🔗 他の言語ではどうやっているか

現在、多くのプログラミング言語(かなり主流のものも含めて)が構造的パターンマッチングをサポートしています。以下にいくつかの例とリンクを示します。

なお、JavaScriptC++にもプロポーザルがあります(導入準備のステータスはさまざまですが)。

このリストを見ただけでも、パターンマッチングがもはや「関数型」言語にのみ許された特権的機能ではなくなっていることがわかります(「関数型」がポストモダンのマルチパラダイム時代において何を意味するかはさておき)。リストには静的言語もあれば動的言語もあり、古い言語もあれば新しい言語もあり、最初からパターンマッチングを備えた言語もあれば後付けで導入した言語もあります。

(少なくとも1件のブログ記事で)これらの構文や振る舞いをすべて比較して議論することは不可能かつ非現実的です。そのため、ここでは浅い観察を簡単にいくつか述べるにとどめておきます。


パターンマッチングを言語に統合する方法は、主に2つに分類できます。

  1. パターンマッチングがバインディングや構造のデストラクト(値の取り出し)の主要な手段となるタイプ
    (条件、代入、関数引数のデコンストラクトなどで使われる形式が一貫している)
  2. 独立したcase/match式を用いるタイプ
    (分岐がパターンで定義され、パターン構文はこのステートメント内でのみ有効となる)

(ただしSwiftのパターンマッチングは代入には含まれているものの、関数の引数には含まれていないようです)

パターンマッチングを主要な代入構造とするか、単なる特殊な式とするかという違いは、パターンマッチングが最初から言語に組み込まれていたかどうかに関係していそうです(ただしScalaには最初からパターンマッチングが備わっていたようですが、利用はmatchに限定されています1)。

関数の引数でパターンマッチングが使える言語では、引数パターンによるメソッドのオーバーロードが許されることを意味するのが普通です(ただしこの概念はやや直交しています: 多くの言語では、パターンマッチングを用いずに引数の形状や型によるオーバーロードを行えますが、強力なパターンマッチングを備えているRustにはそもそもメソッドのオーバーロードがありません)。

Rubyはおおむね「独立した式を後から導入した」言語に該当しますが、=>による擬似代入はある種の思いつきだったのかもしれません。これが今後興味深い結果につながるかどうかはまだ何とも言えません(次のセクションを参照)。

パターン構文を比べるときには、どんな記号を用いるかという決定(対象言語に溶け込ませるため、場合によっては逆に構成要素の独立性を際立たせるため)がたくさん目につくかもしれませんが、そうした基本の構成要素リストは、以下のようにどの言語でも非常に似通っています。

  • リテラル、定数:
    単なる1FOO

  • パターンの"スキップ記号"/"すべてにマッチさせる記号":
    ほぼ_

  • パターンで単に値を名前にバインドする方法:
    単なるvariable_name(任意のものにマッチし、名前付きローカル変数に入れる)

  • クラスチェック/型チェック:
    通常は型名のみで表す(FooはクラスFooの任意の値とマッチする)。

  • シーケンスの形状をチェックするパターン:
    配列([pattern1, pattern2, ...])、タプル、辞書など。
    ほとんどの場合、対象言語のシーケンスリテラルの内部にパターンを含むらしい。

  • レコード型/struct/オブジェクトのチェックおよびunpack:
    多くはコンストラクタ風の構文(Foo(x, y)Foo { x:, y: }など)
    これは設計上、以下のような多くの小さな選択を呼び起こさせる

    • 型を明示せずにunpackするかどうか、それとも可能な属性をリストアップするだけでunpackするか
    • 型名を必須にするかどうか(驚いたことにPythonのような動的言語では必須になる)
  • チェックとバインドを「同時に」行う方法:
    おそらく最も複雑な構文要素であり、さまざまなオプションがある。

    • variable_name @ patternのような書き方もある
      • 例: val @ Integerはinteger値とマッチし、変数valにその値を入れる: RustやHaskell)
    • Python: pattern as variable_name(例:int as val
    • C#: 単にpattern variable_nameを許している
    • Elixir: パターンの途中にpattern = variableを書く
      • 例: [a, b] = c = [1, 2]
      • これは「aに1を入れてbに2を入れ、それからcに配列全体を入れる」と読む
      • 単体だと無意味に見えるが、関数定義では有用
  • "or"によるパターンの組み合わせ:
    pattern1 or pattern2pattern1 | pattern2で表すことが多い。

    • andnotが使える場合もある(例: C#のfoo is not nullはパターンマッチングのインスタンス)
  • ガード句形式の命令的「脱出ハッチ」:
    宣言的な構造だけではチェックできない場合、パターンの後ろにif more_conditions(またはwhen more_conditions)のように書く

    • 例: Foo(x) if x > 7
  • ...その他もろもろ🙂

このリストは網羅的なものではありませんが、利用可能なパターン構文が提供すべきものをほとんどカバーしています。また、ほとんどの実装は(特定の構文的直感に合わせて)似たような方法で提供されているようです。
こうしてみると、Rubyでの決定は良くも悪くも他の言語から浮いていないことがわかります。


ただし、Rubyにはわずかに異色な点が1つあります。ほとんどの言語は、マッチした値をパターン内の値を比較するときにシンプルな等価比較を使っているので、チェック範囲を広げるためのカスタムオブジェクトへの扉が閉ざされています。
これに関する典型的な質問は以下です。

  • 範囲(s, e)内の値と宣言的にマッチさせるにはどうしたらよいか
  • パターンの途中に含まれる文字列が正規表現とマッチするかどうかをチェックするにはどうしたらよいか

Rubyの場合、古くからあるプロトパターンマッチングとでも言うべき方法から派生した一般的な答えがあります。
パターン内のあらゆるオブジェクトは===case等号演算子)で比較されます。これはほとんどの場合==と同じですが、RangeRegexなど一部では再定義されています。

この古き良き===演算子がパターンマッチングという輝かしい場で使われるようになったおかげで、percent in (1..100)と書くこともage in (18...)と書くこともidentifier in /^[a-z]+/と書くことも簡単です。

なお、私がチェックした全言語の中でRubyと同じ決定を下したのは、唯一Swiftだけでした(再定義可能な~=を利用します)。おそらくRubyの方法を受け継いだのではないかと思っています。

高級言語の多くはここで諦めてしまいますが、range専用のソリューションを用いて「範囲内の値」をある程度解決している言語もあります。

Rustはパターン内でのrangeの利用を許しています(ただしこれは実装されているトレイトの一般規則ではなく、言語の単なるコア機能です)。

一方C#には、ステートメントに埋め込まれる比較演算子を用いる「relationalパターン」と呼ばれるものがあります↓。

static string Classify(double measurement) => measurement switch
{
    < -4.0 => "Too low",
    > 10.0 => "Too high",
    double.NaN => "Unknown",
    _ => "Acceptable",
};

しかしどちらの方法も、Ruby(とSwift)の方法ほど一般的ではなさそうです。つまり結論としては、Rubyの新機能であるパターンマッチングは、その先達である古い形式のcaseを完全には無視しませんでした。パターンマッチングとcaseの統合は一部で期待されていたほど緊密ではありませんでしたが、それでも成果があったのです。


最後に、パターンマッチングの設計空間について大事な点をもう1つ書いておきたいと思います。

主流の言語(上述のC#など)が選んのは、「汎用の分岐構造としてではなく、"データからデータへのマッピング"として見え、振る舞う」パターンマッチングです。パターンと分岐の間には(Rubyで言う)=>があり、この分岐は1個の式であることが前提となっています(C#の場合、value switch { patterns }という式全体の形式、および結果が何かに代入される必要があるという事実においてもこのことが強調されています: つまり単独のステートメントにはできないのです)。

Rubyはそのような選択をしませんでしたが、Rubyにおけるパターンマッチングの普及ぶりは、前回のパート2のまとめに記した考えを裏付けているように思えます。パターンマッチングは、データファースト指向をサポートする構造なのです。時が経つに連れて、コードをシンプルにするだけにとどまらない深い影響が顕現する可能性があります。

🔗 ウクライナ通信

ほんの少しだけお時間をください。今の私たちが暮らしている状況を小さな思い出として記事の途中にはさむことにしています。私は現在戦争中の国で暮らしています。先週起きたささやかな出来事を無作為に選んでお伝えするものです。

先週火曜日、軍の春季訓練キャンプ時代の最大の親友が戦死したことを知りました。彼はオデーサ出身の船舶技師でした。全面侵攻が始まったときの彼はシンガポールで故障中の船の修繕に対応中で、やっとのことでウクライナに戻り、軍に入隊しました。36歳、妻と6歳の息子がいました。親友の名前はOlexandr Demydyukです。

引き続き記事をどうぞ。

🔗 この先どうなるか

パターンマッチングは大規模な割にまだ若い機能なので、明らかな改善の余地が散見されます。たとえば「非変数で必須となる"ピニング"を減らす」「Array[*Integers]のようなコレクションの"残り"に対するパターンチェックのサポート」があります。

しかし私が何よりも気になって仕方がないのは、パフォーマンスに関する直感です。

私がプログラミング言語について語るときは、ほとんどの場合「思考のツール」としてのプログラミング言語についてです(「プログラムは人間が読むためにある」)が、実用的な側面(コンピュータで実行するときだけたまに問題になる、小さくても厄介な側面)も決しておろそかにしません。私は営利企業で実利を生み出すプロジェクトに携わっているので、私が言う「思考のツール」とはすべて言語の直感を適切に用いて価値を提供することが目的であり、美しい抽象や学術的な公式を生み出すためではありません。

20年前の私はC++で育ちました。そのおかげで「この構文は素敵だけど、オーバーヘッドはどのぐらいになるのか?」と常に考える癖が付きました。あるいは逆に、最適化を改善する直感をコンパイラに指示できるようになったと言ってもいいかもしれません。そんな私が、クラス内でも実行が遅いという評判のインタプリタ型動的言語であるRubyに乗り換えたのはかなりのパラダイムシフトでした(が何ひとつ後悔していません)。

そして私は、「最も遅いと言われている」言語においても、コードのフレーズ構造というものが、コードを読んだり書いたりする人に計算がどの程度複雑であるかを伝える力があることを今も認識し、礼賛しています。最もストレートに申し上げれば、「明確かつシンプル」に見えるものは必ずうまくいくはずだと私は信じています。この直感に逆らうような機能は、厄介なジレンマを引き起こす可能性があるでしょう2

パターンマッチングに関する直感は、言語のコア構造において可能性のツリーを効果的に分岐する方法になりうるように思われます。たとえば前回記事のコード例を見てみましょう。

case event_data
in type: 'create', role: 'admin', **data
  # ...
in type: 'create', **data
  # ...
in type: 'update', **data
  # ...

このステートメント全体を見て、何らかの「コンパイル済み」内部形式を思い浮かべる人がいるかもしれません。そういう内部形式のコードでは、type:を1回実行すればただちにチェックが完了し、ベストケースなら関連する分岐がただちに示され、少なくともそのtype:は正確に1回だけ実行されると推測できるでしょう。なお、これは実際にそうなるわけではありませんが、ちょっとした黒魔術でそのことを確認できます。

class String
  # `===`を再定義してログを残すようにする
  def ===(*)
    puts(comparing: self)
    self.==(*) # いずれにしろシンプルな == を実行する
  end
end

case {type: 'update', role: 'admin'}
in type: 'create', role: 'admin', **data
  # ...
in type: 'create', **data
  # ...
in type: 'update', **data
  # ...
end

上のコードをRuby 3.2.2で実行すると、チェックが3回実行されていることを出力で確認できます。

# {:comparing=>"create"}
# {:comparing=>"create"}
# {:comparing=>"update"}

つまり、現在の実装の側面では、パターンマッチングは大量のif(これらは互いに独立して実行されます)の上にトッピングされたシンタックスシュガーの1つの形式に過ぎないのです。このケースは、パターンマッチング構文が約束しているものに実際の実装が追いつけるという希望になりうるかもしれません。

しかし、たとえパターンによる選択が最適化されたとしても3caseに渡される引数の多くは(裸のハッシュや配列ではなく)オブジェクトであることを忘れてはいけません。そしてパターンマッチングの効率は、そのオブジェクトの#deconstruct_keysメソッド4の効率と同程度にしかならないことも忘れてはいけません。


別の言い方も可能です。これについては、オブジェクトの作者が「パターンマッチングに適した振る舞いとは、何らかの内部データ構造を単純に(つまり効率よく)公開することである」と配慮してくれるか、それとも「重たい計算を実行したり、オブジェクトグラフを縦横に行き来したり、構造にない部分を実体化したりといった処理を自由きままに行う」かによって変わってきます。言い換えれば、扱うものがデータオブジェクトアクティブなオブジェクトかという話に帰着するのです。

こぼれ話

パターンマッチングのブームに乗って、RailsのActive Modelにパターンマッチングを導入するプルリクがマージされた(#45035)ことがありましたが、その後間もなく持ち上がった議論を優先して取り消されました(#45553)。それ以来、ネストした関連付けにもマッチ可能にする野心的なアイデアが議論され続けています(#45070)。

その結果、私たちが「データオブジェクト」で考えることを好むようになるに連れて、Ruby自体についての考え方(およびRubyistのRubyに対する考え方)がゆっくりと移り変わっていくことが想像できそうです。私たちは「メッセージを送信する不透明なオブジェクト」というパラダイムを長年愛してきましたが、そうした時代が終わりつつあるのかもしれません。「内部の形状(shape)がどのようになっている」かを明確に考慮する何らかの新しいオブジェクト/API/概念が出現する可能性がありそうです。

実は、パターンマッチングと無関係なところで既に起きつつあります。昨年リリースされたRuby 3.2では、オブジェクトシェイプという大規模な内部最適化が行われました(これについてはRubyKaigi 2022のJemma Issroffによる発表を見ることを強くおすすめします。オブジェクトシェイプは「内部的な」変更として導入されましたが、時とともに「オブジェクトシェイプの最適化」に適したオブジェクトを作成するには、その内部構造を考えておく必要があることが判明しました: 参考記事1参考記事2)。

したがって、オブジェクトシェイプ、パターンマッチング、Dataの導入(手前味噌で恐縮です)、そして業界のプログラミング言語設計で起きているあらゆることの帰結として、将来のRubyに「データファースト」のクラスやオブジェクトAPIがもっと登場する可能性があるかもしれません

その結果、「データオブジェクト」がパターンマッチングの普及に影響を与え、もしかすると言語のAPIへの「ブレンド」がいっそう推し進められる可能性も想像できそうです。
たとえば私がRubyの型について書いた記事では、パターン定義用のdefp句を追加してはどうだろうと提案したことがあります。defpdefに似ていますが、以下のように引数をパターンマッチングでunpackできます。

# typeは常に"create"とし、
# "role"をunpackし、
# "temperature"はチェックとunpackを行う
defp handle_create(type: 'create', role:, temperature: 0..40 => temp)
  # ...
end

ところで、同じ記事でdefpはメソッドのオーバーロードにも使えるかもしれないと想像していましたが、今では間違いだったと思います。この記事でも述べたように、オーバーロードはパターンマッチングとかなり直交しているようです(C++にはオーバーロードがありますがパターンマッチングはなく、Rustにはパターンマッチングがありますがオーバーロードはありません)。Rubyでは確実にこの点が考慮されていました。

ただし、ささやかな「未来は既にここにある」ジョークとしてポリモーフィック版Array#sliceの実装をお目にかけたいと思います(これは型に関する記事でデータの形状や型で分岐するデモとして使われたものです)。これはRuby 3.2用で、何も追加せずにポリモーフィックなメソッドそのものに見えます。

def slice(*) = case [*]
in [Integer => index]
  p(index:)
in [Range => range]
  p(range:)
in [Integer => from, Integer => to]
  p(from:, to:)
end

slice(1)    # {:index=>1}を出力
slice(1..3) # {:range=>1..3}を出力
slice(1, 3) # {:from=>1, :to=>3}を出力

これが好きか嫌いかはともかく、少なくともRubyではこんなふうに書いたコードが有効であり、しかも完全に納得できることに気づけたのは私にとって楽しいことす。

🔗 まとめ

ふぅ〜!パターンマッチングの3部作記事は特盛りになってしまいました。まだ私が見落としているものがいろいろあると思いますし、私が間違っていることはさらに多いでしょう。

最後のまとめとして言いたいことは以下です。

大学で教わった「プログラム=アルゴリズム+データ」は的を射ているかもしれないと思えるようになるまでに20年かかりました。データがあり、アルゴリズムがあり、そしてそれらを切り離して考えるのです。

率直に言って、この感情は少なくとも業界の一部では共通しているように思われます。大規模システムを構築するときの「グッドプラクティス」は、かつてのポリモーフィックでアクティブなオブジェクトのスープから、パッシブな「構造体」(おそらく扱いやすくするためのユーティリティメソッドや演算子を完備しているでしょう)と、それらを扱うためのより大規模な「サービス」(多くの場合短命もしくは完全にステートレスな)へと移行しつつあるのは確実です。

パターンマッチングは、この変化に気づいていない多くの言語ユーザーに、その変化を忍び寄らせる一種の「トロイの木馬」です。パターンマッチングは多くの人にとって「クール」です(誰にとってもクールだとは言いませんが、クールでなければ本シリーズで取り上げることもなかったでしょうし、私の人生ももう少し楽だったでしょう)。パターンマッチングのおかげで宣言的な方法で明確に記述できるようになり、コード構造の多くも改善されます。これは、本シリーズのあらまし記事で示した私の価値観「意図を明確に伝える」「わかりきったことを繰り返さないこと」「全体像を把握しやすくすること」などと合致しています。

しかし折に触れてパターンマッチングの用語で考えるようになってくると、「どんなものがマッチング可能なのか」「どうすれば効率よくマッチできるか」「どうすればパターンマッチングに適したオブジェクトになるか」といったシンプルな疑問が心に浮かぶようになり、誰かが(全員とまでは言いません!)コードの一般的なレイアウトや、コードをオブジェクトやクラスやモジュールに分割するときの説得力や期待に影響を与えるかもしれません。その世界観全体は、かつて優れているとされていたオブジェクト指向コードと最終的にかなりかけ離れたものになります。

はい、「最も古典的なオブジェクト指向言語」の1つであるRuby言語のコントリビュータ兼エバンジェリストである私の立場からこんなことを申し上げるのは皮肉だと思います。しかし私はどういうわけか、Rubyはこれを乗り越えられるだろうと信じています。美しく混乱し、内部の概念セットを最小限に抑え、実装は複雑で、設計プロセスがほんのり混沌としているRubyが、これを乗り越えるだろうと。

それによって私たちがどんな境地にたどり着くのかを見届けるのは興味深いことです。


次回はハッシュ/キーワード引数の値の省略を取り上げます。非常に議論を呼んでいる機能ですが、今度の記事は長くならないだろうと思います。

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


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

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

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

関連記事

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


  1. さもなければ、私が何かを見落としているのかもしれません(もっぱら言語のドキュメントとオンラインサンドボックスでの簡単な実験を頼りにしているので)。このChangelogエントリによると、少なくともScalaには代入時のunpack機能が存在しています。 
  2. 特にRubyにおいては、こうしたジレンマは(多少の差はあるものの)避けられないものです。実行頻度が極めて高い「ホットコード」パスを詳しくプロファイリングした結果、Rubyらしくない形で書き直すことは普通によくあります。レアケースにおける特殊な最適化について話す場合は問題ありませんが、「Rubyらしい書き方」と「パフォーマンスが十分高い書き方」が常に相反しているのであれば、それはよくないことです。 
  3. Rubyのあらゆる最適化は、まさにその柔軟性のせいで難しくなります。私が「デバッグスクリプト」で示したように、誰かが#===を再定義している可能性が常にあるため、オプティマイザは「文字列では単なる#==である」という事実を一般的に当てにするわけにはいきません。もちろん、コアオブジェクトにあるものを再定義するのはよくない習慣ですし、呼び出し順序に依存するもの同士を比較するのもまずい考え方ですが、よく言われるように、何かを変更すれば必ず誰かの仕事が中断されるのです。 
  4. さらに追い打ちをかけてしまいますが、現在のRubyでは、分岐のたびに#deconstruct_keysが繰り返し呼び出されます(つまり分岐が10個あれば、パターンと比較される同じオブジェクトが10回デコンストラクトされます)。いつの日か誰かが最適化してくれますように!現在の仕様では巧妙にも、この大量の呼び出しは「未定義の振る舞い」とされています。 

CONTACT

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