无标签final编程的简介

102 阅读8分钟

我是Vasiliy Kevroletin,我在Serokell和很多不同的人一起工作。团队不仅包括Haskell专家(他们为GHC和Haskell库做出贡献),也包括像我这样的人,他们的Haskell经验较少,但努力学习和扩展他们的Haskell知识。

最近,我的团队决定在一个新项目中使用无标签的最终风格来实现一个eDSL。虽然这是一个相当知名的技术,但我对无标签final的经验为零,而且在无标签、final和eDSL等术语方面存在一些困难。

为了准备这项任务,我做了一些研究,并把学到的材料整理成一个小的HowTo。现在,我想与其他人分享。

前提条件

我假设读者对MTL相当熟悉,因为我将使用大量的MTL类比。

要点

回顾一下你的日常MTL风格的编程。忘掉具体的单体变换器,专注于类型类。没有变换器,就只剩下两件事了。

  1. 使用类型约束而不是具体类型来声明函数:

    getUser :: (MonadReader r m, Has DatabaseConfig r, MonadIO m) => Name -> m User
    
  2. 多态函数对具体类型(又称解释器)的实例化发生在 "最后 "的某个地方:

    liftIO $ runReader (getUser (Name "Pedro")) env
    

这就是全部。我们刚刚涵盖了无标签的最终风格:

  1. 使用重载函数编写代码。
  2. 使用任何合适的实现(又称解释器)运行代码。

无标签final中所有的好(和坏)都来自于临时的多态性和类型类(即重载)。你的输出直接取决于你对重载事物的承诺。

无标记final的一个明显特征是在两个维度上的可扩展性,事实上,这是一个重要的成就(见《表达式问题再探讨》,以解释为什么很难实现这一特性)。

让我们在讨论扩展性的同时牢记这个函数签名。

wimble :: (MonadReader Env m, MonadState State m) => m ()

我们可以使用MonadReaderMonadState 的自定义新实现来运行wimble (通过定义一个新的数据类型并为其定义实例)。这是在第一个维度上的扩展:一个新的解释器。此外,我们可以使用一套新的操作,比如说MonadWriter ,并在一个使用所有3个类的新函数中使用wimbleMonadReader,MonadStateMonadWriter (即新旧操作)。这是在第二个维度上的扩展:一套新的操作。

从我的观点来看,现有的学习资源显示了使用无标记最终的两种不同方法:

  1. 在一个单体上定义抽象的操作

    在这种情况下,我们可以使用do 符号。

  2. 使用重载函数定义一个抽象语法树

    在这种情况下,有可能我们可以漂亮地打印、检查和优化AST。

有经验的人可能会说,这两种方法是完全一样的。在学习了无标签的最后,这个观点对我来说是有意义的。但是早些时候,当我刚刚开始搜索可用的学习资源时,我对所产生的代码的外观和感觉的差异感到困惑。另外,有些人说,有do 符号就足以称得上是eDSL,有些人说eDSL应该定义一个AST。因此,通过说无标签的最终结果,不同的人可能会假设出稍微不同的方法,而这些方法在Haskell新手看来可能是完全不同的技术。我们将简要地探讨用单体编程和用无标签final定义AST,同时也会涉及一些其他相关的话题。

应用单体

在Haskell程序员中,使用单体来组织有效的应用代码是很常见的。不同的实现有不同的细节,但基本的想法是定义一个单体,以及该单体中可用的一组操作。我将把用于组织有效应用代码的单体称为应用单体。

无标签终结是定义应用单体的一种合适的技术。事实上,由于MTL的存在,它是该任务最广泛使用的工具之一。

让我们以一个从数据库中获取/删除用户的简化问题为例,来说明无标签final是如何用来定义do符号中的操作的。我们的应用单体将提供两个操作:getUserdeleteUser 。通过应用无标签的final方法,我们将定义一组重载函数,并在以后提供它们的实现。从一开始就有一个设计决定:哪些操作需要重载。我们可以定义一个带有getUser/deleteUser 操作的新类型,或者我们可以使用MTL中更多的通用函数并建立在它们之上。虽然在实践中我经常会选择第二种方案,但在这里我将展示第一种方案,因为在我们的特殊情况下,它能使代码更短:

