Ruby 2.7.0でキーワード引数として渡された引数なのかどうかフラグを確かめる方法

class Hash
  class << self
    def ruby2_keywords_hash?(hash)
      !new(*[hash]).default.equal?(hash)
    end

    def ruby2_keywords_hash(hash)
      _ruby2_keywords_hash(**hash)
    end

    private def _ruby2_keywords_hash(*args)
      args.last
    end
    ruby2_keywords(:_ruby2_keywords_hash) if respond_to?(:ruby2_keywords, true)
  end
end


RUBY_VERSION # => "2.7.0"

def passed_kw?(*args)
  Hash.ruby2_keywords_hash?(args.last)
end
ruby2_keywords(:passed_kw?) if respond_to?(:ruby2_keywords, true)

passed_kw?({ a: 1 }) # => false
passed_kw?(a: 1) # => true

hash = { a: 1 }
passed_kw?(hash) # => false
passed_kw?(**hash) # => true

kw = Hash.ruby2_keywords_hash(hash)
passed_kw?(kw) # => true
passed_kw?(**kw) # => true

説明書こうとしたけどだいぶめんどくさかったのでコードで感じ取ってほしいんですが、Ruby 2.7.0以降Ruby 3に向けてキーワード引数渡しの非互換をハンドリングするためにruby2_keywordsを使ってキーワード引数渡しされたhashオブジェクトにフラグを付けることができる、というかRuby 2.6以前とRuby 2.7以降の両方サポートしたいライブラリメンテナはこのフラグを付けて回らなければRuby 3ではArgumentErrorでお前はもう死んでいる状態になっています。

フラグが付けれるのはいいとしてRuby 2.7.0ではこのフラグが付いてるhashオブジェクトなのかそうじゃないhashオブジェクトなのか確かめる方法が公式には提供されてなくて、どこで呼び出されたときにフラグを付ける必要があって、ちゃんとフラグが付いたままお届け先のメソッドまでたどり着いてるのかのデバッグが死ぬほどめんどくさかった。

これはmameさんのハックを見て知った方法で、ようはフラグが付いてるhashオブジェクトかそうじゃないオブジェクトかで違う振る舞いをする処理を通らせてその結果を観測することでどっちだったかを確かめるという方法です。

このフラグが付いてるhashオブジェクトかそうじゃないオブジェクトかで違う振る舞いをする処理がRubyの世界からはほとんど存在しないので、普段Rubyでコードを書いてる常人が自力では気づけんやろって方法で、一部のメソッド(initializeとかmethod_missingとか)をRubyのコードから直接呼び出すんじゃなくてCのコードから間接的に呼び出されるときにフラグが付いてるhashオブジェクトをdupするので、dupされずにそのままのオブジェクト(object_id)だったらフラグが付いてなかったってことでdupされて別のオブジェクトが観測されたらフラグが付いてたオブジェクトだったという技を使っています。

原理が分かったので既存のメソッドでこの用途に丁度いいメソッドを探した結果、Hash.newにhashのデフォルト値としてフラグ付きかもしれないhashオブジェクトをsplat渡ししてHash#defaultで取り出して観測するという方法が一番シンプルであろうというところに至り、この技を使ってRailsでもキーワード引数完全分離への対応を進めています。

github.com

このフラグ付きかどうか確かめる方法があるのかないのか常人では思い至らん問題にライブラリメンテナは直面すると思いますが、Ruby 2.7.1にはフラグ付きかどうか確かめる方法が公式にバックポートされる予定なので、これで警告が多い日も安心ですね。

github.com