《Effective-Ruby》讀書筆記

我沒有三顆心臟發表於2019-05-12

《Effective-Ruby》讀書筆記

本篇是在我接觸了 Ruby 很短一段時間後有幸捧起的一本書,下面結合自己的一些思考,來輸出一下自己的讀書筆記

前言

學習一門新的程式語言通常需要經過兩個階段:

  • 第一個階段是學習這門程式語言的語法和結構,如果我們具有其他程式語言的經驗,那麼這個過程通常只需要很短的時間;
  • 第二個階段是深入語言、學習語言風格,許多程式語言在解決常見的問題時都會使用獨特的方法,Ruby 也不例外。

《Effictive Ruby》就是一本致力於讓你在第二階段更加深入和全面的瞭解 Ruby,編寫出更具可讀性、可維護性程式碼的書,下面我就著一些我認為的重點和自己的思考來進行一些精簡和說明

第一章:讓自己熟悉 Ruby

第 1 條:理解 Ruby 中的 True

  • 每一門語言對於布林型別的值都有自己的處理方式,在 Ruby 中,除了 false 和 nil,其他值都為真值,包括數字 0 值。
  • 如果你需要區分 false 和 nil,可以使用 nil? 的方式或 “==“ 操作符並將 false 作為左操作物件。
# 將 false 放在左邊意味著 Ruby 會將表示式解析為 FalseClass#== 方法的呼叫(該方法繼承自 Object 類)
# 這樣我們可以很放心地知道:如果右邊的操作物件也是 false 物件,那麼返回值為 true
if false == x
    ...
end
 
# 換句話說,把 false 置為有操作物件是有風險的,可能不同於我們的期望,因為其他類可能覆蓋 Object#== 方法從而改變下面這個比較
class Bad
    def == (other)
        true
    end
end
 
irb> false == Bad.new
---> false
irb> Bad.new == false
---> true

第 2 條:所有物件的值都可能為 nil

在 Ruby 中倡導介面高於型別,也就是說預期要求物件是某個給定類的例項,不如將注意力放在該物件能做什麼上。沒有什麼會阻止你意外地把 Time 型別物件傳遞給接受 Date 物件的方法,這些型別的問題雖然可以通過測試避免,但仍然有一些多型替換的問題使這些經過測試的應用程式出現問題:

undefined method 'fubar' for nil:NilClass (NoMethodError)

當你呼叫一個物件的方法而其返回值剛好是討厭的 nil 物件時,這種情況就會發生···nil 是類 NilClass 的唯一物件。這樣的錯誤會悄然逃過測試而僅在生產環境下出現:如果一個使用者做了些超乎尋常的事。

另一種導致該結果的情況是,當一個方法返回 nil 並將其作為引數直接傳給一個方法時。事實上存在數量驚人的方式可以將 nil 意外地引入你執行中的程式。最好的防範方式是:假設任何物件都可以為 nil,包括方法引數和呼叫方法的返回值。

# 最簡單的方式是使用 nil? 方法
# 如果方法接受者(receiver)是 nil,該方法將返回真值,否則返回假值。
# 以下幾行程式碼是等價的:
person.save if person
person.save if !person.nil?
person.save unless person.nil?
 
# 將變數顯式轉換成期望的型別常常比時刻擔心其為 nil 要容易得多
# 尤其是在一個方法即使是部分輸入為 nil 時也應該產生結果的時候
# Object 類定義了幾種轉換方法,它們能在這種情況下派上用場
# 比如,to_s 方法會將方法接受者轉化為 string:
irb> 13.to_s
---> "13"
irb> nil.to_s
---> ""
 
# to_s 如此之棒的原因在於 String#to_s 方法只是簡單返回 self 而不做任何轉換和複製
# 如果一個變數是 string,那麼呼叫 to_s 的開銷最小
# 但如果變數期待 string 而恰好得到 nil,to_s 也能幫你扭轉局面:
def fix_title (title)
    title.to_s.capitalize
end

這裡還有一些適用於 nil 的最有用的例子:

irb> nil.to_a
---> []
 
irb> nil.to_i
---> 0
 
irb> nil.to_f
---> 0.0

當需要同時考慮多個值的時候,你可以使用類 Array 提供的優雅的討巧方式。Array#compact 方法返回去掉所有 nil 元素的方法接受者的副本。這在將一組可能為 nil 的變數組裝成 string 時很常用。比如:如果一個人的名字由 first、middle 和 last 組成(其中任何一個都可能為 nil),那麼你可以用下面的程式碼組成這個名字:

name = [first, middle, last].compact.join(" ")

nil 物件的嗜好是在你不經意間偷偷溜進正在執行的程式中。無論它來自使用者輸入、無約束資料庫,還是用 nil 來表示失敗的方法,意味著每個變數都可能為 nil。

