章节目录

Parser:把 token 组成 AST

本节阅读量:

上一节 lexer 已经把源码切成了 token。

比如源码:

1
(+ 40 2)

会先变成:

1
["(", "+", "40", "2", ")"]

但 token 只是小块,还不是结构。

parser 要做的是:

1
tokens -> AST

也就是把这一串 token 读成:

1
Add(Int(40), Int(2))

parser 不再关心字符

lexer 已经把连续字符切成了一个个 token,也处理了空白和括号边界。因此 parser 不需要在整段源码上寻找下一个小块从哪里开始、在哪里结束,只需要依次读取 token:

1
(   +   40   2   )

不过,token 目前仍然只是字符串。"40" 是否真的符合整数规则,仍然要由 parser 里的 is_integer 逐个检查字符。

parser 问的是:

1
2
3
下一个 token 是整数吗?
下一个 token 是左括号吗?
如果是左括号,后面是不是一个加法表达式?

第一章的语言规则是:

1
2
expr ::= integer
       | "(" "+" expr expr ")"

parser 的代码基本就是把这两条规则翻译成 C++。

Parser 记住当前位置

代码在:

1
code/01_numbers/src/front/parser.cpp

Parser 这个类主要保存两个东西:

1
2
std::vector<std::string> tokens_;
std::size_t current_ = 0;

可以先这样理解:

1
2
tokens_   lexer 切出来的一串 token
current_  现在读到第几个 token

如果 token 是:

1
["(", "+", "40", "2", ")"]

刚开始:

1
2
current_ = 0
当前 token 是 "("

读走一个 token 后:

1
2
current_ = 1
当前 token 是 "+"

parser 就是靠这个位置,一步一步往前读。

peek、advance 和 expect

先看三个小工具函数:

1
2
3
4
5
6
const std::string& Parser::peek() const {
    if (current_ >= tokens_.size()) {
        throw std::runtime_error("unexpected end of input");
    }
    return tokens_[current_];
}

peek() 只看当前 token,不前进。

1
2
3
4
5
std::string Parser::advance() {
    std::string token = peek();
    ++current_;
    return token;
}

advance() 做两件事:

1
2
取出当前 token。
把 current_ 往后移一格。

比如当前是:

1
2
current_ = 0
tokens_[0] = "("

调用 advance() 后:

1
2
返回 "("
current_ 变成 1

再看 expect:

1
2
3
4
5
6
void Parser::expect(const std::string& token, const char* message) {
    if (current_ >= tokens_.size() || tokens_[current_] != token) {
        throw std::runtime_error(message);
    }
    advance();
}

expect("+", "...") 的意思是:

1
2
3
下一个 token 必须是 "+"。
如果不是,就报错。
如果是,就把它读走。

它适合处理语法里固定必须出现的东西,比如:

1
2
加法开头的 +
加法结尾的 )

parse_program 只允许一个表达式

对外使用 parser 时,调用的是:

1
2
3
4
std::unique_ptr<Expr> parse(const std::string& source) {
    Parser parser(lex(source));
    return parser.parse_program();
}

parse 先调用 lex(source),拿到 token 列表,再创建 Parser。

真正读完整程序的是:

1
2
3
4
5
6
7
std::unique_ptr<Expr> Parser::parse_program() {
    auto expr = parse_expr();
    if (current_ != tokens_.size()) {
        throw std::runtime_error("expected end of input");
    }
    return expr;
}

第一章规定:

1
一个完整程序就是一个表达式。

所以 parse_program() 会先读一个表达式。读完以后,它还要检查后面没有多余 token。

比如:

1
42

可以。

但:

1
42 43

不可以。因为第一个表达式 42 已经读完了,后面还剩一个多余的 43。

parse_expr 对应语言规则

最核心的函数是 parse_expr():

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
std::unique_ptr<Expr> Parser::parse_expr() {
    std::string token = advance();

    if (is_integer(token)) {
        return std::make_unique<IntExpr>(std::stol(token));
    }

    if (token == "(") {
        expect("+", "expected '+' after '('");
        auto lhs = parse_expr();
        auto rhs = parse_expr();
        expect(")", "expected ')' after addition");
        return std::make_unique<AddExpr>(std::move(lhs), std::move(rhs));
    }

    throw std::runtime_error("expected integer or '(+ expr expr)'");
}

