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

Ruby: パターンマッチングをカスタムオブジェクトで実装するときの注意点(翻訳)

概要

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

日本語タイトルは内容に即したものにしました。deconstructの仮訳は「分解」としています。

参考: Data#deconstruct (Ruby 3.2 リファレンスマニュアル)
参考: Data#deconstruct_keys (Ruby 3.2 リファレンスマニュアル)

なお、記事中のサンプルコードにあるDate.todayメソッドを使うにはrequire 'date'が必要です。

参考: Date.today (Ruby 3.2 リファレンスマニュアル)

また、記事中のサンプルコードの中には、現在が2022年12月であることを前提としているものもいくつかあります。コード中の年や月が202212のままだと期待通りに動作しないものもあるので、サンプルコードを動かすときは現在の年や月(20233など)に適宜変更してください。

Ruby: パターンマッチングをカスタムオブジェクトで実装するときの注意点(翻訳)

こんにちは皆さん。

私はハルキウ出身のウクライナ人Rubyistです。私の国ではまだ戦争が続いています。ロシアは2/24に全面侵攻を開始し、8年間におよぶハイブリッド戦争が継続しています。私の街は数か月前よりも前線から遠ざかり、直接的な脅威は減ったものの、ロシアの砲撃によって先週の金曜日にも全面的に停電しました(03/0303/15の記事もご覧ください)。

そんな状況にもかかわらず、Ruby 3.2のリリースを控えている今、仕事と家庭とボランティア活動の合間に少し時間が取れたので、Rubyについての執筆を緩やかに再開しつつあります。私の記事はSubstackでフォローいただけます。

今回は、クラスでパターンマッチングを正しく実装するときに考慮すべき点を解説する(やや)小さめの実用記事です。

🔗 私たちがパターンマッチングでやっていること

Ruby 2.7で真の強力なパターンマッチングが実験的機能として導入され、3.03.1で磨きをかけてきました。パターンマッチングによって、データの構造に応じて分岐できるようになります。

case user
in role: 'admin', name:
  puts "Hello, mighty #{name}!"
in role: 'user', registered_at: ...Date.new(2022)
  puts "Hi, old friend"
else
  puts "I don't know you well yet"
end

データ構造のチェックにも使えます。

if post in status: 'pending', author: {role: 'editor'}
  # ...

既知の構造からデータを取り出すこともできます。

config => {db: {name:, user:, password:}, logger: {target:}}

# ここでは`name`、`user`、`password`、`target`変数が利用可能で、
# configから特定の値を設定する
# 期待される構造とconfigがマッチしない場合は、
# NoMatchingPatternが発生する

この方法の最も素晴らしい点は、素の配列やハッシュにマッチできるだけではなく、#deconstruct#deconstruct_keysを実装する任意のオブジェクトにマッチできることです。

12/24にリリース予定のRuby 3.2で、私はRubyのTimeDateおよびDateTimeにパターンマッチングによるデータの分解(deconstruction)機能を追加しました。

本記事では、これを独自オブジェクトで行いたい場合に役に立つと思われる一般的な所見をいくつか紹介したいと思います。

たとえばruby-headや、本記事でこれから書くコードでは、基本的に以下のようなことができるようになります。

require 'date'
case Date.today
in year: ...2022
  puts "昔むかし..."
in month: 6..8, day:
  puts "今日は美しい夏の月の#{day}日..."
in wday: 3
  puts "水曜日だよ、おい!"
# ...etc.

これについて、いくつかの簡単なルールを紹介します。

🔗 1: 良いキーを選ぶこと

ハッシュパターン(caseブランチとinまたは=>)でマッチするオブジェクトの場合、Rubyはそのオブジェクトが#deconstruct_keysメソッドに応答するかどうかをチェックしてから、そのパターンに含まれるキーでこのメソッドを呼び出します。
たとえば以下の場合、

# yearが2022の場合にのみマッチする
# monthとdayはローカル変数にunpackされる
Date.today in year: 2022, month:, day:

