输入类型
上一节中我们定义了一个带泛型的函数。实际中我们对泛型“使用”的多,“定义”的少。本章再讨论一个“使用”的示例,就是整个解释器的输入类型,即词法分析模块读取源代码。
目前只支持从文件中读取源代码,并且Rust的文件类型std::fs::File
还不包括标准输入。词法分析数据结构Lex的定义如下:
pub struct Lex {
input: File,
// 省略其他成员
读字符的方法read_char()
定义如下:
impl Lex {
fn read_char(&mut self) -> char {
let mut buf: [u8; 1] = [0];
self.input.read(&mut buf).unwrap();
buf[0] as char
}
这里只关注其中的self.input.read()
调用即可。
使用Read
而Lua官方实现是支持文件(包括标准输入)和字符串这两种类型作为源代码输入的。按照Rust泛型的思路,我们要支持的输入可以不限于某些具体的类型,而是某类支持某些特性(即trait)的类型。也就是说,只要是字符流,可以逐个读取字符就行。这个特性很常见,所以Rust标准库中提供了std::io::Read
trait。所以修改Lex的定义如下:
pub struct Lex<R> {
input: R,
这里有两个改动:
- 把原来的
Lex
改成了Lex<R>
,说明Lex是基于泛型R
, - 把原来的字段input的类型
File
改成了R
。
相应的,实现部分也要改:
impl<R: Read> Lex<R> {
加入了<R: Read>
,表示<R>
的约束是Read
,即类型R必须支持Read
trait。这是因为read_char()
的方法中,用到了input.read()
函数。
而read_char()
方法本身不用修改,其中的input.read()
函数仍然可以正常使用,只不过其含义发生了细微变化:
- 之前input使用
File
类型时,调用的read()
函数,是File
类型实现了Read
trait的方法; - 现在调用的
read()
函数,是所有实现了Read
trait的类型要求的方法。
这里说法比较绕,不理解的话可以忽略。
另外,其他使用到了Lex的地方都要添加泛型的定义,比如ParseProto定义修改如下:
pub struct ParseProto<R> {
其load()
方法的参数也从File
修改为R
:
pub fn load(input: R) -> Self {
支持了Read
后,就可以使用文件以外的类型了。接下来看看使用标准输入类似和字符串类型。
使用标准输入类型
标准输入std::io::Stdin
类型是实现了Read
trait,所以可以直接使用。修改main()
函数,使用标准输入:
fn main() {
let input = std::io::stdin(); // 标准输入
let proto = parse::ParseProto::load(input);
vm::ExeState::new().execute(&proto);
}
测试来自标准输入的源代码:
echo 'print "i am from stdin!"' | cargo r
使用字符串类型
字符串类型并没有直接支持Read
trait,这是因为字符串类型本身没有记录读位置的功能。可以通过封装std::io::Cursor
类型来实现Read
,这个类型功能就是对所有AsRef<[u8]>
的类型封装一个位置记录功能。其定义很明确:
pub struct Cursor<T> {
inner: T,
pos: u64,
}
这个类型自然是实现了Read
trait的。修改main()
函数使用字符串作为源代码输入:
fn main() {
let input = std::io::Cursor::new("print \"i am from string!\""); // 字符串+Cursor
let proto = parse::ParseProto::load(input);
vm::ExeState::new().execute(&proto);
}
使用BufReader
直接读写文件是很消耗性能的操作。上述实现中每次只读一个字节,这对于文件类型是非常低效的。这种频繁且少量读取文件的操作,外面需要一层缓存。Rust标准库中的std::io::BufReader
类型提供这个功能。这个类型自然也实现了Read
trait,并且还利用缓存另外实现了BufRead
trait,提供了更多的方法。
我最开始是把Lex的input字段定义为BufReader<R>
类型,代替上面的R
类型。但后来发现不妥,因为BufReader
在读取数据时,是先从源读到内部缓存,然后再返回。虽然对于文件类型很实用,但对于字符串类型,这个内部缓存就没必要了,多了一次无谓的内存复制。并且还发现标准输入std::io::Stdin
也是自带缓存的,也无需再加一层。所以在Lex内部还是不使用BufReader
,而是让调用者根据需要(比如针对File
类型)自行添加。
下面修改main()
函数,在原有的File
类型外面封装BufReader
:
fn main() {
// 省略参数处理
let file = File::open(&args[1]).unwrap();
let input = BufReader::new(file); // 封装BufReader
let proto = parse::ParseProto::load(input);
vm::ExeState::new().execute(&proto);
}
放弃Seek
本节开头说,我们只要求输入类型支持逐个字符读取即可。事实上并不正确,我们还要求可以修改读位置,即Seek
trait。这是原来的putback_char()
方法要求的,使用了input.seek()
方法:
fn putback_char(&mut self) {
self.input.seek(SeekFrom::Current(-1)).unwrap();
}
这个函数的应用场景是,在词法分析中,有时候需要根据下一个字符来判断当前字符的类型,比如在读到字符-
后,如果下一个字符还是-
,那就是注释;否则就是减法,此时下一个字符就要放回到输入源中,作为下个Token。之前介绍过,在语法分析中读取Token也是这样,要根据下一个Token来判断当前语句类型。当时是在Lex中增加了peek()
函数,可以“看”一眼下个Token而不消费。这里的peek()
和上面的putback_char()
是处理这种情况的2种方式,伪代码分别如下:
// 方式一:peek()
if input.peek() == xxx then
input.next() // 消费掉刚peek的
handle(xxx)
end
// 方式二:put_back()
if input.next() == xxx then
handle(xxx)
else
input.put_back() // 塞回去,下次读取
end
之前使用File
类型时,因为支持seek()
函数,很容易支持后面的put_back
函数,所以就采用了第二种方式。但现在input改为了Read
类型,如果还要使用input.seek()
,那就要求input也有std::io::Seek
trait约束了。上面我们已经测试的3种类型中,带缓存的文件BufReader<File>
和字符串Cursor<String>
都支持Seek
,但标准输入std::io::Stdin
是不支持的,而且可能还有其他支持Read
而不支持Seek
的输入类型(比如std::net::TcpStream
),如果我们这里增加Seek
约束,就把路走窄了。
既然不能用Seek
,那就不用必须使用第二种方式了。也可以考虑第一种方式,这样至少跟Token的peek()
函数方式保持了一致。
比较直白的做法是,在Lex中增加一个ahead_char: char
字段,保存peek到的字符,类似peek()
函数和对应的ahead: Token
字段。这么做比较简单,但是Rust标准库中有更通用的做法,使用Peekable。在介绍Peekable之前,先看下其依赖的Bytes类型。
使用Bytes
本节开头列出的read_char()
函数的实现,相对于其目的(读一个字符)而言,有点复杂了。我后来发现了个更抽象的方法,Read
triat的bytes()
方法,返回一个迭代器Bytes
,每次调用next()
返回一个字节。修改Lex定义如下:
pub struct Lex<R> {
input: Bytes::<R>,
相应的修改构造函数和read_char()
函数。
impl<R: Read> Lex<R> {
pub fn new(input: R) -> Self {
Lex {
input: input.bytes(), // 生成迭代器Bytes
ahead: Token::Eos,
}
}
fn read_char(&mut self) -> char {
match self.input.next() { // 只调用next(),更简单
Some(Ok(ch)) => ch as char,
Some(_) => panic!("lex read error"),
None => '\0',
}
}
这里read_char()
的代码似乎并没有变少。但是其主体只是input.next()
调用,剩下的都是返回值的处理,后续增加错误处理后,这些判断处理就会更有用。
使用Peekable
然后在Bytes
的文档中发现了peekable()
方法,返回Peekable
类型,刚好就是我们的需求,即在迭代器的基础上,可以向前“看”一个数据。其定义很明确:
pub struct Peekable<I: Iterator> {
iter: I,
/// Remember a peeked value, even if it was None.
peeked: Option<Option<I::Item>>,
}
为此,再修改Lex的定义如下:
pub struct Lex<R> {
input: Peekable::<Bytes::<R>>,
相应的修改构造函数,并新增peek_char()
函数:
impl<R: Read> Lex<R> {
pub fn new(input: R) -> Self {
Lex {
input: input.bytes().peekable(), // 生成迭代器Bytes
ahead: Token::Eos,
}
}
fn peek_char(&mut self) -> char {
match self.input.peek() {
Some(Ok(ch)) => *ch as char,
Some(_) => panic!("lex peek error"),
None => '\0',
}
}
这里input.peek()
跟上面的input.next()
基本一样,区别是返回类型是引用。这跟Lex::peek()
函数返回&Token
的原因一样,因为返回的值的所有者还是input,并没有move出来,而只是“看”一下。不过我们这里是char
类型,是Copy的,所以直接解引用*ch
,最终返回char类型。
小结
至此,我们完成了输入类型的优化,从最开始只支持File
类型,到最后支持Read
trait。整理下来内容并不多,但在开始的实现和探索过程中,东撞西撞,费了不少劲。这个过程中也彻底搞清楚了标准库中的一些基本类型,比如Read
、BufRead
、BufReader
,也发现并学习了Cursor
和Peekable
类型,另外也更加了解了官网文档的组织方式。通过实践来学习Rust语言,正是这个项目的最终目的。