跳轉到內容

Haskell/可變物件

來自 Wikibooks,開放世界中的開放書籍

函式式純度是 Haskell 的一個決定性特徵,它導致了 Haskell 的許多優勢。因此,語言和生態系統鼓勵完全避免可變狀態。由於諸如State 單子之類的工具,它允許我們以一種方便且函式式純淨的方式跟蹤狀態,以及高效的不可變資料結構(例如 containersunordered-containers 包提供的結構),Haskell 程式設計師可以在絕大多數情況下,透過完全不可變性完美地解決問題。但是,在某些情況下,使用可變狀態是最明智的選擇。例如,你可能對以下情況感興趣:

  • 從 Haskell 程式碼中使用用另一種語言編寫的庫,該庫在所有地方都假設可變狀態。這種情況經常發生在事件回撥 GUI 工具包中。
  • 使用 Haskell 實現提供命令式可變變數的語言。
  • 實現本質上需要對變數進行破壞性更新的演算法。
  • 處理大量資料,這些資料量大到足以證明擠壓所有可用的計算能力以使手頭問題變得可行。

任何值得一提的通用程式語言都應該能夠處理這些任務。在 Haskell 中,情況也不例外:不僅有建立可變物件的方法,還有控制可變性的方法,這些方法可以在以不可變性為預設值的設定中和平共處。

讓我們從上面最簡單的用例開始。構建使用者介面程式碼的一種常見方法是透過事件和回撥模型。事件可能是按鈕點選或按鍵,而回調只是一段程式碼,旨在響應事件時呼叫。客戶端程式碼(即你的程式碼,如果你正在使用這樣的庫)應該設定連線介面元素、涉及它們的事件以及相應回撥的連線。一個假設的安排回撥的函式可能具有以下型別

register :: (Element -> Event) -> Element -> IO () -> IO ()

IO () 引數是回撥,而 register 的結果是設定連線的 IO 操作。執行 register click button1 (print "Hello") 將導致每次點選 button1 後在控制檯中列印“Hello”。

register(具有普遍的 IO 且缺乏有用的返回值)和我們上面的描述都具有明顯的命令式風格。這是因為我們假設的 GUI 庫是用完全不同的語言以更命令式的風格編寫的。有些人寫了一個外殼,以便我們能夠從 Haskell 中使用它,但外殼非常薄,因此原始庫的風格洩露到我們的程式碼中 [1]

使用 register 執行 IO 操作(如列印到控制檯或顯示對話方塊)非常容易。但是,如果我們想在每次點選按鈕時將計數器加 1 怎麼辦?register 的型別沒有顯示任何方法可以將資訊傳遞給回撥,也沒有方法從回撥中獲取資訊(返回值是 ())。State 也無濟於事:即使有一種方法可以將初始狀態傳遞給回撥,並在其中執行 State 計算,我們該怎麼辦呢?我們需要將計數器的結果狀態傳遞給下次按鈕點選時的回撥,我們不知道那會在何時發生,也不知道在何時儲存該值。

在許多語言中,這個問題的明顯解決方案是在回撥之外建立一個可變變數,然後為回撥提供對它的引用,以便它的程式碼可以隨意更改變數的值。不過,我們不用擔心,因為 Haskell 允許我們做到這一點。實際上,有幾種型別的可變變數可用,其中最簡單的是 IORefIORef 非常簡單;它們只是包含可變值的盒子。我們可以按如下方式建立一個

GHCi> import Data.IORef
GHCi> :t newIORef 
newIORef :: a -> IO (IORef a)
GHCi> box <- newIORef (4 :: Int)

newIORef 接受一個值並返回一個初始化為該值的 IORef,作為 IO 操作的結果。然後,我們可以使用 readIORef 檢索其中的值...

GHCi> :t readIORef
readIORef :: IORef a -> IO a
GHCi> readIORef box >>= print
4

... 並使用 modifyIORefwriteIORef 來更改它

GHCi> modifyIORef box (2*)
GHCi> readIORef box >>= print
8
GHCi> writeIORef box 0
GHCi> readIORef box >>= print
0

考慮到它將在按鈕點選之間持續存在,IORef 足以實現計數器。程式碼可能如下所示

