刚把一个“从文法文件到 LL(1) 预测分析表”的小工具写完,整个过程比我想象的有意思,也踩了几个坑。编译原理课上学的时候觉得这块理论性很强,真正动手用 C++ 实现一遍,才发现很多细节光看课本是体会不到的,比如 First 集和 Follow 集的迭代计算到底怎么终止、空产生式怎么处理、预测分析表的冲突怎么定位。这篇就聊聊我的设计思路、核心数据结构和 C++ 实现细节,最后再把常见问题整理成一份排查笔记。想自己手写 LL(1) 分析器、或者正在做编译原理课程设计的同学,这篇可以直接参考。
1. 整体设计与思路拆解
1.1 这个项目到底在做什么
先明确一下目标:输入是一个纯文本格式的文法文件,里面写着一组产生式;程序负责解析这个文件,自动求出每个非终结符的 First 集和 Follow 集,然后根据这两个集合构建预测分析表。如果文法不是 LL(1) 文法,程序还要能明确指出哪里出现了冲突,方便你回头去改文法。
很多人在网上搜索 LL(1) 预测分析表相关的内容,其实核心诉求是两类:一是纯粹想知道这个表是怎么算出来的,二是想拿到一套能跑的代码去应付课程设计或实际项目。我写这个工具的时候,刻意让整个过程保持透明——每个集合的求解结果都打印出来,表格构建完成后也以可视化的文本形式输出,这样既能当学习工具,也能当生成器用。
整个系统的输出是分析表,但真正花功夫的部分在前面的集合计算。我在实验中发现一个规律:如果你把 First 集和 Follow 集算得足够准确,填表本身只是一个查表动作,代码量很少;反之,集合计算只要有一步出错,最终表会错得非常诡异,而且很难通过检查表来定位问题。所以这篇博文会把重点放在集合计算的实现逻辑上。
1.2 程序总流程与模块划分
我建议把程序拆成四个层次,每一层之间用清晰的接口隔开:
- 文件解析层:读入文法文本,转成内存中的产生式列表,顺便完成符号的分类(终结符、非终结符)。
- 符号管理层:为每个符号分配一个整数 ID,并维护符号名、是否为终结符等元信息。
- 集合计算层:计算所有非终结符的 First 集与 Follow 集。
- 表格构建层:根据 First/Follow 集填充预测分析表,并做冲突检测。
这四个模块独立测试都很容易。我在写代码时是先把符号管理层做完,单独写了一个测试函数打印所有符号和产生式;等 First 集算完再打印一次;整个过程像搭积木,每一步都验证过了再往下走,最终联调时基本没费什么劲。
模块划分有一个额外的好处:如果以后想扩展成 SLR(1) 分析器,只需要替换集合计算层和表格构建层,文件解析和符号管理可以直接复用。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心数据结构与文法文件解析
2.1 符号编码与产生式结构
C++ 里处理文法的第一步是设计符号表示。直接用字符串来做集合运算虽然直观,但效率很低,而且 set
符号表的实现可以用一个 vector
cpp复制vector<string> symbol_names;
unordered_map<string, int> symbol_id_map;
int get_symbol_id(const string& name) {
auto it = symbol_id_map.find(name);
if (it != symbol_id_map.end()) return it->second;
symbol_names.push_back(name);
symbol_id_map[name] = symbol_names.size() - 1;
return symbol_names.size() - 1;
}
这里有一个关键点:终结符和非终结符都放在同一个表中,不要分开两个表。原因是产生式的右侧经常混合终结符和非终结符,分离存储会引入额外的类型判断和转换。用一个统一的表,在解析产生式时用一个小规则决定符号类型即可:产生式左侧出现的符号一定是非终结符,右侧中所有不在左侧出现的符号就是终结符。特殊符号 epsilon(空串)和结束标记 $ 也需要分配固定 ID,我习惯把它们定义在符号表的最前面,方便后续判断。
产生式结构体可以这样定义:
cpp复制struct Production {
int lhs; // 左部非终结符 ID
vector<int> rhs; // 右部符号序列,epsilon 用特殊 ID 表示
int index; // 产生式编号,方便调试引用
};
vector<Production> productions;
注意右部的类型是 vectorE' -> epsilon 会被表示成 rhs = {EPSILON_ID}。实际计算时遇到 EPSILON_ID 要单独处理,它不能参与终结符合集的普通合并。
2.2 文法文件格式设计
文件格式我参考了常见的教材写法,用 -> 分隔左右部,多个候选用 | 分隔:
code复制E -> T E'
E' -> + T E' | epsilon
T -> F T'
T' -> * F T' | epsilon
F -> ( E ) | id
这个格式的好处是直白,不需要像 yacc 那样写 %token 声明。读取时按行处理,遇到 -> 拆分左部和右部,右部再按 | 切分候选。空的候选需要特别小心,比如 E' -> | + T E' 这种写法应当被识别为含有一个空串候选,而不是直接忽略。
有些读者习惯用 ε 表示空串,我在解析时做了兼容:遇到 epsilon、ε、# 这三种写法都映射到 EPSILON_ID。这样不同教材的表示习惯都能直接使用,不用手工改文件。
文件读取完整代码大致如下:
cpp复制void parse_grammar_file(const string& filename) {
ifstream fin(filename);
string line;
while (getline(fin, line)) {
// 去掉行首行尾空白
trim(line);
// 跳过空行和注释行
if (line.empty() || line[0] == '#') continue;
// 按 -> 分离左右部
auto arrow_pos = line.find("->");
if (arrow_pos == string::npos) {
cerr << "无效文法行: " << line << endl;
continue;
}
string lhs_str = trim_copy(line.substr(0, arrow_pos));
string rhs_part = trim_copy(line.substr(arrow_pos + 2));
int lhs_id = get_symbol_id(lhs_str);
nonterminals.insert(lhs_id);
// 按 | 切分候选
stringstream ss(rhs_part);
string candidate;
while (getline(ss, candidate, '|')) {
candidate = trim_copy(candidate);
Production prod;
prod.lhs = lhs_id;
prod.index = productions.size();
if (candidate == "epsilon" || candidate == "ε" || candidate == "#") {
prod.rhs.push_back(EPSILON_ID);
} else {
stringstream cs(candidate);
string sym;
while (cs >> sym) {
int sid = get_symbol_id(sym);
prod.rhs.push_back(sid);
// 右侧出现的符号先统一收集,最后根据是否在左侧出现判断类型
all_rhs_symbols.insert(sid);
}
}
productions.push_back(prod);
}
}
}
解析完成后,需要做一轮终结符分类:所有符号中,属于 nonterminals 集合的就是非终结符,其余(除 epsilon 外)全部是终结符。$ 特殊标记需要人为加入终结符集合,因为 Follow 集会用到它。
2.3 文件解析的边界问题
文法文件中最容易踩的坑是空白字符和注释。我一开始没做 strip 处理,结果符号名里带着空格,集合怎么算都不对。建议每读一行就先 trim,再去掉行内多余空白。注释行可以用 # 开头,这样实验时可以放心地在文件里写说明。
另一个容易出问题的是多个候选的写法。很多人习惯把 | 后面的候选写到下一行,比如:
code复制E -> T E'
| + T E'
这种格式在严格按行解析时会失败,因为第二行没有 ->。我的方案比较简单粗暴:一条规则必须写在完整的一行内。如果你想支持跨行候选,需要增加一个“当前是否有未完结产生式”的状态变量,遇到没有 -> 的行就追加到上一个产生式的候选列表。我在代码里没做这个扩展,因为手工整理文法时把候选写在一行并不费力,建议你也先这样用,保持解析逻辑简单。
3. First 集与 Follow 集的计算逻辑
3.1 为什么必须先算 First 集
First 集的定义是:从某个符号开始推导,能出现在句首的所有终结符集合。预测分析表填表时,对于产生式 A -> α,要决定它在哪些输入符号下被选用,本质就是看 α 能推导出什么开头的终结符。所以 First 集是整个表格构建的地基。
如果 α 能推导出空串(即 α 经过若干步推导变成 epsilon),那么还需要知道 A 之后可能跟着什么终结符,这就需要 Follow 集。简单来说,First 集解决“这个产生式能引出什么开头的输入”,Follow 集解决“当前非终结符什么时候该被忽略”。两者配合,才能覆盖所有输入情况。
从实现顺序上看,Follow 集的计算依赖 First 集,因为判断“β 是否能推导出 epsilon”时需要查 First(β) 里有没有 epsilon。所以程序里必须先完整算完所有非终结符的 First 集,再算 Follow 集,顺序不能颠倒。
3.2 First 集求解规则与实现
First 集的计算规则可以总结成三条:
- 如果 X 是终结符,First(X) = {X}。
- 如果 X 有产生式
X -> Y1 Y2 ... Yn,先把 First(Y1) 中除 epsilon 外的符号加入 First(X);如果 First(Y1) 包含 epsilon,再处理 First(Y2),依此类推。 - 如果所有 Yi 的 First 都包含 epsilon,那么把 epsilon 也加入 First(X)。
实现时我采用的是反复迭代法:初始化后,不断扫描所有产生式,把能合并的集合合并,直到所有集合都不再变化为止。这个方法效率不是最高,但逻辑简单、不容易出错。对课程设计规模的文法,几十条产生式通常几十轮迭代内就收敛了,实测没有性能压力。
核心代码如下:
cpp复制void compute_first_sets() {
first_sets.resize(symbol_names.size());
// 终结符的 First 集是其自身
for (int i = 0; i < symbol_names.size(); i++) {
if (nonterminals.count(i) == 0 && i != EPSILON_ID) {
first_sets[i].insert(i);
}
}
bool changed = true;
while (changed) {
changed = false;
for (const auto& prod : productions) {
int lhs = prod.lhs;
auto& lhs_set = first_sets[lhs];
int before_size = lhs_set.size();
bool all_epsilon = true;
for (int sym : prod.rhs) {
if (sym == EPSILON_ID) {
lhs_set.insert(EPSILON_ID);
break;
}
const auto& sym_set = first_sets[sym];
for (int t : sym_set) {
if (t != EPSILON_ID) lhs_set.insert(t);
}
if (!sym_set.count(EPSILON_ID)) {
all_epsilon = false;
break;
}
}
if (all_epsilon) lhs_set.insert(EPSILON_ID);
if (lhs_set.size() != before_size) changed = true;
}
}
}
这里有一个细节值得注意:我用 before_size 和当前 size 做比较来判断集合是否变化,比每次用 != 比较整体集合更高效。因为 set 的 == 要逐个元素比较,而比较 size 是 O(1) 的操作。虽然在这个场景下这点优化意义不大,但养成这样的习惯在后面对集合规模较大时会有帮助。
另外一个重要细节是:产生式右部遇到终结符时,直接把该终结符加入左部 First 集,然后立刻停止处理后续符号。因为终结符的 First 集就是它自己,不含 epsilon,所以它后面的符号对当前产生式的 First 集没有贡献。这个“短路”逻辑漏掉的话,会产生大量多余推导,结果虽然碰巧相同,但计算量会明显增加。
3.3 Follow 集求解规则与实现
Follow 集的定义是:在某些句型中,紧跟在非终结符 A 后面的终结符集合。求解规则有三条:
- 如果 A 是开始符号,把
$加入 Follow(A)。 - 对于形如
B -> α A β的产生式,把 First(β) 中除 epsilon 外的所有符号加入 Follow(A)。 - 对于形如
B -> α A的产生式,或者B -> α A β且 β 能推导出 epsilon,把 Follow(B) 的所有符号加入 Follow(A)。
这里最容易理解错的是第二条和第三条之间的关系。很多人会把第二条实现成“只要看到 A 后跟 β 就把 First(β) 并入”,但忘记检查 β 是否能推导出 epsilon。如果 β 能推出 epsilon,那么不仅要并入 First(β) 中的非 epsilon 符号,还要继续用 Follow(B) 来补充。
我第一次实现 Follow 集时就漏了第三种情况,导致 E' 的 Follow 集少了一个 $,最终分析表构建出来在遇到输入结束时无法正确归约。排查过程花了不少时间,最后是对照课本例子逐步推演才发现问题。这里建议遇到 Follow 集结果和教材不一致时,优先检查是否漏了“β 可空”的情形。
Follow 集合计算代码:
cpp复制void compute_follow_sets() {
follow_sets.resize(symbol_names.size());
// 开始符号的 Follow 集初始包含 $
follow_sets[start_symbol].insert(DOLLAR_ID);
bool changed = true;
while (changed) {
changed = false;
for (const auto& prod : productions) {
int lhs = prod.lhs;
for (int i = 0; i < prod.rhs.size(); i++) {
int sym = prod.rhs[i];
if (nonterminals.count(sym) == 0) continue;
auto& follow_sym = follow_sets[sym];
int before_size = follow_sym.size();
// 处理 B -> α A β 的情况
bool beta_can_be_empty = true;
for (int j = i + 1; j < prod.rhs.size(); j++) {
int beta_sym = prod.rhs[j];
const auto& first_beta = first_sets[beta_sym];
for (int t : first_beta) {
if (t != EPSILON_ID) follow_sym.insert(t);
}
if (!first_beta.count(EPSILON_ID)) {
beta_can_be_empty = false;
break;
}
}
// 如果 β 为空或可推空,把 Follow(A) 并入 Follow(B)
if (beta_can_be_empty) {
const auto& follow_lhs = follow_sets[lhs];
for (int t : follow_lhs) follow_sym.insert(t);
}
if (follow_sym.size() != before_size) changed = true;
}
}
}
}
这个循环里有一个很隐蔽的问题:我在同一轮迭代中更新了 follow_sym,然后又用更新后的 follow_sym 去参与后续符号的判断。这实际上是允许一次迭代内信息向前传播,我测下来收敛速度更快。但有些实现为了保证严格的“上一轮结果作为本轮输入”,会用临时副本保存旧集合。这两种方式在这个算法里都能收敛,效果也没区别,我选择就地更新只是因为代码更短。如果你想从原理上更严谨,建议用临时副本,方便和别人讨论时对齐实现细节。
关于 Follow 集还有一个容易混淆的点:epsilon 永远不会出现在 Follow 集中。因为 Follow 集定义的是“跟在非终结符后面的终结符”,空串不是终结符。所以在第三条规则里,合并 Follow(A) 时不需要过滤 epsilon,直接全量并入即可。
4. 预测分析表构建与冲突检测
4.1 填表规则说明
拿到所有 First 集和 Follow 集之后,预测分析表的构建逻辑很公式化。对每条产生式 A -> α,分两步:
- 对 First(α) 中的每个终结符 a,在表格 M[A][a] 位置填入该产生式。
- 如果 epsilon ∈ First(α),那么对 Follow(A) 中的每个符号 b,在表格 M[A][b] 位置也填入该产生式。
如果某个格子已经填过一个产生式,再填一个不同的产生式进去,就说明文法不是 LL(1) 的,存在冲突。冲突有两种:第一种是 First/First 冲突,即两个不同产生式的 First 集有交集;第二种是 First/Follow 冲突,即某个产生式右部可空,它的 Follow 集中出现了其他产生式能推导出的首终结符。
为了在冲突时给出有意义的提示信息,表格的每个格子不能只存一个产生式。我用了 map<pair<int,int>, vector<int>> 来存放产生式编号列表,插入时如果目标格子非空,就把新产生式追加进去,同时标记冲突。最后输出时,把有多个产生式的格子单独打印出来,方便你直接看到是哪两条产生式在打架。
4.2 表格构建的 C++ 实现
cpp复制map<pair<int,int>, vector<int>> parse_table;
void build_parse_table() {
parse_table.clear();
for (const auto& prod : productions) {
int lhs = prod.lhs;
// 计算 First(rhs)
set<int> first_rhs;
bool rhs_can_be_empty = true;
for (int sym : prod.rhs) {
if (sym == EPSILON_ID) {
first_rhs.insert(EPSILON_ID);
break;
}
const auto& sym_set = first_sets[sym];
for (int t : sym_set) {
if (t != EPSILON_ID) first_rhs.insert(t);
}
if (!sym_set.count(EPSILON_ID)) {
rhs_can_be_empty = false;
break;
}
}
if (rhs_can_be_empty) first_rhs.insert(EPSILON_ID);
// 填表第一步:First(rhs) 中的终结符
for (int t : first_rhs) {
if (t == EPSILON_ID) continue;
parse_table[{lhs, t}].push_back(prod.index);
}
// 填表第二步:如果 rhs 可空,在 Follow(lhs) 中所有终结符处填表
if (first_rhs.count(EPSILON_ID)) {
for (int t : follow_sets[lhs]) {
parse_table[{lhs, t}].push_back(prod.index);
}
}
}
}
这里实际是把之前算好的 First(α) 重新遍历了一遍。你也可以单独写一个 compute_first_of_rhs 函数,求任意符号串的 First 集,然后在填表时直接调用。我因为项目规模小,直接在循环里内联计算了,两处逻辑保持一致很重要——如果你分两个地方写,很容易它们的行为出现细微差别,然后在定位问题时特别痛苦。
4.3 冲突检测与输出格式
表格构建完成后,我统一做一次冲突扫描:
cpp复制bool has_conflict = false;
for (const auto& entry : parse_table) {
if (entry.second.size() > 1) {
has_conflict = true;
int lhs = entry.first.first;
int terminal = entry.first.second;
cout << "冲突:M[" << symbol_names[lhs] << "]["
<< symbol_names[terminal] << "] 有多个产生式:" << endl;
for (int pid : entry.second) {
cout << " 产生式 " << pid << ": "
<< symbol_names[productions[pid].lhs] << " -> ";
for (int s : productions[pid].rhs) {
cout << symbol_names[s] << " ";
}
cout << endl;
}
}
}
输出表格时,我用了制表符对齐,每行代表一个非终结符,每列代表一个终结符,格子中填产生式编号。为了直观,也可以把产生式内容直接拼进格子,不过这样表格会很宽。我的建议是表格里用编号,表格下方附上产生式编号对照,这样既紧凑又清楚。
表格打印代码:
cpp复制void print_parse_table(const vector<int>& terminals) {
// 打印表头
cout << "\n预测分析表:" << endl;
cout << left << setw(12) << "非终结符";
for (int t : terminals) {
cout << setw(18) << symbol_names[t];
}
cout << endl;
for (int nt : nonterminals) {
if (nt == EPSILON_ID) continue;
cout << setw(12) << symbol_names[nt];
for (int t : terminals) {
auto it = parse_table.find({nt, t});
string cell = "-";
if (it != parse_table.end()) {
cell.clear();
for (int pid : it->second) {
cell += "P" + to_string(pid) + " ";
}
}
cout << setw(18) << cell;
}
cout << endl;
}
}
4.4 验证表格正确性的方法
表格构建完之后,光看表格对不对是不够的,我建议用三个步骤验证:
- 用手工推演的经典文法做基准测试,比如
E -> E + T | T消除左递归后的版本,将程序输出与课本对照。 - 对每个终结符 + 非终结符组合,检查表格中是否最多只有一个产生式,这个程序已经自动做了。
- 对每个非终结符,检查它是否对
$有对应的动作,尤其是那些可以推导出空串的非终结符。如果漏了,说明 Follow 集计算有问题。
我习惯在代码里留一个调试开关,打印每个非终结符的 First 集和 Follow 集。这比只打印最终表格更有利于定位问题。遇到集合结果和课本不一致时,先检查第一步的终结符分类是否正确,再检查 epsilon 是否被意外放进 Follow 集,最后检查 Follow 集的迭代是否收敛。这三步排查下来,绝大多数问题都能定位。
5. 常见问题与排查技巧实录
5.1 左递归必须先处理
如果直接把原始表达式文法写进文件:
code复制E -> E + T | T
程序会怎么表现?我来实际跑了一下:First 集计算正常,因为 E -> E + T 的右部第一个符号 E 是左部自身,遍历时第一轮 First(E) 里还没有内容,所以没有新增;但是后续 E -> T 会先把 First(T) 加进去,最终 First(E) 结果还是对的。
问题出在 Follow 集和预测分析表上。Follow 集计算时,E -> E + T 这样的产生式会导致 Follow(E) 依赖 Follow(E) 自身,迭代时会无谓地反复传播。而预测分析表构建时,E -> E + T 和 E -> T 在 First 集上会产生交集(因为 First(T) 是 First(E + T) 的子集),必然报出冲突。
所以使用这个工具前,文法必须已经是无左递归的。消除左递归的通用转换不复杂:把 A -> A α | β 转换成 A -> β A' 和 A' -> α A' | epsilon。如果你拿的是一个左递归文法,一定要先做这一步,否则后面的分析表没法生成。工具本身不自动消除左递归,设计上保持纯粹性,这样代码逻辑更聚焦,也方便你作为学习材料理解每一步在做什么。
5.2 “悬空 else”冲突案例分析
用一个经典的二义性文法测试:
code复制S -> iEtS | iEtSeS | a
E -> b
这里的 i 代表 if、t 代表 then、e 代表 else、E 代表表达式。这个文法中,S -> iEtS 和 S -> iEtSeS 的 First 集都是 {i},所以预测分析表必然冲突。我运行后程序给出的冲突报告如下:
code复制冲突:M[S][i] 有多个产生式:
产生式 P0: S -> i E t S
产生式 P1: S -> i E t S e S
这正是典型的 First/First 冲突。它说明对于输入 i E t,分析器不知道应该选择没有 else 的规则还是选择带 else 的规则。如果希望这个文法变成 LL(1) 文法,通常的做法是把文法改造成每个产生式的 First 集互不相交,或者接受它的二义性并指定优先级。
这个例子很适合作为测试用例,因为它非常小,但冲突点明确,输出容易阅读。我在调试工具时就用这个例子验证冲突检测和报告是否正确。
5.3 从文法到表的三个调试技巧
调试过程中我发现的几个实用技巧,分享给大家。
技巧一:打印迭代过程。在 First 和 Follow 计算循环中加一个计数器,打印第几轮迭代后哪些集合发生了变化。如果迭代轮次异常多,说明文法里有比较长的依赖链,比如 A 依赖 B、B 依赖 C、C 又依赖 A。这种循环依赖在合法文法中也存在,一般是多个非终结符之间互相引用产生的,只要集合最终收敛就没事。但如果超过 20 轮还在变化,多半是某个集合漏了 epsilon 判断,导致条件永不停止。
技巧二:单独测试右部可空判定。产生式右部是否可空是填表分支的关键,这个判定错了,整个表都会错。我另外写了一个辅助函数,遍历所有产生式,打印每条产生式的右部是否能推导出空串。对照课本手动算一遍,就能发现哪条产生式的判定有误。这个函数只有十几行,收益却很大。
技巧三:用最小文法做回归测试。我留了一个只包含两条产生式的测试文件:
code复制A -> a
A -> epsilon
这个文法的 First(A) = {a, epsilon},Follow(A) = {$},预测分析表里 M[A][a] 填入 A -> a,M[A][$] 填入 A -> epsilon。运行后如果这张表正确,说明基本的 First/Follow 计算和填表流程没问题。然后逐步增加规则数量,每加一条都验证一次。这种增量式测试是我在所有调试方法中觉得最有效的,能迅速定位哪条规则引入的问题。
5.4 关于 C++ 实现的一些杂项经验
最后说一下用 C++ 实现时的具体注意事项。
集合容器我全程用了 std::set<int>,因为不需要频繁查找,容量也小。如果文法规模很大(比如几百条产生式),可以换 unordered_set 或 vector<bool> 做位图,但那需要提前知道符号总数,而我们是一边读文件一边分配 ID 的,所以 set 最方便。性能实测处理经典表达式文法没什么压力。
map<pair<int,int>, vector<int>> 作为表格容器是我比较满意的选择。虽然它没有二维数组紧凑,但避免了符号 ID 是离散值中间留空洞的问题。如果你想用二维数组,需要自己维护一个终结符列表把 ID 映射到列下标,代码会多一层间接。除非目标是追求极致性能,否则 map 足够。
调试时我还加了 -Wall -g 编译选项,配合 gdb 检查越界问题。特别提醒一下:symbol_names 是通过 get_symbol_id 动态扩展的,如果在解析产生式过程中先用了 first_sets[i] 再分配新 ID,会导致 vector 重新分配,之前拿到的引用失效。这也是我为什么把所有符号都解析完成后再分配 First/Follow 集合的 vector,而不是边读边计算的原因。
如果你是在 VS Code 里配的 C++ 环境,调试集合计算时可以给 changed 变量加断点每轮检查,或者直接把迭代轮次打印出来。VS Code 的调试器看 set 内容不如看 vector 直观,所以我额外写了个 set_to_string 辅助函数,把集合输出成 {a, b, epsilon} 的形式,日志可读性会高很多。
我做完这个项目最大的感受是:LL(1) 分析表看起来只是一个二维表格,但背后其实是文法、集合运算、自动机理论的综合。用 C++ 从零实现一遍,比刷十遍教材的习题都管用。如果你也在做类似的项目,建议先把一个简单文法完整跑通,再逐步增加特性,这个过程会让你对这些概念的理解牢固很多。
