Cycoe@Home

Template Haskell 旅程 – 第一弹

1. 前言

此博客翻译自 Mark Karpov 大佬的 Template Haskell Tutorial 教程。

以下是 Mark Karpov 在博客中的前言:

此教程的目的是向读者介绍 Template Haskell(以下简称 TH)作为 Haskell 语言的扩展,如何向 Haskell 语言提供元编程的能力。以下教程中我将假定读者具有一定的 Haskell 基础,用更通俗的话说,如果你知道什么是 Monad 那么阅读此教程的问题就不大。

TH 总是被认为是一个高阶的话题,一般人都理解不了,但我不认为如此。TH 背后的原理是简单并且合乎逻辑的,其内部细节可以在 Haddock 中找到。

本教程不可能对 TH 的使用方法面面俱到,但我会尽可能展示 TH 作为 GHC 特性中最常用、最实用、最易用的部分。

2. 动机

在使用 TH 中最主要的难点是确定它是否是我们解决手头上的问题的最好方法。使用代码生成代码通常说明编程语言提供的表达能力或者程序员的想法无法解决问题,此时元编程就是最后的选择。不管怎样,TH 是一种相当流行的技巧,了解一点可以以备不时之需。

TH 有以下应用:

  • 自动继承类型类的实例仍是 TH 最主要的应用场景。虽说我们也可以通过泛型来解决同样的问题,但是可能会导致编译时间与使用 TH 的方法相比更长。因此 TH 仍是 aesonlens 等库实现中倾向于使用的自动化实例继承的方法;
  • 创建能集成在 Haskell 当中构建的 TH DSL 语言,此类 DSL 语言能在 persistent 作为模型声明语言,或者在例如 yesod 网络框架中作为一些迷你语言;
  • 在编译期构造变量并将非法的输入显示为编译错误;
  • 在编译期加载和处理外部文件的数据,有些时候会很有用。虽说这会导致在编译期引入 IO ,但好过使用一些更危险的特性来实现功能。

不使用 TH 的理由:

  • TH 的帮助函数通常被视为“魔法”黑盒。我们根本不清楚 Q [Dec] 类型会做什么事情,它可以做任何事(后面我们会看到,生成声明的代码不管生成什么样的声明,都是相同的 Q [Dec] 类型)。大部分时候只能通过文档解释 TH 代码语义。
  • 当用户自己实现 TH 函数以及需要在文件中对函数定义进行排序时,就会发现 TH 存在一些限制。

3. Q Monad

想要生成代码我们需要以下的特性:

  • 生成不可被捕获并且独一无二的名字的能力;
  • 通过一个东西的名字恢复出它的信息的能力。通过我们对函数和类型感兴趣,但也需要方法获取模块、特定类型类的所有实例等信息;
  • 获取和设置能够被同一个模块中所有 TH 代码共享的自定义状态的能力;
  • 在编译期运行 IO 的能力,以便我们可以从文件中读取一些东西。

这些特性在 Haskell 中通常是通过 Monad 实现,那么有一个名为 Q 的 Monad 也就不奇怪了(此处 Q 为“引用”的缩写), Q Monad 用于管理 TH 提供的所有函数。

4. Splicing

类型 Q a 的值的唯一作用是在 Haskell 程序中使用类型 aa 可以是任何间接的 monadic 表达式,但当我们在 Haskell 文件中插入生成的代码时,只有以下 5 种选择:

  • 声明 Dec :用于表示像函数或者类型定义等<ruby>顶层的<rt>top-level</rt></ruby>东西;
  • 表达式 Exp :形如 x + 1\x -> x + 1 等,可能是最常生成的东西;
  • 带类型的表达式 TExp :与表达式 Exp 等价,但带有一个与内部包含的表达式对应的虚拟类型标签。比如 TExp Int 代表内部包含一个可求值为 Int 的表达式;
  • 类型 Type :比如 IntMaybe Int 或是 Maybe 。这个类型不一定是具体的,可能是在类型层面遇到的任何一种类型;
  • 模式 Pat :用于模式匹配。

我建议你按照以上列表中的链接先看一下 DecExpTExpTypePat 类型的定义。注意此处的命名习惯:构造器的后缀表示了其所属的数据类型, Dec 构造器以 D 结尾, Exp 构造器以 E 结尾, Type 构造器以 T 结尾, Pat 构造器以 P 结尾。这样就可以清楚地区分变量表达式 VarE 和变量模式 VarP

译注:此处变量表达式和变量模式分别表示将一个变量当作表达式或者当作模式使用。

使用数据类型我们可以实际地构造一个表达式了:

