Template Haskell有什么不好的?

What's so bad about Template Haskell?

似乎haskell社区经常将模板haskell视为一种不幸的便利。很难准确地说出我在这方面所观察到的情况,但请考虑以下几个例子

  • 模板haskell在"丑陋(但必要)"下面列出,以回答用户应该使用/避免哪些haskell(ghc)扩展?
  • 模板haskell在newtype'd values线程(库邮件列表)的未装箱向量中考虑了一个临时/劣质的解决方案。
  • Yesod经常因过分依赖模板haskell而受到批评(参见博客帖子以回应这种情绪)

我看到过各种各样的博客文章,人们使用模板haskell做一些非常整洁的事情,实现了在常规haskell中根本不可能实现的更漂亮的语法,以及极大的样板文件减少。那么为什么哈斯克尔的模板会被这样的方式看不起呢?是什么让它不受欢迎?在什么情况下应该避免模板haskell,为什么?


避免使用haskell模板的一个原因是,它作为一个整体根本不是类型安全的,因此与"haskell的精神"背道而驰。下面是一些例子:

  • 您无法控制哪种haskell ast类型的代码将生成,超出它将出现的位置;您可以有一个类型为Exp的值,但您不知道它是表示[Char](a -> (forall b . b -> c))或其他类型的表达式。如果可以表示一个函数只能生成某种类型的表达式,或者只生成函数声明,或者只生成与模式匹配的数据构造函数等,那么它就更可靠了。
  • 您可以生成不编译的表达式。您生成了一个引用不存在的自由变量foo的表达式?不幸的是,只有在实际使用代码生成器时,并且仅在触发生成特定代码的情况下,您才会看到这一点。单元测试也很难。

这也是非常危险的:

  • 在编译时运行的代码可以执行任意的IO,包括发射导弹或窃取您的信用卡。你不想为了寻找漏洞而浏览你下载的每一个阴谋集团软件包。
  • 它可以访问"模块私有"函数和定义,在某些情况下完全破坏封装。

还有一些问题使得函数作为库开发人员的使用变得不那么有趣:

  • 代码并不总是可组合的。假设有人为透镜制作了一个生成器,通常情况下,该生成器的结构只能由"最终用户"直接调用,而不能由其他th代码调用,例如以类型构造函数列表为参数生成透镜。很难用代码生成这个列表,而用户只需编写generateLenses [''Foo, ''Bar]
  • 开发人员甚至不知道代码是可以组合的。你知不知道你能写信给forM_ [''Foo, ''Bar] generateLensQ只是一个monad,所以您可以在它上面使用所有常见的函数。有些人不知道这一点,因此,他们创建了多个基本上相同函数的重载版本,具有相同的功能,并且这些函数会导致一定的膨胀效应。而且,大多数人甚至在不需要的情况下也会在Qmonad中编写他们的生成器,这就像编写bla :: IO Int; bla = return 3一样;您提供的函数比它需要的"环境"更多,并且该函数的客户机需要为此提供该环境。

最后,还有一些事情会降低作为最终用户使用功能的乐趣:

  • 不透明度。当th函数具有Q Dec类型时,它绝对可以在模块的顶层生成任何内容,并且您绝对不能控制将生成什么内容。
  • 一元论。除非开发人员允许,否则您无法控制th函数生成的量;如果您找到一个生成数据库接口和JSON序列化接口的函数,您就不能说"不,我只需要数据库接口,谢谢;我将滚动自己的JSON接口。"
  • 运行时间。这段代码的运行时间相对较长。每次编译一个文件时,代码都会被重新解释,通常,运行th代码所需的大量包都必须被加载。这大大降低了编译时间。


