Clojure

值與變更:Clojure 對身分與狀態的方法

許多人從命令式語言接觸 Clojure,在面對 Clojure 的做事方法時,發現自己格格不入;而其他人則來自更函式的背景,並假設一旦離開 Clojure 的函式子集,他們將會面臨與 Java 中相同的狀態故事。本文旨在闡明 Clojure 對命令式和函式程式在建模世界時所面臨問題的方法。

命令式程式設計

命令式程式直接操作其世界(例如記憶體)。它建立在一個現在無法持續的單執行緒前提上 - 在你查看或變更世界時,世界會停止。你說「執行這個」,它就會發生,「變更那個」,它就會變更。命令式程式語言以執行這個/執行那個為導向,並變更記憶體位置。

即使在多執行緒之前,這也從來都不是個好主意。加入並行處理,你就會遇到一個真正的問題,因為「世界會停止」的前提根本不再成立,而且要恢復這個錯覺極為困難且容易出錯。多個參與者,每個參與者都表現得好像他們無所不能,必須設法避免破壞其他參與者的假設和效果。這需要互斥鎖和鎖定,為每個參與者劃出操作的區域,以及大量的額外負擔來傳播變更至共用記憶體,以便其他核心可以看到它們。它的運作效果並不好。

函式式程式設計

函式式程式設計採用更數學化的世界觀,並將程式視為函式,這些函式會取得特定值並產生其他值。函式式程式避開命令式程式的外部「效果」,因此變得更容易理解、推理和測試,因為函式的活動完全是本地的。在程式的一部分純粹是函式式的程度,並行處理不是問題,因為根本沒有變更需要協調。

工作模型和身分

雖然有些程式只是大型函式,例如編譯器或定理證明器,但許多其他程式並非如此 - 它們更像是工作模型,因此需要支援我在此討論中稱為身分的東西。我所謂的身分是指隨著時間推移與一系列不同值關聯的穩定邏輯實體。模型需要身分,原因與人類需要身分相同 - 為了表示世界。如果像「今天」或「美國」這樣的身分必須永遠表示單一常數值,它怎麼可能運作?請注意,我所說的身分並非名稱(我稱呼我的母親為媽媽,但你不會這樣稱呼)。

因此,對於此討論,身分是一個具有狀態的實體,其狀態是其在某個時間點的值。而且值是不會變化的東西。42 沒有變化。2008 年 6 月 29 日沒有變化。點不會移動,日期不會變更,無論一些不良的類別庫可能讓你相信什麼。甚至聚合也是值。我最喜歡的食品的集合沒有變化,也就是說,如果我未來偏好不同的食品,那將會是一個不同的集合。

身分是我們用來將連續性疊加在一個不斷、函式式地創造自身新值的世界上所使用的思維工具。

物件導向程式設計 (OO)

OO 除了其他事項外,還試圖提供工具來建模程式中的身分與狀態(以及將行為與狀態關聯在一起,以及階層分類,這兩個在此處都忽略了)。OO 通常會統一身分與狀態,亦即物件(身分)是指向包含其狀態值的記憶體的指標。沒有辦法取得獨立於身分之外的狀態,除了複製它。沒有辦法觀察穩定的狀態(甚至複製它),而不阻止其他人變更它。沒有辦法將身分的狀態與其他值關聯在一起,除了原地的記憶體突變。換句話說,典型的 OO 已經將命令式程式設計烘焙到其中!OO 不一定得這樣,但通常都是這樣(Java/C++/Python/Ruby 等)。

習慣 OO 的人會設想他們的程式會突變物件的值。他們了解值的真正概念,例如 42,作為永遠不會變化的東西,但通常不會將值的這個概念延伸到他們的物件狀態。這是他們的程式語言的失敗。這些語言對建模值使用與對身分、物件使用的相同建構,並預設為可變性,導致除了最有紀律的程式設計師之外,所有人都會建立比他們應該要建立的更多的身分,從應該為值的項目建立身分等。

Clojure 程式設計

還有另一種方法,那就是將身分與狀態分開(再一次,間接儲存拯救了程式設計)。我們需要從「這個記憶體區塊的內容」的狀態概念轉移到「目前與這個身分關聯的」。因此,一個身分可以在不同的時間處於不同的狀態,但狀態本身不會變更。也就是說,身分不是狀態,身分狀態。在任何時間點上只有一個狀態。而且那個狀態是一個真實的值,亦即它永遠不會變更。如果一個身分看起來會變更,那是因為它會隨著時間與不同的狀態值關聯在一起。這是 Clojure 模型。

在 Clojure 的模型中,值計算是純粹函數式的。值從不改變。新值是舊值的函數,而不是變異。但邏輯身分受到良好支援,透過對值的原子參考(RefsAgents)。對參考的變更由系統控制/協調 - 換句話說,合作不是可選的,也不是手動的。世界由於其參與者的合作努力而向前推進,而程式語言/系統 Clojure 負責世界一致性管理。參考的值(身分的狀態)始終可以在沒有協調的情況下觀察到,並可以在執行緒之間自由共享。

即使只有一個參與者(執行緒),也值得用這種方式建構程式。當函數值計算獨立於身分/值關聯時,程式更容易理解/測試。而且當(不可避免地)需要時,很容易新增其他參與者。

