Haskell 教程的问题

网上扒了不少链接, 看了以后对问题有点改观, 但是消化不掉
所以整理一下放在这里, 希望有点提升, 而且可以讨论下这个问题
Clojure 教程当中明明白白讲过 Atom, 所以可变数据的态度明确
Clojure 里就是整体用不可变数据, 而可变的部分用 Atom 包裹起来
到了 ClojureScript 更放开了调用 JavaScript 代码时的可变状态
由于 Clojure 没有强制封装副作用, 整体感觉是比较轻松的

与 Clojure 对比, Haskell 里对于可变数据, 在教程上极少提及
作为纯函数语言, 而且限制副作用, 因而 IO 相关的篇幅很晚才介绍
比如 learn you a haskell 半本过去还是纯数据, 都没有讲副作用
我猜测这也许是 Haskell 推广的一个问题, 副作用讲得太不清楚了
对于应用开发者而言, 文件, 数据库, 图形界面, 网络, 这才是工作的主体
当 Haskell 对我们工作的主体部分做大量的限制, 等于限制我们的工作

比如我在写网页应用时, 大量的 DOM 操作, 网络读取, 都是副作用
直到 React 对 DOM 整体进行了封装, 事情才有所改观
但即便这样, 副作用还是任务的主体, 在纯的组件之外, 丝毫不减少副作用
我回顾自己学 Haskell, 熟悉了高阶函数很多用法, 但类型和副作用仍然菜鸟
除了 cljs 更合适的原因之外, 我真的开始认为 Haskell 教程设计存在问题
对于副作用的讲解和改进, 做得也许真的不够多

比如 IORef 这个东西, 我刷了几年社区了, 根本不知道有
交流会第二天早上我试着去搜 Haskell 有没有办法模拟 Clojure 的 Atom
结果给我搜索到了, Wiki 上有, StackOverflow 有, 个人博客有
然而却不在教程上, 倒是 PureScript 那份很长的教程结尾有一些
所以结论是, Haskell 当中有可变数据, 但很少有介绍, 而且不推荐用
从这个角度看 Haskell 的教程真的没有把我们照顾好

可变状态的需求

只是说我遇到的例子吧, 在用了 Clojure 之后发现还是很难干掉 mutable 状态
在 MVC 中, 虽然 View 组件内部, Model 单页内部, 可以做到 pure
但是在 MVC 各个部分之间, 难以梳理形成直白的依赖关系
比如说 Model 的更新, 下一个 Model 依赖上一个 Model 状态, 自己依赖自己
Controller 要把 MVC 连接成一个循环, 而循环导致循环引用
单纯靠不可变数据的写法, 不可能构造出这样一个程序出来
加上 View 比如是 DOM, DOM 是可变状态, 很难说用不可变数据去搞定

在 React 社区当中各种观点说, 需要用 Immutable 来增强性能和可靠
但 Immutable 是体现在组件共享数据的过程当中, 并不是所有位置
这也许是我学习过程的一个误区, 以为 Immutable 解决一切, 实际当然不是
而 js 整体可变带来一种误解, 我们不知道区分数据可变和不可变
(在 C 层级的语言中有 const, 那属于硬件性能因素, 场景不同不讨论)
换成 Atom 的概念就是, 数据不可变的, 但会用到可变的引用

比如有一个状态称为 a, 最开始数据是 a0, 经过操作后数据是 a1
那一般我们就理解 a 是从 a0 变成了 a1, 在 js 里写 a=a0a=a1
但按照 Atom 的概念仔细想, 不对, aa0 a1 的类型是不一样的
比如说 a 是车的位置, 那么 a 将随着时间改变, 相当于 f(t)
a0 作为车的位置, 它却不变, 为什么, 因为它是具体一个时间的位置
一个是跟时间相关的状态, 一个是坐标, 怎么能完全一致呢?
在 js 里没有区分, 但是 Clojure 区分了, aAtom, 类型不一样

编程里黑科技太多, 我也不敢把话说绝, 但至少我认为这是一个场景
这种情况下不可变数据是可靠的, 而可变状态也是被需要的
Haskell 作为通用编程语言, 缺失这种功能太不合常理了
实际上可变状态这种东西, Haskell 从来没说过没有, 只是我会错意了
Haskell 说, 没有可变数据, 所有状态都是隔离的
而且教程上不会教我怎么去写模拟 Atom 的可变状态这种东西 - -!

IORef, State

然后我就搜索到了各样一个问题, 怎么模拟全局变量:
Haskell - simulate global variable(function)
问题的回答里首先给了经典的 do 表达式封装局部状态的写法
然后才开始讲万不得已的话, 用 ugly tricky 的 IORef:

import Data.IORef
import System.IO.Unsafe
import qualified Data.Map as Map

