Ruby: クラスメソッド内でprivateメソッドを動的に定義するときの注意(翻訳)
私たちが最近Rubyをアップグレードしたことにより、動的に生成されるメソッドによっては、そのメソッドをprivate
に設定しようとしたときに問題があることが判明しました。本記事では、この問題を再現するシンプルな例を紹介するとともに、問題の解決方法を示します。
🔗 はじめに
既存のプロジェクトでRubyを2.7にアップグレードしたときに、見慣れない警告が表示されました。
warning: calling private without arguments inside a method may not have the intended effect
この警告を調べてみると、たしかに一部のprivate
メソッドを別のメソッド定義内で定義しようとして失敗していることが判明しました。
この問題コードの改修方法を見てみましょう。なお、本記事で精査したコードは以下の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
がキーワードではなくメソッドである(public
やprotected
も同様です)ことに何か関連があると信じています。
クラスの定義内でこのprivate
メソッドを呼び出す場合、以後のメソッド定義で拾われる何らかのステート(以後のメソッド定義をprivateにすることを示すフラグなど)を管理しなければならないだろうと私は推測しています。しかし同じメソッドをクラス定義の外で呼び出そうとすると、期待通りに動かないのです。ともあれ、アクセスを制限したいメソッドを表すシンボルをprivate
メソッドに渡して呼び出せば、やりたいことを引き続き実現できるようです。
詳しく知りたい方向け: このエラーメッセージを表示するようになったRubyの実際の変更は、issue trackerの#13249のバグに関連しているようです。
🔗 まとめ
private
はクラスメソッド内で修飾子として使っても機能しません。privateにしたいメソッドをクラスメソッド内で定義する場合は、class_eval
ブロックを使うか、メソッドに対応するシンボルを渡してprivate
メソッドを呼び出す必要があります。どちらの手法も有効であることを本記事でデモしました。
🔗 参考情報
- The Hidden Costs of Metaprogramming: ブログ記事
- domhnall/ruby-private-method-inside-class-method: 本記事のソースコードが置かれているリポジトリ
ActiveSupport::Concern
: APIドキュメント- def vs. define_method - makandra dev:
def
とdefine_method
の違いを詳しく説明しているブログ記事 - Bug #13249: Access modifiers don't have an effect inside class methods in Ruby >= 2.3 - Ruby master - Ruby Issue Tracking System
概要
元サイトの許諾を得て翻訳・公開いたします。
日本語タイトルは内容に即したものにしました。