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

Ruby: クラスメソッド内でprivateメソッドを動的に定義するときの注意(翻訳)

概要

元サイトの許諾を得て翻訳・公開いたします。

日本語タイトルは内容に即したものにしました。

Ruby: クラスメソッド内でprivateメソッドを動的に定義するときの注意(翻訳)

私たちが最近Rubyをアップグレードしたことにより、動的に生成されるメソッドによっては、そのメソッドをprivateに設定しようとしたときに問題があることが判明しました。本記事では、この問題を再現するシンプルな例を紹介するとともに、問題の解決方法を示します。

🔗 はじめに

既存のプロジェクトでRubyを2.7にアップグレードしたときに、見慣れない警告が表示されました。

warning: calling private without arguments inside a method may not have the intended effect

この警告を調べてみると、たしかに一部のprivateメソッドを別のメソッド定義内で定義しようとして失敗していることが判明しました。
この問題コードの改修方法を見てみましょう。なお、本記事で精査したコードは以下のGitHubリポジトリで参照できます。

domhnall/ruby-private-method-inside-class-method - GitHub

🔗 モジュールのmix-in

クラス間で共有する機能をモジュールで定義する手法はよく使われています。モジュールを"ホスト”クラスにmix-inする方法はたくさんあります。
問題となった元のコードは、実際にはActiveSupport::Concernに関連していたのですが、この後の例では不必要に複雑にならないように取り除けてあります。

ここでは、ホストクラスでextend可能なHasContentモジュールを定義することにします。このモジュールにhas_contentメソッドを定義します。このメソッドが受け取る名前は、content属性を識別する目的に使われ、デフォルト値は(ショッキングですが)contentとします。

このhas_contentメソッドを用いて、ある名前付きインスタンス変数のゲッターとセッター用に新たなインスタンス変数を2つ定義することにします。このインスタンス変数のシンプルなゲッターとセッターに加えて、これらのメソッドはlog関数で操作をログ出力することにします。

原注

ホストクラスはこのモジュールをincludeするのではなくextendする点にご注意ください。つまり、このモジュールはメソッドを"クラスメソッド"として公開するということです。
対照的に、あるモジュールをクラスにincludeすると、モジュール内に定義されているメソッドはホストクラスの"インスタンスメソッド"になります。

以下のhas_contentメソッドでは、logメソッドをprivateにするためにprivateアクセス修飾子を用いていることがわかります。

module HasContent
  def has_content(field_name=:content)
    define_method "get_#{field_name}" do
      log("Getting #{field_name}")
      instance_variable_get("@#{field_name}")
    end

    define_method "set_#{field_name}" do |content|
      log("Setting #{field_name}=#{content}")
      instance_variable_set("@#{field_name}", content)
    end

    private

    define_method "log" do |msg|
      puts "Logging: #{msg}"
    end
  end
end

このmixinモジュールを、以下のように定義されたシンプルなBoxクラス内で利用します。

class Box
  extend HasContent
  has_content(:stuff)
end
box = Box.new
box.set_stuff("Jack")
puts box.get_stuff # "Jack"を出力する
puts "!!WARNING!! Box#log should be private" if Box.instance_methods.include?(:log)

Boxのインスタンスを作成してから、HasContentモジュールによって追加されたメソッドのいくつかにアクセスを試みます。#set_stuffメソッドと#get_stuffメソッドについては成功です。
privateなlogメソッドは呼び出し可能であってはならないのですが、警告メッセージに表示されているように、logメソッドへのアクセスを禁止できませんでした。

> ruby main.rb
/home/domhnall/code/ruby-private-method-inside-class-method/has_content.rb:20: warning: calling private without arguments inside a method may not have the intended effect
Logging: Setting stuff=Jack
Logging: Getting stuff
Jack
!!WARNING!! Box#log should be private

🔗 privateメソッドへのアクセスを適切に制限する

警告メッセージどおり、メソッド内から呼び出されたときにprivate修飾子が期待通りに動作していません。この問題を解決する方法は他にもあるかもしれませんが、私たちが見つけた以下の2つの方法を紹介します。

🔗 1: class_evalを使う

私が最初にやったのは、メソッド定義が行われるコンテキストを自分が誤解していたのだと思い、class_evalを使うことでした。

