前言
与其他教程不同,本文实现的词法分析器
- 借鉴于 C++ 输入流
我搜过的教程基本上都是从状态转换的思想入手,虽然本文思路类似于状态转换,但也有独到之处。
- 从面向对象的角度
其他教程大多采用面向过程,二者都能解决问题,各有优劣。只不过我从面向对象的角度,给读者提供一个新的实现思路。
本文重点在思路,因此
- 对于其他一些概念不作讲解,如有不会的可自行查阅相关资料。
- 仅在必要的地方讲解实现代码,简单的地方只注明作用
- 你要对 C++ 输入流、面向对象 等知识有一定的了解
- 如果你是抱着赶实验报告的心态——只要源码,建议你去搜别人的。
[ 本文约定 ]
- key:关键字
- Identifier: 标识符 (简称 ID)
- Uint: 非负十进制整数
- Lexer: 词法单元,由类型(kind),值(value)组成,在书写时记为 <kind, val>
- Lex: 词法分析器
目标任务
- 首先先声明总目标:实现 Lex
主要功能:能从指定输入流 (一般是文件流 ifstream) 中识别并返回一个个的 Lexer
[例] 现有文件内容如下:
int a = 1 + 2;
那么 Lex 应能按顺序依次返回以下 Lexer:
<key,int>、<ID,a>、
<Uint,1>、<+,+>、
<Uint,2>、<;,;>
- 可能还有一些其他功能:
- 忽略注释 (注释同C++)
- 此过程中检测到错误(特指注释嵌套)时,抛出异常,并返回出错之处所在行的行号
- … …
但是鉴于篇幅原因,并且实现思路也比较简单,故不作讲解。
文章末尾有整个程序的示例代码
- 定义 Lexer
需要识别的词法单元如下:
类别 | 描述 |
---|---|
key | if、else、for、while、int |
ID | 格式同 C++ 标识符命名规则 |
Uint | 非负整数,为十进制 |
双分界符 | >=、<=、==、!= |
单分界符 | +、-、*、/、=、{、}、(、)、<、>、;、!、, |
进入正题
概要设计
概要设计部分,为了突出重点,省去了不必要的部分,因此有的代码与解释有一些不打紧的错误
1. 为何借鉴 istream
每学一门编程语言,必定要学输入输出。刚开始学 C++ 时,对输入的理解仅停留在输入的内容可以赋给某临时变量,然后进行相关操作。因为那时处理的输入大多是控制台输入,用 cin 接收赋值。零星几个字符而已,没引起多大重视。后来学了 C++ 的 istream 部分,感觉自己的见识又被打开了一些。
以 cin 为例
在 C++ 处理输入时,常常用到 cin 关键字。“cin >> id;” 相信大家都不陌生,但 cin.get() 可能用得偏少。
有以下代码:
while (true) {
char c = cin.get();
if (cin.eof()) break;
cout << c << "-";
}
现在输入为:“int a=1;”
那么输出为:“i-n-t- -a-=-1-;”。(包括空格)
可以看出:cin.get() 函数,用于返回输入流中下一个字符。
来看我们的任务:
能从指定输入流中识别并返回一个个的 Lexer
是不是很类似?也就是说我们主要任务是实现类似 cin.get() 的 Lex.get()。
不仅如此,我们还可以实现类似的 Lex.peek()、Lex.putback() 等
虽然目前用不到,但是 语法分析 部分可能会用到
假如现在 Lex 的要求是 “按空格分隔处理”,那么 Lex.get() 就可以如下实现:
while (true) {
cin >> s;
cout << s << "|";
}
当输入为:“a = 1 ;” (空格分隔)
由于 cin 默认跳过空格,因此输出为:“a|=|1|;”。
但是这种方式显然与我们的目标不相符:
当输入为:“a=1;”,输出为:“a=1;”
我们需要的是:
读取内容:“a=1;”,Lex 应能返回如下的 Lexer
<ID, a>,<=, =>
<Uint, 1>,<;, ;>
现在问题就明朗了:如何准确定义出 Lex 处理输入字符流的方式
。
2. 重载输入运算符
说之前先看一个例子:
int n;
cin >> n;
cout << n;
输入为: “123end”,
输出为: “123”。
string s;
cin >> s;
cout << s;
输入为: “123end”,
输出为: “123end”。
上面两段代码:不同之处在于变量类型不同,一个 int,一个 string,使得 cin 处理结果不同
- 对于 int,当遇见输入 “end” 时,无法读取,使得 cin.fail() = true,但是 “123” 被读取了;
- 而对于 string,则是将 “123end” 全部读取了。
这不就跟我们的任务类似吗:
当 Lex 处理的输入: “a=1” :
在读取到 “a” 时,由于下一个字符是 “=”,不属于标识符的一部分,因此 Lex 将停止读取,将 “a” 作为一个整体,返回 “<ID, a>”… …
因此我们可以用类似 cin 对不同类型的处理方法 去看待我们即将设计的词法分析器。
对于一个类而言,可以通过
重载 >> 运算符
实现自定义输入处理。
比如现在有一个类 A,要求 :
A 只能接收以 ‘;’ 结尾的字符串。
那么我们可以如下对于 A 类重载 >>:
istream& operator>>(istream& is, A& a)
{
string s;
is >> s;
if (!s.empty() && s.back() == ';')
a = A{ s }; // 满足条件,给 a 赋值
else {
// 进行相关操作,比如:
// 1. 可以将流的状态置为 fail
// 2. 又或者 throw exception
// 3. 也可以将 a 置为 empty
a = A{};
}
return is;
}
那么用 cin 去读取 A
A a;
cin >> a;
当输入为:“s;” 时,那么 a 的值为 “s;”;
当输入为:“ss” 时,那么 a 为空。
现在再看目标任务,相当于需要在输入字符流中识别出 ID、Uint 等 基本单元。那么,我们就可以创建 ID、Uint 等类,并重载 >> 运算符实现识别功能。
比如有如下输入(假设已经实现了 ID、Uint 对 >> 的正确重载):
123ID1
现在执行以下代码:
ID id;
cin >> id;
那么 id 会读取失败,因为标识符不能以数字开头。
执行另外一个代码:
Uint u;
ID i;
cin >> u >> i;
那么 u 的值为 “123”,而 i 的值为 “ID1”。
这样是不是就能识别出不同的词法单元?
3. Lexer
-
上一部分说了可以通过 创建类并重载 >> 来识别出不同的词法单元,那么还要 Lexer 干嘛?
诚然,“创建类并重载>>” 的目的就是识别出不同的词法单元,对于我们的目标,如果按此方法至少需要创建 5 个类。
我们知道 类的一大特性:封装——隐藏内部实现细节,接口尽量越小越好。
如果采用 “创建类并重载>>” 的方式,那么 Lex 就需要返回五种类型。如此,往后(比如语法分析部分)需要使用 Lex 时,就要自己处理五种返回类型,这就很麻烦;
但若采用 只返回一种类型 Lexer,而将对这 “五个类” 的处理隐藏与 Lex类 内部,是不是之后使用 Lex 只需要关心一种返回类型即可,会更方便。 -
Lexer 为什么需要有 kind、val 属性?
- 前面也说了将 “五个类” 整合为一个,那如何标识不同的类呢?—— kind 就是起此作用;
- 由于我们处理的是字符流,因此它的字面量是我们所关心的,采用 val 用来保存其值。
4. 综合
综上部分,可以看做我们的主要任务被拆分为两大部分:
- 实现 “五大类” 重载 >>,使得能从输入字符流中识别出基本单元
- 通过 Lex.get() 将 “五大类” 整合为一个 Lexer,并返回
下面进入详细设计:
详细设计
1. 基本单元的识别
根据目标任务分析,输入字符流中有五种基本单元:key、ID、Uint、双分界符、单分界符,我们该如何识别呢?
1.1 先从人的视角入手:
给你一个文本如下,你如何识别出基本单元?
int a=1;
尽管文本很短,你能一眼扫出来,但是你应该也是从左到右、逐个字符查看,同时遇见空格就停止。
- 你先看到了 “i” 字符,由于它之后没有空格,因此你会将其后的字符当做它的一部分 … 因此扫描出来 “int”。现在查表发现 “int” 是关键字,所以你得出: <key,int>
- 接下来是 “a”,但是 “a” 后是 “=”,因此你知道它不能和 “a” 作为一个整体,所以得出: <ID, a>
- 依次类推… …
这也是接下来程序大体的运作方式:先判断首字符,在判断之后的字符、遇见空格结束。
我们采用按空格分隔处理的方式,比如:“inta = 1;”,那么第一个 Lexer 为 <ID, inta>
1.2 再来分析五种基本单元:
-
key:
显然关键字也是标识符,属于 ID,因此可以先用 ID 识别出标识符,然后在通过查表确定是否是关键字
-
ID:
由 字母、数字、下划线组成、不能以 数字 开头
也就是说,ID 的首字符一定是 字母 或者 下划线,首字符后只能跟 字母、数字、下划线。
-
Uint:
非负十进制整数
首字符为 数字,其后只能跟 0-9 字符。
-
双分隔符:
>=、<=、==、!=
由于所给字符的特殊性:都是以 ‘=’ 结尾的,我们可以简单地认为:首字符为单分界符,第二个字符为 ‘=’ 的字符为双分隔符。
如果需要识别的有其他的,比如: >>、<<、++ 等,由于没有规律,那么我们只能采用逐一判断的方法。
比如识别 ++:先读入 ‘+’,然后判断下一个字符是否为 ‘+’,如果是则返回 <双分界符,++>;反之返回 <单分界符,+> -
单分隔符:
+、-、*、/、=、{、}、(、)、<、>、;、!、,
因为单分界符都是非字母、非数字字符(后面都记为特殊字符),但并不是所有的 特殊字符,因此也是采用查表的方式:先判断首字符是否是 特殊字符,再查表确定是不是给定的单分界符。
1.3 方案
通过 1.2 可以看出,虽然有五种类型,但是实际只需要创建两个类:ID 与 Uint,双分界符与单分界符最多只有两个字符,完全可以直接当做 char 处理,没必要在创建一个类。那么下面就来实现 ID 与 Uint,但是之前先要说 Lexer 类。
因为最终的 Lex 只需要返回 Lexer,ID、Uint 类则是被隐藏与 Lex 内部,也就是说 ID、Uint 需要为 Lexer 服务。既然是提供服务的,那我们就需要知道 Lexer 需要什么服务吧。
2. Lexer 类
Lexer 有两个属性:kind、val,因此成员变量已定。
- kind
之前说了 kind 用于标识 新类 (也就是基本单元)的类型,那么怎么标识,采用什么类型?看之前的任务,Lexer 只有五大类型: key、 ID、Uint、单分界符、双分界符
那么我们需要五种 kind 吗?当然你可这样设计,但是我采用在一本教科书上看到的类似的方式:
constexpr char KEY_KIND = 'k'; // 关键字
constexpr char ID_KIND = 'i'; // 标识符
constexpr char UINT_KIND = 'u'; // 非负整数
constexpr char DCHAR_KIND = 'd'; // 双分界符
constexpr char NULL_KIND = '`'; // 空 类型
看了上述定义,你可能有疑问:
2.1 单分界符呢?
对于 kind,我采用的是 char 类型,那么是不是有个发现:单分界符也是char,并且是特殊字符。
那么对于单分界符可不可以直接用它的值来作为它的 kind?显然可以。
首先 kind 需要唯一,对于每个单分界符来说,肯定各不相同,同时与上述已经定义了的类型(都是字母)也同样不重复。
2.2 为什么有空类型?
在文件流 ifsream 中,我们知道有一个函数为 eof(),用于判断是否到了文件末尾。对于 Lex,显然也需要此函数。
在 ifstream 中,采用 EOF(-1) 字符来作为文件结束标记:因为文件未结束前,所有字符的 ASCII 一定都大于等于 0,因此可以用 EOF 来标记。那么,我们也可以采用类似的方法,用一个一定不会出现的单字符(‘`’)来标识这种状态。
-
val
由于处理的是字符串,同时我们关心的事字面量,显然应该采用 string 类型,即便需要转 整型 也方便。
综上,Lexer 设计如下:
struct Lexer { char kind; string val; Lexer() :kind{ NULL_KIND }, val{} { }; // NULL_Lexer: <NULL_KIND, ""> Lexer(char c); Lexer(const string& s); // Lexer(char kind, const string& s); bool empty() const; };
成员变量 kind、val 采用 pubilc 是为了方便后续的语法分析部分,但是此处不涉及语法分析,因此你也可以选择采用 private
-
Lexer(char c)
用于 单分界符 初始化
单分界符因为只有一个字符,并且它的 kind == val,因此单独拆分出来
Lexer::Lexer(char c) :kind{ c }, val{ c } { // 因为是给单分界符初始化的,需要判断是否是单分界符 if (!is_single_ch(c)) throw string(val + "isn't a single char"); }
-
Lexer(const string& s)
用于给 除单分界符外的其他基本单元 初始化的。但你会发现为什么没有指定 kind?
据之前分析,给定一个字符串,我们完全可以自动推断出其类型,因此可以将类型推导放给程序自己控制,比起每次都要加上 kind 更为方便。所以需要一个自动推导类型的函数:(显然单分界符就不需要此函数)
其中 KEY_KIND 的判断必须在 ID_KIND 之前。
char ret_kind(const string& s) // ** 注意优先级 { if (is_key(s)) return KEY_KIND; if (is_id(s)) return ID_KIND; if (is_dchar(s)) return DCHAR_KIND; if (is_uint(s)) return UINT_KIND; return NULL_KIND; // 未定义的类型 }
由于 ret_kind 可能返回 NULL_KIND,这就表示 s 是一个未定义的 Lexer,需要抛出异常。
Lexer::Lexer(const string& s) :kind{ ret_kind(s) }, val{ s } { if (kind == NULL_KIND) throw string(s + "isn't a legal lexer"); }
-
Lexer(char c, const string& s)
刚说了不需要 kind,直接自动推导类型即可,那怎么还有这个函数?
因为可能出现需要强制转换类型的情况,当然目前不需要,所以此函数可以删除 -
empty()
Lexer 是否为空(NULL_Lexer)
bool Lexer::empty() const { return kind == NULL_KIND && val.empty(); }
3. Identifier
根据上述的分析可知,ID 需要返回字符串:因为要用 Lexer(const string&) 初始化,同时还要重载 >>,因此:
class Identifier {
public:
Identifier(); // _val = "" -> "空标识符"
Identifier(const string& s);
string str() const;
bool empty() const;
private:
std::string _val;
};
istream& operator>>(istream& is, Identifier& n);
-
Identifier() 与 empty()
看到上面的声明你可能有疑问:标识符不是不能为空吗,为什么还有一个 empty() 函数?
来看一个例子:
当前输入为:“2+1”,此时 ID 去读取,那么肯定需要 ID类 无法读取,那么我们怎么来标识读取失败呢?在之前我提到了三种方法:
i. 将 istream.fail() 设为 true比如代码 “int n; cin >> c;”
输入为 xxxx,那么 c++ 内部的做法就是将 cin.fail() 设为 true,以此来判定是否读取成功。可以这么做,但是会导致 流状态 为 fail,无法正常使用输入流,需要执行 cin.clear() 消除 fail 状态。
ii. 抛出异常当读取失败直接抛出异常,但你觉得在此处直接抛出异常合适吗?显然不太合适,因为字符 ‘2’ 是 Uint,也是合法的 Lexer。当然也可以抛出异常,只不过后续需要处理异常,过于麻烦,因此不推荐。
iii. 将其设一个非法的值
标识符不能为空,就像文件流的 EOF 一样,我们可以用 “空标识符” 来标识标识符读取失败,本文采用此方法。
插一句,int 怎么不使用此方法?显然找不到一个能表示 “非法 int” 的数字
-
istream& operator>>(istream& is, Identifier& n)
采用类似于编译原理中提到的状态转换的方式,我们需要考虑两个问题:- 什么时候读取失败:
显然由首字符决定。前面分析过,ID 的首字符一定是 字母 或者 下划线,因此当第一个字符不是二者时,读取失败; - 什么时候读取结束:
当首字符合法时,那么找它之后就只能跟 字母、数字、下划线,也就是说当遇见其他字符时(包括空白字符),读取结束
- 什么时候读取失败:
这里需要注意一个问题:
- 之前的 A 的重载 >> 函数,读取的 string 并没有被放回输入流中,但是在 Lex 中,当读取失败时需要放回。
- 比如输入为:“1+2”,现在使用 ID 来读取。如果它读取了第一个字符 ‘1’ 后,发现不是合法的标识符就直接读取失败,而没有将字符 ‘1’ 放回输入流中,那么现在输入流就变为:“+2”, ‘1’ 字符相当于被丢弃了,这种做法不可取。
istream& operator>>(istream& is, Identifier& n)
{
// 先判断首字符
if (is.peek() != '_' && !isalpha(is.peek())) {
n = Identifier{};
return is;
}
string s;
while (true) {
char c;
is >> c;
if (is.eof() || isspace(c)) break; // 空格就没必要放回去了
if (!is_id_ch(c)) { // 不是 下划线、数字、字母
is.putback(c); // * 放回输入流
break;
}
s += c;
}
n = Identifier{ s };
return is;
}
【注】
上面的代码存在 bug:
在我最后调试程序时发现的
-
is.peek()
作用:返回输入流中下一个字符,包括空白字符。
如果是上面的代码,那么输入为:" id" (第一个字符为空格),当执行到第一个判断条件时由于 is.peek() 为空格,条件正确,返回空,显然这里不适合,我们期待的是返回 “id”。
因此更换后的程序为:
istream& operator>>(istream& is, Identifier& n)
{
char c = '0'; // 初始化,避免读取失败后,c的值随机
is >> c; // 读取第一个非空格字符
if (c != '_' && !isalpha(c)) {
if (!is.eof()) is.putback(c);
n = Identifier{};
return is;
}
string s{c};
// 当 peek 为 空白字符或者 is.eof() == true 退出
while (!isspace(is.peek()) && is >> c) {
if (!is_id_ch(c)) {
is.putback(c); // ******
break;
}
s += c;
}
n = Identifier{ s };
return is;
}
代码还增加了对 输入流 eof() 的处理,如果对其处理不当会导致各种 bug。后面的代码也进行了处理。
下面举个例子供读者思考为什么有bug:
// 目的是读取输入流中的所有字符后,去掉空格,输出结果
// 当输入为 ^Z (Ctrl+Z) 时,结束
// * cin 默认跳过空格
string s;
while (!cin.eof()) {
char c;
cin >> c;
s += c;
}
cout << s;
s 应该等于 输入字符除去空白字符,但是结果是这样吗?
4. Uint
与 ID 类似,故
class Uint {
public:
Uint(); // _val = ""
Uint(const string& n);
bool empty() const;
string str() const;
private:
string _val;
};
istream& operator>>(istream& is, Uint& u);
-
Uint()、empty()
与 ID 一样,需要标识出读取失败的情况,此处用 empty (_val == “”) 来表示 Uint 不合法,也就是读取失败了。
-
istream& operator>>(istream& is, Uint& u)
也是需要考虑两个问题:
- 什么时候读取失败:当首字符不为数字时。
- 什么时候读取结束:当其后字符不为数字时。
比较舒服的一点是,C++ 中的 整型类型 的读取机制恰好满足此特性,因此代码简单了不少。
istream& operator>>(istream& is, Uint& u) { char c = ' '; is >> c; if (!isdigit(c)) { // 如果不判断,当输入为负数时,也会正常读取 if (!is.eof()) is.putback(c); u = Uint{}; return is; } is.putback(c); // * 别忘了放回 c long long n; is >> n; u = Uint{ to_string(n) }; return is; }
但是可能会导致 溢出问题(比如数字很大的情况),因此还是换用以下代码:
istream& operator>>(istream& is, Uint& u) { char c = ' '; is >> c; if (!isdigit(c)) { if (!is.eof()) is.putback(c); u = Uint{}; return is; } string s{c}; while (!isspace(is.peek()) && is >> c) { if (!isdigit(c)) { is.putback(c); break; } s += c; } u = Uint{ s }; return is; }
5. Lex
现在 ID、Uint、Lexer 都已经创建完毕,到最后的 Lex了。
前面说过,Lex 与 istream 类似,需要从指定流中返回 Lexer,那么 Lex 就需要能与 输入流 关联,有 get() 函数,因此:
class Lex {
public:
Lex(istream& is);
Lexer get();
private:
istream& _is;
};
-
_is、Lex(istream& is)
因为需要和输入流关联,因此成员变量为 istream&,用 Lex(istream& is) 来初始化关联。
-
Lex.get()
重点在此函数。
在 基本单位的识别 部分我们分析有:类别 首字符 key 同 ID ID 字母、下划线 Uint 数字 双分界符 >=、<=、==、!= 单分界符 +、-、*、/、=、{、}、(、)、<、>、;、! 显然,通过首字符基本可以判断出应该使用哪种类型去读取,最多就是双分界符与单分界符需要多判断一次而已。
其类型之前说了使用 Lexer自动推导类型,因此不必再需要给 Lexer.kind 赋值。
Lexer Lex::get() { Identifier id; _is >> id; // * 1. id or key if (!id.empty()) return Lexer{ id.str() }; if (_is.eof()) return Lexer{}; // ** 注意所在位置 Uint u; _is >> u; // * 2. Uint if (!u.empty()) return Lexer{ u.str() }; char c = NULL_KIND; _is >> c; if (is_dchar_sta(c)) { // 首字符是双分界符的第一个字符 if (_is.peek() == '=') { // * 3. 双分界符 _is.get(); // ** 记得把 '=' 读取了 return Lexer{ string{c} + "=" }; } } return Lexer{ c }; // * 4. 单分界符 }
- if (_is.eof()) return Lexer{}; 语句的位置如果随便移动(当然也可以放在其他地方),也会导致之前说的例子类似的 bug
- 上面的代码(包括示例代码)对于双分界符的处理有一个问题:
如果输入是:“> =”(之间有空格),那么输出是:"<>, >>,<=, =>。
经过之前的说明,想必你也知道为什么。如果你觉得有修改的必要,请自行修改。
6. 完成
现在完成了,那么我们可以使用 Lex:
比如将 Lex 与 cin 关联,那么将接受控制台输入
Lex lex(cin);
auto lexer = lex.get();
if (!lexer.empty()) cout << lexer.val;
也可以关联文件流,这里不再演示。
示例代码
由于代码较多,因此放在 gitee 上,你可去直接下载或者直接复制即可。
链接: Lex
如果本文出现任何错误,欢迎指正。
如果你有任何问题,欢迎评论,作者看到了会第一时间回复。
标签:字符,读取,Lexer,分析器,C++,词法,Uint,Lex,string From: https://blog.csdn.net/weixin_73162565/article/details/139239279