並行

處理並行意味著放棄全能的錯覺。程式必須承認會有其他參與者,而且世界會持續改變。因此,程式必須了解,如果它觀察到某些身分的狀態值,它能獲得的最好的結果就是快照,因為它們隨後可能會取得新的狀態。但這通常足以用於決策或報告目的。我們人類對於我們的感官系統提供的快照應付得很好。好處是任何這樣的狀態值在處理過程中都不會改變,因為它是不可變的。

另一方面,將狀態變更為新值需要存取「目前的」值和身分。Clojure 的 Refs 和 Agents 會自動處理這項工作。在 Refs 的情況下,您執行的任何互動都必須在交易中發生(否則 Clojure 會擲回例外),所有這樣的互動都會看到某個時間點的世界一致性檢視,而且除非要變更的狀態在同時未被其他參與者變更,否則不會進行任何變更。交易支援對多個 Refs 的同步變更。另一方面,Agents 提供對單一參考的非同步變更。您傳遞函數和值,然後在未來的某個時間點,該函數將傳遞 Agent 的目前狀態,而函數的傳回值將成為 Agent 的新狀態。

在所有情況下,程式都會看到世界中值的穩定檢視,因為那些值無法改變,而且在核心之間分享它們是沒問題的。訣竅在於,「值永遠不會改變」表示從舊值建立新值必須有效率,而且在 Clojure 中,由於其持續資料結構,這一點做得到。它們讓您最終可以遵循經常提出的建議,以不變性為優先。因此,您可以透過讀取身分的目前值,呼叫該值上的純粹函數來建立新值,並將該值設定為新狀態,來將身分的狀態設定為新狀態。這些複合運算透過 altercommutesend 函數變得容易且原子化。

訊息傳遞和 Actor

還有其他方法可以建構身分和狀態,其中一種較受歡迎的方法是傳遞訊息的 Actor 模型。在 Actor 模型中,狀態會封裝在 Actor(身分)中,而且只能透過傳遞訊息(值)來影響/查看。在非同步系統中,讀取 Actor 狀態的某個層面需要傳送要求訊息、等待回應,以及 Actor 傳送回應。了解Actor 模型是設計來解決分散式程式的問題非常重要。而分散式程式的問題難度高出許多,因為有多個世界(位址空間)、無法直接觀察、互動會透過可能不可靠的管道進行等等。Actor 模型支援透明分散。如果您以這種方式撰寫所有程式碼,您就不會受限於其他 Actor 的實際位置,讓系統可以在不變更程式碼的情況下,散布在多個處理程序/機器上。

我選擇不使用 Actor 模型來管理 Clojure 中的同處理程序狀態,原因有幾個

  • 它是一個更複雜的程式設計模型,需要 2 個訊息對話才能讀取最簡單的資料,而且強制使用會阻擋訊息接收,這會引發死結的可能性。針對分散的失敗模式進行程式設計表示要使用逾時等。它會導致程式協定的分歧,其中一些協定以函數表示,而其他協定則以訊息的值表示。

  • 它不讓您充分利用在同個處理程序中的效率。在執行緒之間有效率地直接分享大型不變資料結構是很有可能的,但 Actor 模型會強制介入對話,而且可能會複製。讀取和寫入會序列化並互相阻擋等等。

  • 它會降低您在建模方面的靈活性 - 這是個每個人都坐在沒有窗戶的房間裡,只透過郵件溝通的世界。程式被分解為一堆會阻擋的 switch 陳述式。您只能處理您預期會收到的訊息。協調涉及多個參與者的活動非常困難。您無法在沒有其合作/協調的情況下觀察任何事物 - 這使得臨時報告或分析變得不可能,反而強迫每個參與者參與每個協定。

  • 通常情況下,將某個在本地運作良好的東西透明地分發出去並不會奏效 - 對話的粒度太過瑣碎,或訊息的負載太大,或失敗模式會改變最佳的工作分割,也就是說,透明的分發並非透明的,而且程式碼無論如何都必須變更。

Clojure 最終可能會支援用於分散式程式設計的參與者模型,僅在需要分散式時才付出代價,但我認為這對於同一個程式的程式設計來說相當繁瑣。當然,您的里程數可能會有所不同。

摘要

Clojure 是一種函式語言,它明確地支援將程式作為模型,並提供強健且易於使用的設施,以在並發的情況下管理單一程式中的身分和狀態。

在從物件導向語言轉換到 Clojure 時,您可以使用它的其中一個 持續集合,例如,使用映射,而不是物件。盡可能使用值。對於那些您的物件真正建模身分的情況(在您開始以這種方式思考之前,您會發現比您想像的要少很多),您可以使用 Ref 或 Agent,並將映射作為其狀態,以建模具有變動狀態的身分。如果您想封裝或抽象出您的值的詳細資料,如果它們是非平凡的,那麼這是一個好主意,請為它們撰寫一組用於檢視和操作它們的函式。如果您想要多型,請使用 Clojure 的多重方法。

在本地情況下,由於 Clojure 沒有可變的局部變數,因此,您可以在變異迴圈中建立值,也可以使用 recurreduce 以函式的方式執行此操作。