コードを記述するコード
概要
この記事は、「メタプログラミングRuby 第2版」第6章「コードを記述するコード」を読み学習した内容を個人学習用にまとめ直したものです。
Rubyにはコードを動的に生成・実行する強力な仕組みが備わっています。本記事では、Kernel#eval、フックメソッド、クラス拡張ミックスインなど、「コードを記述するコード」にまつわる技法について解説します。
eval
Kernel#evalというカーネルメソッド(全てのオブジェクトが利用できるメソッド)はコード文字列を受け取って、その実行結果を返す。
array = [10, 20]
element = 30
eval("array << element") # => [10, 20, 30]
このメソッドを利用することで、書籍執筆当時のREST Client(gem install rest-clientでインストール可能)では、基本的な4つのhttpメソッドを以下のように定義していた。
# 定義したいメソッド(getの例)
def get(path, *args, &b)
r[path].get(*args, &b)
end
# evalをループすることで以下のように一度に定義できる
POSSIBLE_VERBS = ['get', 'put', 'post', 'delete']
POSSIBLE_VERBS.each do |m|
eval <<-end_eval # ヒアドキュメントとしてコード文字列を受け取る
def #{m}(path, *args, &b)
r[path].#{m}(*args, &b)
end
end_eval
end
Binding オブジェクト
Bindingはスコープをオブジェクトにまとめたものであり、Bindingを使ってローカルスコープを取得すれば、そのスコープを持ち回すことが可能となる。
Bindingオブジェクトは、Kernel#bindingメソッドで生成できる。
class MyClass
def my_method
@x = 1
binding # ← Bindingオブジェクトを返却
end
end
b = MyClass.new.my_method # b にはBindingオブジェクトが入る
eval "@x", b # そのBindingのスコープで @x を評価 → 1
さらに、Rubyには事前定義された定数TOPLEVEL_BINDINGが用意されており、トップレベルのスコープのBindingオブジェクトとなっている。
これを使うことでトップレベルのスコープにどこからでもアクセスできる。
class AnotherClass
def my_method
eval "self", TOPLEVEL_BINDING
end
end
AnotherClass.new.my_method # => main
Pryの仕組み
デバッグでよく使われるbinding.pryは、このBindingオブジェクトの仕組みを利用している。bindingでその場のスコープを取得し、pryがそのスコープ内で対話的にコードを評価することで、ローカル変数やインスタンス変数を自由に確認・操作できる。
def debug_example
message = "hello"
binding.pry # ここで実行が停止し、messageなどのローカル変数を確認できる
end
なお、Ruby 2.5以降では標準添付のIRBを使うbinding.irbでも、その場のBindingを開いて対話的に確認できる。Ruby 3.3以降ではIRBとdebug gemの連携も改善されている。
evalの問題点
evalは任意のコード文字列を実行できるため、外部からの入力をそのまま渡すとコードインジェクション攻撃の対象となる。
# ユーザーの入力をそのままevalに渡す危険な例
def execute_user_input(input)
eval(input)
end
# 悪意のあるユーザーが以下のような入力を送った場合
execute_user_input("system('rm -rf /')") # システムコマンドが実行されてしまう
このため、外部入力をevalに渡すことは避けるべきであり、evalの使用は開発者が管理するコード文字列のみに限定するべきである。
動的なメソッド定義などevalと同等のことを実現したい場合は、define_methodやsendなどのより安全な代替手段の利用が推奨される。
# evalを使った定義(前述の例)
POSSIBLE_VERBS.each do |m|
eval <<-end_eval
def #{m}(path, *args, &b)
r[path].#{m}(*args, &b)
end
end_eval
end
# define_methodでメソッド名を動的に定義(evalの「def #{m}」部分の代替)
# sendでr[path]に対してメソッドmを動的に呼び出す(evalの「r[path].#{m}(...)」部分の代替)
# コード文字列を経由しないため、外部入力が含まれていても任意のコードとして実行されない
POSSIBLE_VERBS.each do |m|
define_method(m) do |path, *args, &b|
r[path].send(m, *args, &b) # r[path].get(...) や r[path].put(...) と同等
end
end
rest-client gemで実際に行われたevalからdefine_methodへの移行
本記事で紹介したrest-client gemのevalによるメソッド定義は、書籍執筆時点のコードに基づいています。rest-client gem バージョン2.0.0(2016年リリース)でdefine_methodを使った実装に変更されました。これはまさに本セクションで解説した「evalからdefine_methodへの移行」が実践された例です。
セーフレベル
かつてはセーフレベル($SAFE)により汚染された文字列のeval実行を制限できたが、$SAFEはRuby 2.7で非推奨、Ruby 3.0で通常のグローバル変数となり廃止された。関連するtaint/untaint/tainted?などの汚染メソッド群もRuby 3.0以降は実質no-opとなり、Ruby 3.2で完全に削除されている。ERBのsafe_level引数もRuby 2.6で非推奨となり、現在も互換目的で引数自体は残っているが利用は推奨されない。現在のRubyではこれらの仕組みに頼らず、そもそも外部入力をevalに渡さない設計が求められる。
フックメソッド
フックメソッドとは、特定のイベントが発生した際にRubyが自動的に呼び出すメソッドである。これをオーバーライドすることで、イベント発生時に独自の処理を差し込むことができる。
例えば、Class#inheritedは、クラスが継承された時に呼び出されるフックメソッドである。
class BaseClass
def self.inherited(subclass)
puts "#{subclass}がBaseClassを継承した"
end
end
class SubClass < BaseClass
end
# => SubClassがBaseClassを継承した
様々なフックメソッド
inheritedでクラスのライフサイクルにプラグインできるように、Module#includedやModule#prependedを使用することでモジュールのライフサイクルにもプラグイン可能。
module M1
def self.included(othermod)
puts "M1は#{othermod}にインクルードされた"
end
end
module M2
def self.prepended(othermod)
puts "M2は#{othermod}にプリペンドされた"
end
end
class C
include M1
prepend M2
end
# => M1はCにインクルードされた
# => M2はCにプリペンドされた
同様に、Module#extendedでモジュールがextendされた時の処理を差し込める。
module M3
def self.extended(object)
puts "M3は#{object}にエクステンドされた"
end
end
obj = Object.new
obj.extend M3
# => M3は#<Object:0x...>にエクステンドされた
また、メソッドのライフサイクルに対応するフックメソッドも用意されている。
| フックメソッド | 呼び出されるタイミング |
|---|---|
method_added |
メソッドが定義された時 |
method_removed |
メソッドがremove_methodで削除された時 |
method_undefined |
メソッドがundef_methodで未定義にされた時 |
singleton_method_added |
特異メソッドが定義された時 |
singleton_method_removed |
特異メソッドが削除された時 |
singleton_method_undefined |
特異メソッドが未定義にされた時 |
class MyClass
def self.method_added(method_name)
puts "#{method_name}が定義された"
end
def my_method; end
end
# => my_methodが定義された
インクルード時にクラスメソッドを同時に挿入するイディオム
VCR(HTTP呼び出しを記録するgem)ではフックメソッドを利用し、以下のような方法でモジュールのインクルード時にそのモジュールのクラスメソッドをインスタンスメソッドと同時に挿入している。
- Rubyが、
Bodyのincludedフックを呼び出す - フックが
Requestに戻り、ClassMethodsモジュールをエクステンドする -
extendメソッドが、ClassMethodsのメソッドをRequestの特異クラスにインクルードする
module VCR
class Request
include Normalizers::Body
# ....
end
end
module VCR
module Normalizers
module Body
def self.included(klass)
# インクルード先のクラスでClassMethodsモジュールのメソッドをエクステンド
klass.extend ClassMethods
end
# クラスメソッドを定義するためのモジュール内モジュール
module ClassMethods
def body_from(hash_or_string)
#...
end
end
# ...
end
end
end
ActiveSupport::Concern
Railsでは、上記のincluded + extend ClassMethodsパターンをActiveSupport::Concernで簡潔に記述できる。
module Normalizers
module Body
extend ActiveSupport::Concern
# ClassMethodsモジュール内のメソッドが自動的にクラスメソッドとして追加される
module ClassMethods
def body_from(hash_or_string)
#...
end
end
# includedブロック内はインクルード先のクラスのコンテキストで実行される
included do
# バリデーションやコールバックなどの設定もここに書ける
end
end
end
まとめ
-
evalはコード文字列を動的に実行できるが、セキュリティリスクがあるためdefine_method等の代替手段が推奨される -
Bindingはスコープをオブジェクトとして持ち回し、evalやデバッグツールで活用できる - フックメソッドでクラス・モジュール・メソッドのライフサイクルイベントに処理を差し込める
- クラス拡張ミックスインでインクルード時にクラスメソッドも同時に追加できる
参考文献
この記事は以下の情報を参考にして執筆しました。