天天看點

《深入了解Scala》——第2章,第2.3節優先選擇不變性

本節書摘來自異步社群《深入了解scala》一書中的第2章,第2.3節優先選擇不變性,作者[美]josh suereth,更多章節内容可以通路雲栖社群“異步社群”公衆号檢視

2.3 優先選擇不變性

深入了解scala

程式設計中的不變性指對象一旦建立後就不再改變狀态。這是函數式程式設計的基石之一,也是jvm上的面向對象程式設計的推薦實踐之一。scala也不例外,在設計上優先選擇不變性,在很多場景中把不變性作為預設設定。對此,你可能一下子會不适應。本節中,我們将學到不變性對于判等問題和并發程式設計能提供什麼幫助。

scala裡首先要明白的是不變對象和不變引用(immutable referene)的差別。scala裡的所有變量都是指向對象的引用。把變量聲明為val意味着它是個不變“引用”。所有的方法參數都是不變引用,類參數預設為不可變引用。建立可變引用的唯一方法是使用var文法。引用的不變性不影響它指向的對象是否是不可變的。你可以建立一個指向不變對象的可變引用,反之亦然。這意味着,重要的是知道對象本身是不變的還是可變的。

對象是否有不變性限制不是那麼顯然的事。一般來說如果文檔指出一個對象是不可變的,那麼可以安全地假定它就是不可變的,否則就要小心。scala标準庫裡的集合類庫把可變還是不變描述得很清楚,它有并列的兩個包,一個放不變類,一個放可變類。

scala裡不變性很重要,因為它有助于程式員推理代碼。如果一個對象的狀态不改變,那程式員找到對象建立的地方就可以确定其狀态。這也可以簡化那些基于對象狀态的方法,這個好處在定義判等或寫并發程式時尤其明顯。

2.3.1 判等

優先選擇不變性的關鍵原因之一在于簡化對象判等。如果一個對象在生命周期中不改變狀态,你就能為該類型對象建立一個既深又準的equals實作。在建立對象的散列(hash)函數時這一點也很關鍵。

散列函數傳回對象的簡化表現形式,通常是個整數,可以用來快速地确定一個對象。好的散列函數和equals方法一般是成對的,即使不通過代碼展現,也會以某種邏輯定義的方式展現。如果一個對象的生命周期中改變了狀态,那就會毀掉為該對象生成的散列代碼。這又會連帶着影響對象的判等測試。我們來看個非常簡單的例子:一個二維幾何點類。

《深入了解Scala》——第2章,第2.3節優先選擇不變性

point2d類非常簡單,它包含x和y值,對應x和y坐标軸上的位置。它還有個move方法,用來在平面上移動點。想象我們要在這個二維平面上的特定點上貼個标簽,每個标簽就隻用一個字元串表示。要實作這功能,我們會考慮定義一個point2d到字元串的映射。出于性能考慮,我們打算寫個散列函數并用hashmap來存放這個映射。我們來試試可行的最簡單方法:直接對x和y變量做散列。

《深入了解Scala》——第2章,第2.3節優先選擇不變性

一開始代碼執行結果看上去完全符合預期。但到我們試圖構造一個與點x的值一樣的新點對象時就不對了。這個新的點對象的散列值應該對應到map的同一塊,然而判等檢查卻是否定的。這是因為我們沒有為之建立自己的判等方法(equals)。預設情況下scala用對象位置判等法和散列,而我們隻覆寫了散列代碼(hashcode)方法。對象位置判等法用對象在記憶體中的位置來作為判等的唯一因素。在我們的point2例子裡,對象位置判等可能是判等的一種便捷方法,但是我們也可以用x和y的位置來判等。

你可能已經注意到point2類覆寫了hashcode方法,但我對x執行個體調用的卻是##方法。這是scala的一個規約。為了與java相容,scala同樣使用在java.lang.object裡定義的equals和hashcode方法。但是scala把基礎資料類型也抽象成了完整的對象。編譯器會在需要的時候為你自動打包和拆包基礎資料類型。這些類基礎資料類型(primitive-like)的對象都是scala.anyval的子類,而那些繼承自java.lang.objec的“标準”對象則都是scala.anyref的子類。scala.anyref可以看作java.lang.object的别名。因為hashcode和equals方法隻在anyref中有定義(anyval裡沒有),是以scala就提供了可以同時用于anyref和anyval的##和==方法。