data Name = Name String
data User = User { name :: Name, age :: Int }

class Monad m => MonadDatabase m where
    getUser    :: Name -> m User
    deleteUser :: User -> m ()

使用上面给出的操作,我们可以定义一些像这样的逻辑:

test :: MonadDatabase m => m ()
test = do user <- getUser (Name "Pedro")
          when (age user < 18) (deleteUser user)

test 请注意,test 函数是抽象的:它可以使用不同的MonadDatabase 实现来执行。现在,让我们定义一个适合运行该MonadDatabase 函数的实例。一种方法是建立在MTL变换器之上。我假设getUser/deleteUser 函数可以在ReaderIO 单元之上实现,我也省略了一些实现细节*(用... 标注):

data DatabaseConfig = DatabaseConfig { ... }

newtype AppM a =
    AppM { unAppM :: ReaderT DatabaseConfig IO a }
    deriving (Functor, Applicative, Monad, MonadIO, MonadReader DatabaseConfig)

instance MonadDatabase AppM where
    getUser name = do cfg <- ask
                         ...

    deleteUser user = do cfg <- ask
                         ...

runAppM :: AppM a -> DatabaseConfig -> IO a
runAppM app config = runReaderT (unAppM app) config

现在,我们可以使用一个特定的AppM 实现来执行抽象的test 函数:

main = do cfg <- ...
          runAppM test cfg

通过使用无标记的最终风格,我们已经将抽象操作的定义与它们的实现分离开来,这给了我们可扩展性。通过这种方法,我们有可能定义一套新的操作,并与MonadDatabase 一起使用。也有可能添加一个新的解释*(例如,为了测试目的)*。

即使是这样一个小例子,也有很多组织代码的可能性。第一个问题:如何选择重载操作的集合?是定义一个新的typeclass,如MonadDatabase ,加上特定应用的函数,还是坚持使用MTL typeclasses,在更多的通用函数之上定义操作?第二个问题是:如何编写实现?除了MTL变换器,还有其他实用的替代方法吗?尽管在这里讨论这些问题和其他几个问题是非常诱人的,但我并不知道所有的答案,而且适当的应用架构的话题太广泛了。关于应用架构的更深入的资源,你可以访问其他博客:(123456)。

小型问答

Q:我听说你在描述使用MTL的无标签最终。那著名的n^2问题呢?

:n^2问题出现在变压器的实现中(因为变压器需要传播其子单体的方法)。变换器与无标记final没有关系。我们只是在讨论类型约束和实现之间的自由切换。

如果你仍然想知道n^2的问题,这里有一个小技巧来缓解它(将方法的实现作为独立的函数导出,希望其他实例会使用你的实现)。

如果你创建了许多类似的实现,无标签的最终会导致一些工作的重复。在这种情况下,你可能想使用转化器,这就导致了n^2的问题。

Q:你说的是一个 "应用 "单体和MTL风格。它真的是一个eDSL吗?

A:即使eDSL这个词有一个规范的科学定义,人们还是用这个词来谈论不同的东西。观点从 "eDSL是一种完全不同的语言,有自己的语义 "到 "eDSL是一个具有漂亮、一致的界面的库"。以下是我在几个与Haskell相关的公共频道中得到的关于 "在Haskell中实现的eDSL的好例子是什么?"的答案。SBV、diagrams、accelerate、blaze、esqueleto、shake、lens、gloss、流媒体库(pipes、streamly等)、Servant、opaleye、frp-arduino、HHDL、ivory、pandoc。正如你所看到的,这些答案清楚地表明,eDSL这个词是模糊的。但不管怎么说,无标签的final既可以用来创建 "真正的 "eDSL,也可以用来创建漂亮的库接口(可能用monads)。

eDSLs

Oleg Kiselyov和他的同事对无标签的最终方法进行了最完整的讨论。他主要谈论了使用无标签最终编码的不同版本的类型化lambda calculus的嵌入。他取得了非常具有激励性的结果,比如用线性类型嵌入λ微积分和转换AST。

让我们挑选一种简单的语言作为例子,探索两种编码AST的方法:使用Initial和Final编码。所选的语言有整数常数、加法运算和lambda函数。从一方面来说,把大部分的实现放在这篇博文中是很简单的。另一方面,讨论两个版本的Initial编码和展示无标签的Final方法的可扩展性也很复杂。

