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

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

概要

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

日本語タイトルは内容に即したものにしました。訳文の一部に強調を加え、段落編成を若干変更しています。

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

本記事は、Rubyのパターンマッチングに関する記事のパート2であり、この記事自体は最近のRubyで導入された機能を扱うシリーズの1つです。最近のRubyで出現した"無用な"(さもなければ物議を醸す)構文要素を扱うシリーズ記事の一環です。可能でしたらパート1からお読みください。シリーズの目次については本シリーズのあらまし記事をどうぞ。

前回のパート1では、Rubyでパターンマッチングが求められていた理由と、導入の経緯について書きました。パターンマッチングは大きな機能であり、多くの詳細を扱う必要があるため、ブログ記事で何もかも完全に説明するつもりはありません。この機能の完全な定義を見たい方は、比較的わかりやすい公式ドキュメント(英語)をおすすめします1

それでは、パターンマッチングの強みとつらみについて見ていきましょう!

🔗 パターンマッチングのむずかゆい点

パターンマッチングには、前回の記事で述べた、Rubyで選ばれたパターン構文が自然に見えるという大きな長所がある一方で、パターンマッチングがコードの構文上に存在する領域は、他のものから切り離されています。これはRubyでは非常に珍しいことです。Rubyについて詳しく知るとわかってきますが、Rubyには「セマンティクスが統一されている」という特徴があるからです。

他の言語では多くのものが互いに独立した文法要素で表現されますが、Rubyではオブジェクトでの単なるメソッド呼び出しになります。

class A
  # ここは通常のコード実行環境であり、
  # 以下のように何でも実行できる。
  p self #=> "A"を出力("A"はこのコードを内部で実行している現在のオブジェクト)

  # `:a`を受け取ってゲッターを定義する単なる`attr_reader()`メソッド。
  # 特殊な言語構造ではない。
  attr_reader :a

  # 単なる`private()`メソッド。
  # 引数`:foo`(定義されるメソッド名)を受け取り、
  # `def`ステートメントがそれを返す。
  private def foo
    # ...
  end

  # クラスメソッドはこのように定義可能だが、
  # これは特殊な構文ではなく、任意の`def obj.method`が動作する。
  # `self`はここではクラスオブジェクトを指す。
  def self.bar
    # ...
  end
end

# オブジェクトの作成ですら特殊な構造を使わず、
# オブジェクト`A`の`.new`メソッドを呼び出すだけ。
# これはイントロスペクション、再定義、削除が可能
a = A.new

パターンマッチングは、このような状況では浮いています。パターンにあるものは、マッチするものと一見形が似ているのですが、「両者が文法上で同じエンティティだから」似て見えるのではありません。パターン構文は完全に別物のエンティティであり、パターンのコンテキストに置かれた場合にのみ意味を持つのですが、「形が同じに見える」ように作られています。言い方を変えると、パターンは言語内で外見上は統合されているものの、意味上の統合は完全ではありません

パターンマッチングが他の構文から切り離されているという事実は、リテラルでないオブジェクトに対するパターンマッチングで^によるピニング(pinning: ピン止め)が要求されるという形で「唐突に」表面化することがあります。まるで完全な統合という幻想が崩れてしまったかのように。

# rangeリテラル: 動作する
number in 1..10

# 以下はほぼ同じだが構文エラーになる
time in Time.new('2023-10-01')..Time.now

# ただし以下のように書き換えることは可能
time in ^(Time.new('2023-10-01')..Time.now)

新しいパターンマッチングは、Rubyユーザーが従来正規表現やrangeなどで抱いていたパターン的な何かに関する直感(=パターンとは変数に入れたりメソッドに送信したりできるオブジェクトである)を覆します。

# 以下は動く:
# 「昔ながらの」`===`チェックでコレクションのアイテム(integer)を選択する
arguments.grep(Integer)

# ...しかし以下は動かない:
# 「integerがペアになった以外同じでは?」と思えるが動かない
arguments.grep([Integer, Integer])

# 最もそれっぽく書けるのは以下:
arguments.select { _1 in [Integer, Integer] }

その結果、「パターン」という名詞は用語集にあるものの、拡張したり遊んだりできるPatternクラスは存在しません。たとえばunpacking戦略をカスタム定義できないという問題があります。その理由については論理的に説明可能(パターン内にローカル変数への参照が含まれる可能性があるので、それらをラップするパターンオブジェクトを実装するのは不可能に近い)ということはわかっているのですが、それでも直感に逆らいます