setupGUI :: IORef Int -> IO ()
setupGUI counter = do
    -- However much other GUI preparation code we need.
    register click button1 (modifyIORef counter (+1))

main :: IO ()
main = do
    -- etc.
    counter <- newIORef (0 :: Int)
    setupGUI counter
    -- Then just use the counter value wherever necessary.

請注意,沒有必要不加選擇地使用 IORef,沒有充分的理由這樣做。除了對可變狀態的更基本問題之外,這樣做很不方便,因為需要進行所有顯式的讀/寫/修改呼叫,更不用說需要在額外的地方引入 IO 來處理 IORef(在我們假設的示例中,這不是什麼大問題,因為 GUI 程式碼必須在 IO 中執行,我們假設會將其與構成我們程式核心的純函式分開,就像良好的 Haskell 實踐所要求的那樣)。儘管如此,IORef 還是在你無法避免它們時可以使用的。

併發陷阱

[編輯 | 編輯原始碼]

可變變數還有另一個非常重要的用例,我們在引言中沒有提到:併發性,即計算機同時執行計算的情況。併發場景從簡單(進度條顯示後臺任務的狀態)到極其複雜(伺服器端軟體同時處理數千個請求)都有。鑑於原則上沒有任何東西能保證同時計算會按步執行,它們之間的任何通訊都需要可變變數。然而,這帶來了一個複雜的問題:在存在具有不可預測時序的獨立計算的情況下,使用可變狀態的程式碼的可理解性和可預測性問題變得更加嚴重。例如,計算 A 可能需要計算 B 的結果,但它可能比預期的更早地請求該結果,從而獲得一個錯誤的結果。編寫正確的併發程式碼可能很困難,並且如果未採取適當的措施,很容易引入微妙的錯誤。

Data.IORef 中提供併發程式碼額外安全性的唯一函式是 atomicModifyIORefatomicModifyIORef'atomicWriteIORef,它們只在非常簡單的情況下有用,在這種情況下只有一個 IORef 被用作計算之間的共享資源。併發 Haskell 程式碼應該利用為併發定製的更復雜的工具,例如 MVar(可變變數,一個計算可以根據需要將其變為不可用 - 請參閱 Control.Concurrent.MVar)和來自 stm 包的 Control.Concurrent.STM軟體事務記憶體的實現,併發模型允許編寫安全的併發程式碼,同時避免了顯式管理所有共享變數可用性的醜陋和複雜性)[2]

ST 單子

[edit | edit source]

在上面的 IORef 示例中,可變性是由於外部需求強加於我們的程式碼的。然而,在引言中建議的最後兩種情況下(需要可變性的演算法和極端的計算需求),對可變狀態的需求是內部的 - 也就是說,它在整體結果中沒有任何反映。例如,對列表進行排序不需要以任何本質方式進行可變性,因此原則上,排序列表並返回新列表的函式應該是函式式純的,即使排序演算法使用破壞性更新來交換元素的位置。在這種情況下,可變性只是一個實現細節。標準庫提供了一個用於處理此類情況的巧妙工具,同時仍然最終得到純函式:ST 單子,可以在 Control.Monad.ST 中找到。

data ST s a

ST s a 看起來很像 State s a,實際上它們的思想很相似。ST 計算是一種使用內部狀態來產生結果的計算,除了狀態是可變的。為此,Data.STRef 提供了 STRefSTRef s a 就像 IORef a 一樣,但它存在於 ST s 單子中而不是在 IO 中。

有一個主要的區別將 STStateIO 區分開來。Control.Monad.ST 提供了一個 runST 函式,其型別如下:

runST :: (forall s. ST s a) -> a

起初,這是一個令人震驚的型別簽名。如果 ST 涉及可變性,我們怎麼能簡單地從單子中提取 a 值?答案在於型別中的 forall s. 部分。在引數型別中包含 forall s. 等同於告訴型別檢查器“s 可以是任何東西。不要對其進行任何假設”。然而,不進行任何假設意味著 s 無法與其他任何東西匹配 - 甚至無法與來自另一個 runST 呼叫[3]s 匹配

