Ruby: Mixinモジュールに private メソッドを作る

TL;DR

  • Mixin用のモジュールで、それをインクルードするクラスからも見えないような private スコープが欲しい。
  • Ruby2.4以降なら、自分の中で自分自身を refine する事でそれっぽい事はできる。
  • 通常の private とは違うしイマイチな点もある。

サブクラスからも見えない private スコープ

Mixin用にモジュールを作った時、「それをインクルードするクラスからも見えない private スコープ」 が欲しくなる事ってありませんか?

Rubyの private はそのクラス/モジュールを継承するクラスからも見えてしまうので、 Mixin用モジュールの中で private メソッドを定義しても、それをインクルードするクラスから呼べてしまいます。 特に、例えば Rails の Helper のように色んなところでインクルードされるモジュールでは、 インクルードするクラスから見えない private スコープが欲しくなる時が自分はあります。

module SomeUtil
  def do_something
    # ...
  end

  private

  # private method を定義しても...
  def secret
    "some secret"
  end
end

class Someone
  include SomeUtil

  # include したクラスからは見えちゃう
  def leak
    "I know #{secret}"
  end
end

p Someone.new.leak
#=> "I know some secret"

解決方法をちょっとググってみると、モジュール内に別のクラスを定義してその中にメソッドを置く方法がありました ()。 実際に private になるわけではありませんが、ネームスペースの汚染を防ぐには有効そうです。 とはいえクラスが変わるとselfも変わってしまって何だかなーという気がするし、 いちいちクラスを定義するのも面倒です。

ActiveSupportが使っていた方法

そんな中、先日社内勉強会でActiveSupportのソースを読んでいた時に面白いコードを見つけました。 Array#sumを拡張する以下のようなコードです (source)。

module Enumerable
  # ...

  # Using Refinements here in order not to expose our internal method
  using Module.new {
    refine Array do
      alias :orig_sum :sum
    end
  }

  class Array
    def sum(init = nil, &block) #:nodoc:
      if init.is_a?(Numeric) || first.is_a?(Numeric)
        init ||= 0
        orig_sum(init, &block)
      else
        super
      end
    end
  end
end

ここではsumメソッドを再定義する前に、 元々あるArray#sumにもアクセスできるようにorig_sumという alias をつくっているのですが、 興味深いのはその処理がusingrefineによるブロックの中で行われている点です。

usingrefine

RefinementsはRuby2.0から導入された機能で、 限定した範囲でのみオープンクラスによるクラス拡張を有効にする事ができます。

module StringExtensions
  refine String do
    def loudly(volume = 3)
      "#{self}#{'!' * volume}"
    end
  end
end

module Hello
  using StringExtensions

  def self.world
    "hello, world".loudly
  end
end

p Hello.world # => "hello, world!!!"
p "good bye".loudly # => NoMethodError

しかし先程ActiveSupportが使っていた Refinements は、このような例とは違いました。 その場で無名モジュールを作り、その中でrefineを使ってArrayに alias を追加し、即座にそれをusingしています。 このコードはEnumerableモジュール内に書かれているので、こうするとEnumerable内でのみorig_sumメソッドが使えるようになります。 つまり、orig_sumという alias を外部に公開する事なく使えるわけです。面白いですね。

ところでこの方法って、通常のモジュールでも使えないでしょうか? つまり、自分自身の中でのみ自身をrefine & usingしてメソッドを定義すれば、 自分自身の中でのみ使用可能なメソッドを定義できるのではないでしょうか (言うなれば self-refinements?)。

Self Refinements による擬似 private

実は、 2.3 までの Ruby ではモジュールでこの方法を使う事は不可能でした。 refineはクラスにしか使えず、モジュールを渡すとエラーになっていたからです。 しかし 2.4 からはモジュールにも使えるようになったため (release note)、 それが可能になりました。実際にやってみます。

module Greeter
  using Module.new {
    refine Greeter do
      def world
        "world"
      end
    end
  }

  def hello
    "hello, #{world}"
  end
end

class Stuff
  include Greeter

  def greet
    hello
  end

  def greet2
    world
  end
end

p Stuff.new.greet   # => "hello, world"
p Stuff.new.greet2  # => undefined local variable or method 'world'

動きました。 もう少し簡単に書けるようにするとこんな感じでしょうか。

class Module
  # Add `privates` method to Module class.
  def privates(&block)
    target = self
    Module.new do
      refine target do
        class_eval &block
      end
    end
  end
end

module Greeter
  # Define private-like methods using Refinements feature.
  using privates {
    def world
      "world"
    end
  }

  def hello
    "hello, #{world}"
  end
end

class Stuff
  include Greeter

  def greet
    hello
  end

  def greet2
    world
  end
end

p Stuff.new.greet   # => "hello, world"
p Stuff.new.greet2  # => undefined local variable or method 'world'

usingをメソッド内で使う事は禁止されているので、refineの部分だけメソッド化しています。 これでもう少し簡単に書けるようになりました。 この方法なら、サブクラスからも見えない private スコープを手軽に実現する事ができます。

問題いろいろ

が、この方法にはいくつかイマイチな点があります。

定義ブロックにdo ... endを使えない

以下のようにprivatesに渡すブロックをdo endで記述すると、 そのブロックはusingへの引数だと解釈されてしまうため、 代わりにブレース ({}) を使う必要があります。

# ArgumentError: wrong number of arguments
using privates do
  def world
    "world"
  end
end
# => `using(privates) do; end`

# OK
using privates {}  # => using(privates {})

# OK
using (privates do; end)

複数行になるブロックにはdo endを使う方が一般的だと思うので、 ブレースで書かないといけないのはちょっと気持ち悪いです。

using privatesの定義場所に気をつけないといけない

using文は、それによって拡張されるメソッドを使用している箇所よりも前に記述する必要があるようです。 例えば以下のように順番を変えてhelloを実行すると、「worldは未定義です」というエラーになってしまいます。

module Greeter
  def hello
    "hello, #{world}"  # Error
  end

  using privates {
    def world
      "world"
    end
  }
end

定義したメソッドをsendで呼べない

これは当然ですが、 Refinements が効いていないモジュールの外からはrefineブロックで定義されたメソッドは全く見えないので、 通常の private とは違いsendを使っても呼び出す事ができません。 逆に言えば、他の言語にあるような完全な private を実現できる、とも言えるかもしれませんが。

まとめ

自身の Refinements を自身内でのみ使う事で、外部からは見えないメソッドを定義できました。 この「外部」にはサブクラスも含まれるので、 Mixin用モジュール内でのみ使える private-like なメソッドを定義できます。 また通常のクラスでも、サブクラスに公開したくないメソッドを定義するのに使えます。 ただし、記述方法は微妙と言わざるを得ません。