输入类型

上一节中我们定义了一个带泛型的函数。实际中我们对泛型“使用”的多,“定义”的少。本章再讨论一个“使用”的示例,就是整个解释器的输入类型,即词法分析模块读取源代码。

目前只支持从文件中读取源代码,并且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。整理下来内容并不多,但在开始的实现和探索过程中,东撞西撞,费了不少劲。这个过程中也彻底搞清楚了标准库中的一些基本类型,比如ReadBufReadBufReader,也发现并学习了CursorPeekable类型,另外也更加了解了官网文档的组织方式。通过实践来学习Rust语言,正是这个项目的最终目的。