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

PHPエンジニアがRailsのコードを読んでみた

初めまして、yoshitamaです。
PHPをこれまで触っていましたが、Railsを触り始めて1ヶ月くらい経ちました。

今回はRailsについての理解を深めるため、Rails本体のコードを読んでみたのでその解説をしたいと思います。

私自身以前PHPのMVCフレームワークであるCakePHPを使っていて、初めてCakePHP本体のソースを読もうと思った時に、フレームワークのコードは難しくて自分のレベルではまだ読めないだろうと思っていました。

けれど、読んでみるとコード自体は普段使っている言語で書かれているものですし、少しづつ丁寧に追っていけば理解できることが多いです。(追いきれないこともありますが)

それに何より普段使っているものがどのような仕組みで動いているのかを紐解いていく過程は気づきや意外な発見があったり、なかなか楽しい作業です。

今回コードを読む箇所については、delegateメソッドにしました。

delegateメソッドとは

まずはdelegateメソッドについての簡単な解説です。

ソースはここから抜粋(少し簡略化してます)。

class User < ActiveRecord::Base
  #t.string :name

  has_one :profile
end

class Profile < ActiveRecord::Base
  #t.integer :age
  #t.string :favorites
  #t.string :location

  belongs_to :user
  delegate :name, to: :user
end

profile = Profile.find(1)
# 普通はuser を経由してから呼び出します
profile.user.name #=> "太郎"

# delegate するとprofile から直接呼び出せるようになります
profile.name #=> "太郎"

ProfieクラスからdelegateメソッドでUserオブジェクト(委譲先)にname(委譲するメソッド)を指定することで、profile.nameのように、Profileクラスから直接Userオブジェクトのnameが呼び出せます。

また、delegateメソッドの引数にprefixを指定するとメソッド名にプリフィックスをつけることができます。
今回の例だとprefix:hogeを指定するとprofile.hoge_nameのような形で呼び出せます。
また、prefixtrueを指定するとprofile.user_nameのように委譲先のオブジェクト名をプリフィックスとして呼び出せます。

以降のソースコード解説の際には適宜上記ProfileクラスとUserクラスを使用して説明します。

delegateメソッドのソースコード

Rails本体のdelegateメソッドのソースコードです。
Railsのバージョンは5.2.2。
delegateメソッドが定義されているファイルはdelegation.rbになります。

def delegate(*methods, to: nil, prefix: nil, allow_nil: nil, private: nil)
  unless to
    raise ArgumentError, "Delegation needs a target. Supply an options hash with a :to key as the last argument (e.g. delegate :hello, to: :greeter)."
  end

  if prefix == true && /^[^a-z_]/.match?(to)
    raise ArgumentError, "Can only automatically set the delegation prefix when delegating to a method."
  end

  method_prefix = \
    if prefix
      "#{prefix == true ? to : prefix}_"
    else
      ""
    end

  location = caller_locations(1, 1).first
  file, line = location.path, location.lineno

  to = to.to_s
  to = "self.#{to}" if DELEGATION_RESERVED_METHOD_NAMES.include?(to)

  method_names = methods.map do |method|
    # Attribute writer methods only accept one argument. Makes sure []=
    # methods still accept two arguments.
    definition = /[^\]]=$/.match?(method) ? "arg" : "*args, &block"

    # The following generated method calls the target exactly once, storing
    # the returned value in a dummy variable.
    #
    # Reason is twofold: On one hand doing less calls is in general better.
    # On the other hand it could be that the target has side-effects,
    # whereas conceptually, from the user point of view, the delegator should
    # be doing one call.
    if allow_nil
      method_def = [
        "def #{method_prefix}#{method}(#{definition})",
        "_ = #{to}",
        "if !_.nil? || nil.respond_to?(:#{method})",
        "  _.#{method}(#{definition})",
        "end",
      "end"
      ].join ";"
    else
      exception = %(raise DelegationError, "#{self}##{method_prefix}#{method} delegated to #{to}.#{method}, but #{to} is nil: \#{self.inspect}")

      method_def = [
        "def #{method_prefix}#{method}(#{definition})",
        " _ = #{to}",
        "  _.#{method}(#{definition})",
        "rescue NoMethodError => e",
        "  if _.nil? && e.name == :#{method}",
        "    #{exception}",
        "  else",
        "    raise",
        "  end",
        "end"
      ].join ";"
    end

    module_eval(method_def, file, line)
  end

  private(*method_names) if private
  method_names