初始编码

初始编码意味着我们使用给定的代数数据类型的值来表示AST。术语 "初始编码 "是受范畴理论的启发,它来自于归纳数据类型可以被看作是一个 "初始代数 "的观察。Bartosz Milewski对什么是初始代数以及为什么归纳数据结构可以被视为初始代数做了温和的描述

标签初始编码

这里是为我们的简单语言表示抽象语法树的一种方式*(为了简单起见,我们在Lambda 的定义中重新使用了Haskell的lambda函数,这样我们就不需要自己实现标识符赋值/查找,这种方式被称为高阶抽象语法):

data Expr = IntConst Int
          | Lambda   (Expr -> Expr)
          | Apply    Expr Expr
          | Add      Expr Expr

这种表示方法使我们能够定义像这样的格式良好的eDSL表达式:

-- 10 + 20
t1 = IntConst 10 `Add` IntConst 20

-- (\x -> 10 + x) 20
t2 = Apply (Lambda $ \x -> IntConst 10 `Add` x) (IntConst 20)

不幸的是,它也允许我们定义像这样的畸形表达式:

-- Trying to call integer constant as a function
e1 = Apply (IntConst 10) (IntConst 10)

-- Trying to add lambda functions
e2 = Add f f where f = Lambda (\x -> x)

Expr 值的评估会产生错误,因为我们的eDSL的表示法允许对畸形的表达式进行编码。因此,解释器eval ,在其工作过程中应该检查类型错误。更准确地说,它应该对结果值进行模式匹配,以找出在运行时从eval 函数中出来的具体数值,以确保Add 操作被应用于整数常数,而Apply 操作被用于lambda 函数。我们定义Result 数据类型来表示可能的结果值,并使用Maybe Result 来表示可能的错误:

data Result = IntResult Int
            | LambdaResult (Expr -> Expr)

eval :: Expr -> Maybe Result
eval e@(IntConst x) = Just (IntResult x)
eval e@(Lambda   f) = Just (LambdaResult f)
eval (Apply f0 arg) = do
    f1  <- eval f0
    case f1 of
        LambdaResult f -> eval (f arg)
        _              -> Nothing
eval (Add l0 r0) = do
    l1 <- eval l0
    r1 <- eval r0
    case (l1, r1) of
        (IntResult l, IntResult r) -> Just $ IntResult (l + r)
        _                          -> Nothing

这种技术被称为 "标记",因为Haskell中的和类型是标记的和类型。在运行时,这样的值被表示为一对(tag, payload)tag ,用于执行模式匹配。eval 函数在IntResultLambdaResult 上使用模式匹配来执行类型检查和错误检查,或者换句话说,它在运行时使用标签。因此而得名。

无标签的初始编码

我们的想法是,我们可以使用GADT将值的信息添加到Expr 类型中,并使用它来使畸形的eDSL表达式无法表示。我们不再需要Result 数据类型,在eval 函数中也不再有运行时类型检查。在Finally Tagless, Partially Evaluated这篇论文中,作者将他们版本的数据构造器IntResultLambdaResult 称为 "标签"。而由于基于GADTs的方法没有标签,他们称之为 "无标签初始 "编码。

下面给出了基于GADTs的AST定义和相应的解释器eval 。新的AST能够表示上一节中的例子t1,t2 ,而使e1,e2 无法表示。Expr a 数据类型的想法是,a 参数持有一个给定表达式应该评估的类型。IntConstLambda 只是在a 参数中重复其字段类型,因为评估一个值只是意味着解包。在Add 构造函数的情况下,a 参数等于Int ,这意味着Add 评估为一个整数。Apply 评估为一个传递的lambda函数的结果:

data Expr a where
    IntConst :: Int                     -> Expr Int
    Lambda   :: (Expr a -> Expr b)      -> Expr (Expr a -> Expr b)
    Apply    :: Expr (Expr a -> Expr b) -> Expr a -> Expr b
    Add      :: Expr Int -> Expr Int    -> Expr Int

