【问题标题】:Why are ML/Haskell datatypes useful for defining "languages" like arithmetic expressions?为什么 ML/Haskell 数据类型可用于定义算术表达式等“语言”?
【发布时间】:2018-02-13 14:27:31
【问题描述】:

这更像是一个关于函数式语言(如 ML 家族的静态类型系统)的软问题。我理解为什么需要数据类型来描述列表和树之类的数据结构,但是在数据类型中定义命题逻辑之类的“表达式”似乎只是带来了一些便利,而且没有必要。例如

datatype arithmetic_exp = Constant of int
                        | Neg of arithmetic_exp
                        | Add of (arithmetic_exp * arithmetic_exp)
                        | Mult of (arithmetic_exp * arithmetic_exp)

定义一组值,您可以在其上编写一个eval 函数,该函数将为您提供结果。您也可以定义 4 个函数:const: int -> intneg: int -> intadd: int * int -> intmult: int * int -> int,然后是 add (mult (const 3, neg 2), neg 4) 类型的表达式会给您同样的东西,而不会损失任何静态安全性。唯一的麻烦是你必须做四件事而不是两件事。在学习 SML 和 Haskell 时,我一直在尝试思考哪些功能为您提供了必要的东西,哪些只是一种方便,所以这就是我问的原因。我想如果您想将评估值的过程与值本身分离,这将很重要,但我不确定这在哪里有用。

非常感谢。

【问题讨论】:

  • 您是在问 AST 的目的是什么?

标签: haskell types ml


【解决方案1】:

ADT 是一种您可以通过其他方式检查和操作的形式,而不仅仅是评估它。一旦你在一个函数调用中隐藏了所有有趣的数据,你就无法对它做任何事情,只能对其进行评估。考虑这个定义,类似于您问题中的定义,但使用 Var 术语来表示变量,并删除了 Mul 和 Neg 术语以专注于加法。

data Expr a = Constant a
            | Add (Expr a) (Expr a)
            | Var String
            deriving Show

当然,要写的函数是eval。它需要一种查找变量值的方法,而且很简单:

-- cheating a little bit by assuming all Vars are defined
eval :: Num a => Expr a -> (String -> a) -> a
eval (Constant x) _env = x
eval (Add x y) env = eval x env + eval y env
eval (Var x) env = env x

但假设您还没有变量映射。您有一个大型表达式,您将针对不同的变量选择多次评估。一些愚蠢的递归函数构建了如下表达式:

Add (Constant 1) 
    (Add (Constant 1) 
         (Add (Constant 1) 
              (Add (Constant 1) 
                   (Add (Constant 1) 
                        (Add (Constant 1) 
                             (Var "x"))))))

每次评估时都重新计算1+1+1+1+1+1 会很浪费:如果您的评估者能够意识到这只是Add (Constant 6) (Var "x") 的另一种写作方式,那不是很好吗?

因此,您编写了一个表达式优化器,它会在任何变量可用之前运行并尝试简化表达式。当然,您可以应用许多简化规则;下面我只实现了两个非常简单的例子来说明这一点。

simplify :: Num a => Expr a -> Expr a
simplify (Add (Constant x) (Constant y)) = Constant $ x + y
simplify (Add (Constant x) (Add (Constant y) z)) = simplify $ Add (Constant $ x + y) z
simplify x = x

现在我们愚蠢的表情看起来如何?

> simplify $ Add (Constant 1) (Add (Constant 1) (Add (Constant 1) (Add (Constant 1) (Add (Constant 1) (Add (Constant 1) (Var "x"))))))
Add (Constant 6) (Var "x")

所有不必要的东西都被删除了,你现在有一个很好的干净的表达式来尝试x的各种值。

你如何在函数中使用这个表达式的表示来做同样的事情?你不能,因为在表达式的初始规范和它的最终评估之间没有“中间形式”:你只能将表达式视为一个单一的、不透明的函数调用。以特定值 x 对其进行评估必然会重新评估每个子表达式,并且无法将它们分开。

这是您在问题中提出的函数类型的扩展,再次丰富了变量:

type FExpr a = (String -> a) -> a

lit :: a -> FExpr a
lit x _env = x

add :: Num a => FExpr a -> FExpr a -> FExpr a
add x y env = x env + y env

var :: String -> FExpr a
var x env = env x