这完全是我自己的意见。好的。

  • 使用起来很难看。$(fooBar ''Asdf)看起来不好看。当然是表面的,但它是有贡献的。好的。

  • 写起来更难看。有时报价是可行的,但很多时候你不得不手工嫁接和管道。API是大而笨拙的,总是有很多您不关心但仍然需要调度的情况,并且您关心的情况往往以多个相似但不相同的形式出现(数据与newtype、记录样式与普通构造函数,等等)。写东西很无聊,重复性很强,复杂到不能机械化。改革建议解决了其中的一些问题(使报价更广泛地适用)。好的。

  • 舞台限制是地狱。无法拼接在同一个模块中定义的函数是其较小的一部分:另一个后果是,如果您有一个顶级拼接,那么模块中该模块之后的所有内容都将超出其之前的任何内容的范围。其他具有此属性的语言(C,C++)使它可以通过允许向前声明事物而工作,但是Haskell不这样做。如果在拼接声明或它们的依赖项和依赖项之间需要循环引用,那么通常只需拧紧。好的。

  • 这是没有纪律的。我的意思是,大多数时候,当你表达一个抽象的时候,在这个抽象背后有某种原则或概念。对于许多抽象,它们背后的原理可以用它们的类型来表示。对于类型类,您通常可以制定实例应该遵守和客户可以假定的法律。如果使用ghc的新泛型特性在任何数据类型(在界限内)上抽象实例声明的形式,您可以说"对于sum类型,它的工作方式是这样的,对于产品类型,它的工作方式是那样的"。另一方面,模板haskell只是宏。它不是在思想层面上的抽象,而是在ASTS层面上的抽象,这比纯文本层面上的抽象更好,但只是谦逊而已。*好的。

  • 它把你和GHC联系在一起。理论上,另一个编译器可以实现它,但在实践中,我怀疑这是否会发生。(这与各种类型的系统扩展形成了鲜明对比,尽管目前它们可能只由GHC实现,但我可以很容易地想象,在未来的道路上,其他编译器会采用它们,并最终实现标准化。)好的。

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

  • 有一个一般原则,你应该为工作使用合适的工具,最小的工具就足够了,在这个类比模板中,haskell是这样的。如果有一种方法可以做到这不是模板haskell,它通常更可取。好的。

模板haskell的优点是你可以用它做你不能用其他方法做的事情,这是一个很大的方法。在大多数情况下,使用th的事情只能在它们作为编译器特性直接实现的情况下才能完成。这两种方法都非常有益,因为它可以让你做这些事情,而且它可以让你以一种更加轻量级和可重用的方式为潜在的编译器扩展原型(例如,看看各种透镜包)。好的。

总结一下为什么我认为模板haskell存在负面情绪:它解决了很多问题,但对于它解决的任何给定问题,它感觉应该有一个更好、更优雅、更规范的解决方案,更适合于解决该问题,而不是通过自动生成样板来解决问题,但是不需要样板文件。好的。

*虽然我经常觉得CPP对于那些它可以解决的问题有更好的权值比。好的。

编辑23-04-14:我在上面经常尝试的,并且最近才得到的,是抽象和重复数据消除之间有一个重要的区别。适当的抽象通常会导致重复数据消除作为副作用,而重复常常是抽象不足的一个信号,但这并不是它有价值的原因。正确的抽象是使代码正确、可理解和可维护的原因。重复数据消除只会缩短时间。与宏一样,模板haskell通常是用于重复数据消除的工具。好的。好啊。


我想谈谈Dflestr提出的一些观点。

我不认为你不能排版就这么担心。为什么?因为即使有错误,它仍然是编译时。我不确定这是否加强了我的论点,但这在精神上和在C++中使用模板时所收到的错误是相似的。我认为这些错误比C++的错误更容易理解,因为你会得到生成代码的一个漂亮的打印版本。

如果一个th表达式/拟引用者做了一些高级的事情,以至于棘手的角落可以隐藏,那么也许这是不明智的?

我最近一直在研究的准引用程序(使用haskell-src-exts/meta)打破了这个规则——https://github.com/mgsloan/sisisi-extras/tree/master/examples。我知道这会引入一些错误,比如无法在通用列表理解中拼接。但是,我认为http://hackage.haskell.org/trac/ghc/blog/template%20haskell%20提案中的一些想法很有可能会在编译器中结束。在此之前,用于分析haskell到th树的库几乎是一个完美的近似值。

关于编译速度/依赖性,我们可以使用"零"包内联生成的代码。对于给定库的用户来说,这至少是件好事,但是对于编辑库来说,我们做不到更好的事情。依赖项是否会膨胀生成的二进制文件?我认为它遗漏了编译代码没有引用的所有内容。

haskell模块编译步骤的分段限制/拆分确实很糟糕。

不透明度:这对于您调用的任何库函数都是一样的。您无法控制data.list.groupby将要执行的操作。您只是有一个合理的"保证"/约定,版本号告诉您一些关于兼容性的信息。这在某种程度上是一个不同的变化问题。

这就是使用零开始的地方——您已经在对生成的文件进行版本控制了——所以您总是知道生成的代码的形式何时发生了变化。但是,对于大量生成的代码来说,查看diff可能有点麻烦,因此这是一个更好的开发人员界面可以方便使用的地方。