eval :: Expr a -> a
eval (IntConst x) = x
eval (Lambda f)   = f
eval (Apply f x)  = eval (eval f x)
eval (Add l r)    = (eval l) + (eval r)

最终编码

尽管 "Initial "一词来自范畴理论,但 "Final "一词并不是。Oleg表明 ,"最终和初始类型的无标记表示是通过双射关系的",这意味着这些方法在某种意义上是等价的,从范畴理论家的角度看,两者都是 "初始"。Finally Tagless论文指出 "我们称这种方法为final(与initial相反),因为我们不是通过其抽象的语法来表示每个对象术语,而是通过其在语义代数中的指称来表示"。我的最佳猜测是,选择 "final "这个名字是为了尽可能地与Initial这个词相区别。

通过无标记的final,我们使用重载函数而不是数据构造函数来构建表达式。上一节中的表达式将看起来像这样。

test = lambda (\x -> add x (intConst 20))

让它工作的机械由两部分组成。

  1. 组合器的定义:

    class LambdaSYM repr where
        intConst :: Int -> repr Int
        lambda   :: (repr a -> repr b) -> repr (a -> b)
        apply    :: repr (a -> b) -> repr a -> repr b
    
  2. 解释器的实现:

    data R a = R { unR :: a }
    
    instance LambdaSYM R where
        intConst x = R x
        lambda f   = R $ \x -> unR (f (R x))
        apply f a  = R $ (unR f) (unR a)
    
    eval :: R a -> a
    eval x = unR x
    

应用解释器:

testSmall :: LambdaSYM repr => repr Int
testSmall = apply (lambda (\x -> x)) (intConst 10)

main = print (eval testSmall) -- 10

有趣的地方:

  1. eval 函数将 表达式实例化为一个具体的类型 (又称解释器)。testSmall R Int

  2. 定义其他解释器是很容易的。比如说,一个漂亮的打印机。不过有一个小插曲:漂亮的打印机需要为自由变量分配名字,并跟踪分配的名字,所以打印解释器会传递一个环境,它看起来和Reader monad非常相似。

  3. 用新的操作来扩展该语言是非常容易的。

在我们之前的例子中添加一个新的add 操作,只需要定义一个新的类型类并为每个解释器实现一个新的实例。使用新的add 操作的函数应该为其类型添加额外的AddSYM repr 约束:

class AddSYM repr where
    add :: repr Int -> repr Int -> repr Int

instance AddSYM R where
    add a b = R $ (unR a) + (unR b)

test :: (LambdaSYM repr, AddSYM repr) => repr Int
test = apply (apply (lambda (\y -> lambda (\x -> x `add` y))) (intConst 10)) (intConst 20)

请注意,在这种特殊情况下,我们是幸运的,因为有可能写成instance AddSYM R 。或者,换句话说,可以在现有解释器的基础上实现新的操作。有时我们需要扩展现有的解释器或编写新的解释器。

自省、主机与目标语言

在Oleg的论文中,他对无标签的最终AST进行了漂亮的打印和转换。期待这种实用程序的存在是非常反常的,因为组合器是函数,而我们习惯于操作代数数据类型的值。然而,我们有可能提取关于无标签最终AST的结构的事实(也就是说,自省是可能的),并对其进行转换。关于这个说法的证明,请查看Oleg课程的第3.4节(第28页),他在那里介绍了pretty-printer和transformer。

AST的pretty-printer和transformer只是无标签的最终解释器,它们记录了一些额外的信息,并在解释过程中从父辈传播到子辈。两者都是可扩展的,与其他无标记最终解释器的方式相同。

然而,如果我们回到第一节,考虑将无标记的最终方法应用于定义应用单体的简单案例,那么我们很快就会发现,我们无法检查和转换产生的单体。考虑一下我们的简单例子:

class Monad m => HasDatabaseConfig m where
    getDatabaseConfig :: m DatabaseConfig

getUser :: (HasDatabaseConfig m, MonadIO m) => Name -> m User
getUser = ...

test :: (HasDatabaseConfig m, MonadIO m) => m String
test = do user <- getUser (Name "Pedro")
          if age user > 3 then pure "Fuzz"
                          else pure "Buzz"

