Rust2018版:生产力提升的开始

本文有 6722 字,大约需要 16 分钟可以读完, 创建于 2019-01-19

不同于传统的工业标准的C++/Java语言缓慢的更新节奏(最近几年大家都加快了演进的节奏),Rust从一开始就采用了滚动发布的模型, 基本上是按照固定的步伐每个六个星期就会放出来一个新版本,并且从正式发布了1.0开始,最近两年来一直是保持向后兼容的。 社区在2018年初的时候,就约定好会在18年发一个大版本出来,以便对外界宣布语言上面的一些重要变化来引起用户的关注;只是这个选择过程似乎一直难产, 之前一直时不时关注着每个新版本的进展,可惜一直没有等到官方宣布;幸好最终在接近年底的时候迟到但是没有爽约,官方将1.31版本宣布为正式的2018版本。

这个版本和其它小版本的不同在于,这种年份命名的版本的上一个还要追溯于2015年,所以在设计上有很多预留的考量,并且在工具上也给予了特殊照顾。 正式的版本是于12月中旬发布的,前面因为时间关系并没有投入多大精力来仔细关注变更和改进之处。好在过了一个月,社区中的介绍文字也多了起来, 可以在读官方文档的基础上,再查引其它作者的解读,并且尝试着按照文档的介绍,改写了自己之前缩写的代码,加深对这些改变的理解。

官方介绍

官方社区维护了一个关于年份版本的电子书,是用GitBook方式发布在语言项目的页面上, 包含了三个部分的介绍

  • 什么是“年份版本”以及它和每六周发布的常规版本的关系
  • 每个版本包含了那些内容,目前有2个版本,那么分别有两个章节介绍2015版本和2018版本
  • 如何做项目版本的迁移

年份版本的意义

年份版本的用意其实跟多是出于宣传的需要,相当于软件项目中的里程碑版本;如果没有这样吸引人眼球的打包处理方式,普通的用户可能或觉得无所适从, 因为每六个星期就发布一个版本的方法虽然更加敏捷,不想跟着升级的用户或者新用户就会觉得无所适从,不知道该选择哪个版本做项目。

年份版本其实是通过将一段时间内发布的语言特性和工具、库等集中起来,用特殊的版本标识符展现出来,给用户提供一个选择的基准。 在这个新版本之前,其实只有一个2015版本,并且被设置为是默认的版本,因而如果抛去默认版本不算,2018版本可以算作是第一个新版本。 从编译器的角度来看,所有在这个编译器版本对应的小版本被发布之前所提供的语言特性都是可以支持和编译的,不同的依赖库也可以用不同的版本编译和链接,都没有问题。

从兼容性上来说,官方保证了所有的新版本都是后向兼容的,并且有些关键的语言特性也会向后加入到老的版本中。 大部分情况下如果吃不准,总是用新的版本就没错了。

新版本支持一个新的cargo属性,默认没有设置的情况下,编译器会按照2015版本来解析和编译代码;如果需要使用新版本,需要指定如下的属性

edition="2018"

新版本的改进可以看做两个部分,一个是语言特性上的改进就是纯粹的语言改进,另一个是工具链和生态系统中重要软件和工具的改进。 传统的编程语言版本更新往往只侧重于第一个方面,但是进入最近十几年的软件工程实践表明,一门语言要想维持和发展, 生态系统中工具的支持是至关重要的;Rust社区很明智的仔细考虑了这一趋势,并花了比较大的力气改进程序员的生产效率。

Rust语言本身的改进

语言特性本身的改进比较多,大的特性方面主要包括如下几个

非作用域生命周期 (NLL)

这个特性其实主要是用于解决变量访问的引用检查过于严格引起的”误杀”的问题,提高引用检查的智能化水平, 使一些人眼看起来完全没有问题的代码,也可以被编译器正确识别而编译通过,在不损失正确性的前提下,减少程序员取悦编译器的不方便之处,减少程序员的心智负担

