关于Template Haskell有什么不好的?

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

我看过很多博客文章,人们用Template Haskell做了一些非常整洁的事情,实现了在普通Haskell中无法实现的漂亮语法,以及大量的样板文件缩减。那么为什么Template Haskell会这样被轻视呢?是什么让它不受欢迎?在什么情况下应该避免使用Template Haskell,为什么?

26014 次浏览

避免Template Haskell的一个原因是,它作为一个整体根本不是类型安全的,因此与“Haskell的精神”相悖。以下是一些例子:

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

TH也是完全危险的:

  • 在编译时运行的代码可以做任意的IO,包括发射导弹或偷你的信用卡。你不会想要查看你下载的每一个阴谋包来寻找TH漏洞。
  • TH可以访问“模块私有”函数和定义,在某些情况下完全打破了封装。

还有一些问题使得TH函数对于库开发人员来说不那么有趣:

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

最后,对于最终用户来说,TH函数使用起来不那么有趣:

  • 不透明度。当TH函数具有Q Dec类型时,它绝对可以在模块的顶层生成任何东西,并且你完全无法控制将生成什么。
  • Monolithism。除非开发者允许,否则你无法控制TH函数的生成量;如果你找到一个生成数据库接口而且 (JSON序列化接口)的函数,你不能说“不,我只想要数据库接口,谢谢;我将创建自己的JSON接口"
  • 运行时。TH代码运行的时间相对较长。每次编译文件时,代码都会被重新解释,通常,运行TH代码需要大量的包,这些包必须被加载。这大大降低了编译时间。

我想谈谈dflemstr提出的几个问题。

我不认为你不能打字检查TH的事实是令人担忧的。为什么?因为即使有错误,它仍然是编译时的。我不确定这是否加强了我的论点,但这在精神上与您在c++中使用模板时收到的错误类似。不过,我认为这些错误比c++的错误更容易理解,因为您将得到生成代码的漂亮打印版本。

如果一个TH表达式/准引号做了一些如此高级的事情,以至于棘手的角落可以隐藏,那么也许它是不明智的?

我用我最近一直在工作的准引号(使用haskel -src-ext / meta) - https://github.com/mgsloan/quasi-extras/tree/master/examples打破了这个规则。我知道这引入了一些错误,例如不能在广义列表推导中拼接。然而,我认为http://hackage.haskell.org/trac/ghc/blog/Template%20Haskell%20Proposal中的一些想法很有可能最终会出现在编译器中。在此之前,将Haskell解析为TH树的库几乎是完美的近似。

考虑到编译速度/依赖关系,我们可以使用“zero”包来内联生成的代码。这至少对给定库的用户很好,但对于编辑库的情况,我们不能做得更好。TH依赖会膨胀生成的二进制文件吗?我以为它忽略了编译代码没有引用的所有内容。

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

RE不透明度:这对你调用的任何库函数都是一样的。你无法控制Data.List.groupBy将做什么。你只是有一个合理的“保证”/约定,版本号告诉你一些关于兼容性的东西。这在某种程度上是另一回事。

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

RE Monolithism:您当然可以使用自己的编译时代码对TH表达式的结果进行后处理。对顶级声明类型/名称进行筛选的代码并不多。见鬼,你可以想象写一个函数来做这种通用的事情。为了修改/去单一化quasiquoters,您可以在“QuasiQuoter”上进行模式匹配,并提取出所使用的转换,或者根据旧的转换生成新的转换。

这完全是我个人的意见。

  • 用起来很难看。$(fooBar ''Asdf)看起来不太好。当然,这很肤浅,但也有帮助。

  • 写起来更难看。引用有时是可行的,但很多时候您必须手动进行AST嫁接和管道。API又大又笨重,总是有很多你不关心但仍然需要分派的情况,而你真正关心的情况往往以多种相似但不相同的形式出现(data vs. newtype, record style vs.普通构造函数,等等)。写作既无聊又重复,而且复杂到不能机械。改革方案解决了部分问题(使引号更广泛地适用)。

  • 舞台限制是地狱。不能拼接同一个模块中定义的函数是较小的部分:另一个后果是,如果你有一个顶级拼接,那么模块中它之后的所有内容都将超出它之前的范围。其他具有此属性的语言(C, c++)通过允许您转发声明内容使其可行,但Haskell没有。如果您需要在拼接的声明或它们的依赖项和依赖项之间循环引用,那么您通常会被搞砸。

  • < p >不守纪律。我的意思是,大多数时候,当你表达一个抽象概念时,这个抽象概念背后是有某种原则或概念的。对于许多抽象,它们背后的原理可以用它们的类型来表达。对于类型类,通常可以制定实例应该遵守和客户端可以假设的规则。如果你使用GHC的新的泛型特性在任何数据类型上抽象实例声明的形式(在界限内),你就会说“对于和类型,它是这样工作的,对于产品类型,它是那样工作的”。另一方面,Template Haskell只是宏。这不是思想层面的抽象,而是ast层面的抽象,这比纯文本层面的抽象更好,但只是适度的

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

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

  • 有一个普遍的原则,你应该使用正确的工具来完成工作,最小的工具就足够了,在这个类比中Template Haskell是就像这样。如果有一种不是Template Haskell的方法来实现它,那么它通常是更可取的。

Template Haskell的优势在于,你可以用它做其他方法做不到的事情,这是一个很大的优势。大多数时候,使用TH的事情只能在直接作为编译器特性实现时才能完成。拥有TH是非常有益的,因为它可以让您做这些事情,而且它可以让您以一种更轻量级和可重用的方式构建潜在的编译器扩展原型(例如,请参阅各种透镜包)。

