i Cubed Systems Engineering blog

株式会社アイキューブドシステムズの製品開発メンバーが、日頃のCLOMO開発の様子などを紹介します。

【Ruby】図解・定数の探索経路で迷わなくなるために

執筆: Misaki-i3

まえがき

前回の記事でクラス・モジュールの継承チェーンを学び、メソッドの探索経路を攻略した私は、「これで定数も読めるようになった」と安心していた。それはもちろん勘違いであった。
模擬試験サイトの点数に勘違いを突きつけられると、私はさっさと書籍とWebを徘徊する仕事に戻った。
定数の挙動と私の解釈の辻褄が合うまでに、私は定数の探索がメソッドのそれよりも奇妙であることを確信できていた。

本記事では、はじめに定数探索を習得する上で必要な観点をざっくりお伝えし、のちの章で個々を掘り下げていく構成とする。

この記事だけで定数探索のポイントを1から10まで習得できることを目指し、できるだけ多くの混乱しやすいポイントに対応できるよう心がけた。
文量に見合う情報量となっているだろうから、お付き合いいただきたい。

それにしても謎なのが、各所の解答・解説に頻出する『定数の参照はレキシカルに決まるので答えは○○』という、何も明らかにならない説明だ。『レキシカルに決まる』という慣用句でもあるのか? 今でも得心は行っていない。 もし同じ思いをした方がご覧であれば、本記事ではこの言葉を用いずに説明しきるので、よくよく安心してほしい。

対象レベル

前回に引き続き、この記事は入門書を開いたばかりの初心者向きではないが、Rubyの入門書を一周した初心者には十分理解できるものと思う。

Ruby Goldの合格を目指している学習者には、ぜひ理解しておいてもらいたい内容である。定数参照がボーナス問題になると、かなり余裕ができるはずだ。

今回、メソッドの探索経路の詳細な知識は不要であるが、クラス・モジュールの継承チェーンの理解は必須となる。
本記事では継承チェーンの解説は行わないため、自信のない方は前回の記事から必要な部分を補いながら読み進めてもらいたい。

tech.i3-systems.com

注意

今回はRubyの定数探索に限定した解説となる。
他の言語にも当てはめられるものではないので注意していただきたい。

Rubyのフレームワークを利用した場合も同様で、例えばRuby on Railsには独自の定数探索経路が追加されているため、正しく参照先を特定するためにはRuby on Railsの理解が重要となる。

準備運動 2つの前提知識

探索経路の解説をスムーズに理解していただくため、以下の2点を前提知識として確認されたい。

  1. メソッド呼び出しのような、暗黙的なレシーバの補完(self.)は行われない
  2. ダブルコロンの記法

メソッド呼び出しのような、暗黙的なレシーバの補完(self.)は行われない

メソッド呼び出しをよく練習した人ほど、レシーバを記述していない呼び出しを見ると、反射的に頭の中で self. を補完してしまうかもしれない。
(そのような思い込みがない方は、本項を読み飛ばしていただいて問題ない)

ここを勘違いしたままであると、次のコードにおいて、 C2.new.const'C2' を返すような気がしてしまう。

class C1
  FOO = 'C1'
  def const
    FOO
  end
end
class C2 < C1
  FOO = 'C2'
end
C2.new.const # 'C1'

これを見て「なぜ 'C1' を返すのか」が分からない場合は、まず「なぜ 'C2' が返す気がしたか」を明確にしよう。

なぜ C2.new.const'C2' を返すような気がしたか

もし先のサンプルが、メソッド foo のサンプルだったとすると……

class C1
  def foo { 'C1' }; end
  def const
    foo      # self.foo と同じ
  end
end
class C2 < C1
  def foo { 'C2' }; end
end
C2.new.const # 'C2'

このときは、たしかに C2.new.const'C2' を返す。

これは、レシーバの記述を省略して呼び出された foo が、暗黙的にカレントオブジェクトをレシーバとして補うためだ。(self.foo と同等)
サンプルでは C2.new がレシーバであるから、C2.new のインスタンスメソッド foo を実行する。

この仕組みがメソッド呼び出しのポリモーフィックな挙動を実現している。

定数呼び出しは暗黙の self. が補われない

元のサンプルに戻ろう。

class C1
  FOO = 'C1'
  def const
    FOO      # self.FOO ではない
  end
end
class C2 < C1
  FOO = 'C2'
end
C2.new.const # 'C1'

