【问题标题】:Why is Haskell (sometimes) referred to as "Best Imperative Language"?为什么 Haskell(有时)被称为“最佳命令式语言”?
【发布时间】:2011-10-01 03:21:20
【问题描述】:

(我希望这个问题是关于主题的——我尝试寻找答案,但没有找到明确的答案。如果这恰好是题外话或已经回答,请调整/删除它。 )

我记得有几次听到/读过关于 Haskell 是最好的命令式语言的半开玩笑的评论,这当然听起来很奇怪,因为 Haskell 通常以其功能性 功能。

所以我的问题是,Haskell 的哪些品质/特性(如果有)可以证明 Haskell 被认为是最好的命令式语言 - 或者它实际上更像是一个笑话?

【问题讨论】:

标签: haskell imperative-programming


【解决方案1】:

我认为这是半真半假。 Haskell 具有惊人的抽象能力,包括对命令式思想的抽象。例如,Haskell 没有内置的命令式 while 循环,但我们可以直接编写它,现在它可以了:

while :: (Monad m) => m Bool -> m () -> m ()
while cond action = do
    c <- cond
    if c 
        then action >> while cond action
        else return ()

对于许多命令式语言来说,这种抽象级别是困难的。这可以在具有闭包的命令式语言中完成;例如。 Python 和 C#。

但 Haskell 还具有(非常独特的)能力,可以使用 Monad 类表征允许的副作用。例如,如果我们有一个函数:

foo :: (MonadWriter [String] m) => m Int

这可以是一个“命令式”函数,但我们知道它只能做两件事:

  • “输出”字符串流
  • 返回一个整数

它不能打印到控制台或建立网络连接等。结合抽象能力,你可以编写作用于“任何产生流的计算”等的函数。

实际上,Haskell 的抽象能力使它成为一种非常优秀的命令式语言。

但是,错误的一半是语法。我发现 Haskell 非常冗长且难以在命令式风格中使用。下面是一个使用上述while 循环的命令式计算示例,它找到链表的最后一个元素:

lastElt :: [a] -> IO a
lastElt [] = fail "Empty list!!"
lastElt xs = do
    lst <- newIORef xs
    ret <- newIORef (head xs)
    while (not . null <$> readIORef lst) $ do
        (x:xs) <- readIORef lst
        writeIORef lst xs
        writeIORef ret x
    readIORef ret

所有 IORef 垃圾、双重读取、必须绑定读取结果、fmapping (&lt;$&gt;) 以对内联计算的结果进行操作……这一切看起来都非常复杂。从函数式的角度来看,这很有意义,但命令式语言倾向于将这些细节中的大部分隐藏起来,以使它们更易于使用。

诚然,如果我们使用不同的while 风格的组合器,它可能会更简洁。但是,如果您将这种理念走得足够远(使用一组丰富的组合器来清楚地表达自己),那么您就会再次到达函数式编程。命令式 Haskell 只是不像精心设计的命令式语言那样“流动”,例如蟒蛇。

总之,Haskell 在语法上进行了改头换面,很可能是最好的命令式语言。但是,就整容的本质而言,它会用外表美丽和虚假的东西代替内在美丽和真实的东西。

编辑:对比lastElt这个python音译:

def last_elt(xs):
    assert xs, "Empty list!!"
    lst = xs
    ret = xs.head
    while lst:
        ret = lst.head
        lst = lst.tail
    return ret 

行数相同,但每行的噪音要少很多。


编辑 2

对于它的价值,这就是 Haskell 中的 替代品的样子:

lastElt = return . last

就是这样。或者,如果你禁止我使用Prelude.last

lastElt [] = fail "Unsafe lastElt called on empty list"
lastElt [x] = return x
lastElt (_:xs) = lastElt xs

或者,如果您希望它适用于任何 Foldable 数据结构并认识到您实际上并不需要 IO 来处理错误:

import Data.Foldable (Foldable, foldMap)
import Data.Monoid (Monoid(..), Last(..))

lastElt :: (Foldable t) => t a -> Maybe a
lastElt = getLast . foldMap (Last . Just)

Map为例:

λ➔ let example = fromList [(10, "spam"), (50, "eggs"), (20, "ham")] :: Map Int String
λ➔ lastElt example
Just "eggs"

(.) 运算符是function composition

