62

上帝我讨厌“代码气味”这个词,但我想不出更准确的说法。

我在业余时间为Whitespace设计高级语言和编译器,以了解编译器构造、语言设计和函数式编程(编译器是用 Haskell 编写的)。

在编译器的代码生成阶段,我必须在遍历语法树时维护“状态”数据。例如,在编译流控制语句时,我需要为要跳转到的标签生成唯一名称(从传入、更新和返回的计数器生成的标签,并且不得再次使用计数器的旧值)。另一个例子是当我在语法树中遇到内联字符串文字时,需要将它们永久转换为堆变量(在 Whitespace 中,字符串最好存储在堆中)。我目前正在将整个代码生成模块包装在 state monad 中来处理这个问题。

有人告诉我,编写编译器是一个非常适合函数式范例的问题,但我发现我设计它的方式与我在 C 中设计它的方式非常相似(你真的可以用任何语言编写 C - 甚至带有状态单子的 Haskell)。

我想学习如何在 Haskell 中思考(而不是在函数范式中)——而不是在带有 Haskell 语法的 C 中。我真的应该尝试消除/最小化 state monad 的使用,还是它是一种合法的功能性“设计模式”?

4

8 回答 8

47

我在 Haskell 中编写了多个编译器,状态 monad 是许多编译器问题的合理解决方案。但是你想让它保持抽象——不要让它明显你正在使用一个 monad。

这是来自 Glasgow Haskell Compiler 的一个示例(我没有编写它我只是围绕一些边缘工作),我们在其中构建控制流图。以下是制作图表的基本方法:

empyGraph    :: Graph
mkLabel      :: Label -> Graph
mkAssignment :: Assignment -> Graph  -- modify a register or memory
mkTransfer   :: ControlTransfer -> Graph   -- any control transfer
(<*>)        :: Graph -> Graph -> Graph

但正如您所发现的,维持唯一标签的供应充其量是乏味的,因此我们也提供以下功能:

withFreshLabel :: (Label -> Graph) -> Graph
mkIfThenElse :: (Label -> Label -> Graph) -- branch condition
             -> Graph   -- code in the 'then' branch
             -> Graph   -- code in the 'else' branch 
             -> Graph   -- resulting if-then-else construct

整个Graph事情是一个抽象类型,翻译者只是愉快地以纯粹的功能方式构造图形,而不知道任何单子正在发生。然后,当最终构建图时,为了将其转换为可以生成代码的代数数据类型,我们为其提供唯一标签,运行状态单子,并提取数据结构。

状态单子隐藏在下面;虽然它没有暴露给客户端,但定义Graph是这样的:

type Graph = RealGraph -> [Label] -> (RealGraph, [Label])

或者更准确一点

type Graph = RealGraph -> State [Label] RealGraph
  -- a Graph is a monadic function from a successor RealGraph to a new RealGraph

状态单子隐藏在抽象层后面,一点也不臭!

于 2009-03-04T04:23:24.473 回答
43

我会说这种状态通常不是代码气味,只要它保持小且控制良好。

这意味着使用诸如 State、ST 或定制的 monad,或者只是拥有一个包含状态数据的数据结构,然后传递到几个地方,这并不是一件坏事。(实际上,monad 只是帮助做这件事!)然而,拥有遍布整个地方的状态(是的,这意味着你,IO monad!)是一种难闻的气味。