コメントのとおり、定数には暗黙のレシーバ self. が補われない。
そのためこのときの FOO は、記述箇所である C1クラスの FOO を見つけ、'C1' を返す。

ちなみに私は、「 FOO は自分がインスタンスメソッド中にいることに気付いていない」とイメージしている。
FOO から見た景色が次のようであると想像してみると、この FOO'C1' を返すことに違和感はないはずだ。

class C1
  FOO = 'C1'

    FOO

end

ダブルコロンの記法

メソッド呼び出しのドット(.)と同じイメージで考えてよい。
呼び出す側::呼ばれる側 の関係で、チェーンもできる。

  • ダブルコロン(::)を用いて、探索の始点となるオブジェクトを指定できる(Sample::CONST)
  • 左辺を省略(::CONST)すると、左辺をトップレベルとみなす(Object::CONST)
  • チェーンして使える(Object::Sample::CONST)

ダブルコロンの記法のイメージ

ちなみに……

  • ダブルコロンは、ドット(.)の役割も包含している(エイリアスではない)
  • メソッドを呼び出せる(Sample::class, 'str'::reverse)
  • ドット(.)で定数は呼び出せない

重要な前提を共有できたところで、いよいよ本題だ。

定数探索の概要

定数の探索経路は1種類ではない。また、呼び出し方によって探索経路を選択することができる。

定数の探索経路は、大きく2つある。

  1. クラス・モジュールのネスト(入れ子)を辿る探索
  2. 継承チェーンを辿る探索

定数の参照の記述もまた、大きく2つに分けられる。

  1. 定数名のみを記述する
  2. ダブルコロン(::)演算子を用いてオブジェクトを指定し記述する

最後に、参照の記述と探索経路は、次のような関係になる。

  1. 定数名のみ記述・記述箇所からの探索
    • クラス・モジュールのネストを辿る探索を行う
    • 次に、継承チェーンを辿る探索を行う
  2. ダブルコロン演算子を用い記述・指定オブジェクトからの探索
    • 継承チェーンを(Objectクラスの手前まで)辿る探索を行う

定数探索のイメージ図

この時点で、定数の探索は奇妙で易しくないということが分かったかもしれない。(特に米印のあたり)
しかし理解が進めば、その奇妙さにも歴史的経緯があり、いたずらなものではないと気付ける。

探索経路(1) クラス・モジュールのネストを辿る探索

クラス・モジュールのネストとは

ネストの理解は十分であるだろうか。探索経路の基準となるものだ。

  • module キーワードや class キーワードによる定義を入れ子(ネスト)にできる
  • 外部からネストの内側のオブジェクトを参照したい場合は、外側のオブジェクトを経由して参照する
  • ネストの内側からでも、外側のクラス・モジュールを呼び出したり定義したりできる
  • ネストは継承関係を表さない

ネストを利用した定義の例を用意した。
ダブルコロンの記法の復習も兼ねて、自身の理解と異なる結果が生じていないかを確認してほしい。

module M1        # M1
  class C1       # M1::C1
    class C2     # M1::C1::C2
    end
  end
end

class C1         # C1
end

class M1::C1     # M1::C1
  class C2       # M1::C1::C2
  end
  class ::C1     # C1
  end
  class ::M1::C1 # M1::C1
  end
end

M1::C1C1 は別物であることを押さえておくように。
後半は少々イレギュラーな書き方をしているが、難しく考えず、素直に読めばきっと大丈夫。

Module.nesting メソッド

Module.nesting は、クラス・モジュールのネストの状態を確認するためのメソッドだ。

このメソッドの返す配列の内容が、そのまま探索経路となる。
このメソッドは、呼び出した箇所(記述箇所)のネストの状態を、クラス・モジュールの配列にして返してくれる。
現在のクラス→ひとつ外側のクラス→もうひとつ外側のクラス……と、内側から外側の順に並んでいる。

ネストの説明に使ったサンプルの各所で Module.nesting を呼んでみた。

p Module.nesting       # []

module M1
  p Module.nesting     # [M1]
  class C1
    p Module.nesting   # [M1::C1, M1]
    class C2
      p Module.nesting # [M1::C1::C2, M1::C1, M1]
    end
  end
end

class C1
  p Module.nesting     # [C1]
end

class M1::C1
  p Module.nesting     # [M1::C1]
  class C2
    p Module.nesting   # [M1::C1::C2, M1::C1]
  end
  class ::C1
    p Module.nesting   # [C1, M1::C1]
  end
  class ::M1::C1
    p Module.nesting   # [M1::C1, M1::C1]
  end
