Template Haskell有什么不好的地方?

270
似乎Template Haskell经常被Haskell社区视为不幸的便利。很难用准确的语言来描述我在这方面观察到的情况,但请考虑以下几个例子:

我看到过各种博客文章,人们使用Template Haskell做了一些相当不错的东西,使得在普通的Haskell中无法实现更漂亮的语法,并大大减少了样板文件。那么为什么Template Haskell会被这样看低?它有什么不可取之处?在什么情况下应避免使用Template Haskell,为什么?


60
我不同意迁移的投票结果;我提出这个问题的初衷与我之前提出 Lazy I/O有什么不好? 的方式相同,希望看到类似的回答。如果需要,我可以重新措辞问题。 - Dan Burton
55
为什么不让经常浏览这个标签的人决定它是否适合在这里?你似乎唯一与Haskell社区互动的方式就是贬低它的问题。 - Gabriella Gonzalez
31
@ErikPhilips 推荐引擎方面与此问题无关,因为可以注意到这是指在不同工具之间做出决策的情况(例如,“告诉我应该使用哪种语言”)。相反,我仅仅是在询问关于模板哈斯克尔的解释,而FAQ中指出:“如果你的动机是‘我希望别人向我解释[空白]’,那么你可能没问题。”请参照比如“GOTO是否仍然有害?”的例子。 - Dan Burton
5
我已经编辑了标题和结论句,将焦点从社区的行为转向Template Haskell本身。虽然我已经接受了一个答案,但如果还有更多可探讨的内容,我会很乐意看到更多回答。 - Dan Burton
33
投票以重新开放。仅因为这是一个更高级别的问题,并不意味着它不是一个好问题。 - György Andrasek
显示剩余3条评论
6个回答

177

避免使用Template Haskell的一个原因是它整体上完全不具备类型安全,这违背了“Haskell精神”的许多要求。以下是一些例子:

  • 你无法控制TH代码生成的Haskell AST的类型,除非你知道它会出现在哪里;你可以有一个Exp类型的值,但你不知道它是否表示一个[Char](a -> (forall b . b -> c))等表达式。如果能够表达函数只能生成某种类型的表达式、或只能生成函数声明、或只能生成匹配数据构造模式等限制,TH将更加可靠。
  • 你可以生成编译不通过的表达式。你生成了一个引用不存在的自由变量foo的表达式?那就倒霉了,你只有在实际使用代码生成器时,并且在触发生成特定代码的情况下才能看到这个错误。这也很难进行单元测试。

TH也是非常危险的:

  • 在编译时运行的代码可以执行任意的IO,包括发射导弹或窃取您的信用卡。您不希望为了寻找TH漏洞而查看您下载的每个cabal软件包。
  • TH可以访问“模块私有”函数和定义,在某些情况下完全破坏了封装。

然后还有一些问题会使TH函数在作为库开发人员使用时变得不那么有趣:

  • TH代码并不总是可以组合的。比如说,有人为镜头制作了一个生成器,往往这个生成器的结构只能被“最终用户”直接调用,而不能被其他TH代码调用,例如通过将要为其生成镜头的类型构造函数列表作为参数。在代码中生成该列表是棘手的,而用户只需编写 generateLenses [''Foo, ''Bar]
  • 开发者甚至不知道TH代码可以组合。你知道可以编写 forM_ [''Foo, ''Bar] generateLens 吗?Q只是一个单子,因此您可以在其中使用所有常规函数。有些人不知道这一点,因此他们创建多个重载版本的本质上具有相同功能的函数,并且这些函数会导致某种膨胀效应。此外,大多数人即使不必也会在Q单子中编写其生成器,这就像编写 bla :: IO Int; bla = return 3; 您正在为函数提供更多的“环境”而不是它所需的,函数的客户端需要以该效果作为环境提供。

最后,有一些事情会让TH函数在最终用户使用时变得不那么有趣:

  • 不透明性。当TH函数具有类型Q Dec时,它可以在模块的顶层生成任何东西,而您无法控制将生成什么。
  • 整体性。除非开发人员允许,否则您无法控制TH函数生成的数量;如果您找到一个生成数据库接口JSON序列化接口的函数,您无法说“不,谢谢,我只想要数据库接口;我会自己编写JSON接口”
  • 运行时间。TH代码运行时间相对较长。每次编译文件时都会重新解释代码,并且通常需要运行TH代码所需的大量软件包来加载。这会显著减慢编译时间。

