如前一章所学,编译器通常分为前端和后端两部分。在本章中,我们将实现一种编程语言的前端,即主要处理源语言的部分。我们将学习现实世界中编译器使用的技术,并将其应用于我们的编程语言。
我们的旅程将从定义我们的编程语言的语法开始,结束于抽象语法树(AST),它将成为代码生成的基础。你可以将这种方法应用于你想要实现编译器的任何编程语言。
在本章中,你将学到以下内容:
- 定义一个真实的编程语言,你将了解
tinylang
语言,这是一个真实编程语言的子集,你将为其实现编译器前端 - 组织编译器项目的目录结构
- 知道如何处理编译器的多个输入文件
- 处理用户消息的技能,并以愉快的方式告知他们问题
- 使用模块化部件构建词法分析器
- 根据从语法中派生的规则构建递归下降解析器,进行语法分析
- 通过创建AST并分析其特性来进行语义分析
通过本章获得的技能,你将能够为任何编程语言构建编译器前端。
定义一个真实的编程语言
与前一章的简单计算语言相比,真实的编程带来了更多的挑战。为了深入了解细节,我们将在本章及后续章节中使用Modula-2的一个小子集。Modula-2设计精良,可选择支持泛型和面向对象编程(OOP)。然而,我们不打算在本书中创建一个完整的Modula-2编译器。因此,我们将这个子集命名为tinylang
。
让我们从一个tinylang
程序的例子开始。以下函数使用欧几里得算法计算最大公约数:
MODULE Gcd;
PROCEDURE GCD(a, b: INTEGER) : INTEGER;
VAR t: INTEGER;
BEGIN
IF b = 0 THEN
RETURN a;
END;
WHILE b # 0 DO
t := a MOD b;
a := b;
b := t;
END;
RETURN a;
END GCD;
END Gcd;
现在我们对这种语言的程序有了一定的感觉,让我们快速浏览一下本章中使用的tinylang
子集的语法。在接下来的几节中,我们将使用这个语法来派生词法分析器和解析器:
compilationUnit
: "MODULE" identifier ";" ( import )* block identifier "." ;
Import : ( "FROM" identifier )? "IMPORT" identList ";" ;
Block
: ( declaration )* ( "BEGIN" statementSequence )? "END" ;
Modula-2中的编译单元以MODULE
关键字开始,后跟模块名称。模块的内容可以包含导入的模块列表、声明和包含初始化时运行的语句的块:
declaration
: "CONST" ( constantDeclaration ";" )*
| "VAR" ( variableDeclaration ";" )*
| procedureDeclaration ";" ;
声明用于引入常量、变量和过程。常量的声明以CONST
关键字为前缀。类似地,变量声明以VAR
关键字开始。常量的声明非常简单:
constantDeclaration : identifier "=" expression ;
标识符是常量的名称。值来自于一个表达式,该表达式必须在编译时可计算。变量的声明稍微复杂一些:
variableDeclaration : identList ":" qualident ;
qualident : identifier ( "." identifier )* ;
identList : identifier ( "," identifier)* ;
为了能够一次声明多个变量,使用了标识符列表。类型名称可能来自另一个模块,在这种情况下,前缀是模块名称。这称为合格标识符。过程需要最多的细节:
procedureDeclaration
: "PROCEDURE" identifier ( formalParameters )? ";"
block identifier ;
formalParameters
: "(" ( formalParameterList )? ")" ( ":" qualident )? ;
formalParameterList
: formalParameter (";" formalParameter )* ;
formalParameter : ( "VAR" )? identList ":" qualident ;
前面的代码展示了如何声明常量、变量和过程。过程可以有参数和返回类型。普通参数作为值传递,VAR
参数通过引用传递。从block
规则中缺少的部分是statementSequence
,它是单个语句的列表:
statementSequence
: statement ( ";" statement )* ;
如果语句后面跟着另一个语句,则用分号分隔。再次强调,只支持Modula-2语句的一个子集:
statement
: qualident ( ":=" expression | ( "(" ( expList )? ")" )? )
| ifStatement | whileStatement | "RETURN" ( expression )? ;
这条规则的第一部分描述了赋值或过程调用。后跟:=
的合格标识符是一个赋值。如果其后跟着(
,那么它是一个过程调用。其他语句是常规的控制语句:
ifStatement
: "IF" expression "THEN" statementSequence
( "ELSE" statementSequence )? "END" ;
IF
语句也具有简化的语法,因为它只能有一个ELSE
块。有了这个语句,我们可以有条件地保护一个语句:
whileStatement
: "WHILE" expression "DO" statementSequence "END" ;
WHILE
语句描述了一个由条件保护的循环。与IF
语句一起,这使我们能够在tinylang
中编写简单的算法。最后,缺少表达式的定义:
expList
: expression ( "," expression )* ;
expression
: simpleExpression ( relation simpleExpression )? ;
relation
: "=" | "#" | "<" | "<=" | ">" | ">=" ;
simpleExpression
: ( "+" | "-" )? term ( addOperator term )* ;
addOperator
: "+" | "-" | "OR" ;
term
: factor ( mulOperator factor )* ;
mulOperator
: "*" | "/" | "DIV" | "MOD" | "AND" ;
factor
: integer_literal | "(" expression ")" | "NOT" factor
| qualident ( "(" ( expList )? ")" )? ;
表达式语法与前一章的calc非常相似。只支持INTEGER
和BOOLEAN
数据类型。
另外,使用了identifier
和integer_literal
令牌。标识符是以字母或下划线开头,后跟字母、数字和下划线的名称。整数字面量是十进制数字序列或十六进制数字序列,后跟字母H
。
这些规则已经很多了,我们只涵盖了Modula-2的一部分!尽管如此,仍然可以在这个子集中编写小型应用程序。让我们为tinylang
实现一个编译器!
创建项目布局
tinylang
的项目布局遵循了我们在第1章安装LLVM中所描述的方法。每个组件的源代码位于lib
目录的子目录中,头文件位于include/tinylang
的子目录中。子目录以组件的名称命名。在第1章安装LLVM中,我们只创建了Basic
组件。
从前一章我们知道,我们需要实现一个词法分析器、一个解析器、一个AST和一个语义分析器。每个都是独立的组件,分别叫做Lexer
、Parser
、AST
和Sema
。本章将使用的目录布局如下图所示:
图3.1 - tinylang项目的目录布局
组件有明确定义的依赖关系。Lexer
仅依赖于Basic
。Parser
依赖于Basic
、Lexer
、AST
和Sema
。Sema
仅依赖于Basic
和AST
。明确的依赖关系有助于我们重用组件。
让我们更仔细地看看实现吧!
管理编译器的输入文件
一个真实的编译器需要处理许多文件。通常,开发者调用编译器时会指定主编译单元的名称。这个编译单元可以引用其他文件——例如,通过C语言中的#include
指令或Python或Modula-2中的import
语句。一个导入的模块可以导入其他模块,以此类推。所有这些文件必须被加载到内存中,并通过编译器的分析阶段运行。在开发过程中,开发者可能会犯语法或语义错误。当检测到错误时,应该打印包括源代码行和标记的错误消息。这个重要组件并非微不足道。
幸运的是,LLVM提供了一个解决方案:llvm::SourceMgr
类。使用AddNewSourceBuffer()
方法可以向SourceMgr
添加新的源文件。或者,可以使用AddIncludeFile()
方法加载文件。这两种方法都返回一个ID来识别缓冲区。你可以使用这个ID来检索与关联文件的内存缓冲区指针。要定义文件中的位置,你可以使用llvm::SMLoc
类。这个类封装了一个指向缓冲区的指针。各种PrintMessage()
方法允许你向用户发出错误和其他信息性消息。
处理用户消息
现在唯一缺少的是消息的集中定义。在大型软件中(例如编译器),你不希望在所有地方撒播消息字符串。如果有更改消息或将其翻译成另一种语言的请求,那么最好将它们放在一个中心位置!
一个简单的方法是,每个消息都有一个ID(一个enum
成员)、一个严重级别(如Error
或Warning
)和一个包含消息的字符串。在你的代码中,你只引用消息ID。严重级别和消息字符串仅在打印消息时使用。这三个项目(ID、安全级别和消息)必须一致管理。LLVM库使用预处理器来解决这个问题。数据存储在一个带有.def
后缀的文件中,并被包装在一个宏名称中。通常,这个文件会被多次包含,每次都有不同的宏定义。定义在include/tinylang/Basic/Diagnostic.def
文件路径中,如下所示:
#ifndef DIAG
#define DIAG(ID, Level, Msg)
#endif
DIAG(err_sym_declared, Error, "symbol {0} already declared")
#undef DIAG
第一个宏参数ID
是枚举标签,第二个参数Level
是严重级别,第三个参数Msg
是消息文本。有了这个定义,我们可以定义一个DiagnosticsEngine
类来发出错误消息。接口在include/tinylang/Basic/Diagnostic.h
文件中:
#ifndef TINYLANG_BASIC_DIAGNOSTIC_H
#define TINYLANG_BASIC_DIAGNOSTIC_H
#include "tinylang/Basic/LLVM.h"
#include "llvm/ADT/StringRef.h"
#include "llvm/Support/FormatVariadic.h"
#include "llvm/Support/SMLoc.h"
#include "llvm/Support/SourceMgr.h"
#include "llvm/Support/raw_ostream.h"
#include <utility>
namespace tinylang {
在包含必要的头文件后,可以使用Diagnostic.def
来定义枚举。为了不污染全局命名空间,使用了一个叫做diag
的嵌套命名空间:
namespace diag {
enum {
#define DIAG(ID, Level, Msg) ID,
#include "tinylang/Basic/Diagnostic.def"
};
} // namespace diag
DiagnosticsEngine
类使用SourceMgr
实例通过report()
方法发出消息。消息可以有参数。为了实现这个功能,使用了LLVM提供的变长格式支持。消息文本和严重级别可以通过静态方法检索。作为额外的好处,还统计了发出的错误消息数量:
class DiagnosticsEngine {
static const char *getDiagnosticText(unsigned DiagID);
static SourceMgr::DiagKind
getDiagnosticKind(unsigned DiagID);
消息字符串由getDiagnosticText()
返回,而级别由getDiagnosticKind()
返回。这两个方法稍后在.cpp
文件中实现:
SourceMgr &SrcMgr;
unsigned NumErrors;
public:
DiagnosticsEngine(SourceMgr &SrcMgr)
: SrcMgr(SrcMgr), NumErrors(0) {}
unsigned nunErrors() { return NumErrors; }
由于消息可以有可变数量的参数,C++中的解决方案是使用变长模板。当然,这也被LLVM提供的formatv()
函数所使用。要获取格式化的消息,我们只需要转发模板参数:
template <typename... Args>
void report(SMLoc Loc, unsigned DiagID,
Args &&... Arguments) {
std::string Msg =
llvm::formatv(getDiagnosticText(DiagID),
std::forward<Args>(Arguments)...)
.str();
SourceMgr::DiagKind Kind = getDiagnosticKind(DiagID);
SrcMgr.PrintMessage(Loc, Kind, Msg);
NumErrors += (Kind == SourceMgr::DK_Error);
}
};
} // namespace tinylang
#endif
有了这些,我们就实现了大部分类。只有getDiagnosticText()
和getDiagnosticKind()
缺失。它们在lib/Basic/Diagnostic.cpp
文件中定义,也使用了Diagnostic.def
文件:
#include "tinylang/Basic/Diagnostic.h"
using namespace tinylang;
namespace {
const char *DiagnosticText[] = {
#define DIAG(ID, Level, Msg) Msg,
#include "tinylang/Basic/Diagnostic.def"
};
与头文件中一样,定义了DIAG
宏以检索所需的部分。在这里,我们定义了一个包含文本消息的数组。因此,DIAG
宏只返回Msg
部分。我们对级别使用相同的方法:
SourceMgr::DiagKind DiagnosticKind[] = {
#define DIAG(ID, Level, Msg) SourceMgr::DK_##Level,
include "tinylang/Basic/Diagnostic.def"
};
} // namespace
不出所料,这两个函数简单地索引数组以返回所需的数据:
const char *
DiagnosticsEngine::getDiagnosticText(unsigned DiagID) {
return DiagnosticText[DiagID];
}
SourceMgr::DiagKind
DiagnosticsEngine::getDiagnosticKind(unsigned DiagID) {
return DiagnosticKind[DiagID];
}
SourceMgr
和DiagnosticsEngine
类的组合为其他组件提供了良好的基础。我们首先在词法分析器中使用它们!
结构化词法分析器
正如我们在前一章了解到的,我们需要一个Token
类和一个Lexer
类。此外,还需要一个TokenKind
枚举来为每个令牌类型赋予一个唯一的编号。一个全能的头文件和实现文件是不可扩展的,因此我们需要将这些项目分开。TokenKind
可以普遍使用,因此它被放置在Basic
组件中。Token
和Lexer
类属于Lexer
组件,但它们被放置在不同的头文件和实现文件中。
令牌有三种不同的类别:关键字、标点符号,以及表示许多值集合的令牌。例如,CONST
关键字、;
分隔符和ident
令牌,分别代表源码中的标识符。每个令牌都需要一个枚举成员名。关键字和标点符号有自然的显示名称,可以用于消息。
就像许多编程语言一样,关键字是标识符的一个子集。为了将一个令牌归类为关键字,我们需要一个关键字过滤器,它会检查找到的标识符是否确实是一个关键字。这与C或C++中的行为相同,关键字也是标识符的子集。编程语言在发展,可能会引入新的关键字。例如,原始的K&R C语言没有用enum
关键字定义枚举。因此,应该有一个标志表示关键字的语言级别。
我们收集了几条信息,所有这些信息都属于TokenKind
枚举的一个成员:枚举成员的标签、标点符号的拼写和关键字的标志。对于诊断消息,我们在名为include/tinylang/Basic/TokenKinds.def
的.def
文件中集中存储这些信息,其内容如下。需要注意的是,关键字以kw_
为前缀:
#ifndef TOK
#define TOK(ID)
#endif
#ifndef PUNCTUATOR
#define PUNCTUATOR(ID, SP) TOK(ID)
#endif
#ifndef KEYWORD
#define KEYWORD(ID, FLAG) TOK(kw_ ## ID)
#endif
TOK(unknown)
TOK(eof)
TOK(identifier)
TOK(integer_literal)
PUNCTUATOR(plus, "+")
PUNCTUATOR(minus, "-")
// …
KEYWORD(BEGIN , KEYALL)
KEYWORD(CONST , KEYALL)
// …
#undef KEYWORD
#undef PUNCTUATOR
#undef TOK
有了这些集中的定义,就很容易在include/tinylang/Basic/TokenKinds.h
文件中创建TokenKind
枚举。再次地,枚举被放入自己的命名空间tok
中:
#ifndef TINYLANG_BASIC_TOKENKINDS_H
#define TINYLANG_BASIC_TOKENKINDS_H
namespace tinylang {
namespace tok {
enum TokenKind : unsigned short {
#define TOK(ID) ID,
#include "TokenKinds.def"
NUM_TOKENS
};
现在应该对填充数组的模式感到熟悉了。TOK
宏被定义为仅返回ID
。作为有用的补充,我们还定义了NUM_TOKENS
作为枚举的最后一个成员,表示定义的令牌数量:
const char *getTokenName(TokenKind Kind);
const char *getPunctuatorSpelling(TokenKind Kind);
const char *getKeywordSpelling(TokenKind Kind);
}
}
#endif
实现文件lib/Basic/TokenKinds.cpp
也使用了.def
文件来检索名称:
#include "tinylang/Basic/TokenKinds.h"
#include "llvm/Support/ErrorHandling.h"
using namespace tinylang;
static const char * const TokNames[] = {
#define TOK(ID) #ID,
#define KEYWORD(ID, FLAG) #ID,
#include "tinylang/Basic/TokenKinds.def"
nullptr
};
令牌的文本名称来自它的枚举标签ID
。这里有两个特别之处:
-
首先,我们需要定义
TOK
和KEYWORD
宏,因为KEYWORD
的默认定义不使用TOK
宏。 -
其次,在数组的末尾添加了一个
nullptr
值,以计算增加的NUM_TOKENS
枚举成员:const char *tok::getTokenName(TokenKind Kind) { return TokNames[Kind]; }
我们在getPunctuatorSpelling()
和getKeywordSpelling()
函数中采取稍微不同的方法。这些函数只为枚举的一个子集返回有意义的值。这可以通过switch
语句实现,默认返回nullptr
值:
const char *tok::getPunctuatorSpelling(TokenKind Kind) {
switch (Kind) {
#define PUNCTUATOR(ID, SP) case ID: return SP;
#include "tinylang/Basic/TokenKinds.def"
default: break;
}
return nullptr;
}
const char *tok::getKeywordSpelling(TokenKind Kind) {
switch (Kind) {
#define KEYWORD(ID, FLAG) case kw_ ## ID: return #ID;
#include "tinylang/Basic/TokenKinds.def"
default: break;
}
return nullptr;
}
提示
注意宏是如何被定义来从文件中检索必要的信息片段。
在上一章中,Token
类被声明在与Lexer
类相同的头文件中。为了使其更加通用,我们将Token
类放入include/Lexer/Token.h
自己的头文件中。和以前一样,Token
存储了一个指向令牌开始的指针、其长度和之前定义的令牌类型:
class Token {
friend class Lexer;
const char *Ptr;
size_t Length;
tok::TokenKind Kind;
public:
tok::TokenKind getKind() const { return Kind; }
size_t getLength() const { return Length; }
SMLoc
实例,它表示消息中的源位置,是从指向令牌的指针创建的:
SMLoc getLocation() const {
return SMLoc::getFromPointer(Ptr);
}
getIdentifier()
和getLiteralData()
方法允许访问标识符和字面数据的令牌文本。对于其他任何类型的令牌,不需要访问文本,因为这是由令牌类型暗示的:
StringRef getIdentifier() {
assert(is(tok::identifier) &&
"Cannot get identfier of non-identifier");
return StringRef(Ptr, Length);
}
StringRef getLiteralData() {
assert(isOneOf(tok::integer_literal,
tok::string_literal) &&
"Cannot get literal data of non-literal");
return StringRef(Ptr, Length);
}
};
我们在include/Lexer/Lexer.h
头文件中声明Lexer
类,并将实现放在lib/Lexer/lexer.cpp
文件中。结构与上一章的calc语言相同。我们需要更仔细地看看这里的两个细节:
-
首先,一些操作符共享相同的前缀——例如,
<
和<=
。当我们看到的当前字符是<
时,我们必须检查下一个字符,然后决定我们找到了哪个令牌。记住输入需要以空字节结束。因此,如果当前字符有效,总是可以使用下一个字符:case '<': if (*(CurPtr + 1) == '=') formTokenWithChars(token, CurPtr + 2, tok::lessequal); else formTokenWithChars(token, CurPtr + 1, tok::less); break;
-
另一个细节,即现在有更多的关键字,我们应该如何处理?一个简单快速的解决方案是使用哈希表存储这些关键字,这些关键字都保存在
TokenKinds.def
文件中。这可以在Lexer
类的实例化过程中完成。采用这种方法,还可以支持语言的不同级别,因为可以使用附加的标志来过滤关键字。目前,这里还不需要这种灵活性。在头文件中,关键字过滤器定义如下,使用llvm::StringMap
实例作为哈希表:class KeywordFilter { llvm::StringMap<tok::TokenKind> HashTable; void addKeyword(StringRef Keyword, tok::TokenKind TokenCode); public: void addKeywords();
getKeyword()
方法返回给定字符串的令牌类型,如果字符串不代表一个关键字,则返回默认值:tok::TokenKind getKeyword( StringRef Name, tok::TokenKind DefaultTokenCode = tok::unknown) { auto Result = HashTable.find(Name); if (Result != HashTable.end()) return Result->second; return DefaultTokenCode; } };
在实现文件中,填充关键字表:
void KeywordFilter::addKeyword(StringRef Keyword, tok::TokenKind TokenCode) { HashTable.insert(std::make_pair(Keyword, TokenCode)); } void KeywordFilter::addKeywords() { #define KEYWORD(NAME, FLAGS) \ addKeyword(StringRef(#NAME), tok::kw_##NAME); #include "tinylang/Basic/TokenKinds.def" }
利用你刚刚学到的技术,编写一个高效的词法分析器类并不困难。由于编译速度很重要,许多编译器使用手写的词法分析器,clang就是一个例子。
构建递归下降解析器
正如上一章所示,解析器是从语法派生的。让我们回顾一下所有的构造规则。对于语法的每条规则,你创建一个以规则左侧的非终结符命名的方法来解析该规则的右侧。根据右侧的定义,你需要做以下事情:
- 对于每个非终结符,调用相应的方法
- 消耗每个令牌
- 对于选择项、可选项或重复组,检查前瞻令牌(下一个未消耗的令牌)以决定如何继续
让我们将这些构造规则应用于以下语法规则:
ifStatement
: "IF" expression "THEN" statementSequence
( "ELSE" statementSequence )? "END" ;
我们可以轻松地将其转换为以下C++方法:
void Parser::parseIfStatement() {
consume(tok::kw_IF);
parseExpression();
consume(tok::kw_THEN);
parseStatementSequence();
if (Tok.is(tok::kw_ELSE)) {
advance();
parseStatementSequence();
}
consume(tok::kw_END);
}
tinylang
的整个语法都可以以这种方式转换为C++。总的来说,你需要小心避免一些陷阱,因为你在互联网上找到的大多数语法不适合这种构造。
语法和解析器
有两种不同类型的解析器:自顶向下解析器和自底向上解析器。它们的名称来自于在解析过程中处理规则的顺序。解析器的输入是词法分析器生成的令牌序列。
自顶向下解析器扩展规则中最左边的符号,直到匹配到令牌。如果所有令牌都被消耗且所有符号都被扩展,那么解析成功。这正是tinylang
的解析器的工作方式。
自底向上解析器则相反:它观察令牌序列并尝试用语法的符号替换这些令牌。例如,如果下一个令牌是IF
、3
、+
和4
,那么自底向上解析器会将3 + 4
令牌替换为expression
符号,结果是IF
expression
序列。当看到属于IF
语句的所有令牌时,这些令牌和符号序列就被替换为ifStatement
符号。
如果所有令牌都被消耗且剩下的唯一符号是开始符号,则解析成功。虽然自顶向下解析器可以很容易地手工构建,但自底向上解析器则不是这样。
另一种区分这两种类型解析器的方法是首先扩展哪个符号。两者都是从左到右读取输入,但自顶向下解析器首先扩展最左边的符号,而自底向上解析器扩展最右边的符号。因此,自顶向下解析器也被称为LL解析器,而自底向上解析器被称为LR解析器。
要从语法中派生出LL或LR解析器,该语法必须具有某些属性。这些语法相应地命名:你需要一个LL语法来构建LL解析器。
你可以在大学教材中找到更多关于编译器构建的细节,例如Wilhelm, Seidl和Hack的编译器设计。语法分析和语义分析,Springer 2013,以及Grune和Jacobs的解析技术,实用指南,Springer 2008。
一个需要注意的问题是左递归规则。如果右侧以左侧相同的终结符开始,则称规则为左递归。在表达式的语法中可以找到一个典型的例子:
expression : expression "+" term ;
如果从语法中还不明显的话,那么转换为C++后就显而易见,这将导致无限递归:
Void Parser::parseExpression() {
parseExpression();
consume(tok::plus);
parseTerm();
}
左递归也可能间接发生,并涉及更多规则,这要难以发现得多。这就是为什么存在一种算法可以检测并消除左递归的原因。
注意
左递归规则只对LL解析器(如tinylang
的递归下降解析器)构成问题。原因是这些解析器首先扩展最左边的符号。相反,如果你使用解析器生成器生成LR解析器(首先扩展最右边的符号),那么你应该避免右递归规则。
在每一步,解析器仅使用前瞻令牌来决定如何继续。如果这一决定不能确定地做出,则称该语法存在冲突。为了说明这一点,请看一下C#中的using
语句。像在C++中一样,using
语句可以用来在命名空间中使符号可见,例如using Math;
。还可以用using M = Math;
为导入的符号定义别名。在语法中,这可以表示如下:
usingStmt : "using" (ident "=")? ident ";"
这里有一个问题:在解析器消耗using
关键字后,前瞻令牌是ident
。然而,这个信息不足以让我们决定是跳过可选组还是解析它。这种情况总是出现在可选组的开始令牌集与跟随可选组的令牌集重叠的情况下。
让我们用选择项替代可选组重写规则:
usingStmt : "using" ( ident "=" ident | ident ) ";" ;
现在有了不同的冲突:两个选择项都以相同的令牌开始。只看前瞻令牌,解析器无法决定哪个选择项是正确的。
这些冲突非常常见。因此,知道如何处理它们很重要。一种方法是重写语法以消除冲突。在前面的例子中,两个选择项都以相同的令牌开始。这可以通过提取公因子来解决,得到以下规则:
usingStmt : "using" ident ("=" ident)? ";" ;
这种表述没有冲突,但也应该注意到它的表达能力较差。在其他两种表述中,很明显哪个ident
是别名,哪个ident
是命名空间名。在无冲突的规则中,最左边的ident
改变了它的角色。首先,它是命名空间名,但如果后面跟着等号,那么它就变成了别名。
第二种方法是添加一个谓词来区分这两种情况。这个谓词,通常称为解析器,可以使用上下文信息来做出决策(例如在符号表中查找名称)或者查看多于一个令牌。假设词法分析器有一个名为Token &peek(int n)
的方法,它返回当前前瞻令牌之后的第n个令牌。在这里,等号的存在可以作为决策中的额外谓词:
if (Tok.is(tok::ident) && Lex.peek(0).is(tok::equal)) {
advance();
consume(tok::equal);
}
consume(tok::ident);
第三种方法是使用回溯。为此,你需要保存当前状态。然后,你必须尝试解析冲突组。如果这不成功,那么你需要返回到保存的状态并尝试另一条路径。在这里,你正在寻找适用的正确规则,这不如其他方法高效。因此,这种方法应作为最后手段使用。
现在,让我们加入错误恢复。在上一章中,我介绍了所谓的恐慌模式作为错误恢复的技术。基本思想是跳过令牌,直到找到一个适合继续解析的令牌。例如,在tinylang
中,一个语句后面跟着一个分号(:
)。
如果在IF
语句中存在语法问题,那么你跳过所有令牌,直到找到一个分号。然后,你继续处理下一个语句。与其使用临时定义的令牌集,不如使用系统化的方法。
对于每个非终结符,你计算任何地方都可以跟在非终结符后面的令牌集(称为FOLLOW集)。对于非终结符语句,可以跟随的令牌有;
、ELSE
和END
。所以,你必须在parseStatement()
的错误恢复部分使用这个集合。这种方法假设语法错误可以在本地处理。通常,这是不可能的。因为解析器跳过了令牌,可能会跳过很多令牌,以至于到达输入的末尾。在这一点上,本地恢复是不可能的。
为了防止无意义的错误信息,调用方法需要被告知错误恢复还未完成。这可以通过bool
来完成。如果它返回true
,这意味着错误恢复还没有结束,而false
意味着解析(包括可能的错误恢复)成功。
有许多方法可以扩展这种错误恢复方案。使用活动调用者的FOLLOW
集是一种流行的方法。举一个简单的例子,假设parseStatement()
被parseStatementSequence()
调用,而parseStatementSequence()
又被parseBlock()
调用,然后是parseModule()
。
在这里,每个相应的非终结符都有一个FOLLOW
集。如果解析器在parseStatement()
中检测到语法错误,那么令牌将被跳过,直到令牌至少在一个活动调用者的FOLLOW
集中。如果令牌在语句的FOLLOW
集中,则错误在本地得到恢复,并向调用者返回false
值。否则,返回true
值,意味着错误恢复必须继续。对于这种扩展的可能实现策略是传递std::bitset
或std::tuple
来代表当前FOLLOW
集的联合给被调用者。
还有一个问题尚未解决:我们如何调用错误恢复?在上一章中,使用了goto
来跳转到错误恢复块。这是可行的,但并非令人满意的解决方案。鉴于我们之前讨论的内容,我们可以在一个单独的方法中跳过令牌。Clang有一个名为skipUntil()
的方法用于此目的;我们也在tinylang
中使用了这个方法。
因为下一步是向解析器添加语义动作,因此如果必要的话,有一个集中放置清理代码的地方也会很好。嵌套函数将是理想的选择。C++没有嵌套函数。相反,Lambda函数可以起到类似的作用。当添加了完整的错误恢复代码后,最初我们看到的parseIfStatement()
方法如下所示:
bool Parser::parseIfStatement() {
auto _errorhandler = [this] {
return skipUntil(tok::semi, tok::kw_ELSE, tok::kw_END);
};
if (consume(tok::kw_IF))
return _errorhandler();
if (parseExpression(E))
return _errorhandler();
if (consume(tok::kw_THEN))
return _errorhandler();
if (parseStatementSequence(IfStmts))
return _errorhandler();
if (Tok.is(tok::kw_ELSE)) {
advance();
if (parseStatementSequence(ElseStmts))
return _errorhandler();
}
if (expect(tok::kw_END))
return _errorhandler();
return false;
}
解析器和词法分析器生成器
手动构建解析器和词法分析器可能是一项繁琐的任务,特别是如果你尝试发明一种新的编程语言并经常更改语法的话。幸运的是,有些工具可以自动化这项任务。
经典的Linux工具是 flex (flex的GitHub链接) 和 bison (bison的官方网站)。flex 从一组正则表达式生成词法分析器,而bison 从语法描述生成 LALR(1) 解析器。这两个工具都生成C/C++源代码,并且可以一起使用。
另一个流行的工具是 AntLR (AntLR的官方网站)。AntLR可以从语法描述生成词法分析器、解析器和抽象语法树(AST)。生成的解析器属于 LL(*) 类,这意味着它是一个自顶向下的解析器,使用可变数量的前瞻来解决冲突。这个工具是用Java编写的,但可以为许多流行语言生成源代码,包括C/C++。
所有这些工具都需要一些库支持。如果你正在寻找一个可以生成自包含词法分析器和解析器的工具,那么 Coco/R (Coco/R的官方网站) 可能是你要找的工具。Coco/R从 LL(1) 语法描述生成词法分析器和递归下降解析器,类似于本书中使用的那种。生成的文件基于一个可以根据需要更改的模板文件。该工具用C#编写,但也有C++、Java和其他语言的移植版。
还有许多其他工具可用,它们在支持的特性和输出语言方面各不相同。当然,在选择工具时,也需要考虑一些权衡。例如,bison这样的LALR(1)解析器生成器可以处理广泛的语法,你在互联网上找到的免费语法往往是LALR(1)语法。
作为缺点,这些生成器生成的状态机需要在运行时解释,这可能比递归下降解析器慢。错误处理也更复杂。bison对语法错误处理有基本支持,但正确使用需要深入理解解析器的工作原理。相比之下,AntLR处理的语法类稍微小一些,但会自动生成错误处理,并且还可以生成AST。因此,重写语法以便使用AntLR可能会加快后期的开发进度。
进行语义分析
我们在上一节中构建的解析器仅检查输入的语法。下一步是增加执行语义分析的能力。在上一章的calc示例中,解析器构建了一个AST。在一个单独的阶段,语义分析器在这棵树上进行工作。这种方法总是可行的。在本节中,我们将采用稍微不同的方法,将解析器和语义分析器更紧密地结合起来。
那么,语义分析器需要做什么?让我们来看看:
- 对于每个声明,必须检查变量、对象等的名称,确保它们没有在其他地方被声明。
- 对于表达式或语句中的每个名称出现,必须检查该名称是否已声明,以及所需的使用是否符合声明。
- 对于每个表达式,必须计算结果类型。还必须计算表达式是否为常量,如果是,其值是什么。
- 对于赋值和参数传递,我们必须检查类型是否兼容。此外,还必须检查
IF
和WHILE
语句中的条件是否为BOOLEAN
类型。
这已经是对这样一个小型编程语言的子集进行了大量检查!
处理名称的作用域
首先让我们看看名称的作用域。名称的作用域是名称可见的范围。像C语言一样,tinylang
采用声明前使用模型。例如,B
和X
变量在模块级别声明为INTEGER
类型:
VAR B, X: INTEGER;
在声明之前,这些变量是未知的,不能使用。只有在声明之后才可以使用。在过程中,可以声明更多变量:
PROCEDURE Proc;
VAR B: BOOLEAN;
BEGIN
(* 语句 *)
END Proc;
在过程内部,当出现评论时,使用B
指的是B
局部变量,而使用X
指的是X
全局变量。局部变量B
的作用域是Proc
。如果在当前作用域找不到名称,则搜索将继续在包围作用域中进行。因此,X
变量可以在过程内部使用。在tinylang
中,只有模块和过程打开一个新的作用域。其他语言构造,如结构体和类,通常也会打开一个作用域。预定义实体,如INTEGER
类型和TRUE
字面量,在全局作用域中声明,包围着模块的作用域。
在tinylang
中,只有名称是至关重要的。因此,作用域可以实现为从名称到其声明的映射。只有当名称尚未存在时,才能插入新名称。对于查找,还必须知道包围或父作用域。接口(在include/tinylang/Sema/Scope.h
文件中)如下所示:
#ifndef TINYLANG_SEMA_SCOPE_H
#define TINYLANG_SEMA_SCOPE_H
#include "tinylang/Basic/LLVM.h"
#include "llvm/ADT/StringMap.h"
#include "llvm/ADT/StringRef.h"
namespace tinylang {
class Decl;
class Scope {
Scope *Parent;
StringMap<Decl *> Symbols;
public:
Scope(Scope *Parent = nullptr) : Parent(Parent) {}
bool insert(Decl *Declaration);
Decl *lookup(StringRef Name);
Scope *getParent() { return Parent; }
};
} // namespace tinylang
#endif
在lib/Sema/Scope.cpp
文件中的实现如下所示:
#include "tinylang/Sema/Scope.h"
#include "tinylang/AST/AST.h"
using namespace tinylang;
bool Scope::insert(Decl *Declaration) {
return Symbols
.insert(std::pair<StringRef, Decl *>(
Declaration->getName(), Declaration))
.second;
}
请注意,StringMap::insert()
方法不会覆盖现有条目。结果std::pair
的second
成员指示表是否被更新。这个信息被返回给调用者。
为了实现对符号声明的搜索,lookup()
方法在当前作用域中搜索,如果没有找到,继续搜索通过parent
成员链接的作用域:
Decl *Scope::lookup(StringRef Name) {
Scope *S = this;
while (S) {
StringMap<Decl *>::const_iterator I =
S->Symbols.find(Name);
if (I != S->Symbols.end())
return I->second;
S = S->getParent();
}
return nullptr;
}
然后,变量声明按以下方式处理:
- 当前作用域是模块作用域。
- 查找
INTEGER
类型声明。如果没有找到声明,或者它不是类型声明,那么就是一个错误。 - 实例化一个名为
VariableDeclaration
的新AST节点,重要的属性包括名称B
和类型。 - 将名称
B
插入当前作用域,映射到声明实例。如果作用域中已经存在该名称,那么这是一个错误。在这种情况下,当前作用域的内容不会改变。 - 对
X
变量执行相同的操作。
这里执行了两项任务。就像calc示例中一样,构建了AST节点。同时,计算了节点的属性,例如类型。为什么这是可能的?
语义分析器可以依赖两组不同的属性。作用域是从调用者继承的。类型声明可以通过评估类型声明的名称来计算(或合成)。语言设计得足够好,这两组属性足以计算AST节点的所有属性。
一个重要的方面是声明前使用模型。如果一种语言允许在声明前使用名称,例如C++中类内的成员,那么就不可能一次计算出AST节点的所有属性。在这种情况下,必须使用部分计算的属性或只有普通信息(如calc示例中)来构建AST节点。
然后必须访问AST一次或多次以确定缺失的信息。在tinylang
(和Modula-2)的情况下,可以不必构建AST —— AST通过parseXXX()
方法的调用层次结构间接表示。从AST生成代码更为常见,所以我们在这里也构建了一个AST。
在我们把这些部分放在一起之前,我们需要了解LLVM使用运行时类型 信息(RTTI)的风格。
使用LLVM风格的RTTI来处理AST
显然,AST节点是类层次结构的一部分。声明总是有一个名称。其他属性取决于声明的内容。如果声明了一个变量,那么需要一个类型。常量声明需要一个类型、一个值等。当然,在运行时,你需要找出你正在处理的是哪种类型的声明。可以使用C++的dynamic_cast<>
操作符来做这件事。问题是,所需的RTTI只有在C++类附加了虚拟表——即使用虚拟函数时才可用。另一个缺点是C++ RTTI很臃肿。为了避免这些缺点,LLVM开发者引入了一种自制的RTTI风格,这种风格在整个LLVM库中被使用。
我们层次结构的(抽象)基类是Decl
。要实现LLVM风格的RTTI,需要添加一个公共枚举,其中包含每个子类的标签。还需要这种类型的一个私有成员和一个公共getter。私有成员通常称为Kind
。在我们的例子中,这看起来如下所示:
class Decl {
public:
enum DeclKind { DK_Module, DK_Const, DK_Type,
DK_Var, DK_Param, DK_Proc };
private:
const DeclKind Kind;
public:
DeclKind getKind() const { return Kind; }
};
每个子类都需要一个称为 classof
的特殊函数成员。这个函数的目的是确定给定的实例是否为请求的类型。对于 VariableDeclaration
,它的实现如下:
static bool classof(const Decl *D) {
return D->getKind() == DK_Var;
}
现在,你可以使用特殊的模板,llvm::isa<>
,来检查一个对象是否为请求的类型,以及使用 llvm::dyn_cast<>
来动态转换对象。还有更多的模板存在,但这两个是最常用的。关于其他模板,请参阅https://llvm.org/docs/ProgrammersManual.html#the-isa-cast-and-dyn-cast-templates,更多关于LLVM风格的信息,包括更高级的用法,请参阅https://llvm.org/docs/HowToSetUpLLVMStyleRTTI.html。
创建语义分析器
掌握了这些知识后,我们现在可以实现所有部分。首先,我们必须在 include/llvm/tinylang/AST/AST.h
文件中创建变量的AST节点的定义。除了支持LLVM风格的RTTI外,基类还存储声明的名称、名称的位置和指向封闭声明的指针。在嵌套过程的代码生成过程中需要后者。Decl
基类声明如下:
class Decl {
public:
enum DeclKind { DK_Module, DK_Const, DK_Type,
DK_Var, DK_Param, DK_Proc };
private:
const DeclKind Kind;
protected:
Decl *EnclosingDecL;
SMLoc Loc;
StringRef Name;
public:
Decl(DeclKind Kind, Decl *EnclosingDecL, SMLoc Loc,
StringRef Name)
: Kind(Kind), EnclosingDecL(EnclosingDecL), Loc(Loc),
Name(Name) {}
DeclKind getKind() const { return Kind; }
SMLoc getLocation() { return Loc; }
StringRef getName() { return Name; }
Decl *getEnclosingDecl() { return EnclosingDecL; }
};
变量的声明只增加了一个指向类型声明的指针:
class TypeDeclaration;
class VariableDeclaration : public Decl {
TypeDeclaration *Ty;
public:
VariableDeclaration(Decl *EnclosingDecL, SMLoc Loc,
StringRef Name, TypeDeclaration *Ty)
: Decl(DK_Var, EnclosingDecL, Loc, Name), Ty(Ty) {}
TypeDeclaration *getType() { return Ty; }
static bool classof(const Decl *D) {
return D->getKind() == DK_Var;
}
};
解析器中的方法需要扩展,以包含语义动作和收集的信息变量:
bool Parser::parseVariableDeclaration(DeclList &Decls) {
auto _errorhandler = [this] {
while (!Tok.is(tok::semi)) {
advance();
if (Tok.is(tok::eof)) return true;
}
return false;
};
Decl *D = nullptr; IdentList Ids;
if (parseIdentList(Ids)) return _errorhandler();
if (consume(tok::colon)) return _errorhandler();
if (parseQualident(D)) return _errorhandler();
Actions.actOnVariableDeclaration(Decls, Ids, D);
return false;
}
DeclList
是声明列表,std::vector<Decl*>
,而 IdentList
是位置和标识符列表,std::vector<std::pair<SMLoc, StringRef>>
。
parseQualident()
方法返回一个声明,在这种情况下,预期是一个类型声明。
解析器类知道语义分析器类的一个实例,Sema
,存储在 Actions
成员中。调用 actOnVariableDeclaration()
运行语义分析器和AST构建。实现在 lib/Sema/Sema.cpp
文件中:
void Sema::actOnVariableDeclaration(DeclList &Decls,
IdentList &Ids,
Decl *D) {
if (TypeDeclaration *Ty = dyn_cast<TypeDeclaration>(D)) {
for (auto &[Loc, Name] : Ids) {
auto *Decl = new VariableDeclaration(CurrentDecl, Loc,
Name, Ty);
if (CurrentScope->insert(Decl))
Decls.push_back(Decl);
else
Diags.report(Loc, diag::err_symbold_declared, Name);
}
} else if (!Ids.empty()) {
SMLoc Loc = Ids.front().first;
Diags.report(Loc, diag::err_vardecl_requires_type);
}
}
使用 llvm::dyn_cast<TypeDeclaration>
检查类型声明。如果不是类型声明,则打印错误消息。否则,对于 Ids
列表中的每个名称,实例化 VariableDeclaration
并添加到声明列表中。如果由于名称已声明而无法将变量添加到当前作用域,则同样打印错误消息。
其他实体的构建方式大致相同——语义分析的复杂性是唯一的区别。由于它们开启了新的作用域,模块和过程需要更多工作。开启新作用域很简单:只需实例化一个新的 Scope
对象。一旦解析了模块或过程,就必须移除作用域。
这必须可靠地完成,因为我们不希望在语法错误的情况下将名称添加到错误的作用域中。这是C++中资源获取即初始化(RAII)习语的典型用法。另一个复杂性来自于过程可以递归调用自身。因此,过程的名称必须在使用前添加到当前作用域。语义分析器有两种方法进入和离开作用域。作用域与声明相关联:
void Sema::enterScope(Decl *D) {
CurrentScope = new Scope(CurrentScope);
CurrentDecl = D;
}
void Sema::leaveScope() {
Scope *Parent = CurrentScope->getParent();
delete CurrentScope;
CurrentScope = Parent;
CurrentDecl = CurrentDecl->getEnclosingDecl();
}
一个简单的帮助类用于实现RAII习语:
class EnterDeclScope {
Sema &Semantics;
public:
EnterDeclScope(Sema &Semantics, Decl *D)
: Semantics(Semantics) {
Semantics.enterScope(D);
}
~EnterDeclScope() { Semantics.leaveScope(); }
};
在解析模块或过程时,与语义分析器发生两次交互。第一次是在解析名称之后。这里,构建了(几乎空的)AST节点并建立了一个新的作用域:
bool Parser::parseProcedureDeclaration(/* … */) {
/* … */
if (consume(tok::kw_PROCEDURE)) return _errorhandler();
if (expect(tok::identifier)) return _errorhandler();
ProcedureDeclaration *D =
Actions.actOnProcedureDeclaration(
Tok.getLocation(), Tok.getIdentifier());
EnterDeclScope S(Actions, D);
/* … */
}
语义分析器检查当前作用域中的名称并返回AST节点:
ProcedureDeclaration *
Sema::actOnProcedureDeclaration(SMLoc Loc, StringRef Name) {
ProcedureDeclaration *P =
new ProcedureDeclaration(CurrentDecl, Loc, Name);
if (!CurrentScope->insert(P))
Diags.report(Loc, diag::err_symbold_declared, Name);
return P;
}
真正的工作是在解析了所有声明和过程体之后完成的。您只需要检查过程声明末尾的名称是否与过程的名称相同,以及用于返回类型的声明是否为类型声明:
void Sema::actOnProcedureDeclaration(
ProcedureDeclaration *ProcDecl, SMLoc Loc,
StringRef Name, FormalParamList &Params, Decl *RetType,
DeclList &Decls, StmtList &Stmts) {
if (Name != ProcDecl->getName()) {
Diags.report(Loc, diag::err_proc_identifier_not_equal);
Diags.report(ProcDecl->getLocation(),
diag::note_proc_identifier_declaration);
}
ProcDecl->setDecls(Decls);
ProcDecl->setStmts(Stmts);
auto *RetTypeDecl =
dyn_cast_or_null<TypeDeclaration>(RetType);
if (!RetTypeDecl && RetType)
Diags.report(Loc, diag::err_returntype_must_be_type,
Name);
else
ProcDecl->setRetType(RetTypeDecl);
}
有些声明本质上是内在的,不能由开发者定义。这包括 BOOLEAN
和 INTEGER
类型以及 TRUE
和 FALSE
字面量。这些声明存在于全局作用域中,必须通过程序添加。Modula-2 还预定义了一些过程,如 INC
或 DEC
,可以添加到全局作用域中。鉴于我们的类,初始化全局作用域很简单:
void Sema::initialize() {
CurrentScope = new Scope();
CurrentDecl = nullptr;
IntegerType =
new TypeDeclaration(CurrentDecl, SMLoc(), "INTEGER");
BooleanType =
new TypeDeclaration(CurrentDecl, SMLoc(), "BOOLEAN");
TrueLiteral = new BooleanLiteral(true, BooleanType);
FalseLiteral = new BooleanLiteral(false, BooleanType);
TrueConst = new ConstantDeclaration(CurrentDecl, SMLoc(),
"TRUE", TrueLiteral);
FalseConst = new ConstantDeclaration(
CurrentDecl, SMLoc(), "FALSE", FalseLiteral);
CurrentScope->insert(IntegerType);
CurrentScope->insert(BooleanType);
CurrentScope->insert(TrueConst);
CurrentScope->insert(FalseConst);
}
根据这个方案,tinylang
所需的所有计算都可以完成。例如,让我们看看如何计算表达式是否产生一个常量值:
- 我们必须确保字面量或对常量声明的引用是一个常量
- 如果表达式的两边都是常量,那么应用运算符也会产生一个常量
这些规则在创建表达式的AST节点时嵌入到语义分析器中。同样,类型和常量值也可以计算出来。
应该注意的是,并非所有类型的计算都可以以这种方式完成。例如,要检测未初始化变量的使用,可以使用一种称为符号解释的方法。在其通用形式中,该方法需要通过AST的一个特殊遍历顺序,这在构造时间内是不可能的。好消息是,这种方法创建了一个完全装饰的AST,已经准备好进行代码生成。这个AST可以用于进一步的分析,鉴于昂贵的分析可以根据需求开启或关闭。
要与前端玩耍,你还需要更新驱动程序。由于缺少代码生成,正确的tinylang
程序不会产生输出。不过,它可以用来探索错误恢复和引发语义错误:
#include "tinylang/Basic/Diagnostic.h"
#include "tinylang/Basic/Version.h"
#include "tinylang/Parser/Parser.h"
#include "llvm/Support/InitLLVM.h"
#include "llvm/Support/raw_ostream.h"
using namespace tinylang;
int main(int argc_, const char **argv_) {
llvm::InitLLVM X(argc_, argv_);
llvm::SmallVector<const char *, 256> argv(argv_ + 1,
argv_ + argc_);
llvm::outs() << "Tinylang "
<< tinylang::getTinylangVersion() << "\n";
for (const char *F : argv) {
llvm::ErrorOr<std::unique_ptr<llvm::MemoryBuffer>>
FileOrErr = llvm::MemoryBuffer::getFile(F);
if (std::error_code BufferError =
FileOrErr.getError()) {
llvm::errs() << "Error reading " << F << ": "
<< BufferError.message() << "\n";
continue;
}
llvm::SourceMgr SrcMgr;
DiagnosticsEngine Diags(SrcMgr);
SrcMgr.AddNewSourceBuffer(std::move(*FileOrErr),
llvm::SMLoc());
auto TheLexer = Lexer(SrcMgr, Diags);
auto TheSema = Sema(Diags);
auto TheParser = Parser(TheLexer, TheSema);
TheParser.parse();
}
}
恭喜你!你已经完成了tinylang
前端的实现!你可以使用在定义一个真正的编程语言部分提供的示例程序Gcd.mod
来运行前端:
$ tinylang Gcd.mod
当然,这是一个有效的程序,看起来好像什么都没有发生。确保修改文件并引发一些错误消息。我们将在下一章通过添加代码生成继续这个有趣的过程。
总结
在本章中,你学习了现实世界中的编译器在前端使用的技术。从项目布局开始,你为词法分析器、解析器和语义分析器创建了单独的库。为了向用户输出消息,你扩展了一个现有的LLVM类,允许消息被集中存储。现在,词法分析器被分割成了几个接口。
然后,你学习了如何从语法描述构造递归下降解析器,了解了要避免的陷阱,并学习了如何使用生成器来完成这项工作。你构建的语义分析器执行了语言所需的所有语义检查,同时与解析器和AST构建交织在一起。
你的编码努力的结果是一个完全装饰的AST。在下一章中,你将使用这个AST生成IR代码,最终生成目标代码。
标签:解析器,语法,令牌,return,tinylang,源文件,tok,抽象,include From: https://www.cnblogs.com/cppclub/p/17986160