myfreess

今日无事生非

Follow me on GitHub

haskell缺陷之一

内容来源:

https://www.snoyman.com/blog/2020/10/haskell-bad-parts-1

foldl

不止一个人想过要把Prelude和Data.List里的foldl函数换成foldl’了。在长期的实践中,haskeller们实在没找到有什么合理的使用场景需要惰性求值+尾递归,顺便还有通过foldl定义的sum,product。尽管现在的foldl使用了很多优化措施,但是惰性就是惰性。

有些人认为惰性的foldl在Vector上表现很好,所以应该保留。但是这应用场景说实在的太窄了。应该把惰性版本的foldl命名成foldLazy才对。(原作者的命名是foldlButLazyIReallyMeanIt)

sum/product

额,上面不是说过了?

实际上sum/product现在是Foldable类型类下属的函数了,针对list,它的确是使用foldl的特化实现,但是对于一些只实现了最小实例的类型构造子t,还是foldMap搞定。现在换成树形递归哪里不好?和+,*有点不搭,因为+,*它们对自己的参数都是急切求值的。我无意指责+和*, 但是sum/product实现成累积器模式会比较好。转换为list再用foldl’处理会产生不必要的中间列表,大概是时候看看砍伐律/酸雨律了。

或许有人会说,GHC会做这些优化的! 不要这样,期待GHC会把烂出水准的代码优化成一般般的代码,还不如现在就去改行来的爽利。

partial function

现在的Prelude不讨人喜欢,除了著名的FTP(Foldable和Traversable进Prelude), 还有一点就是偏函数的存在了。比如head,比如tail。Prelude中的偏函数默认是没有调用栈追踪的(社区意见不统一,反正问题很捉鸡), 崩了的话,就像这样:

test: Prelude.head: empty list

哪一行的调用出了问题?不知道。具体的调用链?不知道。反正只知道是head搞非暴力不合作。

目前有这么个建议(无意间看到的),对于自己写自认为安全的偏函数,在不可能发生的分支那里用error把你认为为什么这个分支不可能match到的理由打印出来, 举个例子

halfOfEvens :: Rational -> [Rational] -> [Rational]
halfOfEvens n = map (\d -> n `divEx` d) . filter (/= 0)
  where
    x `divEx` 0 = error
      "This should be impossible: We already filtered the list to remove zeroes"
    x `divEx` d = x / d

例子及建议来自:https://github.com/freckle/guides/blob/main/haskell-best-practices.md

如果要加调用栈追踪, 最常见的方法是使用GHC.Stack.HasCallStack。实际上error函数默认就会打印CallStack,但可是如果想显示完整的CallStack,就不得不顺着调用链给所有要用到的函数都加上HasCallStack这个typeclass的约束。例子抄了

f :: HasCallStack => IO ()
f = error "bad bad bad"

g :: HasCallStack => IO ()
g = f

h :: HasCallStack => IO ()
h = g

对h求值,能从h追踪到g追踪到f,可是如果把hg的约束去除,就只能知道是f出错了。

更多细节在文档里:https://ghc.gitlab.haskell.org/ghc/doc/users_guide/exts/callstack.html

HasCallStack会造成一点性能上的损失, 我也只是听说,还是用着吧。

已经有人在做一些自定义的Prelude,力图让偏函数滚出haskell共和国。

Data.Text.IO

这个模块里读写文本文件的函数会跟随主路线反复横跳,编码用啥依照环境变量来(Locale Sensitive)。识相的话,还是离这号人远点吧。对于英语用户,it just works。对于中文用户,Windows的默认编码是GBK。

如果说有什么比它还烂,我怀疑是Data.Text.Lazy.IO。一定要使用Text的话,建议搭配此模块:https://hackage.haskell.org/package/with-utf8

没被捆在Text的战车上,可以考虑rio和Z.haskell。

2021.9.10 Update

https://github.com/haskell/text/pull/365 Switch internal representation to UTF8

喜报,该pull request已经merge

bracket

几乎每个星期的编程任务中都有这样一种:获取资源,使用资源,释放资源。在函数式编程中我们可以用高阶函数稍微做一点抽象。

bracket :: IO a        -- computation to run first ("acquire resource")
        -> (a -> IO b) -- computation to run last ("release resource")
        -> (a -> IO c) -- computation to run in-between
        -> IO c

非常好! 为它写个实现吧

bracket r finish customer = 
    do res <- r
       result <- customer res
       finish res
       return result

ok,完全能过类型检查! 但是,稍微等等,我们带入一个实际例子看看。

bracket
  (openFile "filename" ReadMode)
  hClose
  (\fileHandle -> do { ... })

openFile,常用又常见,但是因为用haskell写实际应用的没几个,好像大家都忘了,打开文件这种操作是有机会引发异常的。

好吧,这个简单的实现肯定是不好用了。haskell的异常在Control.Exception中定义,当然,提供了bracket函数, 它会保证如果在获取/消费资源时内部抛出异常,那么收尾工作一定会完成。看起来事情就这样结束了,但是,等等,朋友要了解一下Async Exception吗?

异步异常是haskell内一种类似Unix中信号的机制,它到底能干嘛?好吧,闲话少说,haskell中的每个线程都有一个ThreadId,只要拿到一个线程的id,就可以用throwTo函数向它发送一个异常。当然了, bracket本身是一个提供“保护”的函数, 所以在进行运算时它会暂时地“masking”掉对外部异步异常的接收,这就完事了。

完事了吗?

还是没有,这里不得不说一下,在现在的实现中对异步异常的“masking”分为可中断/不可中断2种,不可中断的masking就是masking,而可中断的masking(interruptible masking)同样会拒绝异常,但是假如内部运算是阻塞的,那拒绝的态度可能就没有那么坚决。那么Control.Exception.bracket用的啥呢……

对,它使用可中断的masking,所以释放资源的操作如果会阻塞,那它实际上是有概率被外部发来的异常中断的。比如说假如你弄了一堆tmp文件然后打算在释放资源的时候删掉,运气不好就没删掉一直留着了。

具体哪些操作会被打断请看文档。

https://hackage.haskell.org/package/base-4.15.0.0/docs/Control-Exception.html

鉴于笔者到现在既没有用过多线程也木有用过bracket,这对笔者本人应该不是个问题。

不过,至少以下操作是不可中断的。

  • Data.IORef模块中所有涉及IORef的操作

  • 没用retry的STM transactions

  • Foreign模块全体

  • Control.Exception除throwTo以外的操作

还有4个特例,实在头疼,不一一举出了。

异步异常论文在此,这个真的是haskell特色。另外论文的实现和实际GHC中使用的实现不一样,领会下思路即可, 不要背API(也别问我是不是犯傻背了论文里的API然后被编译器毒打)。

https://www.researchgate.net/publication/220752313_Asynchronous_Exceptions_in_Haskell