|
|
51CTO旗下网站
|
|
移动端

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

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

作者:Gil Mizrahi来源:Linux中国|2018-10-31 14:00

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

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

我们将会:

  1. 自定义语言,并用它编写一个简单的程序
  2. 实现一个简单的解析器组合器
  3. 为该语言实现一个解析器
  4. 为该语言实现一个美观的打印器
  5. 为我们的用途定义 JavaScript 的一个子集
  6. 实现代码转译器,将代码转译成我们定义的 JavaScript 子集
  7. 把所有东西整合在一起

开始吧!

1、定义语言

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

  1. program ::= expr
  2. expr ::= <integer> | <name> | ([<expr>])

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

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

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

  • let 表达式使我们可以在它的 body 环境中引入新的变量。语法如下:

    1. let ::= (let ([<letarg>]) <body>)
    2. letargs ::= (<name> <expr>)
    3. body ::= <expr>
  • lambda 表达式:也就是匿名函数定义。语法如下:

    1. lambda ::= (lambda ([<name>]) <body>)

还有一些内建函数: addmulsubdiv 和 print

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

  1. (let
  2. ((compose
  3. (lambda (f g)
  4. (lambda (x) (f (g x)))))
  5. (square
  6. (lambda (x) (mul x x)))
  7. (add1
  8. (lambda (x) (add x 1))))
  9. (print ((compose square add1) 5)))

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

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

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

  1. type Name = String
  2.  
  3. data Expr
  4. = ATOM Atom
  5. | LIST [Expr]
  6. deriving (Eq, Read, Show)
  7.  
  8. data Atom
  9. = Int Int
  10. | Symbol Name
  11. deriving (Eq, Read, Show)

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

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

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

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

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

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

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

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

  1. newtype Parser a
  2. = Parser (ParseString -> Either ParseError (a, ParseString))
  3.  
  4. data ParseString
  5. = ParseString Name (Int, Int) String
  6.  
  7. data ParseError
  8. = ParseError ParseString Error
  9.  
  10. type Error = String

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

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

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

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

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

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

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

  1. instance Functor Parser where
  2. fmap f (Parser parser) =
  3. Parser (\str -> first f <$> parser str)

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

  1. instance Applicative Parser where
  2. pure x = Parser (\str -> Right (x, str))
  3. (Parser p1) <*> (Parser p2) =
  4. Parser $
  5. \str -> do
  6. (f, rest) <- p1 str
  7. (x, rest') <- p2 rest
  8. pure (f x, rest')

(注意:我们还会实现一个 Monad 实例,这样我们才能使用符号

第三个是 Alternative 实例。万一前面的解析器解析失败了,我们要能够提供一个备用的解析器。

  1. instance Alternative Parser where
  2. empty = Parser (`throwErr` "Failed consuming input")
  3. (Parser p1) <|> (Parser p2) =
  4. Parser $
  5. \pstr -> case p1 pstr of
  6. Right result -> Right result
  7. Left _ -> p2 pstr

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

  1. instance Monad Parser where
  2. (Parser p1) >>= f =
  3. Parser $
  4. \str -> case p1 str of
  5. Left err -> Left err
  6. Right (rs, rest) ->
  7. case f rs of
  8. Parser parser -> parser rest

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

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

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

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

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

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

  1. optional :: Parser a -> Parser (Maybe a)
  2. optional (Parser parser) =
  3. Parser $
  4. \pstr -> case parser pstr of
  5. Left _ -> Right (Nothing, pstr)
  6. Right (x, rest) -> Right (Just x, rest)

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

  1. many :: Parser a -> Parser [a]
  2. many parser = go []
  3. where go cs = (parser >>= \c -> go (c:cs)) <|> pure (reverse cs)
  4.  
  5. many1 :: Parser a -> Parser [a]
  6. many1 parser =
  7. (:) <$> parser <*> many parser

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

  1. char :: Char -> Parser Char
  2. char c = oneOf [c]
  3.  
  4. string :: String -> Parser String
  5. string = traverse char
  6.  
  7. space :: Parser Char
  8. space = oneOf " \n"
  9.  
  10. spaces :: Parser String
  11. spaces = many space
  12.  
  13. spaces1 :: Parser String
  14. spaces1 = many1 space
  15.  
  16. withSpaces :: Parser a -> Parser a
  17. withSpaces parser =
  18. spaces *> parser <* spaces
  19.  
  20. parens :: Parser a -> Parser a
  21. parens parser =
  22. (withSpaces $ char '(')
  23. *> withSpaces parser
  24. <* (spaces *> char ')')
  25.  
  26. sepBy :: Parser a -> Parser b -> Parser [b]
  27. sepBy sep parser = do
  28. frst <- optional parser
  29. rest <- many (sep *> parser)
  30. pure $ maybe rest (:rest) frst

现在为该门语言定义解析器所需要的所有东西都有了。

  • 练习 :实现一个 EOF(end of file/input,即文件或输入终止符)解析器组合器。

3、为我们的语言实现解析器

我们会用自顶而下的方法定义解析器。

  1. parseExpr :: Parser Expr
  2. parseExpr = fmap ATOM parseAtom <|> fmap LIST parseList
  3.  
  4. parseList :: Parser [Expr]
  5. parseList = parens $ sepBy spaces1 parseExpr
  6.  
  7. parseAtom :: Parser Atom
  8. parseAtom = parseSymbol <|> parseInt
  9.  
  10. parseSymbol :: Parser Atom
  11. parseSymbol = fmap Symbol parseName

注意到这四个函数是在我们这门语言中属于高阶描述。这解释了为什么 Haskell 执行解析工作这么棒。在定义完高级部分后,我们还需要定义低级别的 parseNameparseInt

我们能在这门语言中用什么字符作为名字呢?用小写的字母、数字和下划线吧,而且名字的第一个字符必须是字母。

  1. parseName :: Parser Name
  2. parseName = do
  3. c <- oneOf ['a'..'z']
  4. cs <- many $ oneOf $ ['a'..'z'] ++ "0123456789" ++ "_"
  5. pure (c:cs)

整数是一系列数字,数字前面可能有负号 -

  1. parseInt :: Parser Atom
  2. parseInt = do
  3. sign <- optional $ char '-'
  4. num <- many1 $ oneOf "0123456789"
  5. let result = read $ maybe num (:num) sign of
  6. pure $ Int result

最后,我们会定义用来运行解析器的函数,返回值可能是一个 Expr 或者是一条错误信息。

  1. runExprParser :: Name -> String -> Either String Expr
  2. runExprParser name str =
  3. case runParser name str (withSpaces parseExpr) of
  4. Left (ParseError _ errMsg) -> Left errMsg
  5. Right (result, _) -> Right result
  • 练习 1 :为第一节中定义的 Program 类型编写一个解析器
  • 练习 2 :用 Applicative 的形式重写 parseName
  • 练习 3parseInt 可能出现溢出情况,找到处理它的方法,不要用 read

4、为这门语言实现一个更好看的输出器

我们还想做一件事,将我们的程序以源代码的形式打印出来。这对完善错误信息很有用。

  1. printExpr :: Expr -> String
  2. printExpr = printExpr' False 0
  3.  
  4. printAtom :: Atom -> String
  5. printAtom = \case
  6. Symbol s -> s
  7. Int i -> show i
  8.  
  9. printExpr' :: Bool -> Int -> Expr -> String
  10. printExpr' doindent level = \case
  11. ATOM a -> indent (bool 0 level doindent) (printAtom a)
  12. LIST (e:es) ->
  13. indent (bool 0 level doindent) $
  14. concat
  15. [ "("
  16. , printExpr' False (level + 1) e
  17. , bool "\n" "" (null es)
  18. , intercalate "\n" $ map (printExpr' True (level + 1)) es
  19. , ")"
  20. ]
  21.  
  22. indent :: Int -> String -> String
  23. indent tabs e = concat (replicate tabs " ") ++ e
  • 练习 :为第一节中定义的 Program 类型编写一个美观的输出器

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

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

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

  1. data JSExpr
  2. = JSInt Int
  3. | JSSymbol Name
  4. | JSBinOp JSBinOp JSExpr JSExpr
  5. | JSLambda [Name] JSExpr
  6. | JSFunCall JSExpr [JSExpr]
  7. | JSReturn JSExpr
  8. deriving (Eq, Show, Read)
  9.  
  10. 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 中做的基本上是一样的。我们还会追踪元素的作用域,这样我们才可以用合适的方式缩进生成的代码。

  1. printJSOp :: JSBinOp -> String
  2. printJSOp op = op
  3.  
  4. printJSExpr :: Bool -> Int -> JSExpr -> String
  5. printJSExpr doindent tabs = \case
  6. JSInt i -> show i
  7. JSSymbol name -> name
  8. JSLambda vars expr -> (if doindent then indent tabs else id) $ unlines
  9. ["function(" ++ intercalate ", " vars ++ ") {"
  10. ,indent (tabs+1) $ printJSExpr False (tabs+1) expr
  11. ] ++ indent tabs "}"
  12. JSBinOp op e1 e2 -> "(" ++ printJSExpr False tabs e1 ++ " " ++ printJSOp op ++ " " ++ printJSExpr False tabs e2 ++ ")"
  13. JSFunCall f exprs -> "(" ++ printJSExpr False tabs f ++ ")(" ++ intercalate ", " (fmap (printJSExpr False tabs) exprs) ++ ")"
  14. 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 转译成一个函数调用或者转译的特例。

  1. type TransError = String
  2.  
  3. translateToJS :: Expr -> Either TransError JSExpr
  4. translateToJS = \case
  5. ATOM (Symbol s) -> pure $ JSSymbol s
  6. ATOM (Int i) -> pure $ JSInt i
  7. LIST xs -> translateList xs
  8.  
  9. translateList :: [Expr] -> Either TransError JSExpr
  10. translateList = \case
  11. [] -> Left "translating empty list"
  12. ATOM (Symbol s):xs
  13. | Just f <- lookup s builtins ->
  14. f xs
  15. f:xs ->
  16. JSFunCall <$> translateToJS f <*> traverse translateToJS xs

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

  1. type Builtin = [Expr] -> Either TransError JSExpr
  2. type Builtins = [(Name, Builtin)]
  3.  
  4. builtins :: Builtins
  5. builtins =
  6. [("lambda", transLambda)
  7. ,("let", transLet)
  8. ,("add", transBinOp "add" "+")
  9. ,("mul", transBinOp "mul" "*")
  10. ,("sub", transBinOp "sub" "-")
  11. ,("div", transBinOp "div" "/")
  12. ,("print", transPrint)
  13. ]

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

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

  1. transLambda :: [Expr] -> Either TransError JSExpr
  2. transLambda = \case
  3. [LIST vars, body] -> do
  4. vars' <- traverse fromSymbol vars
  5. JSLambda vars' <$> (JSReturn <$> translateToJS body)
  6.  
  7. vars ->
  8. Left $ unlines
  9. ["Syntax error: unexpected arguments for lambda."
  10. ,"expecting 2 arguments, the first is the list of vars and the second is the body of the lambda."
  11. ,"In expression: " ++ show (LIST $ ATOM (Symbol "lambda") : vars)
  12. ]
  13.  
  14. fromSymbol :: Expr -> Either String Name
  15. fromSymbol (ATOM (Symbol s)) = Right s
  16. fromSymbol e = Left $ "cannot bind value to non symbol type: " ++ show e

我们会将 let 转译成带有相关名字参数的函数定义,然后带上参数调用函数,因此会在这一作用域中引入变量:

  1. transLet :: [Expr] -> Either TransError JSExpr
  2. transLet = \case
  3. [LIST binds, body] -> do
  4. (vars, vals) <- letParams binds
  5. vars' <- traverse fromSymbol vars
  6. JSFunCall . JSLambda vars' <$> (JSReturn <$> translateToJS body) <*> traverse translateToJS vals
  7. where
  8. letParams :: [Expr] -> Either Error ([Expr],[Expr])
  9. letParams = \case
  10. [] -> pure ([],[])
  11. LIST [x,y] : rest -> ((x:) *** (y:)) <$> letParams rest
  12. x : _ -> Left ("Unexpected argument in let list in expression:\n" ++ printExpr x)
  13.  
  14. vars ->
  15. Left $ unlines
  16. ["Syntax error: unexpected arguments for let."
  17. ,"expecting 2 arguments, the first is the list of var/val pairs and the second is the let body."
  18. ,"In expression:\n" ++ printExpr (LIST $ ATOM (Symbol "let") : vars)
  19. ]

我们会将可以在多个参数之间执行的操作符转译成一系列二元操作符。比如:(add 1 2 3) 将会变成 1 + (2 + 3)

  1. transBinOp :: Name -> Name -> [Expr] -> Either TransError JSExpr
  2. transBinOp f _ [] = Left $ "Syntax error: '" ++ f ++ "' expected at least 1 argument, got: 0"
  3. transBinOp _ _ [x] = translateToJS x
  4. transBinOp _ f list = foldl1 (JSBinOp f) <$> traverse translateToJS list

然后我们会将 print 转换成对 console.log 的调用。

  1. transPrint :: [Expr] -> Either TransError JSExpr
  2. transPrint [expr] = JSFunCall (JSSymbol "console.log") . (:[]) <$> translateToJS expr
  3. transPrint xs = Left $ "Syntax error. print expected 1 arguments, got: " ++ show (length xs)

注意,如果我们将这些代码当作 Expr 的特例进行解析,那我们就可能会跳过语法验证。

  • 练习 1 :将 Program 转译成 JSProgram
  • 练习 2 :为 if Expr Expr Expr 添加一个特例,并将它转译成你在上一次练习中实现的 JSIf 条件语句。

7、把所有东西整合到一起

最终,我们将会把所有东西整合到一起。我们会:

  1. 读取文件
  2. 将文件解析成 Expr
  3. 将文件转译成 JSExpr
  4. 将 JavaScript 代码发送到标准输出流

我们还会启用一些用于测试的标志位:

  • --e 将进行解析并打印出表达式的抽象表示(Expr
  • --pp 将进行解析,美化输出
  • --jse 将进行解析、转译、并打印出生成的 JS 表达式(JSExpr)的抽象表示
  • --ppc 将进行解析,美化输出并进行编译
  1. main :: IO ()
  2. main = getArgs >>= \case
  3. [file] ->
  4. printCompile =<< readFile file
  5. ["--e",file] ->
  6. either putStrLn print . runExprParser "--e" =<< readFile file
  7. ["--pp",file] ->
  8. either putStrLn (putStrLn . printExpr) . runExprParser "--pp" =<< readFile file
  9. ["--jse",file] ->
  10. either print (either putStrLn print . translateToJS) . runExprParser "--jse" =<< readFile file
  11. ["--ppc",file] ->
  12. either putStrLn (either putStrLn putStrLn) . fmap (compile . printExpr) . runExprParser "--ppc" =<< readFile file
  13. _ ->
  14. putStrLn $ unlines
  15. ["Usage: runghc Main.hs [ --e, --pp, --jse, --ppc ] <filename>"
  16. ,"--e print the Expr"
  17. ,"--pp pretty print Expr"
  18. ,"--jse print the JSExpr"
  19. ,"--ppc pretty print Expr and then compile"
  20. ]
  21.  
  22. printCompile :: String -> IO ()
  23. printCompile = either putStrLn putStrLn . compile
  24.  
  25. compile :: String -> Either Error String
  26. compile str = printJSExpr False 0 <$> (translateToJS =<< runExprParser "compile" str)

大功告成。将自己的语言编译到 JS 子集的编译器已经完成了。再说一次,你可以在 这里 看到完整的源文件。

用我们的编译器运行第一节的示例,产生的 JavaScript 代码如下:

  1. $ runhaskell Lisp.hs example.lsp
  2. (function(compose, square, add1) {
  3. return (console.log)(((compose)(square, add1))(5));
  4. })(function(f, g) {
  5. return function(x) {
  6. return (f)((g)(x));
  7. };
  8. }, function(x) {
  9. return (x * x);
  10. }, function(x) {
  11. return (x + 1);
  12. })

如果你在自己电脑上安装了 node.js,你可以用以下命令运行这段代码:

  1. $ runhaskell Lisp.hs example.lsp | node -p
  2. 36
  3. undefined
  • 最终练习 : 编译有多个表达式的程序而非仅编译一个表达式。
【责任编辑:庞桂玉 TEL:(010)68476606】

点赞 0
分享:
大家都在看
猜你喜欢

视频课程+更多

2019年信息系统项目管理师(软考中高项)强化班+冲刺押题视频课程

2019年信息系统项目管理师(软考中高项)强化

讲师:王安28355人学习过

讲师:刘宗泽3462人学习过

CCIE专题-MPLS,MPLS VPN视频课程

CCIE专题-MPLS,MPLS VPN视频课程

讲师:庚振源14752人学习过

读 书 +更多

网络管理员备考训练——计算机与网络基础知识

本书是根据全国计算机技术与软件专业技术资格(水平)考试《网络管理员考试大纲》所要求的考试范围而编写的试题集。全书共分10个单元,同步...

订阅51CTO邮刊

点击这里查看样刊

订阅51CTO邮刊