重整块主义:您当然可以使用自己的编译时代码来后期处理th表达式的结果。在顶级声明类型/名称上过滤的代码不会太多。见鬼,你可以想象写一个普通的函数。对于修改/取消整体化的QuasiQuoter,可以在"QuasiQuoter"上进行模式匹配,并提取所使用的转换,或者根据旧的转换生成新的。


这个答案是对伊利西斯提出的问题的回应,一点一点地:好的。

  • It's ugly to use. $(fooBar ''Asdf) just does not look nice. Superficial, sure, but it contributes.

我同意。我觉得$()被选中看起来像是语言的一部分-使用哈斯克尔熟悉的符号托盘。但是,这正是您/不希望在用于宏拼接的符号中看到的。它们肯定混合得太多了,这一点很重要。我喜欢拼接处的外观,因为它们在视觉上很明显。好的。

  • It's even uglier to write. Quoting works sometimes, but a lot of the time you have to do manual AST grafting and plumbing. The [API][1] is big and unwieldy, there's always a lot of cases you don't care about but still need to dispatch, and the cases you do care about tend to be present in multiple similar but not identical forms (data vs. newtype, record-style vs. normal constructors, and so on). It's boring and repetitive to write and complicated enough to not be mechanical. The [reform proposal][2] addresses some of this (making quotes more widely applicable).

然而,我也同意这一点,正如"新的方向"中的一些评论所观察到的那样,缺乏开箱即用的良好AST报价并不是一个关键的缺陷。在这个WIP包中,我试图以库的形式解决这些问题:https://github.com/mgsloan/sisi-extras。到目前为止,我允许在比平时更多的地方拼接,并且可以在AST上匹配图案。好的。

  • The stage restriction is hell. Not being able to splice functions defined in the same module is the smaller part of it: the other consequence is that if you have a top-level splice, everything after it in the module will be out of scope to anything before it. Other languages with this property (C, C++) make it workable by allowing you to forward declare things, but Haskell doesn't. If you need cyclic references between spliced declarations or their dependencies and dependents, you're usually just screwed.

我遇到过循环定义的问题,以前是不可能的…这很烦人。有一个解决方案,但它很难看——在一个组合了所有生成的声明的th表达式中包装循环依赖关系中涉及的内容。其中一个声明生成器可能只是一个接受haskell代码的准引用程序。好的。

  • It's unprincipled. What I mean by this is that most of the time when you express an abstraction, there is some kind of principle or concept behind that abstraction. For many abstractions, the principle behind them can be expressed in their types. When you define a type class, you can often formulate laws which instances should obey and clients can assume. If you use GHC's [new generics feature][3] to abstract the form of an instance declaration over any datatype (within bounds), you get to say"for sum types, it works like this, for product types, it works like that". But Template Haskell is just dumb macros. It's not abstraction at the level of ideas, but abstraction at the level of ASTs, which is better, but only modestly, than abstraction at the level of plain text.

只有当你用它做没有原则的事情时,它才是没有原则的。唯一的区别是,使用编译器实现的抽象机制,您可以更加确信抽象不会泄漏。也许民主化的语言设计听起来有点可怕!这些库的创建者需要很好地记录并清楚地定义它们所提供工具的意义和结果。Principled Th的一个很好的例子是派生包:http://hackage.haskell.org/package/derive-它使用DSL,这样许多派生的例子/specify/实际派生。好的。

  • It ties you to GHC. In theory another compiler could implement it, but in practice I doubt this will ever happen. (This is in contrast to various type system extensions which, though they might only be implemented by GHC at the moment, I could easily imagine being adopted by other compilers down the road and eventually standardized.)

这是一个很好的观点——THAPI非常大而且笨重。重新实施似乎很困难。然而,对于表示haskell-asts的问题,实际上只有几种方法可以解决。我想复制th ADT,并编写一个内部AST表示的转换器,可以为您提供很多方法。这相当于创建haskell-src-meta的努力(不是微不足道的)。它也可以通过漂亮地打印AST并使用编译器的内部解析器来简单地重新实现。好的。

虽然我可能是错的,但从实现的角度来看,我不认为这是编译器扩展的复杂性。这实际上是"保持简单"的好处之一,并且没有基本层是一些理论上有吸引力的、静态可验证的模板系统。好的。

  • The API isn't stable. When new language features are added to GHC and the template-haskell package is updated to support them, this often involves backwards-incompatible changes to the TH datatypes. If you want your TH code to be compatible with more than just one version of GHC you need to be very careful and possibly use CPP.