第 3 條:避免使用 Ruby 中古怪的 Perl 風格語法

  • 推薦使用 String#match 替代 String#=~。前者將匹配資訊以 MatchDate 物件返回,而非幾個特殊的全域性變數。
  • 使用更長、更表意的全域性變數的別名,而非其短的、古怪的名字(比如,用 $LOAD_PATH 替代 $: )。大多數長的名字需要在載入庫 English 之後才能使用。
  • 避免使用隱式讀寫全域性變數 $_ 的方法(比如,Kernel#print、Regexp#~ 等)
# 這段程式碼中有兩個 Perl 語法。
# 第一個:使用 String#=~ 方法
# 第二個:在上述程式碼中看起來好像是使用了一個全域性變數 $1 匯出第一個匹配組的內容,但其實不是...
def extract_error (message)
    if message =~ /^ERROR:\s+(.+)$/
        $1
    else
        "no error"
    end
end
 
# 以下是替代方法:
def extract_error (message)
    if m = message.match(/^ERROR:\s+(.+)$/)
        m[1]
    else
        "no error"
    end
end

第 4 條:留神,常量是可變的

最開始接觸 Ruby 時,對於常量的認識大概可能就是由大寫字母加下劃線組成的識別符號,例如 STDIN、RUBY_VERSION。不過這並不是故事的全部,事實上,由大寫字母開頭的任何識別符號都是常量,包括 String 或 Array,來看看這個:

module Defaults
    NOTWORKS = ["192.168.1","192.168.2"]
end
def purge_unreachable (networks=Defaults::NETWORKS)
    networks.delete_if do |net|
        !ping(net + ".1")
    end
end

如果呼叫方法 unreadchable 時沒有加引數的話,會意外的改變一個常量的值。在 Ruby 中這樣做甚至都不會警告你。好在有一種解決這個問題的方法——freeze 方法:

module Defaults
    NOTWORKS = ["192.168.1","192.168.2"].freeze
end

加入你再想改變常量 NETWORKS 的值,purge_unreadchable 方法就會引入 RuntimeError 異常。根據一般的經驗,總是通過凍結常量來阻止其被改變,然而不幸的是,凍結 NETWORKS 陣列還不夠,來看看這個:

def host_addresses (host, networks=Defaults::NETWORKS)
    networks.map {|net| net << ".#{host}"}
end

如果第二個引數沒有賦值,那麼 host_addresses 方法會修改陣列 NETWORKS 的元素。即使陣列 NETWORKS 自身被凍結,但是元素仍然是可變的,你可能無法從陣列中增刪元素,但你一定可以對存在的元素加以修改。因此,如果一個常量引用了一個集合,比如陣列或者是雜湊,那麼請凍結這個集合以及其中的元素:

module Defaults
    NETWORKS = [
        "192.168.1",
        "192.168.2"
    ].map(&:freeze).freeze
end

甚至,要達到防止常量被重新賦值的目的,我們可以凍結定義它的那個模組:

module Defaults
    TIMEOUT = 5
end
 
Defaults.freeze

第 5 條:留意執行時警告

  • 使用命令列選項 ”-w“ 來執行 Ruby 直譯器以啟用編譯時和執行時的警告。設定環境變數 RUBYOPT 為 ”-w“ 也可以達到相同目的。
  • 如果必須禁用執行時的警告,可以臨時將全域性變數 $VERBOSE 設定為 nil。
# test.rb
def add (x, y)
    z = 1
    x + y
end
puts add 1, 2
 
# 使用不帶 -w 引數的命令列
irb> ruby test.rb
---> 3
# 使用帶 -w 引數的命令列
irb< ruby -w test.rb
---> test.rb:1: warning: parentheses after method name is interpreted as an argument list, not a decomposed argument
---> test.rb:2: warning: assigned but unused variable - z
---> 3

第二章:類、物件和模組

第 6 條:瞭解 Ruby 如何構建整合體系

讓我們直接從程式碼入手吧:

class Person
    def name
        ...
    end
end
 
class Customer < Person
    ...
end
irb> customer = Customer.new
---> #<Customer>
 
irb> customer.superclass
---> Person
 
irb> customer.respond_to?(:name)
---> true

上面的程式碼幾乎就和你預想的那樣,當呼叫 customer 物件的 name 方法時,Customer 類會首先檢查自身是否有這個例項方法,沒有那麼就繼續搜尋。

《Effective-Ruby》讀書筆記

順著整合體系向上找到了 Person 類,在該類中找到了該方法並將其執行。(如果 Person 類中沒有找到的話,Ruby 會繼續向上直到到達 BasicObject)

但是如果方法在查詢過程中直到類樹的根節點仍然沒有找到匹配的辦法,那麼它將重新從起點開始查詢,不過這一次會查詢 method_missing 方法。

下面我們開始讓事情變得更加有趣一點:

module ThingsWithNames
    def name
        ...
    end
end
 
class Person
    include(ThingsWithNames)
end
irb> Person.superclass
---> Object
irb> customer = Customer.new
---> #<Customer>
irb> customer.respond_to?(:name)
---> true

這裡把 name 方法從 Person 類中取出並移到一個模組中,然後把模組引入到了 Person 類。Customer 類的例項仍然可以如你所料響應 name 方法,但是為什麼呢?顯然,模組 ThingsWithNames 並不在整合體系中,因為 Person 類的超類仍然是 Object 類,那會是什麼呢?其實,Ruby 在這裡對你撒謊了!當你 include 方法來將模組引入類時,Ruby 在幕後悄悄地做了一些事情。它建立了一個單例類並將它插入類體系中。這個匿名的不可見類被鏈向這個模組,因此它們共享了實力方法和常量。

《Effective-Ruby》讀書筆記

當每個模組被類包含時,它會立即被插入整合體系中包含它的類的上方,以後進先出(LIFO)的方式。每個物件都通過變數 superclass 連結,像單連結串列一樣。這唯一的結果就是,當 Ruby 尋找一個方法時,它將以逆序訪問訪問每個模組,最後包含的模組最先訪問到。很重要的一點是,模組永遠不會過載類中的方法,因為模組插入的位置是包含它的類的上方,而 Ruby 總是會在向上檢查之前先檢查類本身。
(好吧······這不是全部的事實。確保你閱讀了第 35 條,來看看 Ruby 2.0 中的 prepend 方法是如何使其複雜化的)

要點回顧:

  • 要尋找一個方法,Ruby 只需要向上搜尋類體系。如果沒有找到這個方法,就從起點開始搜搜 method_missing 方法。
  • 包含模組時 Ruby 會悄悄地建立單例類,並將其插入在繼承體系中包含它的類的上方。
  • 單例方法(類方法和針對物件的方法)儲存於單例類中,它也會被插入繼承體系中。

第 7 條:瞭解 super 的不同行為

  • 當你想過載繼承體系中的一個方法時,關鍵字 super 可以幫你呼叫它。
  • 不加括號地無參呼叫 super 等價於將宿主方法的素有引數傳遞給要呼叫的方法。
  • 如果希望使用 super 並且不向過載方法傳遞任何引數,必須使用空括號,即 super()。
  • 當 super 呼叫失敗時,自定義的 method_missing 方法將丟棄一些有用的資訊。在第 30 條中有 method_missing 的替代解決方案。

第 8 條:初始化子類時呼叫 super

  • 當建立子類物件時,Ruby 不會自動呼叫超類中的 initialize 方法。作為替代,常規的方法查詢規則也適用於 initialize 方法,只有第一個匹配的副本會被呼叫。
  • 當為顯式使用繼承的類定義 initialize 方法時,使用 super 來初始化其父類。在定義 initialize_copy 方法時,應使用相同的規則
class Parent
    def initialize (name)
        @name = name
    end
end
 
class Child < Parent
    def initialize (grade)
        @grade = grade
    end
end
 
# 你能看到上面的窘境,Ruby 沒有提供給子類和其超類的 initialize 方法建立聯絡的方式
# 我們可以使用通用意義上的 super 關鍵字來完成繼承體系中位於高層的辦法:
class Child < Parent
    def initialize (name, grade)
        super(name) # Initialize Parent.
        @grade = grade
    end
end

第 9 條:提防 Ruby 最棘手的解析

這是一條關於 Ruby 可能會戲弄你的另一條提醒,要點在於:Ruby 在對變數賦值和對 setter 方法呼叫時的解析是有區別的!直接看程式碼吧:

# 這裡把 initialize 方法體中的內容當做第 counter= 方法的呼叫也不是毫無道理
# 事實上 initialize 方法會建立一個新的區域性變數 counter,並將其賦值為 0
# 這是因為 Ruby 在呼叫 setter 方法時要求存在一個顯式接受者
class Counter
    attr_accessor(:counter)
 
    def initialize
        counter = 0
    end
    ...
end
 
# 你需要使用 self 充當這個接受者
class Counter
    attr_accessor(:counter)
 
    def initialize
        self.counter = 0
    end
    ...
end
 
# 而在你呼叫非 setter 方法時,不需要顯式指定接受者
# 換句話說,不要使用不必要的 self,那會弄亂你的程式碼:
class Name
    attr_accessor(:first, :last)
     
    def initialize (first, last)
        self.first = first
        self.last = last
    end
 
    def full
        self.first + " " + self.last # 這裡沒有呼叫 setter 方法使用 self 多餘了
    end
end
 
# 就像上面 full 方法裡的註釋,應該把方法體內的內容改為
first + " " + last

第 10 條:推薦使用 Struct 而非 Hash 儲存結構化資料

看程式碼吧:

# 假設你要對一個儲存了年度天氣資料的 CSV 檔案進行解析並儲存
# 在 initialize 方法後,你會獲得一個固定格式的雜湊陣列,但是存在以下的問題:
# 1.不能通過 getter 方法訪問其屬性,也不應該將這個雜湊陣列通過公共介面向外暴露,因為其中包含了實現細節
# 2.每次你想在類內部使用該雜湊時,你不得不回頭來看 initialize 方法
#   因為你不知道CSV具體的對應是怎樣的,而且當類成熟情況可能還會發生變化
require('csv')
class AnnualWeather
    def initialize (file_name)
        @readings = []
     
        CSV.foreach(file_name, headers: true) do |row|
            @readings << {
                :date => Date.parse(row[2]),
                :high => row[10].to_f,
                :low => row[11].to_f,
            }
        end
    end
end
 
# 使用 Struct::new 方法的返回值賦給一個常量並利用它建立物件的實踐:
class AnnualWeather
    # Create a new struct to hold reading data.
    Reading = Struct.new(:date, :high, :low)
 
    def initialize (file_name)
        @readings = []
     
        CSV.foreach(file_name, headers: true) do |row|
            @readings << Reading.new(Date.parse(row[2]),
                                     row[10].to_f,
                                     row[11].to_f)
        end
    end
end
 
# Struct 類本身比你第一次使用時更加強大。除了屬性列表,Struct::new 方法還能接受一個可選的塊
# 也就是說,我們能在塊中定義例項方法和類方法。比如,我們定義一個返回平均每月平均溫度的 mean 方法:
Reading = Struct.new(:date, :high, :low) do
    def mean
        (high + low) / 2.0
    end
end

另外從其他地方看到了關於 Struct::new 的實踐

  • 考慮使用 Struct.new, 它可以定義一些瑣碎的 accessors, constructor(建構函式) 和 comparison(比較) 操作。
# good
class Person
  attr_reader :first_name, :last_name
 
  def initialize(first_name, last_name)
    @first_name = first_name
    @last_name = last_name
  end
end
 
# better
class Person < Struct.new(:first_name, :last_name)
end
  • 考慮使用 Struct.new,它替你定義了那些瑣碎的存取器(accessors),構造器(constructor)以及比較操作符(comparison operators)。
# good
class Person
  attr_accessor :first_name, :last_name
 
  def initialize(first_name, last_name)
    @first_name = first_name
    @last_name = last_name
  end
end
 
# better
Person = Struct.new(:first_name, :last_name) do
end
  • 要去 extend 一個 Struct.new - 它已經是一個新的 class。擴充套件它會產生一個多餘的 class 層級 並且可能會產生怪異的錯誤如果檔案被載入多次。

第 11 條:通過在模組中嵌入程式碼來建立名稱空間

  • 通過在模組中嵌入程式碼來建立名稱空間
  • 讓你的名稱空間結構和目錄結構相同
  • 如果使用時可能出現歧義,可使用 ”::” 來限定頂級常量(比如,::Array)

第 12 條:理解等價的不同用法

看看下面的 IRB 回話然後自問一下:為什麼方法 equal? 的返回值和操作符 “==” 的不同呢?

irb> "foo" == "foo"
---> true
irb> "foo".equal?("foo")
---> false

事實上,在 Ruby 中有四種方式來檢查物件之間的等價性,下面來簡單總個結吧:

  • 絕不要過載 equal? 方法。該方法的預期行為是,嚴格比較兩個物件,僅當它們同時指向記憶體中同一物件時其值為真(即,當它們具有相同的 object_id 時)
  • Hash 類在衝突檢查時使用 eql? 方法來比較鍵物件。預設實現可能和你的想像不同。遵循第 13 條建議之後再使用別名 eql? 來替代 “==” 書寫更合理的 hash 方法
  • 使用 “==” 操作符來測試兩個物件是否表示相同的值。有些類比如表示數字的類會有一個粗糙的等號操作符進行型別轉換
  • case 表示式使用 “===“ 操作符來測試每個 when 語句的值。左運算元是 when 的引數,右運算元是 case 的引數

第 13 條:通過 "<=>" 操作符實現比較和比較模組

要記住在 Ruby 語言中,二元操作符最終會被轉換成方法呼叫的形式,左運算元對應著方法的接受者,右運算元對應著方法第一個也是唯一的那個引數。

  • 通過定義 "<=>" 操作符和引入 Comparable 模組實現物件的排序
  • 如果左運算元不能與右運算元進行比較,"<=>" 操作符應該返回 nil
  • 如果要實現類的 "<=>" 運算子,應該考慮將 eql? 方法設定為 "==" 操作符的別名,特別是當你希望該類的所有例項可以被用來作為雜湊鍵的時候,就應該過載雜湊方法

第 14 條:通過 protected 方法共享私有狀態

  • 通過 protected 方法共享私有狀態
  • 一個物件的 protected 方法若要被顯式接受者呼叫,除非該物件與接受者是同類物件或其具有相同的定義該 protected 方法的超類
# Ruby 語言中,私有方法的行為和其他物件導向的程式語言中不太相同。Ruby 語言僅僅在私有方法上加了一條限制————它們不能被顯式接受者呼叫
# 無論你在繼承關係中的哪一級,只要你沒有使用接受者,你都可以呼叫祖先方法中的私有方法,但是你不能呼叫另一個物件的私有方法
# 考慮下面的例子:
# 方法 Widget#overlapping? 會檢測其本身是否和另一個物件在螢幕上重合
# Widget 類的公共介面並沒有將螢幕座標對外暴露,它們的具體實現都隱藏在了內部
class Widget
    def overlapping? (other)
        x1, y1 = @screen_x, @screen_y
        x2, y2 = other.instance_eval {[@screen_x, @screen_y]}
        ...
    end
end
 
# 可以定義一個暴露私有螢幕座標的方法,但並不通過公共介面來實現,其實現方式是宣告該方法為 protected
# 這樣我們既保持了原有的封裝性,也使得 overlapping? 方法可以訪問其自身以及其他傳入的 widget 例項的座標
# 這正式設計 protected 方法的原因————在相關類之間共享私有資訊
class Widget
    def overlapping? (other)
        x1, y1 = @screen_x, @screen_y
        x2, y2 = other.screen_coordinates
        ...
    end
 
    protected
     
    def screen_coordinates
        [@screen_x, @screen_y]
    end
end

第 15 條:優先使用例項變數而非類變數

  • 優先使用例項變數(@)而非類變數(@@)
  • 類也是物件,所以它們擁有自己的私有例項變數集合

第三章:集合

第 16 條:在改變作為引數的集合之前複製它們

在 Ruby 中多數物件都是通過引用而不是通過實際值來傳遞的,當將這種型別的物件插入容器時,集合類實際儲存著該物件的引用而不是物件本身。
(值得注意的是,這條準則是個例如:Fixnum 類的物件在傳遞時總是通過值而不是引用傳遞)

這也就意味著當你把集合作為引數傳入某個方法並進行修改時,原始集合也會因此被修改,有點間接,不過很容易看到這種情況的發生。

Ruby 語言自帶了兩個用來複制物件的方法:dup 和 clone。

它們都會基於接收者建立新的物件,但是與 dup 方法不同的是,clone 方法會保留原始物件的兩個附加特性。

首先,clone 方法會保留接受者的凍結狀態。如果原始物件的狀態是凍結的,那麼生成的副本也會是凍結的。而 dup 方法就不同了,它永遠不會返回凍結的物件。

其次,如果接受這種存在單例方法,使用 clone 也會複製單例類。由於 dup 方法不會這樣做,所以當使用 dup 方法時,原始物件和使用 dup 方法建立的副本對於相同訊息的響應可能是不同的。

# 也可以使用 Marshal 類將一個集合及其所持有的元素序列化,然後再反序列化:
irb> a = ["Monkey", "Brains"]
irb> b = Marshal.load(Marshal.dump(a))
irb> b.each(&:upcasel); b.first
---> "MONKEY"
irb> a.last
---> "Brains"

第 17 條:使用 Array 方法將 nil 及標量物件轉換成陣列

  • 使用 Array 方法將 nil 及標量物件轉換成陣列
  • 不要將雜湊傳給 Array 方法,它會被轉化成一個巢狀陣列的集合
# 考慮下面這樣一個訂披薩的類:
class Pizza
    def initialize (toppings)
        toppings.each do |topping|
            add_and_price_topping(topping)
        end
    end
end
 
# 上面的 initialize 方法期待的是一個 toppings 陣列,但我們能傳入單個 topping,甚至是在沒有 topping 物件的時候直接傳入 nil
# 你可能會想到使用可變長度引數列表來實現它,並將引數型別改為 *topping,這樣會把所有的引數整合成一個陣列。
# 儘管這樣做可以讓我們傳入單個 topping 物件,擔當傳入一組物件給 initialize 方法的時候必須使用 "*" 顯式將其擴充成一個陣列。
# 所以這樣做僅僅是拆東牆補西牆罷了,一個更好的解決方式是將傳入的引數轉換成一個陣列,這樣我們就明確地知道我要做的是什麼了
# 先對 Array() 做一些探索:
irb> Array('Betelgeuse')
---> ["Betelgeuse"]
irb> Array(nil)
---> []
irb> Array(['Nadroj', 'Retep'])
---> ["Nadroj", "Retep"]
irb> h = {pepperoni: 20,jalapenos: 2}
irb> Array(h)
---> [[:pepperoni, 20], [:jalapenos, 2]]
# 如果你想處理一組雜湊最好採用第 10 條的建議那樣
 
# 回答訂披薩的問題上:
# 經過一番改造,它現在能夠接受 topping 陣列、單個 topping,或者沒有 topping(nil or [])
class Pizza
    def initialize (toppings)
        Array(toppings).each do |topping|
            add_and_price_topping(topping)
        end
    end
    ...
end

第 18 條:考慮使用集合高效檢查元素的包含性

(書上對於這一條建議的描述足足有 4 頁半,但其實可以看下面結論就ok,結尾有例項程式碼)

  • 考慮使用 Set 來高效地檢測元素的包含性
  • 插入 Set 的物件必須也被當做雜湊的鍵來用
  • 使用 Set 之前要引入它
# 原始版本
class Role
    def initialize (name, permissions)
        @name, @permissions = name, permissions
    end
 
    def can? (permission)
        @permissions.include?(permission)
    end
end
 
# 版本1.0:使用 Hash 替代 Array 的 Role 類:
# 這樣做基於兩處權衡,首先,因為雜湊只儲存的鍵,所以陣列中的任何重複在轉換成雜湊的過程中都會丟失。
# 其次,為了能夠將陣列轉換成雜湊,需要將整個陣列對映,構建出一個更大的陣列,從而轉化為雜湊。這將效能問題從 can? 方法轉移到了 initialize 方法
class Role
    def initialize (name, permissions)
        @name = name
        @permissions = Hash[permissions.map {|p| [p, ture]}]
    end
 
    def can? (permission)
        @permissions.include?(permission)
    end
end
 
# 版本2.0:引入 Set:
# 效能幾乎和上一個雜湊版本的一樣
require('set')
class Role
    def initialize (name, permissions)
        @name, @permissions = name, Set.new(permissions)
    end
         
    def can? (permission)
        @permissions.include?(permission)
    end
end
 
# 最終的例子
# 這個版本自動保證了集合中沒有重複的記錄,且重複條目是很快就能被檢測到的
require('set')
require('csv')
class AnnualWeather
    Reading = Struct.new(:date, :high, :low) do
        def eql? (other) date.eql?(other.date); end
        def hash; date.hash; end
    end
 
    def initialize (file_name)
        @readings = Set.new
        CSV.foreach(file_name, headers: true) do |row|
            @readings << Reading.new(Date.parse(row[2]),
                                     row[10].to_f,
                                     row[11].to_f)
        end
    end
end

第 19 條:瞭解如何通過 reduce 方法摺疊集合

儘管可能有點雲裡霧裡,但還是考慮考慮先食用程式碼吧:

# reduce 方法的引數是累加器的起始值,塊的目的是建立並返回一個適用於下一次塊迭代的累加器
# 如果原始集合為空,那麼塊永遠也不會被執行,reduce 方法僅僅是簡單地返回累加器的初始值
# 要注意塊並沒有做任何賦值。這是因為在每個迭代後,reduce 丟棄上次迭代的累加器並保留了塊的返回值作為新的累加器
def sum (enum)
    enum.reduce(0) do |accumulator, element|
        accumulator + element
    end
end
 
# 另一個快捷操作方式對處理塊本身很方便:可以給 reduce 傳遞一個符號(symbol)而不是塊。
# 每個迭代 reduce 都使用符號作為訊息名稱傳送訊息給累加器,同時將當前元素作為引數
def sum (enum)
    enum.reduce(0, :+)
end
 
# 考慮一下把一個陣列的值全部轉換為雜湊的鍵,而它們的值都是 true 的情況:
Hash[array.map {|x| [x, true]}]
# reduce 可能會提供更加完美的方案(注意此時 reduce 的起始值為一個空的雜湊):
array.reduce({}) do |hash, element|
    hash.update(element => true)
end
 
# 再考慮一個場景:我們需要從一個儲存使用者的陣列中篩選出那些年齡大於或等於 21 歲的人群,之後我們希望將這個使用者陣列轉換成一個姓名陣列
# 在沒有 reduce 的時候,你可能會這樣寫:
users.select {|u| u.age >= 21}.map(&:name)
# 上面這樣做當然可以,但並不高效,原因在於我們使用上面的語句時對陣列進行了多次遍歷
# 第一次是通過 select 篩選出了年齡大於或等於 21 歲的人,第二次則還需要對映成只包含名字的新陣列
# 如果我們使用 reduce 則無需建立或遍歷多個陣列:
users.reduce([]) do |names, user|
    names << user.name if user.age >= 21
    names
end

引入 Enumerable 模組的類會得到很多有用的例項方法,它們可用於對物件的集合進行過濾、遍歷和轉化。其中最為常用的應該是 map 和 select 方法,這些方法是如此強大以至於在幾乎所有的 Ruby 程式中你都能見到它們的影子。

像陣列和雜湊這樣的集合類幾乎已經是每個 Ruby 程式不可或缺的了,如果你還不熟悉 Enumberable 模組中定義的方法,你可能已經自己寫了相當多的 Enumberable 模組已經具備的方法,知識你還不知道而已。

Enumberable 模組

戳開 Array 的原始碼你能看到 include Enumberable 的字樣(引入的類必須實現 each 方法不然報錯),我們來簡單闡述一下 Enumberable API:

irb> [1, 2, 3].map {|n| n + 1}
---> [2, 3, 4]
irb> %w[a l p h a b e t].sort
---> ["a", "a", "b", "e", "h", "l", "p", "t"]
irb> [21, 42, 84].first
---> 21

上面的程式碼中:

  1. 首先,我們使用了流行的 map 方法遍歷每個元素,並將每個元素 +1 處理,然後返回新的陣列;
  2. 其次,我們使用了 sort 方法對陣列的元素進行排序,排序採用了 ASCII 字母排序
  3. 最後,我們使用了查詢方法 select 返回陣列的第一個元素

reduce 方法到底幹了什麼?它為什麼這麼特別?在函數語言程式設計的範疇中,它是一個可以將一個資料結構轉換成另一種結構的摺疊函式。

讓我們先從巨集觀的角度來看摺疊函式,當使用如 reduce 這樣的摺疊函式時你需要了解如下三部分:

  • 列舉的物件是 reduce 訊息的接受者。某種程度上這是你想轉換的原始集合。顯然,它的類必須引入 Enumberable 模組,否則你無法對它呼叫 reduce 方法;
  • 塊會被源集合中的每個元素呼叫一次,和 each 方法呼叫塊的方式類似。但和 each 不同的是,傳入 reduce 方法的塊必須產生一個返回值。這個返回值代表了通過當前元素最終摺疊生成的資料結構。我們將會通過一些例子來鞏固這一知識點。
  • 一個代表了目標資料結構起始值的物件,被稱為累加器。每一次塊的呼叫都會接受當前的累加器值並返回新的累加器值。在所有元素都被摺疊進累加器後,它的最終結構也就是 reduce 的返回值。

此時瞭解了這三部分你可以回頭再去看一看程式碼。

試著回想一下上一次使用 each 的場景,reduce 能夠幫助你改善類似下面這樣的模式:

hash = {}
 
array.each do |element|
    hash[element] = true
end

第 20 條:考慮使用預設雜湊值

我確定你是一個曾經在塊的語法上徘徊許久的 Ruby 程式設計師,那麼請告訴我,下面這樣的模式在程式碼中出現的頻率是多少?

def frequency (array)
    array.reduce({}) do |hash, element|
        hash[element] ||= 0 # Make sure the key exists.
        hash[element] += 1  # Then increment it.
        hash                # Return the hash to reduce.
    end
end

這裡特地使用了 "||=" 操作符以確保在修改雜湊的值時它是被賦過值的。這樣做的目的其實也就是確保雜湊能有一個預設值,我們可以有更好的替代方案:

def frequency (array)
    array.reduce(Hash.new(0)) do |hash, element|
        hash[element] += 1  # Then increment it.
        hash                # Return the hash to reduce.
    end
end

看上去還真是那麼一回事兒,但是小心,這裡埋藏著一個隱蔽的關於雜湊的陷阱。

# 先來看一下這個 IRB 會話:
irb> h = Hash.new(42)
irb> h[:missing_key]
---> 42、
irb> h.keys # Hash is still empty!
---> []
irb> h[:missing_key] += 1
---> 43
irb> h.keys # Ah, there you are.
---> [:missing_key]
 
# 注意,當訪問不存在的鍵時會返回預設值,但這不會修改雜湊物件。
# 使用 "+=" 操作符的確會像你想象中那般更新雜湊,但並不明確,回顧一下 "+=" 操作符會展開成什麼可能會很有幫助:
# Short version:
hash[key] += 1
 
# Expands to:
hash[key] = hash[key] + 1
 
# 現在賦值的過程就很明確了,先取得預設值再進行 +1 的操作,最終將其返回的結果以同樣的鍵名存入雜湊
# 我們並沒有以任何方式改變預設值,當然,上面一段程式碼的預設值是數字型別,它是不能修改的
# 但是如果我們使用一個可以修改的值作為預設值並在之後使用了它情況將會變得更加有趣:
irb> h = Hash.new([])
irb> h[:missing_key]
---> []
irb> h[:missing_key] << "Hey there!"
---> ["Hey there!"]
irb> h.keys # Wait for it...
---> []
irb> h[:missing_key]
---> ["Hey there!"]
 
# 看到上面關於 "<<" 的小騙局了嗎?我從沒有改變雜湊物件,當我插入一個元素之後,雜湊並麼有改變,但是預設值改變了
# 這也是 keys 方法提示這個雜湊是空但是訪問不存在的鍵時卻反悔了最近修改的值的原因
# 如果你真想插入一個元素並設定一個鍵,你需要更深入的研究,但另一個不明顯的副作用正等著你:
irb> h = Hash.new([])
irb> h[:weekdays] = h[:weekdays] << "Monday"
irb> h[:months] = h[:months] << "Januray"
irb> h.keys
---> [:weekdays, :months]
irb> h[:weekdays]
---> ["Monday", "January"]
irb> h.default
---> ["Monday", "Januray"]
 
# 兩個鍵共享了同一個預設陣列,多數情況你並不想這麼做
# 我們真正想要的是當我們訪問不存在的鍵時能返回一個全新的陣列
# 如果給 Hash::new 一個塊,當需要預設值時這個塊就會被呼叫,並友好地返回一個新建立的陣列:
irb> h = Hash.new{[]}
irb> h[:weekdays] = h[:weekdays] << "Monday"
---> ["Monday"]
irb> h[:months] = h[:months] << "Januray"
---> ["Januray"]
irb> h[:weekdays]
---> ["Monday"]
 
 
# 這樣好多了,但我們還可以往前一步。
# 傳給 Hash::new 的塊可以有選擇地接受兩個引數:雜湊本身和將要訪問的鍵
# 這意味著我們如果想去改變雜湊也是可的,那麼當訪問一個不存在的鍵時,為什麼不將其對應的值設定為一個新的空陣列呢?
irb> h = Hash.new{|hash, key| hash[key] = []}
irb> h[:weekdays] << "Monday"
irb> h[:holidays]
---> []
irb> h.keys
---> [:weekdays, :holidays]
 
# 你可能發現上面這樣的技巧存在著重要的不足:每當訪問不存在的鍵時,塊不僅會在雜湊中建立新實體,同時還會建立一個新的陣列
# 重申一遍:訪問一個不存在的鍵會將這個鍵存入雜湊,這暴露了預設值存在的通用問題:
# 正確的檢查一個雜湊是否包含某個鍵的方式是使用 hash_key? 方法或使用它的別名,但是深感內疚的是通常情況下預設值是 nil:
if hash[key]
    ...
end
 
# 如果一個雜湊的預設值不是 nil 或者 false,這個條件判斷會一直成功:將雜湊的預設值設定成非 nil 可能會使程式變得不安全
# 另外還要提醒的是:通過獲取其值來檢查雜湊某個鍵存在與否是草率的,其結果也可能和你所預期的不同
# 另一種處理預設值的方式,某些時候也是最好的方式,就是使用 Hash#fetch 方法
# 該方法的第一個引數是你希望從雜湊中查詢的鍵,但是 fetch 方法可以接受一個可選的第二個引數
# 如果指定的 key 在當前的雜湊中找不到,那麼取而代之,fetch 的第二個引數會返回
# 如果你省略了第二個引數,在你試圖獲取一個雜湊中不存在的鍵時,fetch 方法會丟擲一個異常
# 相比於對整個雜湊設定預設值,這種方式更加安全
irb> h = {}
irb> h[:weekdays] = h.fetch(:weekdays, []) << "Monday"
---> ["Monday"]
irb> h.fetch(:missing_key)
keyErro: key not found: :missing_key

所以看過上面的程式碼框隱藏的內容後你會發現:

  1. 如果某段程式碼在接受雜湊的非法鍵時會返回 nil,不要為傳入該方法的雜湊使用預設值
  2. 相比使用預設值,有些時候用 Hash#fetch 方法能更加安全

第 21 條:對集合優先使用委託而非繼承

這一條也可以被命名為“對於核心類,優先使用委託而非繼承”,因為它同樣適用於 Ruby 的所有核心類。

Ruby 的所有核心類都是通過 C語言 來實現的,指出這點是因為某些類的例項方法並沒有考慮到子類,比如 Array#reverse 方法,它會返回一個新的陣列而不是改變接受者。

猜猜如果你繼承了 Array 類並呼叫了子類的 reverse 方法後會發生什麼?

# 是的,LikeArray#reverse 返回了 Array 例項而不是 LikeArray 例項
# 但你不應該去責備 Array 類,在文件中有寫的很明白會返回一個新的例項,所以達不到你的預期是很自然的
irb> class LikeArray < Array; end
irb> x = LikeArray.new([1, 2, 3])
---> [1, 2, 3]
irb> y = x.reverse
---> [3, 2, 1]
irb> y.class
---> Array

當然還不止這些,集合上的許多其他例項方法也是這樣,整合比較操作符就更糟糕了。

比如,它們允許子類的例項和父類的例項相比較,這說得通嘛?

irb> LikeArray.new([1, 2, 3]) == [1, 2, 3,]
---> true

繼承並不是 Ruby 的最佳選擇,從核心的集合類中繼承更是毫無道理的,替代方法就是使用“委託”。

讓我們來編寫一個基於雜湊但有一個重要不同的類,這個類在訪問不存在的鍵時會丟擲一個異常。

實現它有很多不同的方式,但編寫一個新類讓我們可以簡單的重用同一個實現。

與繼承 Hash 類後為保證正確而到處修修補補不同,我們這一次採用委託。我們只需要一個例項變數 @hash,它會替我們幹所有的重活:

# 在 Ruby 中實現委託的方式有很多,Forwardable 模組讓使用委託的過程非常容易
# 它將一個存有要代理的方法的連結串列繫結到一個例項變數上,它是標準庫的一部分(不是核心庫),這也是需要顯式引入的原因
require('forwardable')
class RaisingHash
    extend(Forwardable)
    include(Enumerbale)
    def_delegators(:@hash, :[], :[]=, :delete, :each,
                           :keys, :values, :length,
                           :empty?, :hash_key?)
end

(更多的探索在書上.這裡只是簡單給一下結論.感興趣的童鞋再去看看吧!)

所以要點回顧一下:

  • 對集合優先使用委託而非繼承
  • 不要忘記編寫用來複制委託目標的 initialize_copy 方法
  • 編寫 freeze、taint 以及 untaint 方法時,先傳遞資訊給委託目標,之後呼叫 super 方法。

第四章:異常

第 22 條:使用定製的異常而不是丟擲字串

  • 避免使用字串作為異常,它們會被轉換成原生的 RuntimeError 物件。取而代之,建立一個定製的異常類
  • 定製的異常類應該繼承自 StandardError,且類名應該以 "Error" 結尾
  • 當為一個工程建立了不止一個異常類時,從建立一個繼承自 StandardError 的基類開始。其他的異常類應該繼承自該定製的基類
  • 如果你對你的定製異常類編寫了 initialize 方法,務必確保其呼叫了 super 方法,最好在呼叫時以錯誤資訊作為引數
  • 在 initialize 方法中設定錯誤資訊時,請牢記:如果在 raise 方法中再度設定錯誤資訊會覆蓋原本在 initialize 中設定的那一條
class TemperatureError < StandardError
    attr_reader(:temperature)
 
    def initialize(temperature)
        @temperature = temperature
        super("invalid temperature: #@temperature")
    end
end

第 23 條:捕獲可能的最具體的異常

  • 只捕獲那些你知道如何恢復的異常
  • 當捕獲異常時,首先處理最特殊的型別。在異常的繼承關係中位置越高的,越應該排在 rescue 鏈的後面
  • 避免捕獲如 StandardError 這樣的通用異常。如果你已經這麼做了,就應該想想你真正想做的是不是可以通過 ensure 語句來實現
  • 在異常發生的情況下,從 resuce 語句中丟擲的異常將會替換當前異常並離開當前的作用域

第 24 條:通過塊和 ensure 管理資源

  • 通過 ensure 語句來釋放任何已獲得的資源
  • 通過在類方法上使用塊和 ensure 語句將資源管理的邏輯抽離出來
  • 確保 ensure 語句中使用的變數已經被初始化過了

第 25 條:通過臨近的 end 退出 ensure 語句

  • 避免在 ensure 語句中顯式使用 return 語句,這意味著方法體記憶體在著某些錯誤的邏輯
  • 同樣,不要在 ensure 語句中直接使用 throw,你應該將 throw 放在方法主體內
  • 當執行迭代時,不要在 ensure 語句中執行 next 或 break。仔細想想在迭代內到底需不需要 begin 塊。將關係反轉或許更加合理,就是將迭代放在 begin 塊中
  • 一般來說,不要再 ensure 語句中改變控制流,在 rescue 語句中完成這樣的工作,你的意圖會更加清晰

第 26 條:限制 retry 次數,改變重試頻率並記錄異常資訊

  • 永遠不要無條件 retry,要把它看做程式碼中的隱式迴圈;在程式碼塊的外圍定義重試次數,當超出最大重試次數時重新丟擲異常
  • retry 時記錄具有審計作用的異常資訊,如果重試有問題的程式碼解決不了問題,需要追根溯源地去了解異常是如何發生的
  • 當在 retry 之前使用延時時,需要考慮增加延時避免加劇問題

第 27 條:throw 比 raise 更適合用來跳出作用域

  • 在複雜的流程控制中,可以考慮使用 throw 和 raise,這種方法一個額外的好處是可以把一個物件傳遞到上層呼叫棧並作為 catch 的最終返回值
  • 儘量使用簡單的方法來控制程式結果,可以通過方法呼叫和 return 重寫 catch 和 throw

第五章:超程式設計

第 28 條:熟悉 Ruby 模組和類的鉤子方法

  • 所有的鉤子方法都需要被定義為單例方法
  • 新增、刪除、取消定義方法的鉤子方法引數是方法名,而不是類名,如果需要,使用 self 去獲取類的資訊
  • 定義 singleton_method_added 會出發自身
  • 不要覆蓋 extend_object、append_features 和 prepend_features 方法,使用 extended、included 和 prepended 替代

第 29 條:在類的鉤子方法中執行 super 方法

  • 在類的鉤子方法中執行 super 方法

第 30 條:推薦使用 define_method 而非 method_missing

  • define_method 優於 method_missing
  • 如果必須使用 method_missing,最好也定義 respond_to_missing? 方法

第 31 條:瞭解不同型別的 eval 間的差異

  • 使用 instance_eval 和 instance_exec 定義的單例方法
  • class_eval、module_eval、class_exec 和 module_exec 方法只可以被模組或者方法使用。通過這些定義的方法都是例項方法

第 32 條:慎用猴子補丁

  • 儘管 refinement 已經不再是實驗性的功能,它仍然有可能被修改得更加成熟
  • 在不同的語法作用域,在使用 refinement 之前必須先啟用它

第 33 條:使用別名鏈執行被修改的方法

  • 在設定別名鏈時,需要確保別名是獨一無二的
  • 必要的時候要考慮提供一個撤銷別名鏈的方法

第 34 條:支援多種 Proc 引數數量

  • 與弱 Proc 物件不同,在引數數量不匹配時,強 Proc 物件會丟擲 ArgumentError 異常
  • 可以使用 Proc#arity 方法得到 Proc 期望的引數數量,如果返回的是正數,則意味著有多少引數是必須的。如果返回的是負數,則意味著 Proc 有些引數是可選的,可以通過 "~" 來得到有多少是必須引數

第 35 條:使用模組前置時請謹慎思考

  • prepend 方法在使用時對類體系機構的影響是:它將模組插入到接受者之前。這和 include 方法有很大不同:include 則是將模組插入到接受者和其超類之間
  • 與 included 和 extended 模組鉤子一樣,前置模組也會出發 prepended 鉤子

第六章:測試

第 36 條:熟悉單元測試工具 MiniTest

  • 測試方法需要以 "test_" 作為字首
  • 簡短的測試更容易理解,也更容易維護
  • 使用合適的斷言方法生成更易讀的出錯資訊
  • 斷言(Assertion)和反演(refutation)的文件在 MiniTest::Assertions 中

第 37 條:熟悉 MiniTest 的需求測試

  • 使用 describe 方法建立測試類,使用 it 定義測試用例
  • 雖然在需求說明測試中,斷言仍然可用,但是更推薦使用注入到 Object 中的期望方法
  • 在 MiniTest::Expectations 模組中,可以找到關於期望方法更詳細的文件

第 38 條:使用 Mock 模擬特定物件

  • 使用 Mock 來隔離外部系統的不穩定因素
  • Mock 或者替換沒有被測試過得方法,有可能會讓這些被 Mock 的程式碼在生產環境中出現問題
  • 請確保在測試方法程式碼的最後呼叫了 MiniTest::Mock#verity 方法

第 39 條:力爭程式碼被有效測試過

  • 使用模糊測試和屬性測試工具,幫助測試程式碼的快樂路徑和異常路徑。
  • 測試覆蓋率工具會給你一種虛假的安全感,因為被執行過的程式碼不代表這行程式碼是正確的
  • 在編寫特性的同時就加上測試,會讓測試容易得多
  • 在你開始尋找導致 bug 的根本原因之前,先寫一個針對該 bug 的測試
    儘可能多地自動化你的測試

第七章:工具與庫

第 40 條:學會使用 Ruby 文件

  • ri 工具用來讀取文件,rdoc 工具用來生成文件
  • 使用命令列選項 "-d doc" 來為 RI 工具制定在 "doc" 路徑下查詢文件
    執行 rdoc 時,後面跟上命令列選項 "-f ri" 來為 RI 工具生成文件。另外,用 "-f darkfish" 來生成 HTML 格式的文件(自己測試過..對於大型專案生成的 HTML 文件不是很友好..)
  • 完整的 RDoc 文件可以在 RDoc::Markup 類中找到(使用 RI 查閱)

第 41 條:認識 IRB 的高階特性

  • 在 IRB::ExtendCommandBundle 模組,或者一個會被引入 IRB::ExtendCommandBundle 中的模組中自定義 IRB 命令
  • 利用下劃線變數("")來獲取上一個表示式的結果(例如,last_elem =
  • irb 命令可以用來建立一個新的會話,並將當前的評估上下文改變成任意物件
    考慮 Pry gem 作為 IRB 的替代品

第 42 條:用 Bundler 管理 Gem 依賴

  • 在載入完 Bundler 之後,使用 Bundler.require 會犧牲一點點靈活性,但是可以載入 Gemfile 中所有的 gem
  • 當開發應用時,在 Gemfile 中列出所有的 gem,然後把 Gemfile.lock 新增到版本控制系統中
  • 當打包 RubyGem,在 gem 規格檔案中列出 gem 所有依賴,但不要把 Gemfile.lock 新增到你的版本系統中

第 43 條:為 Gem 依賴設定版本上限

  • 忽略掉版本上限需求相當於你說了你可以支援未來所有的版本
  • 相對於悲觀版本操作符,更加傾向於使用明確的版本範圍
  • 當公佈釋出一個 gem 時,指明依賴包的版本限制要求,在安全的範圍內越寬越好,上限可以擴充套件到下一個主要釋出版本之前

第八章:記憶體管理與效能

第 44 條:熟悉 Ruby 的垃圾收集器

擴充套件閱讀:
Ruby GC 自述 · Ruby China 
Ruby 2.1:RGenGC

垃圾收集器是個複雜的軟體工程。從很高的層次看,Ruby 垃圾收集器使用一種被稱為 標記-清除(mark and sweep)的過程。(熟悉 Java 的童鞋應該會感到一絲熟悉)

《Effective-Ruby》讀書筆記

首先,遍歷物件圖,能被訪問到的物件會被標記為存活的。接著,任何未在第一階段標記過的物件會被視為垃圾並被清楚,之後將記憶體釋放回 Ruby 或作業系統。

遍歷整個物件圖並標記可訪問物件的開銷太大。Ruby 2.1 通過新的分代式垃圾收集器對效能進行了優化。物件被分為兩類,年輕代和年老代。

分代式垃圾收集器基於一個前提:大多數物件的生存時間都不會很長。如果我們知道了一個物件可以存活很久,那麼就可以優化標記階段,自動將這些老的物件標記為可訪問,而不需要遍歷整個物件圖。

如果年輕代物件在第一階段的標記中存活了下來,那麼 Ruby 的分代式垃圾收集器就把它們提升為年老代。也就是說,他們依然是可訪問的。

在年輕代物件和年老代物件的概念下,標記階段可以分為兩種模式:主要標記階段(major)和次要標記階段(minor)。

在主要標記階段,所有的物件(無論新老)都會被標記。該模式下,垃圾收集器不區分新老兩代,所以開銷很大。

次要標記階段,僅僅考慮年輕代物件,並自動標記年老代物件,而不檢查能否被訪問。這意味著年老代物件只會在主要標記階段之後才會被清除。除非達到了一些閾值,保證整個過程全部作為主要標記之外,垃圾收集器傾向於使用次要標記。

垃圾收集器的清除階段也有優化機制,分為兩種模式:即使模式和懶惰模式。

在即使模式中,垃圾收集器會清除所有的未標記的物件。如果有很多物件需要被釋放,那這種模式開銷就很大。

因此,清除階段還支援懶惰模式,它將嘗試釋放盡可能少的物件。

每當 Ruby 中建立一個新物件時,它可能嘗試觸發一次懶惰清除階段,去釋放一些空間。為了更好的理解這一點,我們需要看看垃圾收集器如何管理儲存物件的記憶體。(簡單概括:垃圾收集器通過維護一個由頁組成的堆來管理記憶體。頁又由槽組成。每個槽儲存一個物件。)

《Effective-Ruby》讀書筆記

我們開啟一個新的 IRB 會話,執行如下命令:

`IRB``> ``GC``.stat`

`---> {``:count``=>``9``, ``:heap_length``=>``126``, ...}`

GC::stat 方法會返回一個雜湊,包含垃圾收集器相關的所有資訊。請記住,該雜湊中的鍵以及它們對應垃圾收集器的意義可能在下一個版本發生變化。

好了,讓我們來看一些有趣的鍵:

鍵名 說明
count 垃圾收集器執行的總次數
major_gc_count 主要模式下的執行次數
minor_gc_count 次要模式下的執行次數
total_allocated_object 程式開始時分配的物件總數
total_freed_object Ruby 釋放的物件總數。與上面之差表示存活物件的數量,這可以通過 heap_live_slot 鍵來計算
heap_length 當前堆中的頁數
heap_live_slot 和 heap_free_slot 表示全部頁中被使用的槽數和未被使用的槽數
old_object 年老代的物件數量,在次要標記階段不會被處理。年輕代的物件數量可以用 heap_live_slot 減去 old_object 來獲得

該雜湊中還有幾個有趣的數字,但在介紹之前,讓我們來學習垃圾收集器的最後一個要點。還記得物件是存在槽中的吧。Ruby 2.1 的槽大小為 40 位元組,然而並不是所有的物件都是這麼大。

比如,一個包含 255 個位元組的字串物件。如果物件的大小超過了槽的大小,Ruby 就會額外向作業系統申請一塊記憶體。

當物件被銷燬,槽被釋放後,Ruby 會把多餘的記憶體還給作業系統。現在讓我們看看 GC::stat 雜湊中的這些鍵:

鍵名 說明
malloc_increase 所有超過槽大小的物件所佔用的總位元數
malloc_limit 閾值。如果 malloc_increase 的大小超過了 malloc_limit,垃圾收集器就會在次要模式下執行。一個 Ruby 應用程式的生命週期裡,malloc_limit 是被動調整的。它的大小是當前 malloc_increase 的大小乘以調節因子,這個因子預設是 1.4。你可以通過環境變數 RUBY_GC_MALLOC_LIMIT_GROWTH_FACTOR 來設定這個因子
oldmalloc_increase 和 oldmalloc_limit 是上面兩個對應的年老代值。如果 oldmalloc_increase 的大小超過了 oldmalloc_limit,垃圾收集器就會在主要模式下執行。oldmalloc_limit 的調節因子more是 1.2。通過環境變數 RUBY_GC_OLDMALLOC_LIMIT_GROWTH_FACTOR 可以設定它

作為最後一部分,讓我們來看針對特定應用程式進行垃圾收集器調優的環境變數。

在下一個版本的 Ruby 中,GC::stat 雜湊中的值對應的環境變數可能會發生變化。好訊息是 Ruby 2.2 將支援 3 個分代,Ruby 2.1 只支援兩個。這可能會影響到上述變數的設定。

有關垃圾收集器調優的環境變數的權威資訊儲存在 "gc.c" 檔案中,是 Ruby 源程式的一部分。

下面是 Ruby 2.1 中用於調優的環境變數(僅供參考):

環境變數名 說明
RUBY_GC_HEAP_INIT_SLOTS 初始槽的數量。預設為 10k,增加它的值可以讓你的應用程式啟動時減少垃圾收集器的工作效率
RUBY_GC_HEAP_FREE_SLOTS 垃圾收集器執行後,空槽數量的最小值。如果空槽的數量小於這個值,那麼 Ruby 會申請額外的頁,並放入堆中。預設值是 4096
RUBY_GC_HEAP_GROWTH_FACTOR 當需要額外的槽時,用於計算需要增加的頁數的乘數因子。用已使用的頁數乘以這個因子算出還需要增加的頁數、預設值是 1.8
RUBY_GC_HEAP_GROWTH_MAX_SLOTS 一次新增到堆中的最大槽數。預設值是0,表示沒有限制。
RUBY_GC_HEAP_OLDOBJECT_LIMIT_FACTOR 用於計算出發主要模式垃圾收集器的門限值的乘數因子。門限由前一次主要清除後年老代物件數量乘以該因子得到。該門限與當前年老代物件數量成比例。預設值是 2.0。這意味著如果年老代物件在上次主要標記階段過後的數量翻倍的話,新一輪的主要標記過程將被出發。
RUBY_GC_MALLOC_LIMIT GC::stat 雜湊中 malloc_limit 的最小值。如果 malloc_increase 超過了 malloc_limit 的值,那麼次要模式垃圾收集器就會執行一次。該設定用於確保 malloc_increase 不會小於特定值。它的預設值是 16 777 216(16MB)
RUBY_GC_MALOC_LIMIT_MAX 與 RUBY_GC_MALLOC_LIMIT 相反的值,這個設定保證 malloc_limit 不會變得太高。它可以被設定成 0 來取消上限。預設值是 33 554 432(32MB)
RUBY_GC_MALLOC_LIMIT_GROWTH_FACTOR 控制 malloc_limit 如何增長的乘數因子。新的 malloc_limit 值由當前 malloc_limit 值乘以這個因子來獲得,預設值為 1.4
RUBY_GC_OLDMALLOC_LIMIT 年老代對應的 RUBY_GC_MALLOC_LIMIT 值。預設值是 16 777 216(16MB)
RUBY_GC_OLDMALLOC_LIMIT_MAX 年老代對應的 RUBY_GC_MALLOC_LIMIT_MAX 值。預設值是 134 217 728(128MB)
RUBY_GC_OLDMALLOC_LIMIT_GROWTH_FACTOR 年老代對應的 RUBY_GC_MALLOC_LIMIT_GROWTH_FACTOR 值。預設值是 1.2

第 45 條:用 Finalizer 構建資源安全網

  • 最好使用 ensure 子句來保護有限的資源。
  • 如果必須要在 ensure 子句外報錄一個資源(resource),那麼就給它建立一個 finalizer(終結方法)
  • 永遠不要再這樣一個繫結中建立 finalizer Proc,該繫結引用了一個註定會被銷燬的物件,這會造成垃圾收集器無法釋放該物件
  • 記住,finalizer 可能在一個物件銷燬後以及程式終止前的任何時間被呼叫

第 46 條:認識 Ruby 效能分析工具

  • 在修改效能差的程式碼之前,先使用效能分析工具收集效能相關的資訊。
  • 在 ruby-prof gem 和 Ruby 自帶的標準 profile 庫之間,選擇前者,因為前者更快而且可以提供多種不同的報告。
  • 如果使用 Ruby 2.1 或者更新的版本,應該考慮使用 stackprof gem 和 memory_profiler gem。

第 47 條:避免在迴圈中使用物件字面量

  • 將迴圈中的不會變化的物件字面量變成常量。
  • 在 Ruby 2.1 及更高的版本中凍結字串字面量,相當於把它作為常量,可以被整個執行程式共享。

第 48 條:考慮記憶化大開銷計算

  • 考慮提供一個方法通過將快取的變數職位 nil 來重置記憶化。
  • 確保時鐘認真考慮過這些由記憶化而跳過副作用所導致的後果。
  • 如果不希望呼叫者修改快取的變數,那應該考慮讓被記憶化的方法返回凍結物件。
  • 先用工具分析程式的效能,再考慮是否需要記憶化。

總結

週末學習了兩天才勉強看完了一遍,對於 Ruby 語言的有一些高階特性還是比較吃力的,需要自己反反覆覆的看才能理解一二。不過好在也是有收穫吧,沒有白費自己的努力,特地總結一個精簡版方便後面的童鞋學習。

另外這篇文章最開始是使用公司的文件空間建立的,發現 Markdown 雖然精簡易於使用,但是功能性上比一些成熟的寫文工具要差上很多,就比如對程式碼的支援吧,用公司的程式碼塊還支援自定義標題、顯示行號、是否能縮放、主題等一系列自定義的東西,寫出來的東西也更加友好...


按照慣例黏一個尾巴:

歡迎轉載,轉載請註明出處!
簡書ID:@我沒有三顆心臟
github:wmyskxz
歡迎關注公眾微訊號:wmyskxz
分享自己的學習 & 學習資料 & 生活
想要交流的朋友也可以加qq群:3382693