【译】我最喜欢的 Rust 函数签名

我最喜欢的 Rust 函数签名

最近,我对写解析器很感兴趣,而 Rust 已被证明是非常适合写解析器的语言。在我的探索过程中,我想到了以下几点:

fn tokenize<'a>(code: &'a str) -> impl Iterator<Item=&'a str> {
  ...
}

这真的加深了我对 Rust 的热爱。

这个函数是干什么用的?

对于那些不熟悉解析器的人来讲,token 化是解析器的第一步。它需要一个原始字符串作为输入,类似于下面这一行:

let a = "foo";

并将其转化为一些列有意义的符号,比如下面这样:

["let", "a", "=", "\"foo\"", ";"]

这个阶段并不复杂,但它简化了下一个阶段的思维模型:构建一个“抽象语法树”。它移除了源字符串中等式两边的空白符,将字符串和数字之类的元素抽离出来,并使下一步的代码处理更简洁。

如果你单独执行此操作,缺点是,你的解析器现在必须所有所有源代码两次。这可能不是最糟糕的:token 化并不是开销最大的操作。但这确实不理想,因此一些解析器将两次传递合而为一,以牺牲可读性为代价优化了性能。

Rust 版本的解析器是什么样的?

我在这里再拷贝一次函数签名作为引用:

fn tokenize<'a>(code: &'a str) -> impl Iterator<Item=&'a str> {
  ...
}

这里有一些操作。

在 Rust 中,&str 是一个“字符串切片”。它的本质是一个字符指针加长度。切片的内容保证是在有效的内存中的。&'a str 是一个具有生命周期的字符串切片。'a 代表了具体的生命周期。这里的生命周期描述了在一段时间内,保证该引用(和切片的所有内容)是合法的,并且在有效的活内存中。稍后会对此进行更多介绍。

Iterator<Item=&'a str> is an iterator over elements of type &'a str. This is a trait, though, not a concrete type. Rust needs a concrete type with a fixed size when you're defining something like a function, but luckily we can say impl Iterator<Item=&'a str>, which tells Rust, "fill in some type that implements Iterator<Item=&'a str>, to be inferred at compile-time". This is very helpful because in Rust there are lots and lots of different concrete types for Iterator; applying something like a map() or a filter() returns a whole new concrete type. So this way, we don't have to worry about keeping the function signature up to date as we work on the logic.

Iterator<Item=&'a str>&'a str 类型的元素迭代器。不过,它也是一个 trait,而非具体类型。Rust 中,在定义函数时,通常其参数需要是能确定大小的具体类型,但幸运的是,我们可以使用 impl Iterator<Item=&'a str>,这在编译推断时,告诉 Rust 编译器,“这个类型实现了 Iterator<Item=&'a str>”。这是非常有用的,因为在 Rust 中有很多很多不同的 Iterator 的具体类型;基于它可以调用 map() 或者 filter() 之类的函数,然后返回一个全新的具体类型。这样,我们就不用担心,在处理逻辑时要保持函数签名是最新的。

有什么优点?

好了,现在我们有一个函数,它接收一个字符切片引用作为参数,并返回一个字符串切片迭代器。这有什么特别的呢?主要有以下两点原因。

迭代器可以允许你将一次传递当做两次

还记得我之前提到的吗,传统上你必须在分离 token 再传递和实现所有逻辑后单次传递这两种方式中做出选择?而使用迭代器,你可以做到两全其美。

当这个函数完成时,它其实还没有迭代字符串。它没有在内存中分配任何类型的集合。它返回一个结构,该结构准备遍历输入字符串切片并生成新的结构。当这个值随后被 map() 再传递给其他诸如 filter() 等实现了 Iterator 转换的处理函数,整个过程中会交叉执行,而循环的方式会有效地折叠成单一的循环。通过这样做,我们能够获得 token 传递("pass")的干净抽象,而不需要二次循环的运行时开销。

其他语言也有迭代器。但 Rust 的迭代器功能会更强大,符合人体工程学,而且这还不是唯一的功能特性。下一部分讲的是 Rust 中非常独特的特性。

生命周期让你毫无负担的共享引用

tokenize() 函数不会为 token 集合的操作分配新的内存。太好了,但不太明显的是,它也没有为 token 本身分配任何内存!因为每个 token 字符串切片都是 指向原始字符串的切片的指针

当然,这在 C/C++ 中也能做到,但是会有风险:如果原始字符串被释放之后访问这些 token,这将会产生一个内存错误。

例如:假设你打开一个文件并从中加载源码,然后将结果存储在一个本地变量中。接着对其执行 tokenize(),并将 token 发送到那个变量所在函数之外的某个地方,瞧,你会得到释放后使用的错误

防止这种情况发生的一个方法是将每段字符串复制到一个新的字符串中,新的字符串存储在堆上,这样就可以在原始字符串消失后还能将其安全地传递下去。但是这样做有一定的代价:创建、拷贝以及对新字符串的操作都需要时间(和内存)。实现这些逻辑的代码在编写时也必须意识到它负责分配内存给这些字符串,否则会出现内存泄漏。

这正是生命周期的魔力发挥作用的地方。

Rust 完全可以防止上述情况的发生。通常情况下,为了完成这个任务,函数的入参类型是 &str,我们假定它是静态的(生命周期足够长),或者说在程序执行的整个过程中它都是存活的。这种分配状态,就像,你在 Rust 代码中手写的一个字符串字面量一样。Rust 不知道,在函数的上下文中,该引用的有效期是多久,因此需要通过生命周期参数保守地推断值(内存)的生命周期。

然而,小小的字符 'a 表明:“这些东西(变量)在某个时间段内是存活的”。我们可以断言源代码字符串的生命周期至少和引用它的 token 的生命周期一样长。通过这样做,Rust 可以推断出这些 token 结果的引用是合法的,因此不必假定它们是静态的!我们可以对这些 token 做我们想做的任何操作,编译器会保证引用始终指向有效的东西,即使源代码是运行时动态加载的(从一个文件或其他地方)。如果我们在后续通过编译器提示的错误发现它们确实需要比源字符串生存更久,到那时我们可以再拷贝它们(“取得所有权”)。如果编译器没有强迫我们做这些,证明这些引用是安全的,于是就可以继续使用最有效的办法,无所畏惧。

What we've effectively done is written the most optimistic possible function (in terms of memory safety), with no downsides, because the Rust compiler will tell us if we're misusing it and force us to then "step down" to whatever level of extra accommodation is needed.

我们最有效的实现就是在一定的乐观条件的前提下写一个函数(就内存安全而言),没有缺点,因为如果我们错误的使用了它,Rust 编译器会给出提示,然后强迫我们“debug 追踪”到需要修改的地方直到修复为止。

结论

我已经使用(并且喜爱)Rust 大概一年半了。喜欢它的很多特性,但是当我开始使用这个功能的时候,我能立即看出它区别于其他语言的特殊之处。在任何的其他语言中,这是你无法同时做到的,a)安全性,b)高效。这就是 Rust 的强大之处。

posted @ 2020-09-28 09:52  suhanyujie  阅读(454)  评论(0编辑  收藏  举报