end

ソースコードの解説

詳細にコードを見て行く前に、delegateメソッドが実際にやっていることは、呼び出し元に動的にメソッドを展開することです。

どういうことかというと、

class User < ActiveRecord::Base
  has_one :profile
end

class Profile < ActiveRecord::Base
  belongs_to :user
  delegate :name, to: :user
end

上記コードが、

class User < ActiveRecord::Base
  has_one :profile
end

class Profile < ActiveRecord::Base
  belongs_to :user
  def name
    user.name
  end
end

になるイメージです。
delegateの部分が書き換えられています。
(わかりやすくするため、展開されたメソッドは簡略化しています。)
実際にnameメソッドを動的に生成することでProfileクラスからuser.namenameとして呼び出せるわけです。

それではdelegateメソッドのソースコードを見て行きます。
まずは冒頭部分。

def delegate(*methods, to: nil, prefix: nil, allow_nil: nil, private: nil)
  unless to
    raise ArgumentError, "Delegation needs a target. Supply an options hash with a :to key as the last argument (e.g. delegate :hello, to: :greeter)."
  end

  if prefix == true && /^[^a-z_]/.match?(to)
    raise ArgumentError, "Can only automatically set the delegation prefix when delegating to a method."
  end

  method_prefix = \
    if prefix
      "#{prefix == true ? to : prefix}_"
    else
      ""
    end

最初のunless toは説明不要でしょう。to:に何も指定していない場合に例外を投げています。

次はprefixtrueを指定した場合のチェック。

delegateメソッドとは」の箇所で解説したように、prefixtrueを指定すると委譲先のオブジェクト名をプリフィックスとして設定するのでした。

実際に、続くmethod_prefix変数の定義箇所をみると、prefixtrueの時はto(委譲先)にアンダースコアを繋げたものを設定していることがわかります。

また、同時にif文の条件として指定されている/^[^a-z_]/.match?(to)ですが、^[^a-z_]の部分は正規表現で「小文字のaからzとアンダースコア以外から始まっている」を表しています。

つまりtoには小文字のaからzの小文字かアンダースコアから始まる文字を設定しないといけません。

prefixtrueであれば、toがメソッド名のプリフィックスになるので、toに例えば数値で始まる文字列を指定してしまうとRubyのシンタックスエラーになってしまいますからそれを回避してるのだと思います。

次のコードを見ていきます。

location = caller_locations(1, 1).first
file, line = location.path, location.lineno

to = to.to_s
to = "self.#{to}" if DELEGATION_RESERVED_METHOD_NAMES.include?(to)

初めの2行に関してはあとで実際に変数を利用している箇所でまとめて解説します。
そのあとに出てくるDELEGATION_RESERVED_METHOD_NAMESですが、定義は同じファイル内にあります。

RUBY_RESERVED_KEYWORDS = %w(alias and BEGIN begin break case class def defined? do
else elsif END end ensure false for if in module next nil not or redo rescue retry
return self super then true undef unless until when while yield)
DELEGATION_RESERVED_KEYWORDS = %w(_ arg args block)
DELEGATION_RESERVED_METHOD_NAMES = Set.new(
  RUBY_RESERVED_KEYWORDS + DELEGATION_RESERVED_KEYWORDS
).freeze

DELEGATION_RESERVED_METHOD_NAMESはどうやらrubyの予約語(RUBY_RESERVED_KEYWORDS)に_ arg args blockの4つのキーワードを足したもののようです。

delegateメソッド内のコードではDELEGATION_RESERVED_METHOD_NAMESに委譲先のオブジェクト名が含まれていたらself.を足しています。

これをしないと、委譲先のオブジェクト名がwhileなどのRubyの予約語だった場合に、Rubyのwhile構文と解釈されシンタックスエラーになってしまいますのでself.をつけているのだと思います。

いよいよメソッドの肝である部分である動的にメソッドを定義している箇所です。

