《Effective-Ruby》读书笔记
本篇是在我接觸了 Ruby 很短一段時間后有幸捧起的一本書,下面結合自己的一些思考,來輸出一下自己的讀書筆記
前言
學習一門新的編程語言通常需要經過兩個階段:
- 第一個階段是學習這門編程語言的語法和結構,如果我們具有其他編程語言的經驗,那么這個過程通常只需要很短的時間;
- 第二個階段是深入語言、學習語言風格,許多編程語言在解決常見的問題時都會使用獨特的方法,Ruby 也不例外。
《Effictive Ruby》就是一本致力于讓你在第二階段更加深入和全面的了解 Ruby,編寫出更具可讀性、可維護性代碼的書,下面我就著一些我認為的重點和自己的思考來進行一些精簡和說明
第一章:讓自己熟悉 Ruby
第 1 條:理解 Ruby 中的 True
- 每一門語言對于布爾類型的值都有自己的處理方式,在 Ruby 中,除了 false 和 nil,其他值都為真值,包括數字 0 值。
- 如果你需要區分 false 和 nil,可以使用 nil? 的方式或 “==“ 操作符并將 false 作為左操作對象。
第 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 ---> 0irb> 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#~ 等)
第 4 條:留神,常量是可變的
最開始接觸 Ruby 時,對于常量的認識大概可能就是由大寫字母加下劃線組成的標識符,例如 STDIN、RUBY_VERSION。不過這并不是故事的全部,事實上,由大寫字母開頭的任何標識符都是常量,包括 String 或 Array,來看看這個:
module DefaultsNOTWORKS = ["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 DefaultsNOTWORKS = ["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 DefaultsNETWORKS = ["192.168.1","192.168.2"].map(&:freeze).freeze end甚至,要達到防止常量被重新賦值的目的,我們可以凍結定義它的那個模塊:
module DefaultsTIMEOUT = 5 endDefaults.freeze第 5 條:留意運行時警告
- 使用命令行選項 ”-w“ 來運行 Ruby 解釋器以啟用編譯時和運行時的警告。設置環境變量 RUBYOPT 為 ”-w“ 也可以達到相同目的。
- 如果必須禁用運行時的警告,可以臨時將全局變量 $VERBOSE 設置為 nil。
第二章:類、對象和模塊
第 6 條:了解 Ruby 如何構建集成體系
讓我們直接從代碼入手吧:
class Persondef name...end endclass Customer < Person... end irb> customer = Customer.new ---> #<Customer>irb> customer.superclass ---> Personirb> customer.respond_to?(:name) ---> true上面的代碼幾乎就和你預想的那樣,當調用 customer 對象的 name 方法時,Customer 類會首先檢查自身是否有這個實例方法,沒有那么就繼續搜索。
順著集成體系向上找到了 Person 類,在該類中找到了該方法并將其執行。(如果 Person 類中沒有找到的話,Ruby 會繼續向上直到到達 BasicObject)
但是如果方法在查找過程中直到類樹的根節點仍然沒有找到匹配的辦法,那么它將重新從起點開始查找,不過這一次會查找 method_missing 方法。
下面我們開始讓事情變得更加有趣一點:
module ThingsWithNamesdef name...end endclass Personinclude(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 在幕后悄悄地做了一些事情。它創建了一個單例類并將它插入類體系中。這個匿名的不可見類被鏈向這個模塊,因此它們共享了實力方法和常量。
當每個模塊被類包含時,它會立即被插入集成體系中包含它的類的上方,以后進先出(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 方法時,應使用相同的規則
第 9 條:提防 Ruby 最棘手的解析
這是一條關于 Ruby 可能會戲弄你的另一條提醒,要點在于:Ruby 在對變量賦值和對 setter 方法調用時的解析是有區別的!直接看代碼吧:
# 這里把 initialize 方法體中的內容當做第 counter= 方法的調用也不是毫無道理 # 事實上 initialize 方法會創建一個新的局部變量 counter,并將其賦值為 0 # 這是因為 Ruby 在調用 setter 方法時要求存在一個顯式接受者 class Counterattr_accessor(:counter)def initializecounter = 0end... end# 你需要使用 self 充當這個接受者 class Counterattr_accessor(:counter)def initializeself.counter = 0end... end# 而在你調用非 setter 方法時,不需要顯式指定接受者 # 換句話說,不要使用不必要的 self,那會弄亂你的代碼: class Nameattr_accessor(:first, :last)def initialize (first, last)self.first = firstself.last = lastenddef fullself.first + " " + self.last # 這里沒有調用 setter 方法使用 self 多余了end end# 就像上面 full 方法里的注釋,應該把方法體內的內容改為 first + " " + last第 10 條:推薦使用 Struct 而非 Hash 存儲結構化數據
看代碼吧:
# 假設你要對一個保存了年度天氣數據的 CSV 文件進行解析并存儲 # 在 initialize 方法后,你會獲得一個固定格式的哈希數組,但是存在以下的問題: # 1.不能通過 getter 方法訪問其屬性,也不應該將這個哈希數組通過公共接口向外暴露,因為其中包含了實現細節 # 2.每次你想在類內部使用該哈希時,你不得不回頭來看 initialize 方法 # 因為你不知道CSV具體的對應是怎樣的,而且當類成熟情況可能還會發生變化 require('csv') class AnnualWeatherdef 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,}endend 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)endend end# Struct 類本身比你第一次使用時更加強大。除了屬性列表,Struct::new 方法還能接受一個可選的塊 # 也就是說,我們能在塊中定義實例方法和類方法。比如,我們定義一個返回平均每月平均溫度的 mean 方法: Reading = Struct.new(:date, :high, :low) dodef mean(high + low) / 2.0end end另外從其他地方看到了關于 Struct::new 的實踐
- 考慮使用 Struct.new, 它可以定義一些瑣碎的 accessors, constructor(構造函數) 和 comparison(比較) 操作。
- 考慮使用 Struct.new,它替你定義了那些瑣碎的存取器(accessors),構造器(constructor)以及比較操作符(comparison operators)。
- 要去 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 方法的超類
第 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 方法,它會被轉化成一個嵌套數組的集合
第 18 條:考慮使用集合高效檢查元素的包含性
(書上對于這一條建議的描述足足有 4 頁半,但其實可以看下面結論就ok,結尾有實例代碼)
- 考慮使用 Set 來高效地檢測元素的包含性
- 插入 Set 的對象必須也被當做哈希的鍵來用
- 使用 Set 之前要引入它
第 19 條:了解如何通過 reduce 方法折疊集合
盡管可能有點云里霧里,但還是考慮考慮先食用代碼吧:
# reduce 方法的參數是累加器的起始值,塊的目的是創建并返回一個適用于下一次塊迭代的累加器 # 如果原始集合為空,那么塊永遠也不會被執行,reduce 方法僅僅是簡單地返回累加器的初始值 # 要注意塊并沒有做任何賦值。這是因為在每個迭代后,reduce 丟棄上次迭代的累加器并保留了塊的返回值作為新的累加器 def sum (enum)enum.reduce(0) do |accumulator, element|accumulator + elementend 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 >= 21names 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上面的代碼中:
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所以看過上面的代碼框隱藏的內容后你會發現:
第 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 RaisingHashextend(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 中設置的那一條
第 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 的童鞋應該會感到一絲熟悉)
首先,遍歷對象圖,能被訪問到的對象會被標記為存活的。接著,任何未在第一階段標記過的對象會被視為垃圾并被清楚,之后將內存釋放回 Ruby 或操作系統。
遍歷整個對象圖并標記可訪問對象的開銷太大。Ruby 2.1 通過新的分代式垃圾收集器對性能進行了優化。對象被分為兩類,年輕代和年老代。
分代式垃圾收集器基于一個前提:大多數對象的生存時間都不會很長。如果我們知道了一個對象可以存活很久,那么就可以優化標記階段,自動將這些老的對象標記為可訪問,而不需要遍歷整個對象圖。
如果年輕代對象在第一階段的標記中存活了下來,那么 Ruby 的分代式垃圾收集器就把它們提升為年老代。也就是說,他們依然是可訪問的。
在年輕代對象和年老代對象的概念下,標記階段可以分為兩種模式:主要標記階段(major)和次要標記階段(minor)。
在主要標記階段,所有的對象(無論新老)都會被標記。該模式下,垃圾收集器不區分新老兩代,所以開銷很大。
次要標記階段,僅僅考慮年輕代對象,并自動標記年老代對象,而不檢查能否被訪問。這意味著年老代對象只會在主要標記階段之后才會被清除。除非達到了一些閾值,保證整個過程全部作為主要標記之外,垃圾收集器傾向于使用次要標記。
垃圾收集器的清除階段也有優化機制,分為兩種模式:即使模式和懶惰模式。
在即使模式中,垃圾收集器會清除所有的未標記的對象。如果有很多對象需要被釋放,那這種模式開銷就很大。
因此,清除階段還支持懶惰模式,它將嘗試釋放盡可能少的對象。
每當 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
總結
以上是生活随笔為你收集整理的《Effective-Ruby》读书笔记的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: spring使用@Async注解异步处理
- 下一篇: Java Web -【分页功能】详解