先不要被 C++ 写法吓住。按语言规则看,它只有两种分支:

1
2
读到整数      生成 Int
读到左括号    按 (+ expr expr) 读取加法

这正好对应:

1
2
expr ::= integer
       | "(" "+" expr expr ")"

换句话说,这整个函数就是把 expr 的两条规则写成两个 if。

如果 token 是整数,比如:

1
"42"

parser 会生成:

1
Int(42)

代码是:

1
2
3
if (is_integer(token)) {
    return std::make_unique<IntExpr>(std::stol(token));
}

is_integer(token) 判断字符串是不是整数形式。它允许:

1
2
3
0
42
-7

但只有一个 "-" 不行,因为负号后面必须还有数字。

std::stol(token) 把字符串变成真正的整数值。std::make_unique<IntExpr>(...) 创建 IntExpr 节点,并把它交给 unique_ptr 管理。

如果第一个 token 是 "(",第一章只允许它是加法:

1
(+ expr expr)

所以 parser 接下来期待看到 +:

1
expect("+", "expected '+' after '('");

然后读左边表达式:

1
auto lhs = parse_expr();

再读右边表达式:

1
auto rhs = parse_expr();

最后期待右括号:

1
expect(")", "expected ')' after addition");

如果这些都成功,就把左右两边包成一个加法节点:

1
return std::make_unique<AddExpr>(std::move(lhs), std::move(rhs));

这里的 lhs 和 rhs 本身也是 unique_ptr<Expr>。std::move(lhs) 和 std::move(rhs) 表示:

1
把左右子表达式交给 AddExpr 保存。

递归怎样读出嵌套结构

这两行最关键:

1
2
auto lhs = parse_expr();
auto rhs = parse_expr();

parse_expr() 的任务是读一个表达式。加法的左边和右边也都是表达式,所以它要调用自己。

例如:

1
(+ (+ 1 2) 3)

外层加法可以拆成:

1
2
左边:(+ 1 2)
右边:3

读左边时,parser 又调用一次 parse_expr(),把 (+ 1 2) 读成:

1
Add(Int(1), Int(2))

读右边时,再调用一次 parse_expr(),把 3 读成:

1
Int(3)

最后外层加法组合成:

1
Add(Add(Int(1), Int(2)), Int(3))

把调用和游标前进放在一起看:

调用位置 读到什么 动作或结果
外层 parse_expr() "(" 开始读取外层加法
左边 parse_expr() "(" 递归读取 (+ 1 2),得到内层 Add
右边 parse_expr() "3" 得到 Int(3)
回到外层 ")" 组合两个结果,得到外层 Add

这不是炫技。递归正好对应第一节语言规则里的这句话:

1
表达式里面还能继续出现表达式。

这种写法通常叫递归下降 parser:每条语法规则对应一个读取函数,规则里再次出现 expr,代码就再次调用 parse_expr()。“递归下降”只是这个方法的名字,真正重要的是看见语法的递归结构怎样变成函数调用。

常见错误从哪里报出来

看几个非法程序,错误都来自刚才那些固定期待:

1
2
3
4
(- 10 1)     读到 "(" 后期待 "+",却看到 "-"
(+ 1)        读完左边后还要读右边,却直接遇到 ")"
(+ 1 2 3)    读完两个子表达式后期待 ")",却看到多出来的 "3"
(+40 2)      lexer 切成 "(", "+40", "2", ")",parser 期待 "+",却看到 "+40"

这些错误信息还没有行号和列号,也不追求一次指出所有问题。第一章先保证一件事:一旦接下来的输入无法满足当前语法规则,parser 就停下来报错,而不是勉强生成一棵错误的 AST。

试一下 parser

运行:

1
2
3
cd code/01_numbers
make
./mini ast examples/add.lang

输出:

1
Add(Int(40), Int(2))

再运行:

1
./mini ast examples/nested_add.lang

输出:

1
Add(Add(Int(1), Int(2)), Add(Int(3), Int(4)))

这说明 parser 已经把 token 组装成了 AST。

到这里,源码已经从文本变成了结构。下一节就可以沿着这棵 AST 直接算结果了。


1.3 Lexer:把源码切成 token

上一节

1.5 解释器:沿着 AST 直接算结果

下一节