虽然getDatabaseConfig 函数是重载的,但很多逻辑都是用没有重载的函数和其他结构表达的。因此,没有办法静态地检查结果的单体值。这是重要的一点:如果你想对AST进行内省和转换,那么你需要跟踪哪些是重载的,哪些不是。Oleg获得了他的伟大成果,因为他重载了一切,并且只用重载的函数来表达嵌入式lambda计算。换句话说,无标记最终的力量取决于你想在重载方面走多远

与自由单体的关系

人们经常比较无标记final和自由单体。这两种方法都为你提供了在单体上下文中定义重载操作的机制。我不是自由单体的专家,但无标记final:

  • 更快
  • 可扩展性(可以轻松添加新的操作)
  • 需要更少的模板

支持自由单体的一个论点是,可以静态地反省自由单体。这并不完全正确。是的,你可以很容易地一个接一个地执行操作,并且通过交错操作来帮助组合单体值(我们可以通过用无标记的final解释成continuation来实现类似的事情)。但这里有一篇博文,描述了自由单体自省的困难(我们在上一节已经介绍了问题的要点)。另外,请看这篇博文,作者描述了与自由单体相关的困难,并建议使用无标记final来代替。

这里是对自由单体性能挑战的一个非常好的概述。这里Edward Kmett给出了他对同一问题的看法。

用几句话来说:

  1. 一个简单的自由单体的实现会导致左侧相关单体绑定的O(n^2)渐进性。它总是将一个元素添加到像这样的 "列表 "中[1, 2, 3] ++ [4]
  2. 使用连续体(类似于DList 包)可以得到O(n)绑定,但会使一些操作变慢(例如通过交错组合两个序列的命令)。
  3. 使用类似于Seq 数据结构的技术会导致所有操作的良好渐进行为,但也会产生显著的恒定开销。

性能

关于用多态函数优化Haskell代码的一般技术在此适用。简而言之,有时使用重载函数会导致编译器生成使用方法字典来调度调用的代码。编译器通常知道如何专门化函数并摆脱字典,但模块边界阻止了这种情况的发生。为了帮助编译器,我们需要阅读本文档的 "专业化 "部分,然后使用INLINEABLE pragma,像这样

getUser :: (MonadReader r m, Has DatabaseConfig r, MonadIO m) => Name -> m User
...
{-# INLINEABLE  getUser #-}

限制

Haskell缺乏一流的多态性(又称不可预测的多态性),这意味着我们不能将现有的数据类型专门化,以容纳像这样的多态性的值:

Maybe (LambdaSym repr => repr Int)

由此可见,我们不能对这样的多态值进行两次解释(但这种情况在我们只是想要一个带有一些重载操作的Application monad的情况下并不频繁出现)。这是一个问题,例如,当我们解析一些文本文件,得到一个无标记的最终AST,并想对它进行两次解释:评估和打印。有一个有限的解决方法:在多态值周围定义一个新类型的包装器。这个包装器指定了具体的类型约束,因此扼杀了无标记final的一个扩展性维度。

Oleg的论文还提出了另一个解决方法:一个特殊的 "复制 "解释器。不幸的是,它是用一个没有lambda函数的简单eDSL来介绍的,而我没能将同样的技术应用于一个带有lambdas的更复杂的AST。我在这里提到这一点只是为了完整地说明问题。

另外,请注意,有时人们想在运行时而不是在编译时改变实现(又称解释器),或者甚至只改变现有行为的一部分。例如,改变数据源,但保留所有其他特定的应用逻辑。无标签最终可以通过实现一个可在运行时配置的解释器来支持它,该解释器使用某种方法字典(见句柄模式)。

结论

感谢MTL,无标签的final编程风格经过了战斗的考验,并得到了广泛的采用。在我看来,这是一种相当自然的编写Haskell代码的方式,因为它利用了一个非常基本的Haskell特性:类型类。它也远远超出了MTL的范围--它既可以用于编写有或没有单体转换器的特定应用逻辑,也可以用于具有自己语义的 "真正的 "eDSLs。

我还发现,这并不是一个很难掌握的概念,所以它可以安全地用于由不同背景的开发人员组成的大型团队。

就这样,我希望我的文章能够帮助其他人掌握无标签最终的主要思想,并在他们的项目中使用它。