具体来说,在老的语言版本中,下面的代码将无法通过编译器的引用声明周期检查

fn main() {
    let mut x = 5;
    let y = &x;

    //y not used any more
    let z = &mut x;
}

失败的原因在于,编译器的生存周期检查是和作用域绑定在一起的;在函数第二行的时候,我们声明了y是前面可变变量的一个不可变引用, 编译器认为从这一行开始到当前作用域结束(这里是函数作用域,靠大括号约束)前,这个不可变引用总是存在的。 因而在作用域没有结束之前,编译器又发现了对同一个变量的一个可变的引用,那么编译器无法放置潜在的访问冲突,为了保证安全,编译器选择不让代码编译通过。

事实上,写代码的程序员却很清楚,这里的y已经不再使用了,因此再声明一个新的可变引用是没有问题的。 因为编译器不知道,在老版本的编译环境中,程序员不得不想办法创建一个临时的作用域,绕过这个问题

let mut x = 5;
{
    let y = &x;

    //y not used any more, out of scope here
}
let z = &mut x;

对用户而言,这是一个比较麻烦的心智负担,新版本通过让编译器变得更聪明,来自动检测这一情况, 自动判断这里的y/z两个引用是进水不犯河水的,编译仍然可以畅通无阻。

如果用户犯了错误,在新的可变引用之后,还想重新使用前面的不可变引用,那么编译器依然会正确的拦截这一做法, 并且给出的编译错误也会更加直观一些,直接了当的告诉用户,两个引用冲突的点在于实际产生冲突的地方, 而不会像老版本一样给出相对混淆一些的标记于作用域结束处的错误信息。这样便极大地降低了新用户学习的门槛, 即使他们没有完全掌握引用检查的原理和机制,也可以按照编译器的指示讲代码修改正确。

基于这个改进对用户的影响实在太大,同样的改进也会在后续加入到2015版本中。

减少不必要的?重复

这个主要是用于清理宏中的复杂的样板代码,比如如下的宏定义因为第二个表达式部分是可选的,必须重复地写成复杂的形式

# #![allow(unused_variables)]
#fn main() {
macro_rules! foo {
    ($a:ident, $b:expr) => {
        println!("{}", $a);
        println!("{}", $b);
    }
    ($a:ident) => {
        println!("{}", $a); //similar like above
    }
}
#}

新版本力争优化这一重复,通过可选部分的 $(...)? 结构,让宏的设计者可以写出更干净的代码

# #![allow(unused_variables)]
#fn main() {
macro_rules! foo {
    ($a:ident $(, $b:expr)?) => {
        println!("{}", $a);

        $(
            println!("{}", $b);
         )?
    }
}
#}

不过宏的部分目前被很多人认为是语言设计中不太成熟的部分,不管写的很啰嗦,理解起来也很不直观, 距离成熟还有不断的路程要走;且看后面会怎样进一步发展。

模块系统和库路径的简化

模块系统是Rust里面比较让新手感觉迷惑的一块知识;虽然它的基本规则还是比较清晰和直观的, 但是当多个复杂的概念混合在一起的时候,滋生出来的一些复杂的情况却往往违反人的直觉, 这一块明显会成为新手学习的”拦路虎”,使很多人忘而退却。 新版本通过引入一些小的变化来放松一些死板的规则带来的约束,宁可让编译器处理稍微复杂一点,对用户的要求更宽松一些,降低使用的门槛。

主要的变化在于

  • 删除了大部分强制的extern crate声明,大部分情况下,只要在配置文件中引入了依赖的模块,代码里面就可以直接使用了,不用再啰嗦地声明一大堆的extern crate
  • crate关键字指向当前的模块
  • 模块名来标记其它引用模块的绝对路径,而用`crate`来标记当前模块
  • 模块嵌套子模块的情况下,同名文件夹和文件夹名字命名的模块声明文件可以同时存在,不需要在文件夹下面再专门声明一个mod.rs

