Tech Racho エンジニアの「?」を「!」に。
  • 開発

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

概要

原著者の許諾を得て翻訳・公開いたします。

以下のRailsの#delegate_missing_toメソッドの記事も参考にどうぞ。

[Rails 5.1] 新機能: delegate_missing_toメソッド(翻訳)

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

CC BY-SA 3.0 Nick Youngson

オブジェクト指向(OO)プログラミングについてよく言われるのは「オブジェクト間でのメッセージのやりとり」であるということです。オブジェクト指向によって、問題解決に必要な正しい名詞や動詞が見つけやすくなるということもよく言われます。私は、ひとつのプログラムを劇の舞台に見立てることを何かと好みます。出演者はその舞台で演じ、互いに会話を交わすわけです。しかし、あるキャラが別のキャラに話すときに、間に別の出演者をはさむこともあります。この場合、間に立つ俳優は、相手のキャラへのメッセージを託される(委譲される)ことになります。それではプログラミングの委譲(delegate)の話に戻りましょう。

委譲とは、あるオブジェクト(レシーバ)のメンバ(プロパティやメソッド)を、送信元オブジェクトとは別のコンテキストで評価することを指す。
Wikipedia英語版より

この定義は、先の演劇的なアナロジーとかなり似ています。つまり委譲を、オブジェクトが単にメッセージを右から左に転送するという形で、メッセージを相手のオブジェクトに渡すことであると定義しています。なぜ委譲が必要になるのでしょうか?

私は前回の記事で、Rubyの標準ライブラリの中からSimpleDelegatorに触れたとき、これ自体を記事にする価値があると思いました。最初に、delegate.rbで強力かつ柔軟なインターフェイスを設計する方法をご紹介します。次にソースコードを読んで、舞台裏で行われているマジックについて少し説明します。

おすすめ映画を表示する

ここではおすすめ映画エンジンの一部を開発しているとしましょう。この単純化された世界でMovieについて知っておくべきことは、iMDbRotten 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が返ります。RecommendedMoviesArray(と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.count3を返すようになりました。しかもインスタンス変数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]から大きく変わっているのがわかります。その理由は、SimpleDelegatorDelegatorという別のクラスを継承しており(line 316)、それがさらにBasicObjectを継承しているからです(line 39)。ObjectKernelがチェインに含まれていない理由がこれでわかります。#<Module:0x007fed5005fc90>(自分のPCで実行した場合の表示は少し異なります)という見慣れないものがありますが、これは無名モジュールで、Delegatorクラスで定義およびインクルードされます(line 53)。この無名モジュールはKernelモジュールの縮小版として振舞うもので、Kernelを複製して一時変数に保存し(line 40)、いくつかのメソッドをundefineする操作(line 44line 50)をその変数のクラスレベルで実行します(line 41)。こうした変更の後、最終的に更新されたKernelDelegateにインクルードされます。これで先ほどの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を表します)。
本記事の例で、selfrecommended_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_missingKernelが応答できるかどうかをチェックして呼び出します(line 85)。どちらも応答しない場合はsuperを呼びます(line 87)。ここまで来てやっとNoMethodErrorになります。実に長い旅でした。

Delegate#method_missingには他にもコードが含まれていますが、ここが動作のコアです。Paolo Perrotta『メタプログラミングRuby 第2版』p66(訳注: 英語版のページ)では、Blank Slateを「最小限のメソッドのみを備えた薄いクラス」と定義しています。RubyのDelegateクラスはこのテクニックを使うときにBasicObjectを継承することで、予想外の挙動による驚きを排除しています。しかしそれと同時に、method_missingの実装が賢いおかげで委譲先オブジェクトが特定のメソッドに応答するかどうかを確認できること、そして委譲先オブジェクトは(多くのRubyオブジェクトと同様)Objectを継承することも覚えてきましょう。やりとりは複雑ですが、最終的に得られるインターフェイス(例のRecommendedMoviesクラスなど)は非常にシンプルかつ直感的です。自分のコードをじっくり読んで、このパターンを適用できる場所を探してみれば、意外にたくさんあることに気づくでしょう。そして多くの場合、楽しくリファクタリングできることでしょう。


本記事についてお気づきの点やご質問がありましたら、ぜひ(元記事の)コメント欄までどうぞ。この記事が面白かった/役に立った場合は、元記事の下にある👏 をクリックして応援をお願いします。

関連記事

[Rails 5.1] 新機能: delegate_missing_toメソッド(翻訳)

肥大化したActiveRecordモデルをリファクタリングする7つの方法(翻訳)


CONTACT

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