end

すべての結果に違和感がないかをよく確認してほしい。
違和感があればネストの説明に戻って、納得行くまで往復することを強く勧める。
特に、ふたつの M1::C1::C2 クラス内の Module.nesting の違いに気付き、「確かにこの結果になる」と確信できること。

頭の中で Module.nesting の返り値を求められるようになったなら完璧だ。

ネストを辿る探索の経路 = Module.nesting

先程述べた通り、この配列の並びがそのまま探索経路となる。

Module.nesting の返り値が [M1::C1::C2, M1::C1, M1] である箇所なら、
探索経路は M1::C1::C2M1::C1M1 となる。

ここで生じるべき疑問は、「例えば M1::C1 を探す段になったとき、これの superclass の定数は見つかるのか?」というものだ。
見つからない、が答えである。ネストを辿る探索の最中に、継承チェーンを辿る探索は発生しない。

[M1::C1::C2, M1::C1, M1] なら、M1::C1::C2M1::C1M1 のみなのだ。

継承チェーンが絡まないことを確認するサンプルを用意した。

class Parent
  CONST = 'Parent'
end
class C1 < Parent
  class C2
    p Module.nesting # [C1::C2, C1]
    # p CONST        # NameError
  end
end

ネストを辿る探索経路に C1 クラスが存在するが、そのスーパークラスである Parent までは探しに行かないため、定数の呼び出しで NameError が発生している。

上のサンプルをイメージ化すると、このような感じ。

ネストを辿る探索経路のイメージ

ネストと Module.nesting だけ分かれば、ネストを辿る探索は攻略完了!
くれぐれも、深読みしすぎて探索経路を勝手に増やすことのないよう!

探索経路(2) 継承チェーンを辿る探索

これは文字通りの意味しかなく、継承チェーンがそのまま探索経路となっている。
継承チェーンに関しては理解されているものとして進むので、新しく説明できることは実はない。

継承チェーンのおさらい(メソッド探索の記事で使用した図)

継承チェーンの図解

メソッドの探索と同様、この図の青色(superclass)の矢印を追うだけでよい。
もちろん、水色(singleton_class)の矢印を通ることはない。クラスとその特異クラスは継承関係ではないためだ。

概要の章では確か、場合によって継承チェーンをどこまで辿るかが異なるかのような説明を見かけたが……それはのちの章で扱う。

定数の参照の記述

繰り返しになるが、定数の参照の記述は、大きく2つに分けられる。

  1. 定数名のみを記述
  2. ダブルコロン(::)演算子を用いて記述

様々な記述の例

CONST = 'トップレベル'

class Sample
  CONST = 'Sampleクラス'
  puts CONST           # Sampleクラス
  puts ::CONST         # トップレベル
  puts ::Sample::CONST # Sampleクラス
end

puts CONST             # トップレベル
puts Sample::CONST     # Sampleクラス
puts ::Sample::CONST   # Sampleクラス

puts CONST で呼び出されているところが定数のみの記述だ。
他はダブルコロン演算子を用いた記述で、探索の始点となるオブジェクトを指定している。

puts Sample::CONSTputs ::Sample::CONST の違いにも注目してみてほしい。
CONST にとっては、どちらもダブルコロン演算子を用いた記述で、探索の始点は Sample クラスである。
違いがあるのはダブルコロンの左辺だ。 Sample::Sample かという違いが存在している。ということは Sample を見つけるまでの探索経路は異なる。

つまり、今回の例では同じ Sample を取得できているものの、場合によっては別のオブジェクトが取得されている可能性がある。

Ruby のライブラリを始めとした既存のコードを読み解く機会には、このことを忘れないようにしたい。

さて、探索の種類と記述の種類を学び終えたら、あとは組み合わせるだけだ!

参照の記述と探索経路(1) 定数名のみ記述・記述箇所からの探索

定数名のみを記述した場合の探索経路は次のようになる。

  • クラス・モジュールのネストを辿る探索を行う
  • 次に、継承チェーンを辿る探索を行う

非常に喜ばしいことに、これ以上説明すべきことが何もない。
例題で確認してみよう。

module M1
  module M2
    FOO
  end
end

定数名のみで呼び出されている FOO がある。この探索経路はどうなるだろう?

  • クラス・モジュールのネストを辿る探索
    • FOO の箇所に Module.nesting があったとき、返り値はなにか?
  • 継承チェーンを辿る探索
    • FOO が記述されている M1::M2 の継承チェーンは?