一方、「パターンをステートメントとして扱う」手法の適用もかなり限定されています。パターンマッチング以外の代入方法を持たない関数型言語であれば、メソッドに引数を渡すような「暗黙の代入」コンテキストでも機能します。たとえばElixirでは以下のようになります。

# 以下が動くのであれば、
{x, y} = data

# ...マッチ/unpackロジックが同じなので以下も動く
def m({x, y}) do
  # xとyで何かする
end

=>は形式上は代入に似ているのですが、この演算子を暗黙に利用する方法は存在しません。言い換えれば、パターンを渡せるメソッドやコードブロックを定義する方法がないのです。

たとえば、ハッシュや構造体を要素に持つ配列を統一的に処理する一般的なケースを考えてみましょう。

events = [
  {type: 'create', role: 'admin', some: 'details'},
  {type: 'delete', role: 'user', buy: 'coffee'},
  {type: 'create', role: 'user', some: [:other, :stuff]},
]

以下のように書くことは可能です(明示的な代入)。

events.first => type:, role:, **data

以下のように書くことすら可能です(型チェックの後、roleと残りを取り出す)。

events.first => type: 'create' | 'delete', role:, **data

しかしこれは本物の代入ではなく、完全に別の構文なので「何も代入されません」。同様に、パラメータを暗黙にチェックまたは代入する方法もありません2

# これは本物のRubyではありません!
events.select { |type:, role:| type == 'create' || role == 'admin' }

最もそれっぽく書ける方法は以下ですが、右代入=>明示的に書く必要があります。

events.select { _1 => type:, role:; type == 'create' || role == 'admin' }

これは文字数的にほぼ同じぐらいコンパクトですが、読むときの感覚は異なります。ステートメントが2つもありますし(たいていのlinterは「行を分けて書け」と注意してくるでしょう)、unpack操作が「ブロック宣言の一部」としてではなく「ブロック内で処理されている」ように読めます。


統合が「浅い」ことと、使い方が煩雑で直感に逆らう点がいくつもある(例: 任意の個数のintegerを要素に持つ配列のパターンArray[*Integer]論理的に可能に見えても、実際は構文エラーになる)ことは、もちろん大した問題ではありません。パターンマッチングがもたらしたスタイルはもちろん嬉しいのですが、この点を認めるときにちょっぴりほろ苦い気持ちになるというだけのことです。

原注

ここに記した一連の「むずかゆい点」は、手に入れた新しいツールに対して直感を働かせようとしている1人のRubyユーザーの目線で分析したものです。関数型言語のパターンマッチングに慣れている人の目線で分析すると、パターンと値の順序が「逆転している」ように見える点が問題にされがちです。そうした言語のほとんどではpattern = valueとなっており、「代入としてのマッチング」というロジックとより調和します。しかしあらゆる点を考慮すると、前回の記事で示したように、「右代入」=>は興味深い形でメソッドチェインと調和しています。一方、パターンマッチングの別の実装を知る開発者にとって、「なぜパターンがオブジェクトでないのか?」という疑問は縁のないものです。

🔗 ウクライナ通信

ほんの少しだけお時間をください。今回から、今の私たちが暮らしている状況を小さな思い出として記事の途中にはさむことにします。私は現在戦争中の国で暮らしています。私の技術記事を楽しんでいただいた方々に、ぜひ私の国でいま起きていることを知っていただきたいと思います。これは戦況を報道しているのではなく、その週のささやかな出来事をお伝えするものです。

先週土曜日、私の地元ハルキウにある郵便集配所がロシアに爆撃されて郵便局員6名が亡くなり、17名が負傷しました。

引き続き記事をどうぞ。

🔗 導入後どうなったか

円熟した言語にこれほど大きな機能を導入すると、さまざまな影響が生じます。影響のほとんどは、パターンマッチングの採用が進むに連れてゆっくりと現れることでしょう(実際にそうなればの話ですが)。

しかしここで最も重要なのは、「データ」と「アルゴリズム」を分離するコードスタイルを今よりも使いやすくし、さらに広く普及させることです(2023年の様子については一通り知っていますが、今後についてはしばし見守ることにします)。

古典的なオブジェクト指向コード(明示的に定義された唯一のパラダイムに従うべきとされていた時代の産物)では、データの形状や内容に応じた分岐の問題にポリモーフィズムという答えを出しました。

