title: 在 Ruby 中,物件沒有方法。方法查找實際上是怎麼運作的。
published: true
description: 以視覺化方式深入剖析 Ruby 底層的物件模型。為什麼物件只儲存狀態卻不儲存任何方法、隱藏的 eigenclass 如何運作,以及真正的方法查找路徑。
tags: ruby, programming, webdev, oop
cover_image: https://iili.io/nMfrPx1.png

幾乎隨便問一位開發者物件導向程式設計是怎麼運作的,他們都會描述出同一張教科書式的圖像:一個物件把資料和方法打包成一個單一單元。

在 Ruby 裡,你也會不斷聽到這個觀念以口號的形式被強調:「萬物皆物件。」

你建立一個字串,對它呼叫 .upcase,很自然地就會以為這個字串物件擁有那個方法。你建立一個使用者物件,呼叫 .save,也會假設 .save 就存在於你的 user 裡。

但 Ruby 實際上完全不是這樣運作的。

在 Ruby 中,物件不包含任何方法。

如果你從 CRuby(MRI)的 C 原始碼層級去檢視一個實例,你會發現一些令人驚訝的事:物件只儲存它的實例變數、一組內部旗標,以及一個指向另一個結構的指標。它不包含任何一行方法程式碼,也不包含方法表。

那麼方法到底存在於哪裡?Ruby 又是怎麼知道當你輸入 user.greet 時要執行什麼?而當你只在單一特定物件上定義方法時,實際上又發生了什麼事?

若要真正掌握 Ruby,你必須撥開語法糖,理解 Ruby 物件模型的機制:方法表、隱藏的 eigenclass,以及祖先鏈。


第一個驚奇:物件儲存狀態,類別儲存方法

讓我們看一個簡單的 Ruby 類別:

class User
  def initialize(name, email)
    @name = name
    @email = email
  end

  def greet
    "Hello, I am #{@name}"
  end
end

user = User.new("Alice", "[email protected]")

當 Ruby 求值 User.new 時,虛擬機器會配置一個稱為 RObject 的堆積結構。

在這個 RObject 裡,Ruby 會儲存兩件事:

  1. 一個實例變數陣列:@name 被設成 "Alice",@email 被設成 "[email protected]"。
  2. 一個名為 klass 的內部指標,直接指向 User 類別物件。

請注意 user 裡缺少了什麼:找不到 greet 方法。

Ruby 物件記憶體:堆積物件 RObject 與類別物件 RClass

Ruby 的方法只存在於類別之中(內部表示為 RClass)。

User 類別包含一個稱為 m_tbl 的雜湊表(方法表)。當你定義 def greet 時,Ruby 會在 User 的方法表中寫入一筆專案,把符號 :greet 對應到它編譯後的 bytecode 本體。

當你呼叫 user.greet 時:

  1. Ruby 不會去 user 內部找。
  2. Ruby 會沿著 user 的內部 klass 指標前往 User 類別。
  3. Ruby 在 User 的 m_tbl 中搜尋 :greet。
  4. 找到方法後,會在 self = user 的上下文中執行它。

這種分離設計讓 Ruby 更有效率。你可以建立十萬個 User 物件,而這十萬個物件都共享 User 裡同一份方法表。實例只會持有各自的實例變數。


單例方法的謎團

現在來看 Ruby 一個很有名的特性:你可以只替某個單獨物件新增方法,而不影響同一類別的其他實例。

str1 = "hello"
str2 = "world"

def str1.shout
  upcase + "!!!"
end

str1.shout #=> "HELLO!!!"
str2.shout #=> NoMethodError: undefined method 'shout' for "world":String

這引出一個有趣的謎題:

  • 我們已經知道物件不能持有方法,所以 :shout 不可能存在於 str1 裡。
  • :shout 也不可能存在於 String 裡,否則 str2 也會能用到它。

如果物件不能持有方法,而類別也不能持有這個方法,那 shout 到底藏在哪裡?

Ruby 用一個非常巧妙的方式解決:它會悄悄即時創造一個看不見的類別。

定義單例方法:隱藏的 eigenclass

當你在 str1 上定義方法的瞬間,Ruby 會建立一個匿名、隱藏的類別,稱為 eigenclass(正式名稱是 singleton class,單例類別)。

