跳转到内容

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. 有关数组的一般观察,请参阅数据结构入门
华夏公益教科书