Haskell:GHC 9中的类型化模板介绍

237 阅读4分钟

我们将看看GHC 9中关于类型化模板Haskell(TTH)的变化,以及如何使用 th-compat库来编写可以在GHC 8和GHC 9上使用的TTH代码。

类型化Template Haskell规范的变化

template-haskell 包在2.17.0.0版本和GHC 9.0版本中根据Make Q (TExp a)改变为一个新的类型建议,其中类型化表达式quasi-quoter ([|| ... ||])现在返回一个不同的数据类型。

在新的规范下,类型化的引号现在返回Quote m => Code m a ,而不是Q (TExp a) ,并且类型化的拼接也期待这个新类型。这对现有的代码来说是一个突破性的变化,以GHC 9.0为目标的代码库要么需要调整他们的TTH代码,要么使用另一种方法来兼容。

Code 新类型只是一个围绕m (TExp a) 的包装,定义与此类似。

newtype Code m a = Code (m (TExp a))

实际的定义要复杂一些,因为它包含了一些引力多态性的机制。如果你去看它的定义,实际上你会看到这个:

type role Code representational nominal
newtype Code m (a :: TYPE (r :: RuntimeRep)) = Code
  { examineCode :: m (TExp a) -- ^ Underlying monadic value
  }

简化定义和实际定义之间的差异对于理解这篇文章并不重要,你可以在阅读这篇文章的时候记住简化定义。

值得庆幸的是,未类型化的TH接口基本没有变化,类型化的TH接口应该只需要使用Code ,而不是TExp ,就可以再次编译一些辅助函数了。

GHC 8代码示例

为了使这里的例子更容易理解,让我们定义一个小型的类型化Template Haskell模块,我们希望它能在GHC 8下工作,以后我们将把它移植到GHC 9。代码将非常简单:它的目的是将一个环境标志解析成一个已知的数据类型,或者在编译失败时完全停止编译。

我们将使用 template-haskell包,所以别忘了加载它。你还需要激活TemplateHaskell 语言扩展。

