从面向对象角度设计词法分析器 C++

前言

与其他教程不同,本文实现的词法分析器

  • 借鉴于 C++ 输入流

我搜过的教程基本上都是从状态转换的思想入手,虽然本文思路类似于状态转换,但也有独到之处。

  • 面向对象的角度

其他教程大多采用面向过程,二者都能解决问题,各有优劣。只不过我从面向对象的角度,给读者提供一个新的实现思路。

本文重点在思路,因此

  • 对于其他一些概念不作讲解,如有不会的可自行查阅相关资料。
  • 仅在必要的地方讲解实现代码,简单的地方只注明作用
  • 你要对 C++ 输入流、面向对象 等知识有一定的了解
  • 如果你是抱着赶实验报告的心态——只要源码,即便文章末尾有源码,但还是建议你去搜别人的。

[ 本文约定 ]

  • key:关键字
  • Identifier: 标识符 (简称 ID)
  • Uint: 非负十进制整数
  • Lexer: 词法单元,由类型(kind),值(value)组成,在书写时记为 <kind, val>
  • Lex: 词法分析器

目标任务

  1. 首先先声明总目标:实现 Lex
  • 主要功能:能从指定输入流 (一般是文件流 ifstream) 中识别并返回一个个的 Lexer

[例] 现有文件内容如下:

int a = 1 + 2;

那么 Lex 应能按顺序依次返回以下 Lexer:
<key,int>、<ID,a>、
<Uint,1>、<+,+>、
<Uint,2>、<;,;>

  1. 可能还有一些其他功能:
  • 忽略注释 (注释同C++)
  • 此过程中检测到错误(特指注释嵌套)时,抛出异常,并返回出错之处所在行的行号
  • … …

但是鉴于篇幅原因,并且实现思路也比较简单,故不作讲解。

文章末尾有整个程序的示例代码

  1. 定义 Lexer

需要识别的词法单元如下:

类别描述
keyif、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 可以看出,虽然有五种类型,但是实际只需要创建两个类:IDUint,双分界符与单分界符最多只有两个字符,完全可以直接当做 char 处理,没必要在创建一个类。那么下面就来实现 IDUint,但是之前先要说 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

如果本文出现任何错误,欢迎指正。

如果你有任何问题,欢迎评论,作者看到了会第一时间回复。

如果本文对你有用,希望你点赞收藏加关注,并留下你宝贵的评论,希望让更多的人能看到,谢谢。

  • 12
    点赞
  • 28
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值