答えは以下のようになる。

module M1
  module M2
    p Module.nesting # [M1::M2, M1]
    FOO              # (1) M1::M2 → M1  (2) M1::M2 → Object → Kernel → BasicObject
  end
end

よって、FOO の探索経路は、
( M1::M2M1 ) → ( M1::M2ObjectKernelBasicObject )
となる。

Module.nesting を攻略したあなたには、拍子抜けするほど簡単なのではないだろうか。
記事の最後にもいくつかのサンプルを用意しているので、まだ不安という方もそちらで練習していただける。
いまのところは、次へ進もう。

参照の記述と探索経路(2) ダブルコロン演算子を用い記述・指定オブジェクトからの探索

ダブルコロン演算子を用い記述した場合の探索経路は次のようになる。

  • 継承チェーンを(Objectクラスの手前まで)辿る探索を行う

書いてあるとおり、継承チェーンを辿る探索は Object の手前までしか行われない。
例で確認してみる。

module M1
  module M2
    ::M1::M2::FOO
  end
end

ひとつ前のサンプルと同じ構造にしているので、 M1::M2 モジュールの継承チェーンは M1::M2 → Object → Kernel → BasicObject となっていることを覚えていると思う。
しかし、 探索経路には Object 以降が含まれない。

よって、 ::M1::M2::FOO の探索経路は
( M1::M2 )
となる。

Object まで探索されると何が困るのか

「定数名だけのときと、ダブルコロンを使うとき、 Object まで行かないのはどっちだっけ?」
答えはダブルコロンを使うときだが、暗記する必要はない。
事の経緯を知れば、すぐに思い出せるようになる。

ダブルコロン演算子を用い記述する方の定数探索も、かつては Object とその先まで探索されていた。しかし、利便性に難があった。

次のリンクは、ダブルコロンの記法を用いて指定オブジェクトからの探索を行うとき、トップレベルまで探索されてしまうことのデメリットを指摘するチケットである。

Feature #11547: remove top-level constant lookup - Ruby master - Ruby Issue Tracking System

要約すると、「呼び出そうとした定数が指定オブジェクトの配下になかったとき、Object まで探索されると意図しない定数を参照してしまいやすくなり、不便である」という内容である。
(起票された2015年には、探索の結果トップレベルの定数を取得した場合に warning が出力されるだけであった)

あえてオブジェクトを指定して定数を呼び出そうとするときは、トップレベルを探索する意図はないことの方が多い。その意図を持って利用するケースがあるとしても、そうでない多くの開発者の被るデメリットのほうが大きいと考えられる。
そして今の実装に変更された。

規則が一貫していることは重要だが、実用に配慮する柔軟性もかけがえのないものなのだろう。

ここまでで、定数の探索経路の解説は完了だ。
終わってみれば簡単だった……か?

あとがき

記事は続くが、想定より長くなってしまったのであとがきを繰り上げることにした。

私が定数の探索経路を学んでいるとき、見つけられる既存の解説では十分に理解できなかった。
分かった気になって、実行してみて間違いに気付き、資料に戻って誤解に気づいて、分かった気になり、以下繰り返し。

この記事では何が何でも『一読で』『誤解なく』『分かってもらう』つもりで執筆しているが、残念ながら既に誤解を与えている可能性は低くないだろうと覚悟している。

記事中にあるサンプルを実行したり、「ここに定義を追加したらどんな影響があるだろう」とアレンジしてみたりしてほしい。

Ruby Goldの模擬問題を解き、模擬試験サイトの点数に勘違いを突きつけられ、また記事を読み返しに戻ってきてほしい。


弊社では採用活動を実施しています。
アウトプットの盛んな社内で、ともに技術の追求を楽しんでいける仲間をお待ちしております。

新卒採用:https://www.i3-systems.com/new-graduates/
キャリア採用:https://www.i3-systems.com/careers/


以降は、Ruby Goldの受験を目指す向けの追加情報と、定数探索を確認・練習するためのサンプルを記載している。

(Ruby Gold 対策) xxx_eval のスコープと定数定義

メタプログラミングの領域となるが、Ruby Gold の出題範囲であるため、資格取得を目指す人は押さえておくべきだ。

  • xxx_evalに渡すブロックはクラス・モジュールのスコープに入らない
  • ただし、テキストでコードを与えた場合は、class・moduleキーワード等でオープンしたとき相当の処理がされるためスコープに入る
  • スコープに入っているかは Module.nesting で確認できる

