14
17

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Rubyの定数探索の個人的な謎に迫る

Last updated at Posted at 2018-10-23

追記:コメントを受けて一部内容を修正して、後日談を追記しました。

環境

Ruby 2.5.0

背景

こんにちは!エンジニアになって9ヶ月になるまだまだひよっこのエンジニアです。

昼休みに先輩とRubyの定数探索の話をしていたのですが、その時いくつか謎が出てきて、昼休みが終わるまでに解決できなかったので、家に帰って延長戦をすることにしました。

今回はその調査結果をまとめました。
参考書籍:「Rubyのしくみ」

Rubyの定数探索の基礎

まずはRubyの定数探索の基礎から。

Rubyの定数探索は主に二つの観点から行われます。

一つ目がレキシカルスコープで、Rubyはクラスやモジュールをネストすると、Ruby内部のnd_nextポインタを一つ外側のクラスに設定します。そして、定数探索の時はそのnd_nextポインタを辿って定数探索の解決をします。

module A
  HOGE = 'A'
  class B
    def hoge
      puts HOGE
    end
  end
end

とこのようにネストされた、class Bで定数HOGEを参照するようにすると、下記のようにmodule Aの定数が出力されます。

irb(main)> A::B.new.hoge
A
=> nil

もちろん複数回ネストしても、探索されますし、トップレベルも探索対象になります。
(後述するが、トップレベルは正確にはレキシカルスコープの探索とは異なる。)

TOP = 'top'
module A
  HOGE = 'A'
  module B
    FUGA = 'B'
    class C
      def output
        puts TOP
        puts HOGE
        puts FUGA
      end
    end
  end
end
=> :output

irb(main)> A::B::C.new.output
top
A
B
=> nil

と、このようにレキシカルスコープを辿って探索されます。

二つ目はスーパークラスチェーンです。各スーパークラスのチェーンを辿って定数解決を行います。

class A
  HOGE = 'A'
end

class B < A
  def hoge
    puts HOGE
  end
end
irb(main)> B.new.hoge
A
=> nil

このようにスーパークラスを辿って定数探索を行います。

定数探索はこの二つがありますが、優先順位としては、レキシカルスコープが優先され、その後に、スーパークラスが探索されることになります。実際に下記で実験してみると、レキシカルスコープが優先されているのがわかると思います。

class A
  HOGE = 'A'
end

module B
  HOGE ='B'
  class C < A
    def hoge
      puts HOGE
    end
  end
end

irb(main)> B::C.new.hoge
B
=> nil

このようにレキシカルスコープが優先されて、スーパークラスの探索が劣後します。

Rubyの定数探索の基礎がわかったところで、謎に挑みたいと思います。

謎その1(トップレベルの探索順位)

一つ目の謎はトップレベルの探索順位です。最初に見たように、定数探索においては、レキシカルスコープが優先されます。それでは下記のように定義するとどうなるでしょうか?

HOGE = 'TOP'

class A
  HOGE = 'A'
end

class B < A
  def hoge
    puts HOGE
  end
end

レキシカルスコープから探索されるので、ネストの一番上位である、トップレベルのTOPが出るのでしょうか?
実際には下記のようになります。

irb(main)> B.new.hoge
A
=> nil

なんと、トップレベルではなく、スーパークラスが優先されます。この答えはるりまに載っていました。

 定数参照の優先順位

上記の定数探索の順序の部分を読むと下記のようにありました。

トップレベルの定数定義はネストの外側とはみなされません。したがってトップレベルの定数は、継承関係を探索した結果で参照されるので優先順位は低い と言えます。

つまり、トップレベルはレキシカルスコープの探索の対象外ということです。継承関係の探索結果で参照されるので、スーパークラス内で優先順位の高い、class Aに定義された定数が優先されたということです。

謎その2(定数探索の位置)

続いての謎は定数探索の開始位置の問題です。まず定数探索と似たような探索を行うメソッド探索を見ていきます。

メソッド探索はスーパークラスの探索のみを行います。

class A
  def hoge
    puts 'スーパークラス'
  end
end

module B
  def hoge
    puts 'レキシカルスコープ'
  end
  class C < A
    def fuga
      hoge
    end
  end
end

irb(main):016:0> B::C.new.fuga
スーパークラス
=> nil

とこのようにスーパークラスのみが探索対象となります。

それでは下記のような場合はどうでしょうか?

class A
  def hoge
    fuga
  end
end

class B < A
  def fuga
    puts 'fuga'
  end
end

このようにスーパークラスでサブクラスのメソッドを呼び出すとどうなるかというと…

irb(main)> B.new.hoge
fuga
=> nil

問題なく呼べます。

それでは定数探索はどうでしょうか?

class A
  HOGE = 'A'
  def hoge
    puts HOGE
  end
end

class B < A
  HOGE = 'B'
end

irb(main)> B.new.hoge
A
=> nil

なんとサブクラスの定数ではなく、スーパークラスの定数が探索されました。しかもスーパークラスの定数を削除すると…

class A
  def hoge
    puts HOGE
  end
end

class B < A
  HOGE = 'B'
end

irb(main)> B.new.hoge
NameError (uninitialized constant A::HOGE)

とこのようにNameErrorが発生します。どうやら、定数探索の探索位置はメソッド探索の場合とは異なるようです…
どうなっているのか…
さっぱりお手上げです。

で、終わってしまったら、初心者記事丸出しなので、もう少し頑張ることにします。

もう少し頑張る