:{
myFunc :: Q Exp
myFunc = do
  x <- newName "x" -- 生成一个独一无二的变量名,后面我们详细讲解变量名
  return $ LamE    -- lambda 表达式
    [VarP x]       -- 对 'x' 进行模式匹配
    (InfixE (Just (VarE x)) (VarE '(+)) (Just (LitE (IntegerL 1))))
    -- 此处我们生成了一个中缀表达式:将 (+) 应用到了 'x' 和一个字面量 1 上
:}

TemplateHaskell 语言扩展包含了特殊语法 $(exp) ,其中 exp 是一个生成 Q [Dec]Q ExpQ TypeQ Pat 的任意表达式。这个语法允许我们将生成的代码插入到正常的 Haskell 代码中。

比如我们可以这样使用 myFunc

$(myFunc) 3
-- The parentheses are not necessary if 'myFunc' doesn't take any arguments.
-- If it did, it would be something like '$(myFunc arg) 3'. In other words,
-- parentheses are only needed around expressions.

-- 如果 'myFunc' 在生成代码时不需要任何参数,那么括号可以省略。如果需要参数,那调用方法会
-- 类似 '$(myFunc arg) 3'。换言之,只有当调用表达式时才需要括号
$myFunc 3

let f = (* 2) . $myFunc
f 10
<interactive>:157:3-8: error: Variable not in scope: myFunc :: ExpQ
<interactive>:164:2-7: error: Variable not in scope: myFunc :: ExpQ
<interactive>:166:18-23: error:
    Variable not in scope: myFunc :: ExpQ
<interactive>:167:1: error: Variable not in scope: f :: t0 -> t

这被称为接合 ,美元符后面跟的表达式被称为接合处 。接合可以出现在表达式、模式、类型或者一个顶层声明的位置上。声明在接合时可以省略前面的美元符,声明总是处于顶层因此不存在语义上的歧义。 lens 库中的 makeLens 函数就是个很好的例子:

makeLens ''MyRecord    -- 是的!我们后面也会介绍这种引号语法
$(makeLens ''MyRecord) -- 上面的表示与这行相同

注意此处 $ 等号有了更多的含义(译注:相较于原本的函数应用操作符又增加了此处的接合代码生成的含义),因此可能会在某些场景下出现歧义。当在接合中使用 $ 时, $ 和后面的标识符或者括号之间不能有空格。当使用 $ 作为函数应用操作符时,要保证在操作符和后面代码之间至少要有一个空格。

5. TH 的限制

目前使用 TH 有如下限制:

  • 编译单元的约束——也就是说接合内部只可以使用已编译好的函数,比如定义在别的模块中使用了接合的函数。这是个很讨厌的限制,迫使开发者需要提供一个针对 TH 代码的独立模块,一般命名为 TH
  • TH 经常需要你按特定的方式编排你的函数定义,下面引用 GHC 用户手册中的内容来说明:

    顶层的接合将源码文件分割成了许多声明代码块。一个声明代码块由一个顶层的接合声明以及紧随其后的代码组成,直到遇到下一个顶层的接合声明。只有顶层的接合声明会将代码分割,接合表达式不会。模块中的第一个声明代码块包含了从头开始到第一个顶层的接合声明为止的所有顶层定义。

    每个声明代码块都只能在自己块的内部互相递归引用(译注:正常情况下同一个模块中的 Haskell 代码都可以互相引用)。代码块可以引用前面块的定义,不能引用后面的。

让我们看一下例子。假设我们想要使用 lens 库去生成一些 lens ,我们会写如下的代码:

import Control.Lens              -- 引入 Lens 库
:{
data MyRecord = MyRecord         -- <<< 第一个声明代码块
  { _myRecordFoo :: Int
  , _myRecordBar :: Int
  , _myRecordBaz :: Int
  }
:}

:{
getRecordFoo :: MyRecord -> Int
getRecordFoo = view myRecordFoo
:}

makeLenses ''MyRecord            -- <<< 第二个声明代码块
-- ^ 生成 lenses: 'myRecordFoo', 'myRecordBar' 和 'myRecordBaz'.

很可惜,这个代码无法通过编译。第一个声明代码块中包含了 MyRecordgetRecordFoo 的定义,但是此时还未生成 lens ,也就是说 myRecordFoo 不在 getRecordFoo 函数的作用域之内。

我们可以通过将 getRecordFoo 放到 makeLenses ''MyRecord 接合之后来解决这个问题:

import Control.Lens              -- 引入 Lens 库
:{
data MyRecord = MyRecord         -- <<< 第一个声明代码块
  { _myRecordFoo :: Int
  , _myRecordBar :: Int
  , _myRecordBaz :: Int
  }
:}

makeLenses ''MyRecord            -- <<< 第二个声明代码块

:{
getRecordFoo :: MyRecord -> Int  -- 能从前一个代码块中“看到” MyRecord 函数了
getRecordFoo = view myRecordFoo
:}

现在,第一个声明代码块由 MyRecord 组成,“看不到” getRecordFoo 的实现。如果你需要“看到”它的话,你只能将所有使用了 getRecordFoo 的函数移到第二个代码块,也就是 makeLenses ''MyRecord 之后。在大部分场景下这不是个大问题(毕竟大部分的语言都要求将函数定义写在调用处之前),尽管如此如果我们习惯了 Haskell 本身这种不关心定义顺序的特性,这个限制还是有些令人遗憾的。

Author: Cycoe (cycoejoo@163.com)
Date: <2024-02-03 Sat 12:00>
Generator: Emacs 29.2 (Org mode 9.6.15)
Built: <2024-02-04 Sun 22:40>