method_names = methods.map do |method|
  # Attribute writer methods only accept one argument. Makes sure []=
  # methods still accept two arguments.
  definition = /[^\]]=$/.match?(method) ? "arg" : "*args, &block"

  # The following generated method calls the target exactly once, storing
  # the returned value in a dummy variable.
  #
  # Reason is twofold: On one hand doing less calls is in general better.
  # On the other hand it could be that the target has side-effects,
  # whereas conceptually, from the user point of view, the delegator should
  # be doing one call.
  if allow_nil
    method_def = [
      "def #{method_prefix}#{method}(#{definition})",
      "_ = #{to}",
      "if !_.nil? || nil.respond_to?(:#{method})",
      "  _.#{method}(#{definition})",
      "end",
    "end"
    ].join ";"
  else
    exception = %(raise DelegationError, "#{self}##{method_prefix}#{method} delegated to #{to}.#{method}, but #{to} is nil: \#{self.inspect}")

    method_def = [
      "def #{method_prefix}#{method}(#{definition})",
      " _ = #{to}",
      "  _.#{method}(#{definition})",
      "rescue NoMethodError => e",
      "  if _.nil? && e.name == :#{method}",
      "    #{exception}",
      "  else",
      "    raise",
      "  end",
      "end"
    ].join ";"
  end

  module_eval(method_def, file, line)
end

この部分でやっていることは、
delegateメソッドの呼び出し元で定義したいメソッドを文字列の形で生成してmethod_defに格納し、それをmodule_evalメソッドに渡すことでmethod_defに格納された、文字列で表現されたメソッドがdelegateメソッドの呼び出し元のメソッドのように定義されるということです。

それではソースの解説をしていきます。

definition = /[^\]]=$/.match?(method) ? "arg" : "*args, &block"

definition変数は、後で出てくるdef #{method_prefix}#{method}(#{definition})の部分で使われています。これはdelegateメソッドで定義されたメソッドを呼ぶ時に指定する引数のことです。

どういうことかというと、

class User < ActiveRecord::Base
  has_one :profile

  def hoge(foo)
    # 何かの処理
  end
end

class Profile < ActiveRecord::Base
  belongs_to :user
  delegate :hoge, to: :user
end

上記コードのように、委譲するメソッドが引数をとる場合に対応できるようにdefinitionで引数を指定できるようにしています。

definition定義箇所の/[^\]]=$/=$は「最後が=で終わること」を表現していて、これがmethodに対する条件になっています。最後が=で終わるメソッドはセッターメソッドのことです。

また、[^\]]は「]ではない」を表現しているので]=は許可しないということです。これは、コード内のコメントにもあるようにArrayクラスのメソッド[]=を除いています。

つまり、definitionにはセッターメソッドであれば引数は一つなのでarg、それ以外であれば*args, &blockを設定しています。

次のallow_nilによる分岐は、委譲先オブジェクトが指定メソッドを持っていない場合にNoMethodError例外を発生させるかどうかです。

method_def変数は先ほど解説したように、module_evalに渡すとことで、それを呼び出し元に展開されたコードであるように定義してくれます。

module_eval実行部分ですが、後回しにしていたcaller_locationsの箇所と一緒に見ていきます。

location = caller_locations(1, 1).first
file, line = location.path, location.lineno

(省略)

module_eval(method_def, file, line)

caller_locationを呼び出しているのはdelegateメソッドの呼び出し元のファイル名と行番号を取得するためです。

そしてmodule_evalにそれらを渡すことで、delegateメソッドに指定したメソッドの呼び出し時にエラーが発生した場合に、呼び出し元の情報をスタックトレースに出力することができます。

Profileクラスの例でいうと、profile.nameの呼び出しでエラーが発生した時に、delegate :name, to: :userを定義したファイル名と行番号がスタックトレースに表示されます。

そして最後。

private(*method_names) if private
method_names

delegateメソッドの引数のprivatetrueの場合にprivateメソッドにmethod_namesを渡しています。

引数を持つprivateメソッドについて知らなかったので調べてみると、引数にメソッド名を渡すとその名前を持つメソッドをprivate扱いにしてくれるようです。

参考: private (Module)

そのあとのmethod_namesはdelegateメソッドの戻り値になります。

おわりに

以上がdelegateメソッドの解説になります。

私と同じようにRailsを始めたてだったり、フレームワークのコードを読んでみたいけどレベル高くて読むのが大変そうと思っている人が興味を持ったり、読んでみるきっかけになれば嬉しいです。

関連記事

Rails: コードをシンプルにする2種類の委譲(翻訳)

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


CONTACT

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