しばらく調べていると、Railsガイドにヒントとなる記述がありました。

 2.4.1 相対定数を解決するアルゴリズム

  1. ネストが存在する場合、この定数はそのネストの要素の中で順に探索される。それらの要素の先祖は探索されない (訳注: 本章で言う先祖 (ancestors) とはクラス、モジュールのスーパークラスとインクルードしているモジュールのことです)。

2.見つからない場合は、crefの先祖チェーン (継承チェーン) を探索する。

1はレキシカルスコープを探索するということで、基礎で見た通りです。しかし2の記述には見たことがない、crefという謎の記述があります。これは何でしょうか?

上記の記述の一つ上に下記のような記述があります。

ネストが空でなければその最初の要素、空の場合にはObjectを、コードの任意の場所でのcrefというようにしましょう (訳注: crefはRuby内部におけるクラス参照 (class reference) の略であり、Rubyの定数が持つ暗黙のコンテキストです)。

むむ…なんだか日本語なのに全然わからない…
そこでRubyのしくみを読んでいくと、どうやらcrefは構造体で、nd_nextポインタとnd_classポインタを保持しているようです。

nd_nextポインタは基礎の部分で見た通り、レキシカルスコープで探索に使われるポインタです。一方、nd_classはメソッドが定義されている場所のポインタのようです。

上記を勘案すると、定数探索のうち、スーパークラスの探索の時は、メソッドの定義場所を基準として、スーパークラスを探索するのではないかという仮説が立ちます。

ということでclass_evalで無理やりクラスを変えたらいいのではと思い立ちました。やって見たところ…

class A
  def hoge
    puts HOGE
  end
end

class B < A
  HOGE = 'B'
end

class A
  B.class_eval do
    def fuga
      puts HOGE
    end
  end
end

irb(main)> B.new.hoge
NameError (uninitialized constant A::HOGE)

irb(main)> B.new.fuga
NameError (uninitialized constant A::HOGE)

しかし全然ダメ!!全くうまくいかず…
では上記に続けて、トップレベルや適当なmoduleではどうかとやってみるも…

B.class_eval do
  def fuga
    puts HOGE
  end
end

irb(main)> B.new.fuga
NameError (uninitialized constant HOGE)

module C
  B.class_eval do
    def fuga
      puts HOGE
    end
  end
end

irb(main)> B.new.fuga
NameError (uninitialized constant C::HOGE)

やはりダメ…もうダメか、と思いましたが、もう少し続けます。

さらに頑張る

上記の実験を見るに、探索する定数は、selfではなく、定数が定義された位置のネストに基づいて決まっているように思います。実際に下記で実験すると、そのようになっています。

module D
  module E
    B.class_eval do
      def fuga
        puts self
        puts HOGE
      end
    end
  end
end

irb(main)> B.new.fuga
B
NameError (uninitialized constant D::E::HOGE)

やはりそうみたいですね。selfではなく、ネストの位置によって決まっているようです。
つまり探索位置が変わっているのではなく、探索対象となる定数が異なっているようです。ということは逆に探索対象を明示的に示してやると探索してくれるのではという仮説が立ちます。

実際にやってみた結果が下記です。

class A
  def hoge
    puts B::HOGE
  end
end

class B < A
  HOGE = 'B'
end

irb(main)> B.new.hoge
B
=> nil

いけました!!
つまり探索位置の問題ではなく、探索対象の問題だということでした!!
探索位置自体はサブクラスからスタートしているということですね。

あー!スッキリ!!

結論

定数探索は、レキシカルスコープ、スーパークラスの順に探索されるが、トップクラスはレキシカルスコープでは探索されず、スーパークラスで探索される。
定数を相対参照した場合、(呼び出し元のクラスとは関係なく)定義位置のネスティングを暗黙のうちにかぶせられる。

後日談(追記)

コメントを頂いて、自分が何を勘違いしていたのか、よく考えてみました。私が理解していなかったのは、相対参照の仕組みでした。

相対参照の場合、自分はてっきり、selfによって参照する定数が決まると思っていました。

class A
  def hoge
    puts HOGE
  end
end

class B < A
  HOGE = 'B'
end

irb(main)> B.new.hoge

つまり上記の場合、Bがselfになりますので、B::HOGEを探索するのだと勘違いしていました。しかし相対参照で参照した場合、探索対象の定数は、selfではなく定数を呼び出した、メソッド定義位置のネスティングで決まるということがわかりました。

なお定数の定義自体は定義位置のネスティングとは必ずしも一致しません。継承をした場合がそうです。例えば下記のように、親クラスに定数を作ってそれを子クラスが継承したとしましょう。

class A
  HOGE = "hoge"
end

class B < A
  def hoge
    puts A::HOGE
    puts B::HOGE
    puts HOGE
  end
end

この場合B.new.hogeはどうなるでしょうか?答えは…

pry(main)> B.new.hoge
hoge
hoge
hoge
=> nil

三つとも全て出ます。では定数をみてみましょう。

pry(main)> A.constants
=> [:HOGE]
pry(main)> B.constants
=> [:HOGE]

ネスティングだけで考えると、A::HOGEしかないはずですが、上記を見る限り、B::HOGEも定義されていることになります。そのため、定数の定義はネスティングのみでは決まらないということになります。

ちなみにselfによって探索する定数を変えたい場合はどうするのかというと下記のようにすれば、selfに基づいて、探索対象を決めることが可能です。

class A
  def hoge
    puts self.class::HOGE
  end
end

class B < A
  HOGE = "B"
end

pry(main)> B.new.hoge
B
=> nil

まだまだ自分は勉強が足りないなと改めて思いました。
今後も色んな実験を繰り返しながら、理解を深めていきたいと思います。

14
17
4

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
14
17

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?