一个相当明显的例子是,当我的团队为2009 年 ICFP 编程竞赛的参赛作品工作时(代码可在 git://git.cynic.net/haskell/icfp-contest-2009 获得)。我们最终得到了几个不同的模块化部分:

  • VM:运行模拟程序的虚拟机
  • 控制器:几组不同的例程,它们读取模拟器的输出并生成新的控制输入
  • 解决方案:根据控制器的输出生成解决方案文件
  • 可视化器:几组不同的例程,它们读取输入和输出端口,并生成某种可视化或模拟过程中发生的日志

它们中的每一个都有自己的状态,它们都通过 VM 的输入和输出值以各种方式进行交互。我们有几个不同的控制器和可视化器,每个都有自己不同的状态。

这里的关键是任何特定状态的内部都仅限于它们自己的特定模块,每个模块甚至对其他模块的状态存在一无所知。任何特定的有状态代码和数据集通常只有几十行,状态中包含少量数据项。

所有这些都被粘在一个大约十几行的小函数中,它无法访问任何状态的内部,它只是在循环模拟时以正确的顺序调用正确的东西,并通过一个非常有限的每个模块的外部信息量(当然还有模块的先前状态)。

当状态以如此有限的方式使用,并且类型系统阻止您无意中修改它时,它很容易处理。这是 Haskell 的优点之一,它可以让你做到这一点。

一个答案是:“不要使用单子。” 在我看来,这完全是倒退。Monad 是一种控制结构,除其他外,它可以帮助您最大限度地减少接触状态的代码量。如果您以单子解析器为例,解析器的状态(即,正在解析的文本、已进入其中的程度、已累积的任何警告等)必须贯穿解析器中使用的每个组合器. 然而,实际上直接操纵状态的组合器只有少数;其他任何东西都使用这几个功能之一。这使您可以在一个地方清楚地看到所有可以更改状态的少量代码,并且更容易推断出如何更改状态,从而更容易处理。

于 2009-06-30T07:10:14.157 回答
6

你看过属性语法(AG)吗?(有关维基百科的更多信息和Monad Reader 中的文章)?

使用 AG,您可以将属性添加到语法树。这些属性分为合成属性和继承属性。

合成属性是您从语法树生成(或合成)的东西,这可能是生成的代码,或所有注释,或您感兴趣的任何其他内容。

继承的属性被输入到您的语法树中,这可能是环境,或者是代码生成期间要使用的标签列表。

在乌得勒支大学,我们使用属性语法系统( UUAGC ) 来编写编译器。这是一个预处理器,它.hs从提供的文件生成haskell 代码(文件).ag


虽然,如果你还在学习 Haskell,那么也许现在不是开始学习另一层抽象的时候。

在这种情况下,您可以手动编写属性语法为您生成的那种代码,例如:

data AbstractSyntax = Literal Int | Block AbstractSyntax
                    | Comment String AbstractSyntax

compile :: AbstractSyntax -> [Label] -> (Code, Comments)
compile (Literal x) _      = (generateCode x, [])
compile (Block ast) (l:ls) = let (code', comments) = compile ast ls
                             in (labelCode l code', comments)
compile (Comment s ast) ls = let (code, comments') = compile ast ls
                             in (code, s : comments')

generateCode :: Int -> Code
labelCode :: Label -> Code -> Code
于 2009-03-03T20:41:21.387 回答
3

您可能想要一个应用函子而不是单子:

http://www.haskell.org/haskellwiki/Applicative_functor

我认为原始论文比维基解释得更好,但是:

http://www.soi.city.ac.uk/~ross/papers/Applicative.html

于 2009-03-04T11:56:30.017 回答
2

我不认为使用 State Monad 来建模状态时会产生代码异味。

如果您需要通过函数线程化状态,您可以显式执行此操作,将状态作为参数并在每个函数中返回它。State Monad 提供了一个很好的抽象:它为您传递状态并提供许多有用的函数来组合需要状态的函数。在这种情况下,使用 State Monad(或 Applicatives)并不是代码异味。

但是,如果您使用 State Monad 来模拟命令式编程风格,而函数式解决方案就足够了,那么您只会让事情变得复杂。

于 2013-05-03T12:52:24.743 回答
0

一般来说,您应该尽可能避免使用状态,但这并不总是可行的。 Applicative使有效的代码看起来更漂亮、更实用,尤其是树遍历代码可以从这种风格中受益。对于名称生成问题,现在有一个相当不错的包可用:value-supply

于 2009-03-04T15:03:07.663 回答
-5

好吧,不要使用单子。函数式编程的强大之处在于函数纯度和它们的重用。我的一位教授曾经写过这篇论文,他是帮助构建 Haskell 的人之一。

这篇论文叫做“为什么函数式编程很重要”,我建议你通读一遍。这是一个很好的阅读。

于 2009-03-03T20:12:59.223 回答
-13

让我们注意这里的术语。状态本身并不坏;函数式语言有状态。当您发现自己想要分配变量值并更改它们时,什么是“代码气味”。

当然,Haskell 状态 monad 的存在正是出于这个原因——与 I/O 一样,它让您在受限的上下文中执行不安全和无功能的事情。

所以,是的,这可能是代码的味道。

于 2009-03-03T20:14:13.573 回答