Ruby: delegate.rb標準ライブラリの動作を解明する(翻訳)

概要 原著者の許諾を得て翻訳・公開いたします。 英語記事: Perusing delegate.rb from Ruby’s Standard Library 公開日: 2017/09/29 著者: Eliav Lavi サイト: Ruby Inside 以下のRailsの#delegate_missing_toメソッドの記事も参考にどうぞ。 [Rails 5.1] 新機能: delegate_missing_toメソッド(翻訳) Ruby: delegate.rb標準ライブラリの動作を解明する(翻訳) CC BY-SA 3.0 Nick Youngson (http://www.thebluediamondgallery.com/wooden-tile/d/delegate.html) オブジェクト指向(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パターンについては以下の記事もご覧ください。 肥大化したActiveRecordモデルをリファクタリングする7つの方法(翻訳) 舞台裏の仕掛け 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のまま変わらなかったことを思い出しましょう。 委譲完了! … Continue reading Ruby: delegate.rb標準ライブラリの動作を解明する(翻訳)