作者注:hashcode和equals應該總是成對實作。

equals和hashcode方法應該實作為如果x == y則x.## == y.##。

我們來實作自己的判等方法,看看結果會怎樣。

《深入了解Scala》——第2章,第2.3節優先選擇不變性

equals的實作看上去可能有點怪,不過我會在2.5.2節詳做解釋。目前我們注意看strictequals輔助方法直接比較x和y的值。意思是如果兩個點在同一位置,就認為它們是相等的。現在我們的equals和hashcode方法采用相同标準了,也就是x和y的值。我們再次把點x和點y放入hashmap,隻是這次我們準備移動點x,看看與點x綁定的标簽會發生什麼。

《深入了解Scala》——第2章,第2.3節優先選擇不變性

貼在點x上的标簽出什麼問題了?我們是在x為(1,1)的時候把它放進hashmap的,意味着其散列值為32。然後我們把x移到了(2,2),散列值變成了64。現在我們試圖查找x對應的标簽時,hashmap裡存放的是32,而我們卻用64去找。但是為什麼我們用新點z去找也找不到呢?z的散列值還是32啊。這是因為根據我們的規則,x和z不相等。你要知道,hashmap在插入值的時候使用散列值,但是當對象狀态變化時hashmap并不會更新。這意味着我們無法用基于散列的查找來找到x對應的标簽,但是我們在周遊map或者用周遊算法時還是能得到值:

《深入了解Scala》——第2章,第2.3節優先選擇不變性

如你所見,這種行為令人困擾,還會在調試的時候造成無盡的争議。是以,在實作判等的時候一般推薦確定如下的限制。

• 如果兩個對象相等,它們的散列值應該也相等。

• 一個對象的散列值在對象生命周期中不應該變化。

• 在把對象發送到另一個jvm時,應該用兩個jvm裡都有的屬性來判等。

如你所見,第二個限制意味着用來建立散列值的要素在對象生命周期裡不應該變化。最後一個限制則是說,對象的散列和equals方法應該盡量用其内部狀态來計算(而不依賴虛拟機裡的其他因素)。再跟第一個限制結合起來,你會發現唯一滿足這些要求的辦法就是使用不變對象。如果對象的狀态永遠不變,那用狀态來計算散列值或判等就是可以接受的。你可以把對象序列化到另個虛拟機,同時仍然保證其散列和判等的一緻性。

你或許會奇怪為什麼我要關心把對象發送到另一個jvm?我的軟體隻在一個jvm裡跑。甚至我的軟體可能是在移動裝置上跑的,資源是很緊張的。這種想法的問題在于把一個對象序列化到另一個jvm并非一定要是實時的。我們可能會把一些程式狀态儲存到磁盤,過會兒再讀回來。這跟發送對象到另一個jvm其實沒什麼差別。盡管你或許沒有通過網絡傳遞對象,但你實際上是在通過時間傳遞對象,從今天這個寫資料的jvm傳遞到明天啟動的讀資料的jvm。在這種情況下,保持一緻的散列值和判等實作是非常關鍵的。

最後一個限制使不變性成為必要條件了。去掉這個限制的話,其實也隻有以下兩種較簡單的辦法來滿足前兩個限制。

• 在計算散列值時隻使用對象的不可變狀态(不用可變的狀态)。

• 為散列計算和判等使用預設概念。

如你所見,這意味着對象裡的某些狀态必須是不可變的。把整個對象變成不可變實際上極大簡化了整個過程。不變性不僅簡化了對象判等,還簡化了對資料的并發通路。

2.3.2 并發

不變性能夠徹底地簡化對資料的并發通路。随着多核處理器的發展,程式越來越變得并行。無論哪種計算形式,在程式裡運作并發線程的需求都在增長。傳統上,這意味着使用創造性的方式對多線程共享的資料進行保護。通常使用某種形式的鎖來保護共享的可變資料。不變性有助于共享狀态同時減少對鎖的依賴。