RubyはDate#deconstruct_keysメソッドを[:year, :month, :day]というキーで呼び出し、これらのキーと、対応する値のハッシュを返すことが期待されます。

実装は難しくありません。

class Date
# このメソッドの書き方は"山ほど"あるが、
# あえてうんと昔の書き方を選んだ
  def deconstruct_keys(keys)
    res = {}
    keys.each do |k|
      case k
      when :year then res[k] = year
      when :month then res[k] = month
      when :day then res[k] = day
      end
    end
    res
  end
end

これを、たとえばRuby 3.1(Date#deconstruct_keysをネイティブで搭載していない)で実行すると、ちゃんと動くことがわかります。

if Date.today in year: 2022, month: 12, day:
  p "Dec #{day}!"
end

実はRuby 3.0で試す場合は、パターンを{}で囲まなければなりませんでした。

if Date.today in {year: 2022, month: 12, day:}
  p "Dec #{day}!"
end

これができれば十分でしょうか?日付データのすべての要素を分解して取り出せるなら、他に欲しいものがあるでしょうか?しかし優れた日付データ分解機能は最小限と決まっているわけではないので、たとえばwday(曜日)をサポートしてみてはどうでしょう?(Ruby 3.2の実装ならできます)

class Date
  def deconstruct_keys(keys)
    res = {}
    keys.each do |k|
      case k
      when :year then res[k] = year
      when :month then res[k] = month
      when :day then res[k] = day
      when :wday then res[k] = wday
      end
    end
    res
  end
end

すると、「2022年の各月の第1木曜日」を以下のようにワンライナーで書けるようになるのです。

# 年の始めの日から+1日するシーケンスを無限にproduceする
Enumerator
  .produce(Date.new(2022)) { _1 + 1 }
  .lazy
  .select { _1 in day: ..7, wday: 4 } # その中から、その月の最初の7日間にある
                                      # 木曜日を取り出す
  .take_while { _1 < Date.today }
  .to_a

#=> [#<Date: 2022-01-06>,
#    #<Date: 2022-02-03>,
#    #<Date: 2022-03-03>,
#    ...

🔗 2: 未知のキーが来たときに失敗させないこと

有効な入力の有限集合に限定される(つまりDate#deconstruct_keysで特定のキーだけをサポートする)ことがわかってくると、サポートされていない入力が来たら例外をraiseしてエラーから保護したい誘惑にかられます。

しかし、#deconstruct_keysの場合はおそらく間違っています。上のcaseに以下を追加した場合を想像してみましょう。

      # ...
      when :day then res[k] = day
      when :wday then res[k] = wday
      else raise ArgumentError, "Unsupported key #{k}"

時刻と日付がさまざまな形式で入り混じった集合に対して何らかの複雑な処理を行うときに、以下のような感じでやってみたとします。

case creation_mark
in year:,month:,day:,hour:
  # ...
in year:,month:,day:
  # ...
in String
  # ...
end

すると、未知のキーを単に無視する実装は(正しく)最初の分岐で早々にスキップされますが、raiseする実装はマッチング全体がUnsupported key hour (ArgumentError)で壊れてしまうでしょう。おそらくユーザーが最も期待しそうなのは「マッチするな、このブランチを単にスキップしろ!」という振る舞いだったでしょう。

この問題を踏んでしまう別のケースは、メタプログラミングでスマートに処理しようとした場合です。スマートに実装するのは以下のように簡単です。

class Date
  # 超モダンなRuby(ワンライナーメソッドと暗黙のブロック引数を備える)に
  # なつかしのメタプロを併用する
  def deconstruct_keys(keys) = keys.to_h { [_1, public_send(_1)] }
end

この場合、以下は動きます。

Date.today in year: 2022 # OK

しかし、たちまちこうなります。

timestamp = Date.today
# ...
timestamp in hour:, min: # 謎のNoMethodError

ところで、この「認識されないキー」で興味深いのは、認識されないキーが存在すればこのパターンは間違いなくマッチしなくなるので、データをまったく返さないのはOKだという点です。なので以下はOKです。

      # ...
      when :day then res[k] = day
      when :wday then res[k] = wday
      else return {}       # それ以上キーを調べようとせず即座にreturnする

余談: 仮に誰かが本当にパターンマッチングでDateTimeを混ぜたとしても、Rubyは分解処理はもちろん、どんなクラスが期待されているかを明確にするうまい方法も提供しています(いつでも使えるので、そのための実装は不要です)。

  case creation_mark
  in Time(year:,month:,day:,hour:)
    # ...
  in Date(year:,month:,day:)
    # ...
  in String
    # ...
  end

🔗 3: だからといって未知のキーを誤ってサポートしないこと

オブジェクトの知識にまったく存在しないキーが来たらraiseするのは悪手ですが、かといってそれをそのまま吐き出すのも同じぐらい悪手です。上述の「クールなメタプログラミング版」を以下のように修正したくなるかも知れません。

class Date
  def deconstruct_keys(keys)
    keys.to_h { [_1, public_send(_1) if respond_to?(_1)] }
  end
end

# すると...
Date.today.deconstruct_keys(%i[year month day hour])
#=> {:year=>2022, :month=>12, :day=>19, :hour=>nil}

この実装の問題点は、構造のパターンマッチングが「このデータは期待される構造とマッチするか?」というチェックをさんざん繰り返した末に、Dateの知識に存在しないキーを返すので、約束が果たされないことです。

以下のようなコードを考えてみましょう。

def print_time(tm)
  tm => hour:, min:
  puts "%02i:%02i" % [hour, min]
end

print_time(Time.now)
# 出力: "22:01"
# ただしRuby 3.2でのみ有効。それ以前のバージョンではTimeを分解できません :)

このメソッドの1行目は興味深い効果があります。すなわち、tmが契約どおりであるかのチェック(tmにはhourminがあるという契約が果たされなければ即座にNoMatchingPatternraiseする)と、それらをローカル変数に代入して扱いやすくするという2つの処理を(右代入=>で)1度に行っていることです。

上述の欠陥実装はどんなキーを与えてもnilを返し、壊れたときのデバッグも極めて難しくなってしまいます。

print_time(Date.today) # in `%': can't convert nil into Integer (TypeError)

一方、正しい実装(未知のキーは返さない)でもエラーは起きますが、こちらはメソッド作者の期待通りであり、有益な情報を出力します。

in `print_time': #<Date: 2022-12-19 ...>: key not found: :hour (NoMatchingPatternKeyError)

実を言うと、「スマートな」実装の方は他にも問題が山盛りです(d in iso8601:のようにおかしなものにも応答してしまうなど)が、あくまで余談です。

🔗 4: 「任意のキー」がオプションで渡される可能性を忘れないこと

パターンマッチング句では、以下のように「ハッシュの残りの部分はこの変数に代入せよ」と(**で)指定できます。

Date.today in year: 2022, **rest

この場合、#deconstruct_keysメソッドはnilを受け取り、サポートするすべてのキーを返すことが期待されるので、実際の「正しい」実装は以下のようになります。

class Date
  def deconstruct_keys(keys)
    if keys
      res = {}
      keys.each do |k|
        case k
        when :year then res[k] = year
        when :month then res[k] = month
        when :day then res[k] = day
        when :wday then res[k] = wday
        end
      end
      res
    else
      {year:, month:, day:, wday:}
    end
  end
end

Date.today in year: 2022, **rest
pp rest                #=> {:month=>12, :day=>19, :wday=>1}

ここで興味深い点を指摘しておきたいと思います。実は、#deconstruct_keyskeys引数は最適化のために導入されたものなのです。パターンに含まれるキーが多くない場合、そのオブジェクトは、返すものを制限する「可能性」が生じます(制限する義務はありません)。しかし、常に既知のキーをすべて返す可能性もあります。これはエラーにはなりません!

#deconstruct_keyskeys引数が必要となるのは、計算に時間のかかる値がある場合がほとんどですが、オブジェクトがDateのようにシンプルな場合は、この最適化の意味はあまりありません。仮に私がRubyで小さいDate的なカスタムクラスに#deconstruct_keysを実装するなら、以下のようにシンプルに書いて済ませるでしょう。

class Date
  def deconstruct_keys(*) = {year:, month:, day:, wday:}
end

🔗 5: やりすぎないこと

#deconstruct_keysを実装するのはそれほど難しくありませんし、パターンマッチは見た目も麗しいので、少々やりすぎてみたい誘惑にかられるかもしれません。

たとえば、曜日番号は何かと覚えにくいので(0が日曜日ってなぜ?)、以下のように書いたとします。

class Date
  def deconstruct_keys(*)
    {year:, month:, day:, wday:, wday_name: strftime('%A')}
  end
end

続いて以下のように、いい感じに覚えやすくしたとします。

Date.today in wday_name: 'Monday' #=> true

これはこれで、状況によってはクールで表現力を感じられるかもしれませんが、パターンマッチングが本当に輝くのは大量のデータを処理するときなのです。

私たちはたった今、ほんの1、2箇所を「ちょっといい感じ」にしたいがために、それと引き換えにDateをマッチングするたびに発生するパフォーマンス上のペナルティを導入してしまったのです、おそらく。

strftimeは地上で最も遅いメソッドではありませんが、間違いなくシンプルなDateゲッターメソッドより遅くなります。ここで私が強調しておきたい原則は「deconstruct_keysで余計な値を分解する前によーく考えること」です。余分な計算が必要な値もそうですし、DBからフェッチする値はなおさらです。)

🔗 分解の結果を配列にする方法

これまでにお話ししたのは、キー:キー: パターンといったハッシュパターンへのマッチングを処理する#deconstruct_keysメソッドでした。

(訳注: Ruby 3.2のTimeDateに)実装される可能性があったもう1つのメソッドは、配列を分解する#deconstructメソッドです。このメソッドのプロトコルはシンプルで、オブジェクト表現を配列として返します。何らかの論理的順序が定義されていれば、その順序で配列を返します。

class Date
  def deconstruct = [year, month, day]
end

たとえば上のようにすると、以下のように書けます。

if Date.today in [2022, month, *]
  puts "#{month} month of this year"
end

# => "12 month of this year"が出力される

#deconstructにはいくつか注意点があります。

  • ハッシュパターンの場合と異なり、配列パターンはdeconstructの「すべての戻り値」にのみマッチします。つまり、以下はマッチしません。
Date.today in 2022, month
#=> false (マッチするものがmonthより後ろにないため)
  • 配列パターンを使う意味があるのは、オブジェクト全体に対応する明確な論理的順序が存在する場合だけです(サンプル実装には曜日を置く場所が"ない"ので混乱するかもしれません)。

私たちがRuby 3.2のTimeDateを実装したときは、最初にTimeから手を付けました。そのとき、「Timeのすべてのコンポーネントの順序や集合はそこまで明確ではない」というMatzの決定が下されたため、Rubyのそれらのクラスには#deconstruct存在しません

でも、自分のクラスに実装するなら意味があるかもしれません!

🔗 終わりの言葉

記事の末尾で「本記事を形にできたのは、ひとえに○○株式会社/組織/財団のお力添えのおかげです」などの謝辞を見たことがあるでしょう。

私の場合はこうです。私がハルキウの自宅でくつろぎながら腰掛け、いくばくかの自由時間と電力を使って本記事を書けたのは(そしてRubyコアでこれらの変更を実装できたのも)、ひとえに以下の方々のお力添えのおかげなのです。

  • ウクライナ軍
  • 国際的な兵器供与/義援金/広報のサポート

どうかご寄付を、そして声を上げてください!

Слава Україні! 🇺🇦

関連記事

Ruby 3.2のData#initializeがキーワード引数も位置引数も渡せる設計になった理由(翻訳)


CONTACT

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