Ruby の Refinements の挙動に少しハマった話

7 min read読了の目安(約7100字

先日、deep_compact という gem を作り、リリースしました。

https://rubygems.org/gems/deep_compact

README にもある通り、以下のように using DeepCompact と宣言することで、
Array#deep_compactHash#deep_compact がそれぞれ定義されます。

#deep_compact は、対象のオブジェクトの子要素に対し、再帰的に #compact を適用します。

require 'deep_compact'

using DeepCompact

array = [nil, :a, { a: :a, nil: nil, array: [nil, :a] }]
pp array.deep_compact #=> [:a, {:a=>:a, :array=>[:a]}]

hash = { a: :a, nil: nil, array: [nil, :a], hash: { a: :a, nil: nil } }
pp hash.deep_compact #=> {:a=>:a, :array=>[:a], :hash=>{:a=>:a}}

問題のコード

現在のコードは、以下のとおり、「1つのモジュールで 2つのクラスを再定義する」実装となっています。

https://github.com/epaew/deep_compact.rb/blob/24408e02b68ce02ef9dcec9a36a9524ffb599d43/lib/deep_compact.rb#L5-L21
lib/deep_compact.rb
lib/deep_compact.rb
module DeepCompact
  refine ::Array do
    def deep_compact
      compact.map do |value|
        value.respond_to?(:deep_compact) ? value.deep_compact : value
      end
    end
  end

  refine ::Hash do
    def deep_compact
      compact.transform_values do |value|
        value.respond_to?(:deep_compact) ? value.deep_compact : value
      end
    end
  end
end

モジュールを分割したかった

これを以下のように「2つのモジュールでそれぞれ 1つのクラスを再定義する」形に書き直すと、少し挙動が変わります。

lib/deep_compact.rb
lib/deep_compact/array.rb
module DeepCompact
  module Array
    refine ::Array do
      def deep_compact
        compact.map do |value|
          value.respond_to?(:deep_compact) ? value.deep_compact : value
        end
      end
    end
  end
end
lib/deep_compact/hash.rb
module DeepCompact
  module Hash
    refine ::Hash do
      def deep_compact
        compact.transform_values do |value|
          value.respond_to?(:deep_compact) ? value.deep_compact : value
        end
      end
    end
  end
end
lib/deep_compact.rb
require_relative 'deep_compact/array'
require_relative 'deep_compact/hash'

module DeepCompact
  include Array
  include Hash
end
require 'deep_compact'

using DeepCompact

array = [nil, :a, { a: :a, nil: nil, array: [nil, :a] }]
pp array.deep_compact #=> [:a, {:a=>:a, nil: nil, :array=>[nil, :a]}]

hash = { a: :a, nil: nil, array: [nil, :a], hash: { a: :a, nil: nil } }
pp hash.deep_compact #=> {:a=>:a, :array=>[nil, :a], :hash=>{:a=>:a}}

何が起きているのか

  • Array#deep_compactHash#deep_compact はそれぞれ定義される、ただし
    • Array#deep_compact の実行スコープ内では Hash#deep_compact が定義されていない
    • Hash#deep_compact の実行スコープ内では Array#deep_compact が定義されていない

-> Array に含まれる Hash 内の nil 要素、Hash に含まれる Array 内の nil 要素、がそれぞれ削除されない

「じゃあ」、と

  • refine Array のブロック内で using ::DeepCompact::Hash
  • refine Hash のブロック内で using ::DeepCompact::Array

それぞれ宣言すると、以下の通りエラーが発生します。

lib/deep_compact.rb
lib/deep_compact/array.rb
module DeepCompact
  module Array
    refine ::Array do
      using ::DeepCompact::Hash

      def deep_compact
        compact.map do |value|
          value.respond_to?(:deep_compact) ? value.deep_compact : value
        end
      end
    end
  end
end
lib/deep_compact/hash.rb
module DeepCompact
  module Hash
    refine ::Hash do
      using ::DeepCompact::Array

      def deep_compact
        compact.transform_values do |value|
          value.respond_to?(:deep_compact) ? value.deep_compact : value
        end
      end
    end
  end
end
lib/deep_compact.rb
require_relative 'deep_compact/array'
require_relative 'deep_compact/hash'

module DeepCompact
  include Array
  include Hash
end
$ irb
irb(main):001:0> require 'lib/deep_compact'
<internal:/home/epaew/.rbenv/versions/3.0.1/lib/ruby/3.0.0/rubygems/core_ext/kernel_require.rb>:85:in `require': cannot load such file -- lib/deep_compact (LoadError)
        from <internal:/home/epaew/.rbenv/versions/3.0.1/lib/ruby/3.0.0/rubygems/core_ext/kernel_require.rb>:85:in `require'
        from (irb):1:in `<main>'
        from /home/epaew/.rbenv/versions/3.0.1/lib/ruby/gems/3.0.0/gems/irb-1.3.5/exe/irb:11:in `<top (required)>'
        from /home/epaew/.rbenv/versions/3.0.1/bin/irb:23:in `load'
        from /home/epaew/.rbenv/versions/3.0.1/bin/irb:23:in `<main>'
irb(main):002:0>

おそらくですが、

  • ::DeepCompact::Array の構文解析中に ::DeepCompact::Hash の構文解析が必要になり、
    その中でまた ::DeepCompact::Array の構文解析が必要になるため、LoadError が発生する[1]

という状況のようです。

まとめ

  • Refinements 難しい
    • Ruby の Refinements を使って「1つのモジュールで 2つのクラスを再定義する」実装と、
      「2つのモジュールでそれぞれ 1つのクラスを再定義する」実装では、クラスが再定義される scope に差がある
      • もしかしたら、「同一モジュールであれば refine Array 内で定義したメソッドが refine Hash 内から参照できる」この今の挙動こそがバグだったりするのかもしれない
    • Refinements のためのモジュール間で相互参照が発生すると、LoadError が発生する

おまけ(追記)

その1

lib/deep_compact.rb
lib/deep_compact/array.rb
module DeepCompact
  module Array
    module Mixin
      def deep_compact
        compact.map do |value|
          value.respond_to?(:deep_compact) ? value.deep_compact : value
        end
      end
    end

    refine ::Array do
      include ::DeepCompact::Array::Mixin
    end
  end
end
lib/deep_compact/hash.rb
module DeepCompact
  module Hash
    module Mixin
      def deep_compact
        compact.transform_values do |value|
          value.respond_to?(:deep_compact) ? value.deep_compact : value
        end
      end
    end

    refine ::Hash do
      include ::DeepCompact::Hash::Mixin
    end
  end
end
lib/deep_compact.rb
require_relative 'deep_compact/array'
require_relative 'deep_compact/hash'

module DeepCompact
  refine ::Array do
    include ::DeepCompact::Array::Mixin
  end

  refine ::Hash do
    include ::DeepCompact::Hash::Mixin
  end
end
  • Array#deep_compact の再定義は、DeepCompact::Array::Mixin#deep_compact のスコープからは見えない
    • [nil, [nil, :a]].deep_compact ですら再帰処理ができない

その2

lib/deep_compact.rb
lib/deep_compact/array.rb
module DeepCompact
  module Array
    module Mixin
      using ::DeepCompact

      def deep_compact
        compact.map do |value|
          value.respond_to?(:deep_compact) ? value.deep_compact : value
        end
      end
    end

    refine ::Array do
      include ::DeepCompact::Array::Mixin
    end
  end
end
lib/deep_compact/hash.rb
module DeepCompact
  module Hash
    module Mixin
      using ::DeepCompact

      def deep_compact
        compact.transform_values do |value|
          value.respond_to?(:deep_compact) ? value.deep_compact : value
        end
      end
    end

    refine ::Hash do
      include ::DeepCompact::Hash::Mixin
    end
  end
end
lib/deep_compact.rb
require_relative 'deep_compact/array'
require_relative 'deep_compact/hash'

module DeepCompact
  refine ::Array do
    include ::DeepCompact::Array::Mixin
  end

  refine ::Hash do
    include ::DeepCompact::Hash::Mixin
  end
end
  • DeepCompact::Array::Mixin#using を宣言した時点では module DeepCompactrefine 宣言の解析が完了していないため、何も再定義されない
    • [nil, [nil, :a]].deep_compact ですら再帰処理ができない

参考

脚注
  1. 一方的な参照のみであればエラーは出ませんが、相互に参照し合うとエラーになります ↩︎