{-# NOINLINE funcs #-}
funcs :: IORef (Map.Map String Double)
funcs = unsafePerformIO $ newIORef Map.empty

f :: String -> Double -> IO ()
f str d = atomicModifyIORef funcs (\m -> (Map.insert str d m, ()))

g :: IO ()
g = do
  fs <- readIORef funcs
  if (Map.lookup "aaa" fs) == Nothing then error "not defined" else putStrLn "ok"

main = do
  f "aaa" 1
  g

gf 两个函数分别去读和写, 好吧, 真的很像全局变量了
然后第二个答案又来一个局部变量, r, 每次读取时在闭包里 +1:

import Data.IORef

type Counter = Int -> IO Int

makeCounter :: IO Counter
makeCounter = do
    r <- newIORef 0
   return (\i -> do modifyIORef r (+i)
                    readIORef r)

testCounter :: Counter -> IO ()
testCounter counter = do
  b <- counter 1
  c <- counter 1
  d <- counter 1
  print [b,c,d]

main = do
  counter <- makeCounter
  testCounter counter
  testCounter counter

马上脑补一个套路一样的 CoffeeScript 版本, 看闭包:

makeCounter = ->
  r = 0
  (i) ->
    r = r + i
    r

testCounter = (counter) ->
  b = counter 1
  c = counter 1
  d = counter 1
  console.log [b,c,d]

do ->
  counter = makeCounter()
  testCounter counter
  testCounter counter

所以, Haskell 中是有办法模拟出可变状态的, 用的是比如 IORef
答案里也说了, 比较乱来的黑科技, 不推荐用(用了就不严谨不安全了)
然后除了 IORef, 还有 STRef, 比如这两个提问:
When is it OK to use an IORef?
When to use STRef or IORef?

扒到一篇博客讲 IORef, ST, MVar 这几个事情的, 跟并行计算有关
Mutable State in Haskell
开头有句话我觉得比较适合用来解释 Immutable 到底算什么:

All variables are indeed immutable, but there are ways to construct mutable references where we can change what the reference points to.

数据是不可变的, 只是有时候需要一个可变的引用来帮助指向不同的数据
文章还给了一点例子, 其中的写法就和 Clojure 里的 Atom 挺像了:

import Data.IORef

main :: IO ()
main = do
    ref <- newIORef (0 :: Int)
    modifyIORef ref (+1)
    readIORef ref >>= print

Clojure 显得短一些(对比下 print, Clojure 是不考虑副作用的类型问题):


(defn -main []
  (let [ref (atom 0)]
    (swap! ref inc)
    (println @ref)))

整个文章大部分看不懂, 只是了解一下其中可变状态的处理
但是可以看到, Haskell 即便可以搞出可变状态, 它还是做了隔离的
IORef 返回的结果, 依然包裹在一个 IO 当中, 而不是直接的值
通过这一点, Haskell 还是会把副作用给识别出来, 而不像常见编程语言
虽然我不喜欢, 但确实要程序更可靠还是少不了借助这类办法

对了, 忘了贴一个官方 Wiki 上的相关文章, 看不懂地方更多 - -!
Top level mutable state

PureScript 中的副作用

PureScript 听说在状态方面抽象得更细致一点
因为要编译到 JavaScript, 需要涉及状态的地方多了许多
不过它总体的文档不多, 大概找到几个可变状态的介绍:
http://www.purescript.org/learn/eff/

collatz :: Int -> Int
collatz n = pureST do
  r <- newSTRef n
  count <- newSTRef 0
  untilE $ do
    modifySTRef count $ (+) 1
    m <- readSTRef r
    writeSTRef r $ if m `mod` 2 == 0 then m / 2 else 3 * m + 1
    return $ m == 1
  readSTRef count

大致上是 Haskell 的 STRef, 我看不出区别,
编译结果的 JavaScript 是这样:

var collatz = function (n) {
  return Control_Monad_Eff.runPure(function __do() {
    var r = n;
    var count = 0;
    (function () {
      while (!(function __do() {
        count = 1 + count;
        var m = r;
        r = (m % 2 === 0) ? m / 2 : 3 * m + 1;
        return m === 1;
      })()) {
      };
      return {};
    })();
    return count;
  });
};

另外在书上还写了一些, 写得大概能看懂, 但是我估计自己不会用:

https://leanpub.com/purescript/read#leanpub-auto-global-mutable-state
https://leanpub.com/purescript/read#leanpub-auto-mutable-state

看下找到的代码的例子:

https://github.com/purescript/purescript/blob/master/examples/passing/Eff.purs

module Main where

import Prelude
import Control.Monad.Eff
import Control.Monad.ST
import Control.Monad.Eff.Console

test1 = do
  log "Line 1"
  log "Line 2"

test2 = runPure (runST (do
          ref <- newSTRef 0.0
          modifySTRef ref $ \n -> n + 1.0
          readSTRef ref))

test3 = pureST (do
          ref <- newSTRef 0.0
          modifySTRef ref $ \n -> n + 1.0
          readSTRef ref)

main = do
  test1
  Control.Monad.Eff.Console.print test2
  Control.Monad.Eff.Console.print test3

小结

总体感觉就是 Haskell 对副作用进行封装之后, 整个概念多了太多
特别对可变状态这种东西, 也许得说是共享的可变状态, 这种结构, 太不明确
但是应用开发当中遇到可变状态是稀松平常的事情(高端的数据库后端就...)
Haskell 宣称自己适合实际开发, 我们也确实需要很多 Haskell 的特性
但是这么多限制累积在一起成了那么高的门槛, 我还是先折腾 Clojure 吧

Anyway, 通过 uglify 而且 tricky 的手法, 可变状态也能模拟
至少我现在知道了 Haskell 并不是完全把这个路给堵死了


题叶
17.3k 声望2.6k 粉丝

Calcit 语言作者


引用和评论

0 条评论