用 350 行代码从零开始,将 Lisp 编译成 JavaScript

2024年 7月 18日 50.4k 0

用 350 行代码从零开始,将 Lisp 编译成 JavaScript-1

我们将会在本篇文章中看到从零开始实现的编译器,将简单的类 LISP 计算语言编译成 JavaScript。完整的源代码在 这里。

我们将会:

  • 自定义语言,并用它编写一个简单的程序
  • 实现一个简单的解析器组合器
  • 为该语言实现一个解析器
  • 为该语言实现一个美观的打印器
  • 为我们的用途定义 JavaScript 的一个子集
  • 实现代码转译器,将代码转译成我们定义的 JavaScript 子集
  • 把所有东西整合在一起
  • 开始吧!

    1、定义语言

    Lisp 族语言最迷人的地方在于,它们的语法就是树状表示的,这就是这门语言很容易解析的原因。我们很快就能接触到它。但首先让我们把自己的语言定义好。关于我们语言的语法的范式(BNF)描述如下:

    program ::= expr
    expr ::=  |  | ([])
    

    基本上,我们可以在该语言的最顶层定义表达式并对其进行运算。表达式由一个整数(比如 5)、一个变量(比如 x)或者一个表达式列表(比如 (add x 1))组成。

    整数对应它本身的值,变量对应它在当前环境中绑定的值,表达式列表对应一个函数调用,该列表的第一个参数是相应的函数,剩下的表达式是传递给这个函数的参数。

    该语言中,我们保留一些内建的特殊形式,这样我们就能做一些更有意思的事情:

    • let 表达式使我们可以在它的 body 环境中引入新的变量。语法如下:
    let ::= (let ([]) )
    letargs ::= ( )
    body ::= 
    
    • lambda 表达式:也就是匿名函数定义。语法如下:
    lambda ::= (lambda ([]) )
    

    还有一些内建函数: addmulsubdivprint

    让我们看看用我们这门语言编写的入门示例程序:

    (let
      ((compose
        (lambda (f g)
          (lambda (x) (f (g x)))))
      (square
        (lambda (x) (mul x x)))
      (add1
        (lambda (x) (add x 1))))
      (print ((compose square add1) 5)))
    

    这个程序定义了 3 个函数:composesquareadd1。然后将计算结果的值 ((compose square add1) 5) 输出出来。

    我相信了解这门语言,这些信息就足够了。开始实现它吧。

    在 Haskell 中,我们可以这样定义语言:

    type Name = String
    
    data Expr
      = ATOM Atom
      | LIST [Expr]
        deriving (Eq, Read, Show)
    
    data Atom
      = Int Int
      | Symbol Name
        deriving (Eq, Read, Show)
    

    我们可以解析用该语言用 Expr 定义的程序。而且,这里我们添加了新数据类型 EqReadShow 等实例用于测试和调试。你能够在 REPL 中使用这些数据类型,验证它们确实有用。

    我们不在语法中定义 lambdalet 或其它的内建函数,原因在于,当前情况下我们没必要用到这些东西。这些函数仅仅是 LIST (表达式列表)的更加特殊的用例。所以我决定将它放到后面的部分。

    一般来说你想要在抽象语法中定义这些特殊用例 —— 用于改进错误信息、禁用静态分析和优化等等,但在这里我们不会这样做,对我们来说这些已经足够了。

    另一件你想做的事情可能是在语法中添加一些注释信息。比如定位:Expr 是来自哪个文件的,具体到这个文件的哪一行哪一列。你可以在后面的阶段中使用这一特性,打印出错误定位,即使它们不是处于解析阶段。

    • 练习 1:添加一个 Program 数据类型,可以按顺序包含多个 Expr
    • 练习 2:向语法树中添加一个定位注解。

    2、实现一个简单的解析器组合库

    我们要做的第一件事情是定义一个 嵌入式领域专用语言 Embedded Domain Specific Language (EDSL),我们会用它来定义我们的语言解析器。这常常被称为解析器组合库。我们做这件事完全是出于学习的目的,Haskell 里有很好的解析库,在实际构建软件或者进行实验时,你应该使用它们。megaparsec 就是这样的一个库。

    首先我们来谈谈解析库的实现的思路。本质上,我们的解析器就是一个函数,接受一些输入,可能会读取输入的一些或全部内容,然后返回解析出来的值和无法解析的输入部分,或者在解析失败时抛出异常。我们把它写出来。

    newtype Parser a
      = Parser (ParseString -> Either ParseError (a, ParseString))
    
    data ParseString
      = ParseString Name (Int, Int) String
    
    data ParseError
      = ParseError ParseString Error
    
    type Error = String
    

    这里我们定义了三个主要的新类型。

    第一个,Parser a 是之前讨论的解析函数。

    第二个,ParseString 是我们的输入或携带的状态。它有三个重要的部分:

    • Name: 这是源的名字
    • (Int, Int): 这是源的当前位置
    • String: 这是等待解析的字符串

    第三个,ParseError 包含了解析器的当前状态和一个错误信息。

    现在我们想让这个解析器更灵活,我们将会定义一些常用类型的实例。这些实例让我们能够将小巧的解析器和复杂的解析器结合在一起(因此它的名字叫做 “解析器组合器”)。

    第一个是 Functor 实例。我们需要 Functor 实例,因为我们要能够对解析值应用函数从而使用不同的解析器。当我们定义自己语言的解析器时,我们将会看到关于它的示例。

    instance Functor Parser where
      fmap f (Parser parser) =
        Parser (\str -> first f  parser str)
    

    第二个是 Applicative 实例。该实例的常见用例是在多个解析器中实现一个纯函数。

    instance Applicative Parser where
      pure x = Parser (\str -> Right (x, str))
      (Parser p1)  (Parser p2) =
        Parser $
          \str -> do
            (f, rest)   case p1 pstr of
            Right result -> Right result
            Left  _      -> p2 pstr
    

    第四个是 Monad 实例。这样我们就能链接解析器。

    instance Monad Parser where
      (Parser p1) >>= f =
        Parser $
         \str -> case p1 str of
           Left err -> Left err
           Right (rs, rest) ->
             case f rs of
               Parser parser -> parser rest
    

    接下来,让我们定义一种的方式,用于运行解析器和防止失败的助手函数:

    runParser :: String -> String -> Parser a -> Either ParseError (a, ParseString)
    runParser name str (Parser parser) = parser $ ParseString name (0,0) str
    
    throwErr :: ParseString -> String -> Either ParseError a
    throwErr ps@(ParseString name (row,col) _) errMsg =
      Left $ ParseError ps $ unlines
        [ "*** " ++ name ++ ": " ++ errMsg
        , "* On row " ++ show row ++ ", column " ++ show col ++ "."
        ]
    

    现在我们将会开始实现组合器,这是 EDSL 的 API,也是它的核心。

    首先,我们会定义 oneOf。如果输入列表中的字符后面还有字符的话,oneOf 将会成功,否则就会失败。

    oneOf :: [Char] -> Parser Char
    oneOf chars =
      Parser $ \case
        ps@(ParseString name (row, col) str) ->
          case str of
            []     -> throwErr ps "Cannot read character of empty string"
            (c:cs) ->
              if c `elem` chars
              then Right (c, ParseString name (row, col+1) cs)
              else throwErr ps $ unlines ["Unexpected character " ++ [c], "Expecting one of: " ++ show chars]
    

    optional 将会抛出异常,停止解析器。失败时它仅仅会返回 Nothing

    optional :: Parser a -> Parser (Maybe a)
    optional (Parser parser) =
      Parser $
        \pstr -> case parser pstr of
          Left _ -> Right (Nothing, pstr)
          Right (x, rest) -> Right (Just x, rest)
    

    many 将会试着重复运行解析器,直到失败。当它完成的时候,会返回成功运行的解析器列表。many1 做的事情是一样的,但解析失败时它至少会抛出一次异常。

    many :: Parser a -> Parser [a]
    many parser = go []
      where go cs = (parser >>= \c -> go (c:cs))  pure (reverse cs)
    
    many1 :: Parser a -> Parser [a]
    many1 parser =
      (:)  parser  many parser
    

    下面的这些解析器通过我们定义的组合器来实现一些特殊的解析器:

    char :: Char -> Parser Char
    char c = oneOf [c]
    
    string :: String -> Parser String
    string = traverse char
    
    space :: Parser Char
    space = oneOf " \n"
    
    spaces :: Parser String
    spaces = many space
    
    spaces1 :: Parser String
    spaces1 = many1 space
    
    withSpaces :: Parser a -> Parser a
    withSpaces parser =
      spaces *> parser  Parser a
    parens parser =
         (withSpaces $ char '(')
      *> withSpaces parser
       char ')')
    
    sepBy :: Parser a -> Parser b -> Parser [b]
    sepBy sep parser = do
      frst  String
    printExpr = printExpr' False 0
    
    printAtom :: Atom -> String
    printAtom = \case
      Symbol s -> s
      Int i -> show i
    
    printExpr' :: Bool -> Int -> Expr -> String
    printExpr' doindent level = \case
      ATOM a -> indent (bool 0 level doindent) (printAtom a)
      LIST (e:es) ->
        indent (bool 0 level doindent) $
          concat
            [ "("
            , printExpr' False (level + 1) e
            , bool "\n" "" (null es)
            , intercalate "\n" $ map (printExpr' True (level + 1)) es
            , ")"
            ]
    
    indent :: Int -> String -> String
    indent tabs e = concat (replicate tabs "  ") ++ e
    
    • 练习 :为第一节中定义的 Program 类型编写一个美观的输出器

    好,目前为止我们写了近 200 行代码,这些代码一般叫做编译器的前端。我们还要写大概 150 行代码,用来执行三个额外的任务:我们需要根据需求定义一个 JS 的子集,定义一个将我们的语言转译成这个子集的转译器,最后把所有东西整合在一起。开始吧。

    5、根据需求定义 JavaScript 的子集

    首先,我们要定义将要使用的 JavaScript 的子集:

    data JSExpr
      = JSInt Int
      | JSSymbol Name
      | JSBinOp JSBinOp JSExpr JSExpr
      | JSLambda [Name] JSExpr
      | JSFunCall JSExpr [JSExpr]
      | JSReturn JSExpr
        deriving (Eq, Show, Read)
    
    type JSBinOp = String
    

    这个数据类型表示 JavaScript 表达式。我们有两个原子类型 JSIntJSSymbol,它们是由我们这个语言中的 Atom 转译来的,我们用 JSBinOp 来表示二元操作,比如 +*,用 JSLambda 来表示匿名函数,和我们语言中的 lambda expression(lambda 表达式) 一样,我们将会用 JSFunCall 来调用函数,用 let 来引入新名字,用 JSReturn 从函数中返回值,在 JavaScript 中是需要返回值的。

    JSExpr 类型是对 JavaScript 表达式的 抽象表示。我们会把自己语言中表达式的抽象表示 Expr 转译成 JavaScript 表达式的抽象表示 JSExpr。但为了实现这个功能,我们需要实现 JSExpr ,并从这个抽象表示中生成 JavaScript 代码。我们将通过递归匹配 JSExpr 实现,将 JS 代码当作 String 来输出。这和我们在 printExpr 中做的基本上是一样的。我们还会追踪元素的作用域,这样我们才可以用合适的方式缩进生成的代码。

    printJSOp :: JSBinOp -> String
    printJSOp op = op
    
    printJSExpr :: Bool -> Int -> JSExpr -> String
    printJSExpr doindent tabs = \case
      JSInt    i     -> show i
      JSSymbol name  -> name
      JSLambda vars expr -> (if doindent then indent tabs else id) $ unlines
        ["function(" ++ intercalate ", " vars ++ ") {"
        ,indent (tabs+1) $ printJSExpr False (tabs+1) expr
        ] ++ indent tabs "}"
      JSBinOp  op e1 e2  -> "(" ++ printJSExpr False tabs e1 ++ " " ++ printJSOp op ++ " " ++ printJSExpr False tabs e2 ++ ")"
      JSFunCall f exprs  -> "(" ++ printJSExpr False tabs f ++ ")(" ++ intercalate ", " (fmap (printJSExpr False tabs) exprs) ++ ")"
      JSReturn expr      -> (if doindent then indent tabs else id) $ "return " ++ printJSExpr False tabs expr ++ ";"
    
    • 练习 1 :添加 JSProgram 类型,它可以包含多个 JSExpr ,然后创建一个叫做 printJSExprProgram 的函数来生成代码。
    • 练习 2 :添加 JSExpr 的新类型:JSIf,并为其生成代码。

    6、实现到我们定义的 JavaScript 子集的代码转译器

    我们快做完了。这一节将会创建函数,将 Expr 转译成 JSExpr

    基本思想很简单,我们会将 ATOM 转译成 JSSymbol 或者 JSInt,然后会将 LIST 转译成一个函数调用或者转译的特例。

    type TransError = String
    
    translateToJS :: Expr -> Either TransError JSExpr
    translateToJS = \case
      ATOM (Symbol s) -> pure $ JSSymbol s
      ATOM (Int i)    -> pure $ JSInt i
      LIST xs -> translateList xs
    
    translateList :: [Expr] -> Either TransError JSExpr
    translateList = \case
      []     -> Left "translating empty list"
      ATOM (Symbol s):xs
        | Just f 
          f xs
      f:xs ->
        JSFunCall  translateToJS f  traverse translateToJS xs
    

    builtins 是一系列要转译的特例,就像 lambadalet。每一种情况都可以获得一系列参数,验证它是否合乎语法规范,然后将其转译成等效的 JSExpr

    type Builtin  = [Expr] -> Either TransError JSExpr
    type Builtins = [(Name, Builtin)]
    
    builtins :: Builtins
    builtins =
      [("lambda", transLambda)
      ,("let", transLet)
      ,("add", transBinOp "add" "+")
      ,("mul", transBinOp "mul" "*")
      ,("sub", transBinOp "sub" "-")
      ,("div", transBinOp "div" "/")
      ,("print", transPrint)
      ]
    

    我们这种情况,会将内建的特殊形式当作特殊的、非第一类的进行对待,因此不可能将它们当作第一类函数。

    我们会把 Lambda 表达式转译成一个匿名函数:

    transLambda :: [Expr] -> Either TransError JSExpr
    transLambda = \case
    [LIST vars, body] -> do
    vars'

    相关文章

    Linux 命令行的聊天工具 CenterIM
    Linux 桌面年仍未到来 但 Linux 移动之年已到来
    12 个在线学习 Linux 技能网站
    Linux Mint : 会是另一个新的 Ubuntu 吗?
    W3Conf 开发者大会将于下周召开
    Ubuntu 10.04 ARM 处理器上网本版本结束服务期

    发布评论