蒼時弦也
蒼時弦也
資深軟體工程師
發表於

不該使用 Ruby 的 Class Variable 理由

前陣子跟卡米聊到一個神奇的 Ruby Class Variable 使用問題,才想起來從使用 Rubocop 之後會自動建議避免使用,就很久沒有使用 Class Variable 這個機制。

在 Ruby on Rails 中依舊還是有一部分實作會使用,因此並不是完全不使用。然而,在大多數的時候我們應該避免使用,除了對這個特性不夠了解之外,也是因為我們通常用不到。

神奇的現象

原本是發在 Facebook 社團,然而不知道為什麼一段提及 .class_eval 方法的貼文總是被自動偵測刪掉,因此我們先來看一下原始的情況為何。

 1class Counter
 2  @@count = 0
 3end
 4
 5class Counter
 6  puts @@count
 7end
 8# => 0
 9
10Counter.class_eval do
11  puts @@count
12end
13
14# => uninitialized class variable @@count in Object (NameError)

在 Ruby 中使用 Open Class 機制和 .class_eval 方法應該是等價的情況,然而卻在存取 Class Variable 的時候發生問題。

從錯誤訊息來看,我們會直覺的判斷可能是 Proc 的特性差異,也就是是否為 lambda 而影響到變數的查詢,經過一些驗證之後我們基本上可以先排除掉這個可能性。

Instance Variable 的差異

如果有接觸過其他程式語言,對於 Class Variable 的第一印象會是 static 關鍵字,也就是說類似這樣的實作。

1class Person
2  @@type = "Human"
3
4  class << self
5    attr_reader :type
6  end
7end

然而,當我們呼叫 Person.type 的時候卻不會順利的得到 Human 的結果,這是因為對於 Ruby 來說,靜態方法的保存是另外放在 Singleton Class 物件上,而這個物件才是對應到其他程式語言的 static(靜態)資訊,實際上正確的實作是這樣。

1class Person
2  @type = 'Human'
3
4  class << self
5    attr_reader :type
6  end
7end

對於 Person 來說,這些資訊是 Class 定義的 Instance Variable 而非 Ruby 中的 Class Variable 資訊。

繼承特性

在驗證的過程中,我們還發現了一些有趣的情況,像是這樣的實作會順利的讓原本出錯的程式碼能夠正常的運行。

 1@@count = 0
 2
 3class Counter
 4  @@count = 0
 5end
 6
 7Counter.class_eval do
 8  puts @@count
 9end
10
11# => 0

然而,當我們嘗試加入一些新的方法時,會發現這個做法有會被互相影響的缺陷。

 1@@count = 0
 2
 3class Counter
 4  @@count = 0
 5end
 6
 7class Loader
 8 @@count = 100
 9end
10
11Counter.class_eval do
12  puts @@count
13end
14
15# => 100

會發生這樣的現象,是因為 Class Variable 的影響範圍是所有「被繼承」的物件,在大多數的網路文章中通常會討論「繼承」的狀況,卻很少討論到這類兩者沒有共同繼承父物件的情況。

原因也很簡單,因為這兩者都屬於 Object 這個所有物件的父物件,當我們在 Ruby 程式執行中如果不是在某個 Class 或者 Module 的範圍時,預設的對象就是 Object 本身,因此當我們直接呼叫了 @@count = 0 的時候就構成了在 Object 定義 Class Variable 的條件,進而讓兩個物件得以順利存取。

至於 .class_eval 可以順利呼叫的原因,從我們一開始的錯誤訊息會發現,在這個狀況下他會去找的是 Object 的 Class Variable 也因此讓條件成立。

避免使用

有經驗的人看到這裡大概已經理解 Class Variable 在 Ruby 中基本上是一個完全不同的概念,因此我們無法以其他語言中的 static 特性來看待他,然而在 Ruby 語言的實作上 Class Variable 確實是被視為一個 Class 的 Instance Variable 的,關於從原始碼角度去解析的部分,會留在之後的文章討論。

因為這樣的性質,我們在大部分的情況下都不會需要使用 Class Variable 來解決問題,除了少數像是需要讓繼承的物件「共用」某些特殊資訊的情況才會這樣使用。

舉例來說,我們有一個遊戲伺服器會根據連線類型來切換不同的伺服器實作,很可能會像這樣實作。

 1class Server
 2  @@connections = []
 3
 4  def on_connect(connection)
 5    @@connections << connection
 6  end
 7end
 8
 9class LoginServer < Server
10end
11
12class MapServer < Server
13end

看起來似乎能夠完美的處理連線問題,然而這個設計多少還是會造成耦合以及擴充性的問題,以 DI(Dependency Inject,依賴注入)的角度來看,設計一個物件負責管理可能更加合適。

 1class ConnectionPool
 2  def initialize
 3    @connections = []
 4  end
 5
 6  def <<(connection)
 7    @connections << connection
 8  end
 9end
10
11class Server
12  def initialize(pool)
13    @pool = pool
14  end
15
16  def on_connect(connection)
17    @pool << connection
18  end
19end

像是這樣,我們在未來可以更輕鬆的調整連線管理的方式加入最大人數等等機制,假設連線的協定改變也能夠單純調整 ConnectionPool 的封裝而不需要從繼承上下手。

因此,就結論上來說使用 Class Variable 的必要性不大,除非我們找到一個「適合」的情況,大多數時候避免使用會減少更多使用時造成混亂的狀況。