同様に、if event.type == ... process_that_type(event)のようなものが頻繁に出現するなら、それらのイベントを個別のクラスにラップし、「処理の方法」をカプセル化したクラスでevent.process()を呼ぶだけで済むようにするでしょう。巨大なif分岐を書きまくっているオブジェクト指向プログラマーは「このコードが汚いのは承知してます...技術的負債を完全に返済する時間が取れたら、速攻でリファクタリングして小さいメソッドを適切な小さいクラスに小分けしないといけませんよね」という罪悪感にかられがちです。

しかし、マルチパラダイムと実用重視の現代においては、大クラス主義(インターフェイスが統一され、同じアルゴリズムをコンテキストに応じてポリモーフィックに実装する: DBアダプタなど)と、パッシブで小さい構造体またはオブジェクト(処理はそのオブジェクトの外部から選ばれる)は、一般通念において両方とも同じぐらい評価されているようです。

2つの方法を比較すると以下のようになります。

def handle_event_oo(event_data)
  # このクラスを動的に選ぶ
  event = Event::EVENT_TYPES[event_data[:type]].new(event_data)
  # 実装は、対応するクラス内で定義される
  event.process
end
def handle_event_case(event_data)
  case event_data[:type]
  when 'create'
    if event_data[:role] == 'admin'
      admin_create(event_data.except(:type, :role))
    else
      call_create(event_data.except(:type))
    end
  when 'update'
    call_update(event_data.except(:type))
  # ...
end

開発者は、イベント処理コードの長さや複雑さ、使いたい階層化アーキテクチャといった要因に応じて、どちらを選ぶこともあるでしょう。分岐の規模が(上のコードぐらいに)十分小さければ、「イベントに応じて何を行うか」を15〜20行程度の明確なcase文で完全に表現し、それによってコードの理解に要する時間を大幅に短縮できる可能性もあります。

そのようなコードにおけるパターンマッチングは、「どのようなイベント処理を行っているのか」をフラットなリストとして宣言的に記述することで明確さを増してくれる、歓迎すべき機能です。

case event_data
in type: 'create', role: 'admin', **data
  admin_create(data)
in type: 'create', **data
  call_create(data)
in type: 'update', **data
  call_update(data)
# ...

ここで考えたいのは「分岐をどのように編成すべきか」ではなく、「データと、それを処理するアルゴリズムを同じオブジェクトに入れるべきかどうか」です。この文脈で、現代の議論におけるORM(オブジェクト-リレーショナルマッピング)の2つのパターンの共存について深く考えてみるのは興味深いことです。2つのパターンとは、Active Recordパターン(データと関連メソッドを持つ巨大クラス)とRepositoryパターン(小規模で、ほとんどの場合パッシブで、コンテキストに適した構造体をフェッチする)です。

この論理に沿えば、これまで常に実用重視のマルチパラダイム言語であるRubyにパターンマッチングを導入するのは理にかなっていそうですよね?

ただし小さな問題が1つ残っています。

🔗 データはどこに置くのか?

オブジェクト指向が極めて強く「オブジェクト指向ファースト」時代の申し子であるSmalltalkから主要な影響を受けたRubyには、互いにメッセージを送受信する不透明なオブジェクトのみが言語に存在し、データという概念が言語にまったく登場しません。Rubyも他の言語と同様に「メソッド呼び出し」という用語に従っていますが、「名前によるメソッド呼び出し」ですら実態は依然としてobject.send(name)であり、Smalltalkの「メッセージの送信」という用語に敬意を表しています。

これは一見ささいな違いのようですが、実は「ご近所の」言語との重要な違いなのです。

たとえばPythonやJavaScriptでdog.namedog.bark()と書いたとします。このdogオブジェクトは透明で、namebarkという属性(attribute)があり、これは言語の中心となる概念です。そして後者のbark()を追加すれば呼び出すことも可能です。

Rubyコードでdog.namedog.bark()と書いたとします。このdogオブジェクトは不透明で、どちらもnameメソッドとbarkメソッドにそれぞれ対応します。つまりRubyではどちらも単なるメソッド呼び出しであり、メソッド呼び出しの()は省略可能です。PythonやJavaScriptと異なり、Rubyではdog.name()と書いてもdog.barkと書いても効果はまったく同じです。

RubyのStructや、最近追加されたData3(セマンティクスがStructよりも狭く厳密です)は、欲しいデータ構造に似た外見を備え( Dataの場合はイミュータブルになります)、必要なメソッドを備えたオブジェクトを作成するショートカットにすぎません。

Rubyと他の類似言語とのこのような違いは、実用重視のRubyistにとってはさほど明確ではなく、楽しい雑学に過ぎないとすら思われているかもしれませんが、言語そのものを認識する方法や、言語に新しい機能を統合する方法に重大な影響を及ぼします。