6
此外,模板Haskell历史上一直文档不全(虽然我刚才重新查看了一下,似乎现在情况至少稍微好了一些)。此外,要理解模板Haskell,你基本上必须理解Haskell语言的语法,这就带来了一定的复杂性(它不是Scheme)。这两个因素导致我在我刚开始学习Haskell时故意不去理解TH。 - mightybyte
17
请注意,使用模板Haskell突然意味着声明的顺序很重要! 尽管Haskell(无论是1.4,'98,2010甚至是Glasgow)看起来平滑且完美,但TH并没有像人们所希望的那样紧密集成。 - Thomas M. DuBuisson
13
你可以比较轻松地理解 Haskell,但 Template Haskell 不具备这种保证。 - augustss
17
Oleg曾承诺提供一种基于他的“Finally Tagless, Partially Evaluated”论文和更多笔记此处的类型安全TH替代方案,这个计划进展如何?虽然当他们宣布时看起来很有前途,但后来我再也没有听到任何消息。请问发生了什么? - Gabriella Gonzalez
13
有一个提案旨在解决一些 TH(Template Haskell)的问题。 - sdcvvc
显示剩余5条评论

54
这仅仅是我的个人观点。
  • 使用起来很难看。 $(fooBar ''Asdf) 看起来不太好看。表面上是这样,但它确实有影响。

  • 编写起来更加难看。引用有时可以起作用,但大多数情况下你必须手动进行AST嫁接和管道操作。API 转化起来很麻烦,有很多你不关心的情况,但仍然需要分派,并且你关心的情况往往以多个类似但不完全相同的形式存在(数据与新类型、记录样式与正常构造函数等)。编写起来很无聊、很重复,也很复杂,不能机械化。reform proposal 解决了其中的一些问题(使引用更广泛适用)。

  • 阶段限制很麻烦。不能剖析在同一模块中定义的函数只是其中的一部分:另一个后果是,如果你有一个顶级剖析,那么在模块中它之后的所有内容都将对它之前的任何内容不可见。其他具有此属性的语言(C、C++)通过允许您预先声明事物来使其可行,但 Haskell 没有这样做。如果您需要剖析声明或其依赖项和从属项之间的循环引用,通常只是无法解决。

  • 不够规范。我的意思是,大多数情况下,当你表达一个抽象时,这个抽象背后有某种原则或概念。对于许多抽象,它们背后的原则可以在它们的类型中表达。对于类型类,您经常可以制定实例应遵守的法律,并且客户端可以假设。如果您使用 GHC 的 new generics feature 将实例声明的形式抽象为任何数据类型(在限制范围内),则可以说“对于总和类型,它的工作方式是这样的,对于乘积类型,它的工作方式是那样的”。另一方面,模板 Haskell 只是宏。这不是在思想层面上的抽象,而是在 AST 层面上的抽象,比纯文本层面上的抽象好,但仅仅是更好而已。

  • 它将你与 GHC 绑定在一起。理论上,另一个编译器可能会实现它,但实际上我怀疑这将永远不会发生。(这与各种类型系统扩展形成对比,尽管它们目前只由 GHC 实现,但我可以很容易地想象它们在将来被其他编译器采用并最终标准化。)

  • API 不稳定。当新的语言特性添加到 GHC 中并且 template-haskell 包更新以支持它们时,这通常涉及对 TH 数据类型的不兼容更改。如果你希望你的 TH 代码与不止一个版本的 GHC 兼容,你需要非常小心,可能需要使用 CPP

  • 有一个普遍的原则,即你应该使用适合工作的正确工具和最小的工具,在这个类比中,模板 Haskell 就像是 something like this。如果有一种不使用模板 Haskell 的方法,通常是更可取的。

    Template Haskell的优点在于你可以用它做一些其他方法无法做到的事情,而且这是一个很大的优点。大多数时候,如果不是将它们直接实现为编译器特性,否则TH用于的事情就只能以其他方式完成。TH非常有益,因为它让你做这些事情,并且它让你以更轻量级和可重用的方式原型化潜在的编译器扩展(例如,参见各种镜头包)。
    总结一下我为什么认为有人对Template Haskell持负面情绪的原因:它解决了很多问题,但对于任何给定的问题,似乎都应该有更好、更优雅、更有纪律性的解决方案,更适合解决该问题,这种解决方案不是通过自动生成样板文件来解决问题,而是通过消除需要拥有样板文件的需要来解决问题。
    * 尽管我经常觉得CPP在解决它能解决的那些问题时具有更好的功率重量比。
    EDIT 23-04-14:我之前一直试图表达的是,抽象和去重之间有一个重要的区别。适当的抽象通常会导致副作用的去重,而重复通常是不充分抽象的明显标志,但这并不是它有价值的原因。适当的抽象是使代码正确、可理解和可维护的关键。去重只能使代码更短。Template Haskell(像一般的宏一样)是用于去重的工具。

“对于它可以解决的问题,CPP具有更好的功率重量比。”确实如此。在C99中,它可以解决您想要的任何问题。考虑这些:COSChaos。我也不明白为什么人们认为AST生成更好。这只会增加歧义,并减少与其他语言特性的正交性。 - Britton Kerin
您的改革建议链接已经失效。 - eyelash
@eyelash,我更新了链接。 - dfeuer

31
我想谈论一下dflemstr提出的几个观点。
我并不认为无法对TH进行类型检查是太令人担忧的。为什么?因为即使有错误,它仍然是编译时期间发现的。我不确定这是否加强了我的论点,但这类似于在C++中使用模板会收到的错误。我认为这些错误比C++的错误更容易理解,因为您将获得生成代码的漂亮打印版本。
如果TH表达式/准引用程序执行的操作非常先进,那么可能是不明智的吗?
最近我一直在使用quasi-quoters(使用haskell-src-exts/meta)打破了这个规则 - https://github.com/mgsloan/quasi-extras/tree/master/examples。 我知道这会引入一些错误,例如无法将广义列表推导式插入其中。但是,我认为http://hackage.haskell.org/trac/ghc/blog/Template%20Haskell%20Proposal 中的一些想法最终会被纳入编译器。在那之前,将Haskell解析为TH树的库是一个几乎完美的近似。
关于编译速度/依赖性,我们可以使用"zeroth"软件包来内联生成的代码。这至少对于给定库的用户来说很好,但是对于编辑库而言,我们无法做得更好。 TH依赖项是否会使生成的二进制文件膨胀?我认为它会留下未被编译代码引用的所有内容。
Haskell模块的分阶段限制/编译步骤拆分确实很糟糕。
关于不透明性:这与您调用的任何库函数相同。您无法控制Data.List.groupBy将执行什么操作。您只有一个合理的“保证”/约定,即版本号告诉您有关兼容性的一些信息。当然,如果出现更改,则情况会有所不同。

使用 zeroth 的好处在于你已经将生成的文件进行了版本控制,所以你总是知道生成代码的形式何时发生了变化。不过,对于大量生成的代码来说,查看差异可能有些棘手,因此更好的开发者接口会非常方便。

关于“单体”: 你当然可以使用自己的编译时代码对 TH 表达式的结果进行后处理。过滤顶层声明类型/名称不需要太多的代码。你甚至可以想象编写一个通用函数来完成此操作。对于修改或去除单体 quasiquoters,你可以模式匹配 "QuasiQuoter" 并提取出使用的转换,或者用旧的方式创建一个新的 quasiquoter。


1
关于Opacity/Monolithism:当然,你可以穿过一个[Dec]并删除你不想要的东西,但是假设该函数在生成JSON接口时读取外部定义文件。仅因为您不使用该Dec并不意味着生成器停止查找定义文件,从而导致编译失败。因此,最好有一个更严格版本的Q单子,它允许您生成新名称(和类似的内容),但不允许IO,这样,正如您所说,可以过滤其结果/进行其他组合。 - dflemstr
1
我同意,应该有一个非IO版本的Q / quotation!这也将有助于解决- https://dev59.com/Amw05IYBdhLWcg3wqzis。一旦您确保编译时代码不会执行任何不安全操作,似乎可以在生成的代码上运行安全检查器,并确保它不引用私有内容。 - mgsloan
1
你有写下你的反驳吗?还在等待你承诺的链接。对于那些寻找这个答案旧内容的人:http://stackoverflow.com/revisions/10859441/1,对于那些可以查看已删除内容的人:http://stackoverflow.com/revisions/10913718/6 - Dan Burton
1
零版在Hackage上是否有更新版本?那个版本(以及darcs repo)最后更新于2009年。这两个版本都无法使用当前的ghc(7.6)进行构建。 - aavogt
1
@aavogt tgeeky 正在努力修复它,但我不认为他完成了:https://github.com/technogeeky/zeroth如果没有人为此做出贡献或者制作出什么东西,我最终会处理它。 - mgsloan
显示剩余2条评论

15
这个答案是针对illissius提出的问题逐点回应的:
  • 使用起来很丑。$(fooBar''Asdf)看起来不好看。表面上看,当然,但它确实有影响。
我同意。我觉得$( )被选择是为了看起来像语言的一部分 - 使用Haskell熟悉的符号组合。然而,这正是您不希望在宏拼接中使用的符号。它们肯定混合得太多了,这个美学方面非常重要。我喜欢{{ }}的外观,因为它们非常视觉上独特。
它的写法甚至更加丑陋。引用有时有效,但很多时候您需要手动进行AST嫁接和管道操作。 [API] [1] 太大而难以操作,总会有许多您不关心但仍需要分派的情况,并且您关心的情况往往以多个类似但不完全相同的形式存在(数据 vs。newtype,记录样式 vs。普通构造函数等)。编写这些内容是乏味且重复的,并且足够复杂以不是机械化的。[改革提案] [2] 解决了其中一些问题(使引用更广泛适用)。
我也同意这一点,然而,“TH的新方向”中的一些评论指出,缺乏良好的开箱即用的AST引用并不是一个关键性的缺陷。在这个WIP包中,我试图以库的形式解决这些问题:https://github.com/mgsloan/quasi-extras。到目前为止,我允许在一些比平常更多的地方插入代码,并且可以对AST进行模式匹配。
舞台限制是一个大问题。无法拼接在同一模块中定义的函数只是其中的一个较小部分:另一个后果是,如果你有一个顶层splice,那么在它之后的整个模块将对它之前的任何内容都不可见。其他具有此属性的语言(如C、C++)通过允许您进行前向声明使其可行,但Haskell没有这样的功能。如果您需要在spliced声明或其依赖项和从属项之间进行循环引用,则通常会遇到困难。
我之前遇到过不可能存在循环TH定义的问题……非常烦人。有一个解决方案,但很丑陋——将涉及到循环依赖关系的内容包装在一个TH表达式中,该表达式组合了所有生成的声明。其中一个这些声明生成器可以只是一个接受Haskell代码的准引用程序。
“这是不可取的。我的意思是,当你表达一个抽象概念时,通常会有某种原则或概念支撑着这个抽象概念。对于许多抽象概念来说,它们背后的原则可以通过它们的类型来表达。当你定义一个类型类时,通常可以制定实例应该遵守的法则,客户端也可以假设这些法则成立。如果你使用 GHC 的新泛型特性将实例声明的形式抽象化到任何数据类型(在限制范围内),你可以说‘对于和类型,它的工作方式是这样的,对于积类型,它的工作方式是那样的’。但是模板 Haskell 只是愚蠢的宏,它并没有在思想层面上进行抽象,而只是在 AST 层面上进行了抽象,这比纯文本层面上的抽象好一点,但只是稍微好一点而已。”
只有当你使用不道德的方式时,它才是不道德的。唯一的区别在于,通过编译器实现的抽象机制,你更有信心抽象不会泄漏。也许民主化语言设计听起来有点可怕!TH库的创建者需要进行良好的文档记录,并清晰地定义他们所提供的工具的含义和结果。一个良好的TH原则的例子是derive包:http://hackage.haskell.org/package/derive - 它使用DSL,以便许多派生的例子/指定/实际的派生。
它将你与GHC绑定在一起。理论上,另一个编译器可以实现它,但实际上我怀疑这将永远不会发生。(这与各种类型系统扩展形成对比,尽管它们目前可能只由GHC实现,但我可以轻松想象它们在未来被其他编译器采用并最终标准化。)
这是一个很好的观点 - TH API相当庞大且笨重。重新实现似乎会很困难。然而,表示Haskell AST的问题只有几种方式。我想复制TH ADTs,并编写一个转换器将其转换为内部AST表示,这样可以解决大部分问题。这相当于创建haskell-src-meta所需的(不可忽视的)工作量。它也可以通过对TH AST进行漂亮的打印并使用编译器的内部解析器来简单地重新实现。
尽管我可能错了,但从实现的角度来看,我认为TH并不是那么复杂的编译器扩展。这实际上是“保持简单”并且基本层不是一些理论吸引人、静态可验证的模板系统的好处之一。
API不稳定。当新的语言特性添加到GHC中,template-haskell包更新以支持它们时,这通常涉及到TH数据类型的不兼容更改。如果您希望您的TH代码与多个版本的GHC兼容,您需要非常小心,可能需要使用CPP。
这也是一个很好的观点,但有些夸张。虽然最近有一些API添加,但它们并没有造成广泛的破坏。另外,我认为通过我之前提到的优秀AST引用,实际需要使用的API可以大大减少。如果没有构建/匹配需要不同的函数,而是表示为文字,则大部分API都会消失。此外,您编写的代码将更容易移植到类似于Haskell的语言的AST表示形式中。
总之,我认为TH是一个强大但半被忽视的工具。更少的厌恶可能会导致更活跃的库生态系统,鼓励实现更多的语言特性原型。观察到TH是一个过度强大的工具,可以让你/do/几乎任何事情。混乱!我的意见是,这种力量可以让你克服它的大部分限制,并构建能够采用相当原则性元编程方法的系统。值得使用丑陋的黑客技巧来模拟“正确”实现,因为这样“正确”实现的设计将逐渐变得清晰。
在我个人理想的涅磐版本中,很多语言实际上会移出编译器,进入这些类型的库。特性作为库实现并不会严重影响它们忠实地抽象的能力。
Haskell对样板代码的典型回答是抽象。我们最喜欢的抽象是什么?函数和类型类!
类型类让我们定义一组方法,然后可以在所有泛型于该类的函数中使用。然而,除此之外,类别帮助避免样板代码的唯一方法是提供“默认定义”。现在这里有一个不合理功能的例子!
  • 最小绑定集是不可声明/编译器检查的。这可能会导致由于相互递归而产生底部的意外定义。

  • 尽管这将带来极大的便利和能力,但由于孤立实例的存在,您无法指定超类默认值 http://lukepalmer.wordpress.com/2009/01/25/a-world-without-orphans/。这些将让我们优雅地修复数字层次结构!

  • 追求类似TH的方法默认值功能导致了http://www.haskell.org/haskellwiki/GHC.Generics。虽然这很酷,但我使用这些泛型调试代码的唯一经验几乎是不可能的,因为类型大小与AST一样复杂。https://github.com/mgsloan/th-extra/commit/d7784d95d396eb3abdb409a24360beb03731c88c

    换句话说,这是在寻求TH提供的功能,但它必须将语言的整个构造语言提升到类型系统表示中。虽然我可以看到它在解决常见问题方面运行良好,但对于复杂问题,它似乎容易产生比TH hackery更令人恐惧的符号堆。

    TH为您提供输出代码的值级编译时计算,而泛型强制将代码的模式匹配/递归部分提升到类型系统中。虽然这确实限制了用户的一些相当有用的方式,但我认为这种复杂性不值得。

我认为对TH和类Lisp元编程的拒绝导致了对像方法默认值这样的东西的偏好,而不是更灵活的、类似宏展开的实例声明。避免可能导致意外结果的事物的纪律是明智的,然而,我们不应忽视Haskell强大的类型系统可以比许多其他环境更可靠地进行元编程(通过检查生成的代码)。

4
这个答案本身不是很完整:你引用了另一个答案,我必须去找到那个答案,才能正确地阅读你的答案。请问这两个答案有什么关联吗? - Ben Millwood
没错。尽管有些困难,我还是试图清楚地表达谈论的内容。也许我会编辑一下,加入illisuis的观点。 - mgsloan
我想说,“不道德”可能是一个比我应该使用的更强烈的词,带有一些我没有想到的含义 - 它当然不是不道德的!那个要点是我最困难的,因为我有这种感觉或不成形的想法在我的脑海中,并且很难用言语表达,“不道德”是我抓住的某些词之一,就在附近。“自律”可能更接近。由于没有清晰简洁的表述,我选择了一些例子。如果有人能更清楚地解释我可能的意思,我将不胜感激! - glaebhoerl
以下是有关编程的内容,请将其从英语翻译为中文。只返回翻译后的文本:(我认为您可能混淆了分段限制和难写的引号。) - glaebhoerl
1
糟糕!谢谢你指出来!已修复。是的,不守纪律可能是一个更好的说法。我认为这里的区别实际上在于“内置”而且因此“理解良好”的抽象机制,与“特别”,或者用户定义的抽象机制之间。我想我的意思是,你可以构建一个TH库,实现类似于类型类分发的东西(虽然是在编译时而不是运行时)。 - mgsloan

8
Template Haskell存在一个实际的实用问题,它只能在GHC的字节码解释器可用的情况下工作,而不是在所有架构上都适用。因此,如果您的程序使用Template Haskell或依赖于使用它的库,则无法在ARM、MIPS、S390或PowerPC CPU的计算机上运行。
这在实践中是相关的:git-annex是一个用Haskell编写的工具,适合在关注存储的机器上运行,这些机器通常具有非i386 CPU。我个人在NSLU 2上运行git-annex(32 MB RAM,266MHz CPU;你知道Haskell在这样的硬件上运行得很好吗?)如果它使用Template Haskell,则不可能。
(现在GHC在ARM上的情况正在大大改善,我认为7.4.2甚至可以工作,但问题仍然存在)。

1
Template Haskell依赖于GHC内置的字节码编译器和解释器来运行splice表达式。 - Joachim Breitner
3
啊,我发现——不,TH没有字节码解释器是行不通的,但这与ghci虽然有关但又不同。如果ghci的可用性与字节码解释器的可用性之间存在完美的关系,我就不会感到惊讶了,因为ghci确实依赖于字节码解释器,但问题在于缺乏字节码解释器,而不是特别缺乏ghci。 - muhmuhten
1
(顺便说一下,ghci 对 TH 的交互使用支持相当差。试试这个命令:ghci -XTemplateHaskell <<< '$(do Language.Haskell.TH.runIO $ (System.Random.randomIO :: IO Int) >>= print; [| 1 |] )' - muhmuhten
好的,我提到的ghci是指GHC解释(而不是编译)代码的能力,无论代码是从ghci二进制文件中交互式地输入,还是来自TH插值。 - Joachim Breitner

7
为什么TH不好?对我来说,就是这个原因:
如果你需要编写大量重复的代码并尝试使用TH自动生成它,那么你做错了!
想一想,Haskell的吸引力之一就在于其高级设计使您可以避免在其他语言中必须编写的大量无用样板代码。如果您需要在编译时生成代码,那么您基本上是在说要么您的语言,要么您的应用程序设计已经让您失望了。而我们程序员不喜欢失败。
当然,有时候是必要的。但是有时候您可以通过设计更聪明来避免需要TH。
(另一件事是TH相当低级。没有宏大的高级设计;许多GHC的内部实现细节都被暴露出来。这使得API容易发生变化...)

我不认为这意味着你的语言或应用程序失败了,特别是如果我们考虑到 QuasiQuotes。在语法方面总是存在权衡。某些语法能更好地描述某个领域,因此有时您希望能够切换到另一种语法。QuasiQuotes 允许您优雅地在不同语法之间切换。这非常强大,并被 Yesod 和其他应用程序所使用。能够使用类似 HTML 的语法编写 HTML 生成代码是一个令人惊喜的功能。 - CoolCodeBro
@CoolCodeBro 嗯,准引用确实很不错,我想它可能与TH略有不同。(显然,它是在TH之上实现的。)我更多地考虑使用TH生成类实例,或构建多个参数的函数之类的东西。 - MathematicalOrchid

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