【讨论】:

  • 您可以通过进行更多抽象来减少 IORef 噪音的烦人。
  • @augustss,嗯,我很好奇。你的意思是更多的领域级抽象,还是仅仅通过建立更丰富的命令式子语言”。对于前者,我同意——但我认为命令式编程与低抽象(我的工作假设是随着抽象的增加,风格收敛于功能)。对于后者,我真的很想知道你的意思,因为我想不出我的头顶。
  • @luqui 使用 ST 将是描述允许的副作用的一个很好的例子。作为奖励,可以从 ST 跳回纯计算。
  • 使用 Python 作为比较并不完全公平——正如您所说,它设计精良,是我熟悉的语法上最简洁的命令式语言之一。同样的比较会争辩说,大多数命令式语言在命令式风格中使用起来很尴尬……不过,也许这正是你的意思。 ;]
  • 对话的脚注,供后人参考:@augustss using ad-hoc polymorphism to make IORefs implicit,或者至少尝试并被 GHC 的更改所阻挠。 :[
【解决方案2】:

除了其他人已经提到的之外,将副作用操作设置为一流有时很有用。这是一个愚蠢的例子来展示这个想法:

f = sequence_ (reverse [print 1, print 2, print 3])

此示例展示了如何构建具有副作用的计算(在此示例中为 print),然后在实际执行它们之前将其放入数据结构或以其他方式操作它们。

【讨论】:

  • 我认为与此相对应的 javascript 代码是:call = x =&gt; x(); sequence_ = xs =&gt; xs.forEach(call) ;print = console.log; f = () =&gt; sequence_([()=&gt; print(1), () =&gt; print(2), () =&gt; print(3)].reverse())。我看到的主要区别是我们需要一些额外的() =&gt;
【解决方案3】:

这不是玩笑,我相信。我会尽量让那些不了解 Haskell 的人可以使用它。 Haskell 使用 do-notation(除其他外)允许您编写命令式代码(是的,它使用 monad,但不要担心)。以下是 Haskell 为您提供的一些优势:

  • 轻松创建子程序。假设我想要一个函数将值打印到 stdout 和 stderr。我可以编写以下内容,用一条短线定义子程序:

    do let printBoth s = putStrLn s >> hPutStrLn stderr s
       printBoth "Hello"
       -- Some other code
       printBoth "Goodbye"
    
  • 易于传递代码。鉴于我已经编写了上述内容,如果我现在想使用 printBoth 函数打印出所有字符串列表,这很容易通过将我的子例程传递给 mapM_ 函数来完成:

    mapM_ printBoth ["Hello", "World!"]
    

    另一个例子,虽然不是必须的,是排序。假设您只想按长度对字符串进行排序。你可以写:

    sortBy (\a b -> compare (length a) (length b)) ["aaaa", "b", "cc"]
    

    这会给你[“b”,“cc”,“aaaa”]。 (你也可以把它写得更短,但暂时不要介意。)

  • 易于重用代码。 mapM_ 函数被大量使用,并替换了其他语言中的 for-each 循环。还有forever,它的作用类似于一段时间(true),以及可以传递代码并以不同方式执行的各种其他函数。因此,其他语言中的循环被 Haskell 中的这些控制函数所取代(这并不特殊——您可以很容易地自己定义它们)。一般来说,这使得循环条件很难出错,就像 for-each 循环比长手迭代器等价物(例如在 Java 中)或数组索引循环(例如在 C 中)更难出错一样。

  • 绑定而不是赋值。基本上,你只能给一个变量赋值一次(就像单个静态赋值一样)。这消除了对变量在任何给定点的可能值的很多混淆(其值仅设置在一行上)。
  • 含有副作用。假设我想从标准输入读取一行,并在对其应用一些函数后将其写入标准输出(我们称之为 foo)。你可以写:

    do line <- getLine
       putStrLn (foo line)
    

    我马上就知道foo 没有任何意想不到的副作用(比如更新全局变量,或者释放内存等等),因为它的类型必须是 String -> String,这意味着它是一个纯函数;无论我传递什么值,它每次都必须返回相同的结果,没有副作用。 Haskell 很好地将副作用代码与纯代码区分开来。在 C 甚至 Java 之类的语言中,这并不明显(getFoo() 方法会改变状态吗?你希望不会,但它可能会……)。

  • 垃圾收集。现在很多语言都被垃圾回收了,但值得一提的是:没有分配和释放内存的麻烦。

除此之外,可能还有其他一些优点,但这些都是我想到的。

【讨论】:

  • 我会添加强类型安全性。 Haskell 允许编译器消除一大类错误。最近在处理一些 Java 代码后,我被提醒空指针是多么可怕,以及没有 sum 类型会丢失多少 OOP。
  • 感谢您的详细说明!您提到的优势似乎归结为 Haskell 将“命令式”效果视为一流的对象(因此是可组合的)以及将这些效果“包含”到限定范围内的能力。这是一个足够的压缩摘要吗?
  • @Michael Snoyman:但是求和类型在 OOP 中很容易!只需定义一个代表数据类型的 Church 编码的抽象类、案例的子类、可以处理每个案例的类的接口,然后将支持每个接口的对象传递给 sum 对象,使用子类型多态性进行控制流(如你应该)。再简单不过了。你为什么讨厌设计模式?
  • @camccann 我知道你在开玩笑,但这基本上是我在项目中实现的。
  • @Michael Snoyman:那么,不错的选择!真正的笑话是,我以一种听起来像笑话的方式描述了几乎最好的编码。哈哈!一路笑到绞刑架……
猜你喜欢
  • 2010-11-01
  • 2017-07-19
  • 1970-01-01
  • 2018-11-02
  • 2012-11-01
  • 2016-01-31
  • 2013-07-23
  • 1970-01-01
  • 2017-01-14
相关资源
最近更新 更多