話をパターンマッチングに戻すと、Rubyの機能設計では、辞書や配列といった単純なデータ構造にマッチできるだけではなく、以下のように(一部の)オブジェクトにもマッチできます。

class Point
  # (省略)
end

# マッチとPointのパターンへのunpackを試みる
Point.new(0, 0) => x:, y:
# これも動く(クラスを明示的にチェックしている)
Point.new(0, 0) => Point(x:, y:)

しかしRubyのオブジェクトがすべて不透明であれば(かつ「このオブジェクトのデータは何か」というpublicな概念がなく、内部表現しかなければ)、Pointのインスタンスがパターンとマッチするかどうかをパターンマッチングがどうやって知るのでしょうか?もちろんメソッドの助けを借りるのです!

以下は、パターンマッチングで動作する最小限のPoint実装です。

class Point
  def initialize(x, y)
    @x = x
    @y = y
  end

  # このメソッドは、キーが`point => x:, y:`のようにデコンストラクトされるときに
  # パターンマッチングによって呼び出される
  def deconstruct_keys(*) = {x: @x, y: @y}

  # このメソッドは、`point => [x, y]`のように
  # 位置に基づくデコンストラクトが発生するかどうかをチェックするために
  # パターンマッチングによって呼び出される
  def deconstruct_keys = [@x, @y]
end

すなわち、オブジェクトのデコンストラクトに適した表現は完全にオブジェクトの作者側に責任があります。Rubyのカスタムクラスでは、それ用の自明な実装はサポートされていませんが、それ用の複雑な定義が制限されることもありません。

ただし、StructData4についてはデフォルトの実装があります。そういうわけで、Point実際の最小実装は以下だけで済みます。

Point = Data.define(:x, :y)

Point.new(0, 0) => x:, y:
# x = 0, y = 0

ところで、これによって「タグ付きデータ」や「Resultモナド」といった手法を関数型プログラミングから借用する道が多数開かれます。これは小さいながら重要な可能性です。

  Success = Data.define(:value)
  Error = Data.define(:message)

  # 上の定義だけで、いくつかの関数結果を以下のように処理できる:
  case my_cool_func(something)
  in Success(value)
    # 生の`value`で動作する
  in Error(message)
    # エラーの生の`message`で動作する

一部の他言語では「enum」や「caseクラス」といった概念を別途必要とする効果が、ここでは極めて最小限かつ汎用的な手段であっさり実現できることにご注目ください。

しかし最も重要なのは、この方法によって開発者の視点が「Value Object」(おそらくパッシブな)と、それらを操作する「アルゴリズム」にシフトするということです(クラスに含めるもよし、ステートフルオブジェクトとして渡すもよし、ポリモーフィック実装として使うもよしです!)。

Smalltalkの「メッセージを送信し合うオブジェクトのスープ」というイメージからだいぶ遠ざかっていますね。一部の人がこれに反発する理由が想像できそうです。一方、「とにかく、これこそ最もメンテナンス性の高いコードを書く方法だ」と言う人もいそうです。


もうお気づきですか?実はまだ終わりではありません。しかし夜なべ仕事なら1週間もあれば書き終わるぐらいの量なので、おそらく来週には完結編であるパート3を以下のような内容で公開できそうです。

  • 他の言語ではどうやっているか
  • この先どうなるか
  • まとめ

更新情報: パート3を公開しました。ありがたいことに今度こそ完結編です。

巨大で複雑な機能を、構文の小さな進化を扱うときと同じ要領で扱ったため長い記事になってしまいました(人は過ちを認める強さとともに、約束を守る粘り強さも持ち合わせる必要があります。そういうわけでパターンマッチングはこれにて「完結」となりますが、シリーズはまだ続きます)。

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


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

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

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

関連記事

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

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

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


  1. なお、ドキュメントの最初のバージョンには自分も参加した(#2786 #2952)ので、「わかりやすい」と書いたのはそこまで客観的ではないかもしれません。 
  2. Ruby 2.7でキーワード引数が全面的にリファクタリングされる前であれば、パターンマッチングを使わなくても最もシンプルな形である「キーワード引数のunpackのみを行う」ことは可能だったのですが、パーサーをシンプルにするため削除されました。今でも少し悲しく思っています。 
  3. Dataクラスは小生が作りました。 
  4. 繰り返しますが、これらのクラスでdeconstructメソッドとdeconstruct_keysメソッドがデフォルトで定義されているので、言語による深いサポートを必要としないというだけの話です。 

CONTACT

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