用同样的傻表达式计算多次:

sample :: Num a => FExpr a
sample = add (lit 1)
             (add (lit 1)
                  (add (lit 1)
                       (add (lit 1)
                            (add (lit 1)
                                 (add (lit 1)
                                      (var "x"))))))

它按预期工作:

> sample $ \_var -> 5
11

但每次你尝试不同的x 时,它都必须做一堆加法,即使加法和变量大多不相关。而且您无法简化表达式树。你不能在定义它时简化它:也就是说,你不能让add 更聪明,因为它根本无法检查它的参数:它的参数是函数,就add 而言,它可以做任何事。而且你也不能在构造它之后简化它:此时你只有一个不透明的函数,它接受一个变量查找函数并产生一个值。

通过将问题的重要部分单独建模为数据类型,您可以使它们成为您的程序可以智能操作的值。如果您将它们保留为函数,您会得到一个更短但功能更弱的程序,因为您将所有信息锁定在只有 GHC 可以操作的 lambdas 中。

一旦您使用 ADT 编写了它,如果您愿意,不难将该表示法折叠回更短的基于函数的表示法。也就是说,有一个类型的函数可能会很好

convert :: Expr a -> FExpr a

但事实上,我们已经这样做了!这正是eval 的类型。由于 FExpr 类型别名,您可能没有注意到,它没有在 eval 的定义中使用。

因此在某种程度上,ADT 表示更通用、更强大,就像一棵可以以多种不同方式折叠起来的树。其中一种方法是评估它,就像基于函数的表示一样。但还有其他的:

  • 在计算之前简化表达式
  • 生成一个所有变量的列表,这些变量必须为该表达式的格式正确定义
  • 计算树最深部分的嵌套深度,以估计评估者可能需要多少堆栈帧
  • 将表达式转换为近似于 Haskell 表达式的字符串,您可以键入以获得相同的结果

因此,如果可能的话,您希望尽可能长时间地使用信息丰富的 ADT,然后在您有特定的事情与它相关时最终将树折叠成更紧凑的形式。

【讨论】:

    【解决方案2】:

    初始/一阶/基于数据类型的编码(又名深度嵌入)和最终/高阶/基于评估器的编码(又名浅嵌入)之间存在双重性。您确实可以通常使用组合器的类型类而不是数据类型(并在两者之间来回转换)。

    这是一个展示这两种方法的模块:

    {-# LANGUAGE GADTs, Rank2Types #-}
    
    module Expr where
    
    data Expr where
      Val :: Int -> Expr
      Add :: Expr -> Expr -> Expr
    
    class Expr' a where
      val :: Int -> a
      add :: a -> a -> a
    

    您可以看到这两个定义看起来非常相似。 Expr' a 基本上描述了Expr 上的代数,这意味着如果你有这样的Expr' a,你可以从Expr 中得到a。同样,因为您可以编写实例Expr' Expr,所以您可以将forall a. Expr' a => a 类型的术语具体化为Expr 类型的语法值:

    expr :: Expr' a => Expr -> a
    expr e = case e of
      Val n   -> val n
      Add p q -> add (expr p) (expr q)
    
    instance Expr' Expr where
      val = Val
      add = Add
    
    expr' :: (forall a. Expr' a => a) -> Expr
    expr' e = e
    

    最后,选择一个表示而不是另一个表示实际上取决于您的主要关注点:如果您想检查表达式的结构(例如,如果您想优化/编译它),如果您可以访问它会更容易AST。另一方面,如果您只对使用折叠计算不变量感兴趣(例如,表达式的深度或其评估),则可以使用更高阶的编码。

    【讨论】:

    • 谢谢@chi。我不知道这些标签可以让 Haskell 突出显示!
    • 至此,Oleg Kiselyov's lecture notes on Typed Tagless Final Interpreters 值得一读。他们详细解释了初始/最终二元性,展示了最终表示在多态类型系统的上下文中的意想不到的灵活性,并展示了一些令人难以置信的方式可以扩展“最终解释器”以处理新的语言结构而无需修改任何现有代码。当我第一次阅读这些笔记时,它们让我大吃一惊。
    猜你喜欢
    • 2015-04-22
    • 1970-01-01
    • 1970-01-01
    • 2017-08-28
    • 1970-01-01
    • 2012-01-04
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多