GHCi> import Control.Monad.ST
GHCi> import Data.STRef
GHCi> -- Attempt to keep an STRef around to pass to pure code:
GHCi> let ref = runST $ newSTRef (4 :: Int)

<interactive>:125:19:
    Couldn't match type a with STRef s Int
      because type variable s would escape its scope
    This (rigid, skolem) type variable is bound by
      a type expected by the context: ST s a
      at <interactive>:125:11-37
    Expected type: ST s a
      Actual type: ST s (STRef s Int)
    Relevant bindings include ref :: a (bound at <interactive>:125:5)
    In the second argument of ($), namely newSTRef (4 :: Int)
    In the expression: runST $ newSTRef (4 :: Int)
GHCi> -- The error message is quite clear:
GHCi> -- "because type variable ‘s’ would escape its scope"
GHCi> -- Attempt to feed an STRef from one ST computation to another:
GHCi> let x = runST $ readSTRef =<< runST (newSTRef (4 :: Int))

<interactive>:129:38:
    Couldn't match type STRef s1 Int with ST s (STRef s a)
    Expected type: ST s1 (ST s (STRef s a))
      Actual type: ST s1 (STRef s1 Int)
    Relevant bindings include x :: a (bound at <interactive>:129:5)
    In the first argument of runST, namely (newSTRef (4 :: Int))
    In the second argument of (=<<), namely
      runST (newSTRef (4 :: Int))
GHCi> -- The 's' from each computation are necessarily not the same.

這種型別技巧的總體效果是隔離每個 ST 計算中的內部狀態和可變性,因此從程式中其他任何東西的角度來看,runST 是一個純函式。

作為 ST 在實際應用中的一個簡單示例,這裡有一個非常類似於命令式的列表 sum 版本[4]

import Control.Monad.ST
import Data.STRef
import Data.Foldable

sumST :: Num a => [a] -> a
sumST xs = runST $ do
    n <- newSTRef 0
    for_ xs $ \x ->
        modifySTRef n (+x)
    readSTRef n

就所有意圖和目的而言,sumST 與我們熟悉的 sum 一樣純。它破壞性地更新其累加器 n 的事實僅僅是一個實現細節,並且除了透過最終結果之外,沒有任何關於 n 的資訊可以洩露。檢視像這樣簡單的示例就可以清楚地看到,ST s a 中的 s 型別變數並不對應於計算中的任何特定內容 - 它只是一個人工標記。另一個值得注意的細節是,即使 for_ 從右側摺疊列表,但求和是從左側完成的,因為變異是作為從左到右排序的應用效果執行的。

可變資料結構

[edit | edit source]

可變資料結構可以在庫中找到,這些庫是為證明它們必要的一些特殊用例而準備的。例如,可變陣列(以及不可變陣列)可以在 vector 包或與 GHC 捆綁在一起的 array 包中找到[5]。還有可變雜湊表,例如來自 hashtables 包 的雜湊表。在所有提到的情況下,都提供了 STIO 版本。

進一步閱讀

[edit | edit source]
  • Lennart Augustsson 的部落格 展示瞭如何在 Haskell 中實現真正的快速排序(即使用執行破壞性更新來排序列表的原始演算法),就像我們在Haskell/高階函式 中確保的那樣。由於用於處理可變性的組合子,他的實現非常有趣,這使得 Haskell 看起來像 C。一定要檢視與連結的帖子之前的兩篇帖子,看看它是如何完成的。

筆記

  1. 其他語言庫的幕後技術術語是繫結。繫結可以是薄的,透明地暴露原始庫的結構,也可以在其上構建額外的抽象層,以實現更像 Haskell 的感覺。在 Haskell 中建立繫結的基本工具是外部函式介面,我們在Haskell 實踐指南的一章中介紹了它。
  2. 本書的未來章節將介紹其中的一些功能。
  3. 這是一個存在型別的示例。“存在”在精確的技術意義上,但我們可以透過注意到我們所知道的只是它存在來了解其要點。
  4. 改編自HaskellWiki 上關於 ST 的頁面
  5. 有關陣列的一般觀察,請參閱資料結構入門
華夏公益教科書