这也是一个很好的观点,但有些夸张。虽然最近有API添加,但它们并没有广泛地导致破坏。另外,我认为通过前面提到的优越的AST引用,实际需要使用的API可以大大减少。如果没有构造/匹配需要不同的函数,而是用文本表示,那么大多数API都会消失。此外,对于类似haskell的语言,您编写的代码将更容易移植到AST表示。好的。

总之,我认为这是一个强大的、半被忽视的工具。更少的仇恨可能导致一个更加活跃的库生态系统,鼓励更多语言特性原型的实现。据观察,这是一个过度动力的工具,可以让你/做/几乎任何事情。无政府状态!好吧,我的观点是,这种能力可以让你克服它的大部分局限性,并且构建能够使用非常有原则的元编程方法的系统。使用丑陋的黑客来模拟"正确"的实现是值得的,这样"正确"的实现设计就逐渐变得清晰。好的。

在我个人理想的涅磐版本中,很多语言实际上会从编译器转移到这些种类的库中。这些特性作为库实现的事实并不严重影响它们忠实抽象的能力。好的。

典型的haskell对样板代码的回答是什么?抽象。我们最喜欢的抽象是什么?函数和类型类!好的。

typeclasses允许我们定义一组方法,然后可以在该类上的所有通用函数中使用这些方法。但是,除此之外,类帮助避免样板文件的唯一方法是提供"默认定义"。下面是一个无原则特性的例子!好的。

  • 最小绑定集不可声明/编译器可检查。这可能导致无意中的定义,由于相互递归而产生底部。好的。

  • 尽管这会带来极大的便利和强大的功能,但是您不能指定超类默认值,因为孤立实例http://lukepalmer.wordpress.com/2009/01/25/a-world-without-orphans/这些将使我们能够优雅地修复数字层次结构!好的。

  • 在方法默认值的类似功能之后,出现了http://www.haskell.org/haskellwiki/ghc.generics。虽然这是很酷的东西,但我唯一的经验是使用这些泛型调试代码几乎是不可能的,因为类型的大小和ADT的复杂程度与AST类似。https://github.com/mgsloan/th-extra/commit/d7784d95d396eb3abdb409a24360beb0371c88c好的。

    换言之,这符合th提供的特性,但它必须将语言的整个领域(构造语言)提升为类型系统表示。虽然我可以看到它很好地解决了你的常见问题,对于复杂的问题,它似乎倾向于产生一堆比黑客更可怕的符号。好的。

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

我认为,对th和lisp类元编程的拒绝导致了对方法默认值等内容的偏好,而不是更灵活的宏扩展(如实例声明)。避免可能导致不可预见结果的事情的原则是明智的,但是,我们不应该忽视haskell的类型系统允许比许多其他环境(通过检查生成的代码)更可靠的元编程。好的。好啊。


模板haskell的一个相当实际的问题是,它只在GHC的字节码解释器可用时才起作用,而并非所有架构都是如此。因此,如果您的程序使用模板haskell或依赖于使用它的库,它将不会在带有ARM、MIPS、S390或PowerPCCPU的计算机上运行。

这在实践中是相关的:git-annex是一个用haskell编写的工具,在担心存储的机器上运行是有意义的,这样的机器通常有非i386-cpu。就我个人而言,我在NSLU 2上运行Git-Annex(32MB的RAM,266MHzCPU;您知道Haskell在这种硬件上工作正常吗?)如果它使用模板haskell,这是不可能的。

(近几天,有关持械温室气体排放的情况有了很大改善,我认为7.4.2甚至有效,但这一点仍然存在)。


为什么不好?对我来说,归根结底就是:

If you need to produce so much repetitive code that you find yourself trying to use TH to auto-generate it, you're doing it wrong!

想想看。Haskell的一半吸引力在于它的高级设计允许您避免使用其他语言编写大量无用的样板代码。如果您需要编译时代码生成,您基本上是说您的语言或应用程序设计使您失败了。我们程序员不喜欢失败。

当然,有时是必要的。但有时你可以通过对你的设计再聪明一点来避免需要它。

(另一件事是,这是相当低的水平。没有大的高层设计,很多GHC的内部实现细节都暴露了出来。这使得API很容易改变…)