xxx_eval とスコープを確認する例

class C
  p Module.nesting     # [C]
end
C.class_eval do
  p Module.nesting     # []
end
C.class_eval(<<~CODE)
  p Module.nesting     # [C]
CODE
C.instance_eval(<<~CODE)
  p Module.nesting     # [#<Class:C>] instance_evalは特異クラス
CODE

実際に定義した場合の例

class C; end
C.class_eval do
  FOO = 'C'                   # Cの定数のつもりが、Objectの定数として定義されている
end

C.const_defined?(:FOO)        # true  継承チェーンの探索のときにようやく見つかる
C.const_defined?(:FOO, false) # false 継承チェーンを辿らない設定にすると、見つからない

C.class_eval(<<~CODE)
  FOO = 'C'                   # Cに定義されている
CODE

C.const_defined?(:FOO, false) # true  Cに定義できている

(Ruby Gold 対策) 試験中のメモの取り方

定数探索の問題は、定数名のみを記述したときの探索がほとんどなので、それを前提にする。

  1. 問題文の中で定数定義(CONST = 'XXX')を見かけたら、対応表に追加しよう
  2. 呼び出しコードにたどり着いたら、定数探索の(1)と(2)を作ろう
  3. 探索経路が導けたら、その時点での対応表を見ながら探索しよう
module M1
  FOO = 'M1'     # 追加! M1/FOO/'M1'
end
class C1
  include M1
  class << self
    def foo
      p FOO      # 探索経路 (1) #<Class:C1> → C1  (2) #<Class:C1> → Object以降
    end
  end
end

# C1.foo         # 探索! 誰も持っていない ==> NameError

class C1
  FOO = 'C1'     # 追加! C1/FOO/'C1'
end
C1.foo           # 探索! (1)の C1 が持っていた ==> 'C1'

class << C1
  FOO = '特異C1'  # 追加! 特異C1/FOO/'特異C1'
end
C1.foo           # 探索! (1)の #<Class:C1> が持っていた ==> '特異C1'

定数探索の練習・確認

練習1 ネストと継承の基本

module M1
  class ::C1
    Module.nesting # [C1, M1]
    FOO            # (1) C1 → M1  (2) C1 → Object以降
  end
end
class C1
  Module.nesting   # [C1]
  FOO              # (1) C1  (2) C1 → Object以降
end
class C2 < C1
  Module.nesting   # [C2]
  FOO              # (1) C2  (2) C2 → C1 → Object以降
end

練習2 特異クラス

(クラスと特異クラスに継承関係はないからね、ということ)

class C1
end
class << C1
  def foo
    Module.nesting # [#<Class:C1>, C1]
    FOO            # (1) #<Class:C1> (2) #<Class:C1> → Object以降
  end
end

# C1.foo     # (1) #<Class:C1>  (2) #<Class:C1> → Object以降  ==> NameError

class C1
  FOO = 'C1'
end
# C1.foo     # (1) #<Class:C1>  (2) #<Class:C1> → Object以降  ==> NameError

class << C1
  FOO = '特異C1'
end
C1.foo       # (1) #<Class:C1>  (2) #<Class:C1> → Object以降  ==> '特異C1'

練習3 深いネスト

module M1
  class C1
    Module.nesting   # [M1::C1, M1]
  end
end
module M1::C1::M2
  class C2
    Module.nesting   # [M1::C1::M2::C2, M1::C1::M2]
    FOO = 'M1::C1::M2::C2'
  end
end
class C2
  Module.nesting     # [C2]
  FOO = 'C2'
end
module M1
  module C1::M2
    Module.nesting   # [M1::C1::M2, M1]
    class C2
      Module.nesting # [M1::C1::M2::C2, M1::C1::M2, M1]
      FOO            # (1) M1::C1::M2::C2 → M1::C1::M2 → M1  (2) M1::C1::M2::C2 → Object以降  ==> 'M1::C1::M2::C2'
    end
  end
end

練習4 モジュールのinclude

module M1
  FOO = 'M1'

  class ::C1
    Module.nesting # [C1, M1]
    FOO            # (1) C1 → M1  (2) C1 → Object以降       ==> 'M1'
  end
end
class C2 < C1
  Module.nesting   # [C2]
  # FOO            # (1) C2  (2) C2 → C1 → Object以降       ==> NameError
  include M1
  FOO              # (1) C2  (2) C2 → M1 → C1 → Object以降  ==> 'M1'
end