{-# LANGUAGE TemplateHaskell #-}

module TH where

import Control.Monad.IO.Class (liftIO)
import Language.Haskell.TH.Syntax (Q, TExp)
import System.Environment (lookupEnv)

data LogLevel
  = Development  -- ^ Log errors and debug messages
  | Production  -- ^ Only log errors
  deriving (Show)

logLevelFromFlag :: Q (TExp LogLevel)
logLevelFromFlag = do
  flag <- liftIO $ lookupEnv "LOG_LEVEL"
  case flag of
    Nothing -> [|| Production ||]  -- We default to Production if the flag is unset
    Just level -> case level of
      "DEVELOPMENT" -> [|| Development ||]
      "PRODUCTION" -> [|| Production ||]
      other -> fail $ "Unrecognized LOG_LEVEL flag: " <> other

然而,如果你试图用GHC 9来编译这个模块,这个例子将无法编译。为了简洁起见,我们省略了类似的错误。

>>> :l TH

TH.hs:18:16: error:
    • Couldn't match type ‘Code m0’ with ‘Q’
      Expected: Q (TExp LogLevel)
        Actual: Code m0 LogLevel
    • In the Template Haskell quotation [|| Production ||]
      In the expression: [|| Production ||]
      In a case alternative: Nothing -> [|| Production ||]
   |
18 |     Nothing -> [|| Production ||]  -- We default to Production if the flag is unset
   |                ^^^^^^^^^^^^^^^^^^

同样地,拼接也会要求新的代码返回Code ,这一点以一个洞为例。

>>> $$_

<interactive>:19:3: error:
    • Found hole: _ :: Code Q p0
      Where: ‘p0’ is an ambiguous type variable
    • In the Template Haskell splice $$_
      In the expression: $$_
      In an equation for ‘it’: it = $$_

在下面的章节中,我们将看到如何将这段代码移植到GHC 9,而不与GHC 8向后兼容。

移植没有向后兼容的旧TTH代码

对于这一部分,你需要一个版本至少为9的GHC。请确保从template-haskell ,同时导入相应的定义。

-import Language.Haskell.TH.Syntax (Q, TExp)
+import Language.Haskell.TH.Syntax (Code, Q, examineCode, liftCode)

由于我们现在需要返回一个类型为Code 的值,我们改变了类型签名。现在我们可以通过使用两个新的函数,即examineCodeliftCode 来修复代码。

-logLevelFromFlag :: Q (TExp LogLevel)
-logLevelFromFlag = do
+logLevelFromFlag :: Code Q LogLevel
+logLevelFromFlag = liftCode $ do  -- liftCode will turn 'Q' into 'Code'
  flag <- liftIO $ lookupEnv "LOG_LEVEL"
  case flag of
+    -- examineCode will turn 'Code' into 'Q'
-    Nothing -> [|| Production ||]  -- We default to Production if the flag is unset
+    Nothing -> examineCode [|| Production ||]  -- We default to Production if the flag is unset
    Just level -> case level of
-      "DEVELOPMENT" -> [|| Development ||]
-      "PRODUCTION" -> [|| Production ||]
+      "DEVELOPMENT" -> examineCode [|| Development ||]
+      "PRODUCTION" -> examineCode [|| Production ||]
      other -> fail $ "Unrecognized LOG_LEVEL flag: " <> other

上一节中发生的错误是因为引号所返回的类型不再是Q ,而是Code 。解决方案是使用examineCode ,将Code Q a 变成Q (TExp a) ,然后使用liftCode ,做相反的操作:将Q (TExp a) 变成Code Q a

liftCode    :: forall (r :: RuntimeRep) (a :: TYPE r) m. m (TExp a) -> Code m a
examineCode :: forall (r :: RuntimeRep) (a :: TYPE r) m. Code m a -> m (TExp a)

就这样了!如果你想让这些代码在GHC 8上继续工作,请务必阅读下一节。

移植具有向后兼容性的旧TTH代码

对于本节,你可以选择使用GHC 8或GHC 9。我们将使用 th-compat包,除了熟悉的 template-haskell包,所以请确保你同时加载它们。

th-compat 是我们推荐的与TTH向后兼容的库。在这一节中,我们将展示如何将用法从 改为向后兼容的 ,它可以在GHC 9以及以前的版本上工作。Code Splice

这个模式与使用Code 的工作流程非常相似,尽管现在使用Splice

请确保首先导入适当的定义。

-import Language.Haskell.TH.Syntax (Code, Q, examineCode, liftCode)
+import Language.Haskell.TH.Syntax (Q)
+import Language.Haskell.TH.Syntax.Compat (Splice, examineSplice, liftSplice)

现在只要用Splice 替换Code

-logLevelFromFlag :: Code Q LogLevel
-logLevelFromFlag = liftCode $ do  -- liftSplice will turn 'Q' into 'Code'
+logLevelFromFlag :: Splice Q LogLevel
+logLevelFromFlag = liftSplice $ do  -- liftSplice will turn 'Q' into 'Splice'
  flag <- liftIO $ lookupEnv "LOG_LEVEL"
  case flag of
-    -- examineCode will turn 'Code' into 'Q'
-    Nothing -> examineCode [|| Production ||]  -- We default to Production if the flag is unset
+    -- examineSplice will turn 'Splice' into 'Q'
+    Nothing -> examineSplice [|| Production ||]  -- We default to Production if the flag is unset
    Just level -> case level of
-      "DEVELOPMENT" -> examineCode [|| Development ||]
-      "PRODUCTION" -> examineCode [|| Production ||]
+      "DEVELOPMENT" -> examineSplice [|| Development ||]
+      "PRODUCTION" -> examineSplice [|| Production ||]
      other -> fail $ "Unrecognized LOG_LEVEL flag: " <> other

th-compat 就这样!Splice m a 在GHC 9中被定义为Code m a ,在之前的版本中被定义为m (TExp a) ,这就是为什么这段代码可以工作。此外,像liftSpliceexamineSplice 这样的函数要么被定义为liftCodeexamineCode (在 GHC 9 中),要么被定义为id (在 GHC 8 及以下版本)。

拼接中的括号

在我们结束这篇文章之前,还有一件事。你可能会看到关于无类型和有类型的Template Haskell在拼接中使用括号的令人惊讶的行为。解析器在GHC 9中进行了调整,所以与之前的版本相比,在某些情况下,小括号是不必要的。

例如,如果我们导入了logLevelFromFlag 资格,那么在GHC 8中你会写$$(TH.logLevelFromFlag) ,否则你会得到一个解析器错误。

>>> $$TH.logLevelFromFlag

<interactive>:200:1: error: parse error on input ‘$$’

但在GHC 9中,它是有效的。

>>> $$TH.logLevelFromFlag
Production

结论

在这篇文章中,我们已经看到了GHC 9中关于类型化模板Haskell的变化。我们了解了将类型化的Template Haskell代码从GHC 8迁移到GHC 9的两种不同方式,并分析了它们的区别。

使用th-compat ,除了具有与GHC 9相似的接口外,还有一个很大的优势,那就是可以兼容各种GHC版本。另一方面,如果没有必要支持GHC 8,它就会成为你的项目中的一个额外的依赖,对许多用户来说可能是陌生的。选择使用哪种策略最终将取决于你的具体需求,但我们希望能阐明它们的优点和缺点。