去除臃肿的外部模块声明

第一个改动非常直观,原来版本中繁琐而笨拙的一大堆extern crate xxx可以全部删掉了,留下use xxx::yyy; 这里有两个例外情况,对应于系统模块中的crates,分别是stdcore这两个模块; 前者已经被编译器默认当做隐式包含而用户不需要指定(否则可以设想代码会变得臃肿不堪),而core模块大部分情况下也不会用到。 还有一个例外情况是proc_macro的使用,显然这些都是非常罕见的情况。

模块和路径

对于复杂的库实现来说,为了封装内部的细节,往往需要借助于子模块嵌套的办法实现更好的逻辑拆分以提高可维护性; 而旧版本的含糊的子模块规则往往会把初学者绕晕,尤其是当某个嵌套很深的子模块需要引用比较靠顶层的模块中的结构或者函数时, 必须将库本身的名字填上来,或者用super::super这样的方式来绕过重命名模块名带来的尴尬。 新版本通过指定crate本身总是指向根路径来简化重命名操作带来的代码改动。

具体来说,假设我们有这样的模块结构

#inside a libary named "mylib"
lib
|-- top.rs
|-- foo
    |-- funfoo.rs
    |-- bar
        |-- funbar.rs
        |-- sub
            |-- foosub.rs

当我们在最内层的foosub.rs里面想引用top.rs里面定义的top_func,则必须要在代码里面写做

//inside foosub.rs 

//old 2015 edition code, need to adapt for module renaming
use mylib::top::top_func;

//or use super to indirect, no code change needed for renaming
use super::super::super::super::top::top_func;

而新的语言版本允许我们简单地写作

use crate::top::top_func;

无需啰嗦的mod.rs

这个小改进也是一个使用上的简化,更符合人的直觉一点;反过来看,老的版本必须强制程序员在创建子模块的时候, 在文件夹里面添加一个modrs,并且上层目录里面不准有和文件夹同名的源代码文件出现,反而有点麻烦了。 现在可以很愉快地写这样的目录结构了

lib
|-- foo.rs
|-- foo //same module and rs co-exist!
    |-- bar.rs 
    |-- bar
    //no need to put dummy mod.rs here!

统一的use语法

老版本允许一些含混的use声明,尤其是在某些情况下允许声明文件中定义的默写子模块函数,而新版本统一了可以被声明使用的具体类型,只能是如下几者之一

  • 正式的模块名字
  • self用来表明是当前文件中的定义
  • super用来指明是上一级文件目录中的子模块的定义
  • crate用来指明是本模块顶层路径下的子模块

因此相应的代码变化是

mod foo{
    pub struct Bar {...}
}
// use foo::Bar; //valid in 2015 but not valid in 2018
use crate::foo::Bar; //must specify top level 

在不引入更多麻烦的情况下,一致性总是好于过分混乱的简洁;这样才能更好的减少用户的心智负担。

去除匿名trait类型的参数

这个小改动也是为了提高代码的一致性,虽然稍微复杂了一些。具体来说,就是老的版本允许这样写

trait Foo {
    fn foo(&self, u8);
}

新版本必须给一个参数名字,或者用_来代替

trait Foo {
    fn foo(&self, baz: u8); //use _:u8 if not used inside 
}

关键字上的改进

  1. dyn被提升为一个更严格的关键字,而之前的版本中它是一个弱关键字;这意味着编译器会做更多的检查, 确保它没用被使用在不合适的上下文中,如变量和函数声明,结构属性定义,类型参数,生命周期限定,宏,模块等。
  2. 预留了下面几个关键字,主要原因还是对应的功能没用实现完整,先占着再说
    1. async 用于异步编程的异步函数声明
    2. await 用于异步等待,和前一个结合支持协程;这方面的机制在很多语言中都支持的比较完善了
  3. try 这方面的处理还不是很成熟,还需要观察后续语言如何进一步演化

