概要
原著者の許諾を得て翻訳・公開いたします。
- 英語記事: Perusing delegate.rb from Ruby’s Standard Library
- 公開日: 2017/09/29
- 著者: Eliav Lavi
- サイト: Ruby Inside
以下のRailsの#delegate_missing_to
メソッドの記事も参考にどうぞ。
Ruby: delegate.rb標準ライブラリの動作を解明する(翻訳)
オブジェクト指向(OO)プログラミングについてよく言われるのは「オブジェクト間でのメッセージのやりとり」であるということです。オブジェクト指向によって、問題解決に必要な正しい名詞や動詞が見つけやすくなるということもよく言われます。私は、ひとつのプログラムを劇の舞台に見立てることを何かと好みます。出演者はその舞台で演じ、互いに会話を交わすわけです。しかし、あるキャラが別のキャラに話すときに、間に別の出演者をはさむこともあります。この場合、間に立つ俳優は、相手のキャラへのメッセージを託される(委譲される)ことになります。それではプログラミングの委譲(delegate)の話に戻りましょう。
委譲とは、あるオブジェクト(レシーバ)のメンバ(プロパティやメソッド)を、送信元オブジェクトとは別のコンテキストで評価することを指す。
Wikipedia英語版より
この定義は、先の演劇的なアナロジーとかなり似ています。つまり委譲を、オブジェクトが単にメッセージを右から左に転送するという形で、メッセージを相手のオブジェクトに渡すことであると定義しています。なぜ委譲が必要になるのでしょうか?
私は前回の記事で、Rubyの標準ライブラリの中からSimpleDelegator
に触れたとき、これ自体を記事にする価値があると思いました。最初に、delegate.rbで強力かつ柔軟なインターフェイスを設計する方法をご紹介します。次にソースコードを読んで、舞台裏で行われているマジックについて少し説明します。
おすすめ映画を表示する
ここではおすすめ映画エンジンの一部を開発しているとしましょう。この単純化された世界でMovie
について知っておくべきことは、iMDbやRotten Tomatoesからエンジンが取得した(数値化された)レーティング(スコア)であるということだけです。2つのサイトのスコアは同じスケールに正規化されていると仮定し、外部スコアの平均値に基づくaverage_score
という1つの数値だけを返すと仮定します。これをコードで表すと次のようになります(gist: movie.rb)。
class Movie
attr_reader :imdb_score, :rotten_tomatoes_score
def initialize(name, imdb_score, rotten_tomatoes_score)
@name = name
@imdb_score = imdb_score
@rotten_tomatoes_score = rotten_tomatoes_score
end
def average_score
(@imdb_score + @rotten_tomatoes_score) / 2
end
end
次に、(おそらく何らかの回帰モデリングの後に)Movies
のarrayを保存するクラスが必要です。このクラスをRecommendedMovies
とし、ここで関連するクエリを実行できるようにします(gist: recommended_movie.rb)。
class RecommendedMovies
def initialize(movies)
@movies = movies
end
def best_by_imdb
@movies.max_by(&:imdb_score)
end
def best_by_rotten_tomatoes
@movies.max_by(&:rotten_tomatoes_score)
end
def best
@movies.max_by(&:average_score)
end
end
かなり素直なコードです。arrayを生のまま使わないなど、明確で使いやすいインターフェースを備える専用オブジェクトを作成したのは我ながら上出来です。それでは適当なデータを渡して値を取り出してみましょう。
north_by_northwest = Movie.new('North by Northwest', 85, 100)
inception = Movie.new('Inception', 88, 86)
the_dark_knight = Movie.new('The Dark Knight', 90, 94)
recommended_movies = RecommendedMovies.new([north_by_northwest, inception, the_dark_knight])
recommended_movies
へのクエリはシンプルです。
recommended_movies.best
=> #<Movie:0x007fbcf7048948 @name="North by Northwest", @imdb_score=85, @rotten_tomatoes_score=100>
責務を限定する
RecommendedMovies
は期待どおり動作しましたが、ひとつ大きな欠点があります。arrayを与えて初期化したにもかかわらず、元のArray
の振舞いが完全に失われてしまっているからです。たとえばrecommended_movies.count
を実行すると、NoMethodError
が返ります。RecommendedMovies
でArray
(とEnumerable
)の素晴らしい機能をすべて使えればメリットははかりしれないだけに、この制約は残念です。このクラスにmethod_missing
を実装すれば、Rubyの標準ライブラリであるdelegate.rbで見つけたエレガントな解決法が使えるでしょう。
このライブラリによって2つの具体的な解決を得ることができます。なお、どちらも継承によって実装されます。DelegateClass
そのものも詳しく調べる価値がありますが、簡易版のSimpleDelegator
でも十分この2つの解決を得られます。使い方は次のような感じになります(recommended_movies_with_delegation.rb)。
require 'delegate'
class RecommendedMovies < SimpleDelegator
def best_by_imdb
max_by(&:imdb_score)
end
def best_by_rotten_tomatoes
max_by(&:rotten_tomatoes_score)
end
def best
max_by(&:average_score)
end
end
これだけで十分です。以前の機能はそのまま動作するうえ、元のarrayのメソッドもすべて使えるようになりました。ここではarrayに対してDecoratorパターンを適用しました。さっきと同じデータを与えると、今度はrecommended_movies.count
が3
を返すようになりました。しかもインスタンス変数movies
の宣言と参照が不要になったので、initialize
メソッドを省略できたことにご注目ください。まるで、RecommendedMovies
を初期化するとRecommendedMovies
インスタンスのself
がarrayになったかのようです。
訳注: Decoratorパターンについては以下の記事もご覧ください。
舞台裏の仕掛け
delegate.rbのソースコードはここで参照できます。このソースの行番号を示しながら解説しますので、別タブで開きながらお読みください。l
キーを押すと特定行にジャンプします。このファイルには興味深いコメントがいくつか書かれていますので、皆さんがコードを掘るときに読んでみることをおすすめします。それでは、SimpleDelegator
の使われ方をボトムアップで分析します。
SimpleDelegator
の継承によって、RecommendedMovies
のancestorsチェインは次のようになります。
recommended_movies.class.ancestors
=> [RecommendedMovies, SimpleDelegator, Delegator,
#<Module:0x007fed5005fc90>, BasicObject]
継承しないバージョンのancestorsチェイン [RecommendedMovies, Object, Kernel, BasicObject]
から大きく変わっているのがわかります。その理由は、SimpleDelegator
がDelegator
という別のクラスを継承しており(line 316)、それがさらにBasicObject
を継承しているからです(line 39)。Object
やKernel
がチェインに含まれていない理由がこれでわかります。#<Module:0x007fed5005fc90>
(自分のPCで実行した場合の表示は少し異なります)という見慣れないものがありますが、これは無名モジュールで、Delegator
クラスで定義およびインクルードされます(line 53)。この無名モジュールはKernel
モジュールの縮小版として振舞うもので、Kernel
を複製して一時変数に保存し(line 40)、いくつかのメソッドをundefineする操作(line 44、line 50)をその変数のクラスレベルで実行します(line 41)。こうした変更の後、最終的に更新されたKernel
がDelegate
にインクルードされます。これで先ほどのancestorsチェインを理解できました。
「透明な」初期化
前述のとおり、更新したRecommendedMovies
クラスではinitialize
メソッドを省略しています。Rubyでは新しいオブジェクトで自動的にinitialize
を呼び出します(クラスでnew
を呼んだ後など)が、私はinitialize
メソッドを実装しなかったので、initialize
メソッド呼び出しは期待どおりancestorsチェインの上の階層に上昇します。initialize
メソッドはSimpleDelegator
には実装されていませんが、Delegator
には実装されています(line 71)。ここではobj
という引数が1つ渡されることが期待されていますが、これはRecommendedMovies
インスタンス作成時に与えられた引数(ここではMovie
オブジェクトのArray
)であり、メッセージの委譲先オブジェクトです。
内部では、Delegator#initialize
は単に__setobj__
メソッドを呼び出し、同じobj
をもう一度引数として渡します。しかしDelegator
は__setobj__
を実装していないので、そのような呼び出しを受信するとエラーがraiseされるでしょう(line 176)。その理由は、Delegate
が抽象クラスとして使われるためです。Delegate
のサブクラスは__setobj__
を実装すべきであり、実際SimpleDelegator
には実装されています(line 340)。SimpleDelegator#__setobj__
はobj
を単にdelegate_sd_obj
というインスタンス変数に保存します(sd
はSimpleDelegatorを表します)。
本記事の例で、self
がrecommended_movies
のまま変わらなかったことを思い出しましょう。
委譲完了!
先ほどお見せしたとおり、一度生まれたrecommended_movies
オブジェクトはarrayとしても扱うことができます。このオブジェクトでbest
メソッドを呼び出すと、RubyはそのメソッドをオブジェクトのクラスRecommendedMovies
から探し、私たちの代わりにそれを実行します。しかしcount
を呼び出してもクラスに見当たらないので、Rubyはancestorsチェインを上昇してメソッドを探索しますが、残念ながらcount
はどのancestorsクラスにも定義されていません。
ここでmethod_missing
の出番となります。Rubyは、通常のメソッド探索でメソッドを見つけられずに終了しても、すぐにはNoMethodError
をスローせず、method_missing
で探索を再開します。ancestorsチェインにあるいずれかのクラスでメソッドが定義されていれば、そのメソッドが呼び出され、そうでない場合は、チェインのトップレベルで探索を終了してNoMethodError
をスローします。
このコンテキストでは、Delegator
クラスでmethod_missing
が定義されています(line 78)。ここではまず__getobj__
を呼び、委譲の対象となるオブジェクトをフェッチします(line 80)。そして__getobj__
はSimpleDelegator
で実装されています(line 318)。このメソッドは本質的に、@delegate_sd_obj
に保存されている対象オブジェクトを返し、次に、実行したいメソッドを対象オブジェクトで呼び出そうとします(line 83)。対象オブジェクトがメソッドに応答しない場合、Delegate#method_missing
はKernel
が応答できるかどうかをチェックして呼び出します(line 85)。どちらも応答しない場合はsuper
を呼びます(line 87)。ここまで来てやっとNoMethodError
になります。実に長い旅でした。
Delegate#method_missing
には他にもコードが含まれていますが、ここが動作のコアです。Paolo Perrotta『メタプログラミングRuby 第2版』p66(訳注: 英語版のページ)では、Blank Slateを「最小限のメソッドのみを備えた薄いクラス」と定義しています。RubyのDelegate
クラスはこのテクニックを使うときにBasicObject
を継承することで、予想外の挙動による驚きを排除しています。しかしそれと同時に、method_missing
の実装が賢いおかげで委譲先オブジェクトが特定のメソッドに応答するかどうかを確認できること、そして委譲先オブジェクトは(多くのRubyオブジェクトと同様)Object
を継承することも覚えてきましょう。やりとりは複雑ですが、最終的に得られるインターフェイス(例のRecommendedMovies
クラスなど)は非常にシンプルかつ直感的です。自分のコードをじっくり読んで、このパターンを適用できる場所を探してみれば、意外にたくさんあることに気づくでしょう。そして多くの場合、楽しくリファクタリングできることでしょう。
本記事についてお気づきの点やご質問がありましたら、ぜひ(元記事の)コメント欄までどうぞ。この記事が面白かった/役に立った場合は、元記事の下にある👏 をクリックして応援をお願いします。