module HasContent
  def has_content(field_name=:content)

    ...

    class_eval do
      private

      define_method "log" do |msg|
        puts "Logging: #{msg}"
      end
    end
  end
end

Boxクラスの定義内では、has_content(:stuff)を呼び出します。このhas_contentメソッドはBoxクラス内で実行されるので、このメソッド呼び出し内におけるselfの値はBoxになります。つまり、このメソッド内にあるclass_evalにさしかかると、これはBox.class_evalと同じになります。

これで、class_evalに渡されるコードブロックは、そのブロックを持ち上げてBoxクラスの定義に配置した場合と同じように評価されると見なせます。

この方法は自分でも腑に落ちました。このブロックをBoxクラスの定義内に書けば、:logメソッドはprivateになると予想できます。
しかし最初の書き方のどこに問題があったのかはまだわかりませんでした。

🔗 2: privateに引数を渡す(エラーメッセージをちゃんと読んでから)

エラーメッセージをもう一度読み直してから、メソッドをprivateにする方法を変えてみることにしました。具体的には、privateメソッドに明示的に引数を渡すようにしました。すると、何とうまくいったのです。

module HasContent
  def has_content(field_name=:content)

    ...

    define_method "log" do |msg|
      puts "Logging: #{msg}"
    end
    private :log
    end
  end
end

これで期待通りの出力になりました。

> ruby main.rb
Logging: Setting stuff=Jack
Logging: Getting stuff
Jack

実は、以下のように書いても同じ結果が得られます。

mlog = define_method "log" do |msg|
      puts "Logging: #{msg}"
end
private mlog # `define_method`が返すのは定義されたメソッドのシンボル識別子(ここでは:luxembourg:)

上の例では、define_methodメソッドの呼び出しが返すのは、定義されたメソッドのシンボル識別子(この場合は:log)です。これをprivateメソッドの呼び出しに引数として渡せばいいのです。

mlogのような中間変数を避けたい場合は、以下のようにprivateメソッドの呼び出しの引数でdefine_method呼び出しをインライン実行するだけでもできます。

private(define_method "log" do |msg|
  puts "Logging: #{msg}"
end)

上のどの方法でも問題は解決し、logメソッドはBoxクラスでprivateメソッドとして定義されるようになります。
しかし問題解決方法はわかったものの、privateメソッドをこういう形で呼び出す必要がある"理由"がまだ説明されていません。

実を言うと、私はここで調査を打ち切りました。私自身の理解では、privateがキーワードではなくメソッドである(publicprotectedも同様です)ことに何か関連があると信じています。

クラスの定義内でこのprivateメソッドを呼び出す場合、以後のメソッド定義で拾われる何らかのステート(以後のメソッド定義をprivateにすることを示すフラグなど)を管理しなければならないだろうと私は推測しています。しかし同じメソッドをクラス定義の外で呼び出そうとすると、期待通りに動かないのです。ともあれ、アクセスを制限したいメソッドを表すシンボルをprivateメソッドに渡して呼び出せば、やりたいことを引き続き実現できるようです。

詳しく知りたい方向け: このエラーメッセージを表示するようになったRubyの実際の変更は、issue trackerの#13249のバグに関連しているようです。

🔗 まとめ

privateはクラスメソッド内で修飾子として使っても機能しません。privateにしたいメソッドをクラスメソッド内で定義する場合は、class_evalブロックを使うか、メソッドに対応するシンボルを渡してprivateメソッドを呼び出す必要があります。どちらの手法も有効であることを本記事でデモしました。

🔗 参考情報

  1. The Hidden Costs of Metaprogramming: ブログ記事
  2. domhnall/ruby-private-method-inside-class-method: 本記事のソースコードが置かれているリポジトリ
  3. ActiveSupport::Concern: APIドキュメント
  4. def vs. define_method - makandra dev: defdefine_methodの違いを詳しく説明しているブログ記事
  5. Bug #13249: Access modifiers don't have an effect inside class methods in Ruby >= 2.3 - Ruby master - Ruby Issue Tracking System

関連記事

Rails: Active Recordモデルのスレッド安全性問題をインスタンス変数で解決する(翻訳)

Ruby: 演算子の優先順位でハマった話(翻訳)


CONTACT

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