总结一下为什么我认为对Template Haskell有负面的感觉:它解决了很多问题,但对于它解决的任何给定问题,感觉应该有一个更好、更优雅、更有纪律的解决方案更适合解决这个问题,这个解决方案不是通过自动生成样板来解决问题,而是通过消除样板的需要来解决问题。

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

编辑23-04-14:在上面的文章中,我经常试图理解的是,抽象和重复数据删除之间有一个重要的区别,而且直到最近才真正理解。适当的抽象通常会导致重复数据删除作为副作用,而复制通常是抽象不充分的标志,但这并不是它有价值的原因。正确的抽象使代码正确、可理解和可维护。重复数据删除只会使它更短。Haskell模板与一般的宏一样,是用于重复数据删除的工具。

这个答案是对illissius提出的问题的逐条回答:

  • 用起来很难看。$(fooBar " Asdf)看起来不太好。当然,这很肤浅,但也有帮助。

我同意。我觉得选择$()是为了让它看起来像语言的一部分——使用熟悉的Haskell符号托盘。然而,这正是你/不希望/用于宏拼接的符号。他们确实融入太多了,这方面的美容是相当重要的。我喜欢拼接的\{\{}}外观,因为它们在视觉上非常明显。

  • 写起来更难看。引用有时是可行的,但很多时候您必须手动进行AST嫁接和管道。[API][1]又大又笨拙,总是有很多情况你不关心,但仍然需要分派,你关心的情况往往以多种相似但不相同的形式出现(数据vs.新类型,记录样式vs.普通构造函数,等等)。写作既无聊又重复,而且复杂到不能机械。[改革建议][2]解决了其中一些问题(使报价更广泛地适用)。

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

  • 舞台限制是地狱。不能拼接同一个模块中定义的函数是较小的部分:另一个后果是,如果你有一个顶级拼接,那么模块中它之后的所有内容都将超出它之前的范围。其他具有此属性的语言(C, c++)通过允许您转发声明内容使其可行,但Haskell没有。如果您需要在拼接的声明或它们的依赖项和依赖项之间循环引用,那么您通常会被搞砸。

我遇到过循环TH定义是不可能的问题…这很烦人。有一个解决方案,但它很难看——将循环依赖关系中涉及的东西包装在一个TH表达式中,该表达式结合了所有生成的声明。其中一个声明生成器可以是接受Haskell代码的准引号。

  • 这是不道德的。我的意思是,大多数时候,当你表达一个抽象概念时,这个抽象概念背后是有某种原则或概念的。对于许多抽象,它们背后的原理可以用它们的类型来表达。定义类型类时,通常可以制定实例应该遵守和客户端可以假设的规则。如果你使用GHC的[新泛型特性][3]在任何数据类型上抽象实例声明的形式(在界限内),你就可以说“对于和类型,它是这样工作的,对于产品类型,它是那样工作的”。但是Template Haskell只是一个愚蠢的宏。这不是思想层面的抽象,而是ast层面的抽象,这比纯文本层面的抽象要好,但只是适度的。

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

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

这是一个非常好的观点- TH API是相当大和笨拙的。重新实施似乎很困难。然而,对于表示Haskell ast的问题,实际上只有几种方法。我想,复制TH adt,并编写一个转换器到内部AST表示将使您有一个很好的方式。这将相当于创建haskell-src-meta的工作(并非微不足道)。它也可以通过打印TH AST并使用编译器的内部解析器简单地重新实现。

虽然我可能是错的,但从实现的角度来看,我不认为TH是一个复杂的编译器扩展。这实际上是“保持简单”的好处之一,而不是让基础层成为理论上有吸引力的、静态可验证的模板系统。

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

这也是一个很好的观点,但有点戏剧化。虽然最近增加了一些API,但它们并没有引起广泛的破坏。另外,我认为使用我前面提到的高级AST报价,实际上需要使用的API可以大大减少。如果没有构造/匹配需要不同的函数,而是表示为字面量,那么大部分API就消失了。此外,您编写的代码将更容易移植到类似Haskell语言的AST表示。


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

在我个人理想的涅槃版本中,大部分语言实际上会移出编译器,进入这些类型的库中。特性作为库实现的事实并没有严重影响它们忠实抽象的能力。

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

类型类允许我们定义一组方法,然后可以在该类上的所有泛型函数中使用这些方法。然而,除此之外,类帮助避免模式化的唯一方法是提供“默认定义”。这里有一个无原则特征的例子!

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

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

  • 对方法默认值进行类似th的功能导致http://www.haskell.org/haskellwiki/GHC.Generics。虽然这是很酷的东西,但我使用这些泛型调试代码的唯一经验几乎是不可能的,因为引起for的类型的大小和ADT像AST一样复杂

    换句话说,它继承了TH提供的特性,但它必须将该语言的整个领域(构造语言)提升为类型系统表示。虽然我可以看到它对你的常见问题很有效,但对于复杂的问题,它似乎倾向于产生一堆比TH黑客更可怕的符号。

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

我认为拒绝TH和类似lisp的元编程导致了对方法默认值之类的东西的偏好,而不是更灵活的宏扩展,比如实例声明。避免可能导致不可预见结果的规则是明智的,然而,我们不应该忽视Haskell的强大类型系统允许比许多其他环境中更可靠的元编程(通过检查生成的代码)。

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,这是不可能的。

(ARM上关于GHC的情况这些天改善了很多,我认为7.4.2甚至有用,但这一点仍然成立)。

TH为什么不好?对我来说,这可以归结为:

如果你需要生成如此多的重复代码,以至于你发现自己试图使用TH来自动生成它,你做错了!

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

当然,有时候这是必要的。但有时你可以通过设计得更聪明一点来避免TH的需要。

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