为什么有时会将Haskell称为“最佳命令式语言”?

89

(我希望这个问题是符合主题的——我试着搜索答案,但没有找到明确的答案。如果这个问题与主题无关或已经有了答案,请进行适当的调整/删除。)

我记得曾经听说过或读到过几次Haskell被称为最好的命令式语言的半玩笑评论,这当然听起来很奇怪,因为Haskell通常以其函数特性而闻名。

所以我的问题是,Haskell有哪些特点/功能(如果有的话)可以证明Haskell被认为是最好的命令式语言的理由,还是它实际上更像是一个玩笑?


3
可编程分号。 - vivian
11
这句话可能源自于《解决尴尬问题:Haskell中的单子输入/输出、并发、异常和外语调用》一文的介绍部分末尾,其表述为“简而言之,Haskell是世界上最好的命令式编程语言。” - Russell O'Connor
@Russel:感谢您指出最有可能的来源(似乎是SPJ本人)! - hvr
你可以使用Haskell进行严格的命令式面向对象编程:ekmett/structs - Janus Troelsen
4个回答

98

我认为这只是半个真相。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(<$>)操作内联计算结果......这一切看起来都非常复杂。从函数式的角度来看,这一切都是有道理的,但命令式语言往往会隐藏大部分细节,使它们更易于使用。

不可否认的是,也许如果我们使用不同的类while组合子,代码会更加简洁。但是,如果你将这种哲学推得足够远(使用丰富的组合子清晰地表达自己),那么你最终又回到了函数式编程。命令式风格的Haskell没有像设计良好的命令式语言(例如python)那样流畅。

总之,通过一个语法上的改进,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"
< p > < code > (.) 运算符是 函数合成。 < /p >

2
通过更多的抽象,您可以使IORef噪音变得不那么烦人。 - augustss
1
@augustss,嗯,我对此很好奇。您是指更多的领域级抽象,还是通过构建更丰富的命令式子语言来实现?对于前者,我同意——但我的思维将命令式编程与低抽象联系在一起(我的工作假设是随着抽象度的增加,风格会趋向于函数式)。对于后者,我真的很想知道您的意思,因为我无法立即想出如何实现。 - luqui
2
@luqui 使用ST会是一个描述允许副作用的好例子。作为奖励,可以从ST跳回到一个纯计算中。 - fuz
5
将Python作为比较对象并不完全公平,正如你所说,它是经过精心设计的,在我熟悉的语法最干净的命令式语言之一。同样的比较会认为大多数命令式语言在命令式风格下使用起来很麻烦...虽然,也许这正是你的意思。;] - C. A. McCann
5
对话的脚注,供后人参考:@augustss试图使用临时多态使IORef隐式化,至少尝试过,但受到GHC更改的阻挠。:[ - C. A. McCann
显示剩余13条评论

24
这不是一个玩笑,我相信它。我会尽量让这个内容易于理解,适用于那些不了解Haskell的人。 Haskell使用do-notation(以及其他东西)来允许您编写命令式代码(是的,它使用monads,但是不用担心)。以下是Haskell给您带来的一些优势:
  • 创建子程序非常容易。假设我想要一个函数来将值打印到标准输出和标准错误输出。我可以编写以下代码,使用一行短小的代码定义子程序:

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,它像while(true)一样工作,以及可以传递代码并以不同方式执行的各种其他函数。因此,在Haskell中,循环被这些控制函数所替换(它们并不特殊——你可以非常容易地定义它们自己)。总的来说,这使得循环条件难以出错,就像for-each循环比长迭代器等价物(例如在Java中)或数组索引循环(例如在C中)更难出错一样。

  • 绑定而不是赋值。基本上,您只能对变量赋值一次(类似于单静态分配)。这消除了关于变量在任何给定点可能的值的许多困惑(它的值仅在一行上设置)。
  • 包含的副作用。假设我想从stdin读取一行,并在应用某些函数(我们称之为foo)后将其写入stdout。你可以写:

    do line <- getLine
       putStrLn (foo line)
    

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

  • 垃圾回收。 现在许多语言都是垃圾回收的,但值得一提的是:无需分配和释放内存的麻烦。
  • 除此之外可能还有其他优点,但这些是我能想到的。


    9
    我会加入强类型安全。Haskell允许编译器消除大量错误。最近在处理一些Java代码后,我想起了空指针有多可怕,以及没有和类型相加时面向对象编程缺少了多少东西。 - Michael Snoyman
    1
    @Neil:顺便说一下,我本来会把你和luqui的回答都标记为我的问题的答案,但遗憾的是SO系统要求只能有一个最佳答案... - hvr
    19
    @Michael Snoyman: 但是在面向对象编程中,和类型很容易!只需定义一个表示您的数据类型的 Church 编码的抽象类,为每个情况定义子类,为可以处理每种情况的类定义接口,然后将支持每个接口的对象传递给一个和对象,使用子类型多态性进行控制流程(正如您应该做的那样)。再简单不过了。为什么你讨厌设计模式? - C. A. McCann
    9
    我知道你在开玩笑,但这基本上就是我在项目中实现的内容。 - Michael Snoyman
    9
    @Michael Snoyman: 很好的选择!真正的笑话是我用一种听起来像笑话的方式描述了几乎是最佳编码。哈哈!笑到临刑台... - C. A. McCann
    显示剩余5条评论

    17

    除了其他人已经提到的内容之外,使有副作用的动作成为第一类对象有时很有用。这里有一个愚蠢的例子来表明这个想法:

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

    这个例子展示了如何构建带副作用的计算(在这个例子中是 print),然后将它们放入数据结构中或以其他方式操作它们,最后再实际执行这些计算。


    1
    我认为对应于此的JavaScript代码应该是: call = x => x(); sequence_ = xs => xs.forEach(call) ;print = console.log; f = () => sequence_([()=> print(1), () => print(2), () => print(3)].reverse())。 我看到的主要区别是我们需要一些额外的() => - Hjulle

    0
    使用与@Chi在this answer相同的示例,您可以使用State单子来模拟具有递归的命令式循环:

    C代码:

    // sum 0..100
    i = s = 0;
    while (i <= 100) {
       s = s+i;
       i++;
    }
    return s;
    

    Haskell 代码:

    import Control.Monad.State
    final_s :: Int
    final_s = evalState sum_loop (0, 0)  -- evaluate loop with initial state (0, 0)
    sum_loop :: State (Int, Int) Int
    sum_loop = do
      (i, s) <- get           -- retrieve current state
      if i <= 100             -- check loop condition
        then do               -- if condition is true:
          let new_s = s + i
          let new_i = i + 1   
          put (new_i, new_s)  -- update state with new tuple
          sum_loop            -- recursively call loop with new state, simulate iteration with recursion
        else
          return s            -- if condition is false, return s as final result
    
    main = print final_s
    

    正如您所看到的,这与C代码非常相似,我们只有3行额外的代码:

    • (i, s) <- get 用于获取当前状态。
    • put (new_i, new_s) 用于使用新状态更新当前状态
    • sum_loop 用于以递归方式调用带有新状态的循环,模拟迭代

    你可以使用put $ traceShowId (new_i, new_s)来添加仅用于调试的打印,而不是put (new_i, new_s),但你应该只在调试时使用它,因为它会欺骗类型系统。

    因此,还有一些事情需要“手动”处理,但在Haskell中编写相当易读的命令式代码是可能的。


    网页内容由stack overflow 提供, 点击上面的
    可以查看英文原文,
    原文链接