编译检查改进

编译器检查列表中新加了2个默认的编译检查,如果不符合则会被编译器标记为拒绝

  1. 整数字面量越界(向上溢出)检查 - 简单来说,给出的整数值超出了类型所能表达的范围的时候编译器会明确的拒绝; 这会破坏一些从C语言迁移过来的编程习惯,比如把-1赋值给一个无符号8位数,就会被认为是越界。
  2. 未指明类型信息的裸指针解引用会被明确拒绝,避免一些低级而诡异的错误,使得底层编程更加安全

大部分日常使用中的程序都不会被上面的2个新的检查影响到。

工具的改进

新版本带来了不少新的工具来提高程序员的生产效率;包括官方的打包工具,格式检查,新语言特性风格上的检查 (类似于有名的质量检查工具sonar),可惜的是不少工具还处于preview阶段,距离正式推出还需要一些时间。

cargo工具

这一官方的打包和测试工具做了如下的改动

  1. target发现机制在用户制定了target属性的情况下,还会继续扫描和发现其它的平台架构
  2. 没有给出path属性的情况下,即使代码里面有src/{target_name}.rs的存在,cargo也不会自动推断对应的路径;这个改动应该是修复一些已有的bug
  3. 默认情况下,install不会再安装于默认的当前路径,如果用户坚持想这么做,那么必须提供“--path .”参数来显示指定。

fmt工具

目前自动格式化代码的工具还处于预览阶段,没有放在正式的rustup component中,需要通过添加-preview后缀来获取。 看起来新的编程语言都不约而同地强迫大家用同一种编程风格,减少对编码风格的争吵,无疑是比较明智的; 因为绝大部分人一开始在学习一门语言的时候,并没有很强烈的习惯支撑,而编码风格本身又无所谓好坏, 先入为主是个很普遍的现象,减少这方面的无畏的精力消耗,没有什么不好。

clippy代码检查工具

clippy 是一个用于Rust的代码质量检查工具,目前也是处于preview阶段, 尤其是Windows上需要加preview才可以下载使用。它本身已经提供了超过290个检查项, 对于每个失败的检查项,它还会提供链接告诉你应该怎么去修改,不少检查是基于对语言更深入的理解才能做到的, 因此运行的时候需要先编译项目的代码。

clippy自己定义了一些检查集供用户选择,包括

  • style 检查会检查是否遵循语言本身定义的一些惯用法
  • complexity 则检查是否代码的复杂度可以降低却用了一些难以理解的代码
  • perf 检查判断是否代码可以使用更为高效的方式来实现
  • correctness 检查代码的行为是否总是正确的,其中如果发现无用的代码也会给出警告
  • cargo 附带检查项目的cargo声明文件是否符合规范
  • nursery开启一些尚未稳定下的检查项
  • pedantic模式会开启所有的检查项

项目本身配置通过clippy.toml或者.clippy.toml来指定具体的开关,打开哪些检查项,配置复杂度阈值, 设置黑名单等功能。用户也可以通过相应的代码属性,加入allow/deny/warn宏的方式,按需开启或者跳过某段代码的检查。

该项目的文档可以从这里找到。

一点遗憾

新的2018版本带来的改动不光有语言特性上的增强,还有侧重于生产率方面的工具增强和一致性改进。 略微有些美中不足的是,异步编程和协程的支持还没有很完备,不得不通过预留关键字的做法留待后续版本继续解决。

另外一个没有提到的方面是关于Windows上工具链的支持和某些大的IDE工具对Nightly版本的依赖。 尤其是RLS/Racer这两个比较重要的工具时不时会出现循环的重启,还必须依赖于Nightly版本,继续社区加大bug改进力度,早日加到文档版本中去。

Leave a Comment

Your email address will not be published. Required fields are marked *

Loading...