接著 Ruby 會做一個架構上的偷天換日:

  1. 它攔截 str1 的內部 klass 指標,並把它重新指向這個新的單例類別(#<Class:str1>)。
  2. 它把這個單例類別的 superclass 設為 String。
  3. 它把 :shout 方法放進這個單例類別的方法表中。

當你呼叫 str1.shout 時,Ruby 會沿著 klass 指標進入 #<Class:str1>,找到 :shout 並執行它。

當你呼叫 str1.upcase 時,Ruby 會先在 #<Class:str1> 中查找,找不到 :upcase 後,沿著 superclass 指標往上走到 String,找到 :upcase 後執行它。

對一般程式碼來說,單例類別幾乎是完全透明的。你在 str1.class 裡看不到它(仍然會回報 String)。但你可以透過 Ruby 的反射方法證明它存在:

str1.singleton_class
#=> #<Class:#<String:0x00007fffa80c2180>>

str1.singleton_methods
#=> [:shout]

「類別方法」其實不存在

一旦理解了 eigenclass,Ruby 裡最大的迷思之一就會消失:根本沒有什麼「類別方法」這回事。

開發者常常會寫出像這樣的程式:

class Post
  def self.recent
    # 查詢最近的文章
  end
end

教學文章常常會說:「這是在 Post 上定義一個類別方法。」

在 Java 或 C++ 之類的語言裡,靜態方法是獨立的語言結構。但在 Ruby 中,Post 並不是什麼特殊實體。Post 只是一個物件。 它是 Class 類別的實例。

Post.class
#=> Class

因為 Post 是物件,所以寫 def Post.recent 或 def self.recent,跟寫 def str1.shout 做的是完全同一件事:你是在為一個物件定義單例方法。

類別方法其實只是類別物件上的單例方法

:recent 方法既不住在 Post 裡,也不住在 Class 裡。它存在於 Post 自己私有的 eigenclass(#<Class:Post>)中。

在 Ruby 裡,只有實例方法。所謂的「類別方法」,其實只是存在於類別物件的單例類別上的實例方法。整個語言都遵循同一條統一規則運作。


完整的方法查找階梯

當你輸入 receiver.message 時,Ruby 查找程式碼的精確順序是什麼?

Ruby 不會漫無目的地搜尋。它會沿著一條精確且可預測的階梯前進,這條階梯稱為 ancestors chain(祖先鏈)。它遇到的第一個匹配會立刻執行。

Ruby 的方法查找階梯

以下是完整的逐步查找路徑:

  1. 接收者的 eigenclass: 如果物件有任何單例方法,它們優先級最高。
  2. eigenclass 上的 prepend 模組: 任何透過 prepend 加入的模組都會排在 eigenclass 前面。
  3. 接收者的類別: 一般實例方法所在的標準類別。
  4. 類別上被 prepend 的模組: 如果某個模組被 prepend 到類別上,它的方法會覆蓋類別本身的方法。
  5. 類別上被 include 的模組: 如果某個模組被 include 到類別中,它會直接插入在階層中、位於類別之上。
  6. superclass(以及它們的模組): Ruby 會往上爬到父類別,檢查它的 prepend、自有方法與 include。
  7. Object、Kernel 與 BasicObject: 幾乎所有 Ruby 物件共享的根祖先。

如果都找不到會發生什麼?

如果 Ruby 一路爬到 BasicObject 都找不到對應的方法,它不會立刻丟出錯誤。

相反地,Ruby 會回到起始接收者,從步驟一開始整個搜尋流程再跑一次,這次尋找的是名為 method_missing 的方法。

因為 Kernel 提供了一個預設的 method_missing 實作,會拋出 NoMethodError,所以只有在沒有任何中途模組或類別攔截它時,你的程式才會崩潰。

這個雙重搜尋流程正是 Ruby 著名的 metaprogramming 函式庫背後的動力來源,從 ActiveRecord 的動態查找器到 OpenStruct 都是如此。


「萬物皆物件」如何還能跑得快:Tagged Pointer

如果 Ruby 裡每個數字、布林值與 nil 都是具有方法的物件,那 Ruby 又是怎麼避免為基本運算分配堆積記憶體而拖垮效能的?

如果你執行:

total = 0
1_000_000.times { |i| total += i }

Ruby 會在堆積上配置一百萬個 RObject 結構嗎?

在 CRuby 中,答案是不會,這要歸功於一個內部最佳化技術,稱為 Tagged Pointers(標記指標;也常稱為 Immediate Values,立即值)。

CRuby Tagged Pointers:立即值

在 64 位元作業系統上,記憶體位址會對齊到 8 位元組邊界。這表示任何有效堆積指標的最低 3 個 bit 永遠是 000。

CRuby 利用這些未使用的 bit。當一個 64 位元值代表一個小整數(稱為 Fixnum)時,Ruby 會把整數左移一個 bit,並把最低位設為 1:

value = (integer << 1) | 1

當 Ruby 虛擬機器看到某個值的最低位是 1 時,它就知道:「這不是堆積指標,這就是原始整數本身。」

同樣地,CRuby 也為單例值保留了特定的 bit 模式:

  • false 表示為 0x00
  • nil 表示為 0x08
  • true 表示為 0x14

當你呼叫 42.even? 時,Ruby 不會去檢查某個堆積區塊。它會偵測到這個 tagged pointer,去 Integer 類別的方法表查找方法,並在沒有任何記憶體配置或垃圾回收負擔的情況下直接執行程式碼。

你同時得到了「萬物皆物件」這種概念上的純粹性,以及原始暫存器算術的效率。


思維上的轉變

Ruby 的物件模型常被描述得很複雜,但在表面底下,它其實非常精簡:

  1. 物件儲存狀態與內部 klass 指標。 它們永遠不會持有方法。
  2. 類別儲存方法與內部 super 指標。 它們永遠不會持有實例狀態。
  3. 單例方法存在於看不見的 eigenclass 中,並無縫插入查找鏈。
  4. 類別方法其實只是類別物件上的單例方法,因為類別本身也是物件。
  5. Tagged pointer 在不犧牲效能的前提下,保留了純粹的物件語意。

一旦你用這個角度看待物件模型,Ruby 的 metaprogramming 就不再像黑魔法。像 class_eval、instance_eval、prepend 和 singleton class 這些概念,都會變成可預測的機械化工具,遵循同一套一致的規則。

你第一次學 Ruby 時,最讓你停下來重新思考程式語言運作方式的物件模型怪癖是什麼?如果你曾在正式環境中使用過 eigenclass,你是用它解決了什麼問題?


參考資料:


原文出處:https://dev.to/smtahosin/in-ruby-objects-dont-have-methods-how-method-lookup-actually-works-31g9


精選技術文章翻譯,幫助開發者持續吸收新知。

共有 0 則留言


精選技術文章翻譯,幫助開發者持續吸收新知。
🏆 本月排行榜
🥇
站長阿川
258
🥈
boluoji
📝2  
27
🥉
mike
1
評分標準:發文×10 + 留言×3 + 獲讚×5 + 點讚×1 + 瀏覽數÷10
本數據每小時更新一次
📢 贊助商廣告 · 我要刊登