加鎖必然要承擔一定的性能開銷。想要讀資料的線程必須在拿到鎖後才能讀。即使使用讀寫鎖(read-write lock)也可能造成問題,因為寫線程有可能比較慢,妨礙了讀線程去讀想要的資料。jvm上的jit有做一些優化來試圖避免不必要的鎖。一般來說,你希望你的軟體裡的鎖越少越好,但又必須足夠多,以便能夠做較多的并發。你設計代碼時越能避免鎖越好。我們做個案例分析—試試測量加鎖對一個算法的影響,然後看我們能不能設計個新的算法,減少加鎖的數量。

我們來建立個索引服務,讓我們能用鍵值來查找特定項。這服務同時允許使用者把新項加入索引中。我們預期查找值的使用者數量很多,加内容的使用者數量較少。這裡是初始接口:

《深入了解Scala》——第2章,第2.3節優先選擇不變性

服務由兩個方法構成。lookup方法根據key的索引查找值,insert方法插入新值。這服務基本上是個鍵值對的映射。我們用加鎖和可變hashmap來實作它。

《深入了解Scala》——第2章,第2.3節優先選擇不變性

這個類有三個成員,第一個是currentindex,指向我們用來存放資料的可變hashmap。lookup和insert方法都用synchronized塊包起來,表明對mutableservice自身做同步。你應該注意到了我們對mutableservice的所有操作都加了鎖。因為案例背景指出應用場景是lookup方法比insert方法調用頻繁得多,在這種場景下讀寫鎖可能有所幫助,但我們來看看怎麼能不用讀寫鎖而用不變性來達到目的。

我們把currentindex改成一個不可變hashmap,每次調用insert方法的時候覆寫原值。然後lookup方法就可以不加任何鎖了。我們來看以下内容。

《深入了解Scala》——第2章,第2.3節優先選擇不變性

首先要注意的是currentindex是個指向不變變量的可變引用。每次insert操作我們都會更新引用。第二個要注意的是我們沒把這個服務變成完全不可變的。我們唯一做的就是利用不可變hashmap減少了鎖的使用。這個簡單的改變能夠帶來運作時的極大提升。

我為這兩個類設定了簡單的微型性能測試套件。基本原理很簡單:我們建構一組任務向服務寫資料,另一組任務從索引讀資料。然後我們把兩組任務交錯送出給兩個線程的隊列去執行。我們對整個過程的速度做計時并記錄結果。下面是一些“最差場景”(worst case)的結果。

如圖2.2所示,y軸表示測試的執行時間。x軸對應于送出給線程池的插入/查找任務數。你會注意到(完成同樣數量的任務時)可變服務的執行時間增長快于不可變服務的執行時間。這個圖明顯地表現出額外的加鎖對性能有嚴重影響。然而,有人應該會注意到這種測試的執行時間波動可能會很大。由于并行計算的不确定性,可能另一次運作産生的圖上,不可變和可變服務的執行時間軌迹會幾乎相同。一般來說,可變服務慢于不變服務,但是我們不該僅憑一張圖或一次執行來判斷性能。是以圖2.3是另一次執行的圖,你可以看到,在某一次測試裡,可變服務得到上帝垂青,加鎖開銷極大降低。

圖2.2 不可變vs可變服務“最差場景”

《深入了解Scala》——第2章,第2.3節優先選擇不變性

在圖2.2裡你可以看到有一個測試案例執行時所有時機都配合得恰到好處,以至于可變服務在那一瞬間超過了不變服務的性能。盡管存在這種個别案例,一般情況下不變服務的性能好于可變服務。如果我們得出的結論也适用于真實世界的程式的話,就說明不變服務性能一般較優,而且也沒有随機争用降速(random contention slowdown)的問題。

最重要的事是要認識到不可變對象可以安全地在多個線程之間傳遞而不用擔心争用。能夠消除鎖以及鎖所帶來的各種潛在bug,能極大地提高代碼庫(codebase)的穩定性。再加上不變性可以提高代碼的可推理性,如我們在前文equals方法裡所見。我們應該努力在代碼庫裡保持不變性。

scala通過不變性減少了開發者在與不可變對象互動時必須得采用的保護措施,進而簡化了并發程式開發。除了不變性,scala還提供了option類,減少了開發者在處理null時需要采用的保護措施。

圖2.3 不可變vs可變服務“一次完美運作場景”

《深入了解Scala》——第2章,第2.3節優先選擇不變性