Rust编程语言初探

本文有 18789 字,大约需要 46 分钟可以读完, 创建于 2015-11-28

静态、强类型而又不带垃圾收集的编程语言领域内,很久没有新加入者参与竞争了,大概大部分开发者认为传统的C/C++的思路已经不太适合新时代的编程需求,即便有Ken Tompson这样的大神参与设计的golang也采用了GC的思路来设计其新一代的语言;一方面垃圾收集技术和即使编译技术一直在发展和完善,另一方面是大量的未经过严格计算机科学基础训练的开发人员进入市场,似乎让开发者永远停留在逻辑层面而不是去直接操纵内存是个更为现代的选择,Mozilla却仍然坚信一门静态而又高效低利用系统资源的“偏底层”的语言也依然会有巨大的生命力;于是站在现代成熟的软件工程实践上的Rustlang(以下简称Rust)被创造出来,其新版本的发布不时引起HackNews等极客圈的关注。

本文试图通过其官方文档对该语言(以及其相关的生态系统)做简单的研习。

核心语言特性设计目标

按照其官方描述,Rust需要满足其以下几个核心目标

  1. 使用于系统编程场景 - 这意味着能够有直接访问操作系统基础设施和硬件的能力。
  2. 开源参与和协作 - 毕竟其背后的推动者是创造了第一代浏览器的Mozilla,比任何商业公司更懂得依靠开源社区的力量
  3. 安全而又高效 - 在现代的软件工程环境中,安全是不可或缺的,而面向系统编程场景的语言必然少不了对性能的极致要求
  4. 充分利用现代多核和并发处理技术的能力 - 这也是传统的C/C++语言的软肋所在;同时也是Google的Golang的设计目标之一
  5. 容易学习的语法 - 减少类似于段错误或隐式的多线程编程等相对底层的细节应该尽量被隐藏

根据以上目标可以相对容易的理解一些核心的语言设计策略背后的决策依据。

基本语法特性

作为一门面向系统编程的偏底层的程序语言,其基本语法和传统的C/C++/Java系列语言共享了很多共同之处,这里仅需要看看其不同之处。

类型系统

静态语言的基本元素之一是变量和类型;不同的语言会选择不同的类型定义和内置的开箱可用的基本类型;这些类型及内置类的设计往往反映了编程语言设计者的决策策略和权衡要素。

类型声明和自动推断

毕竟要面对的是偏严肃的系统编程领域,选择静态类型可以在编译阶段尽可能早地发现更多的程序错误是题中之义;同时作为一门比较现代的编程语言,每次让程序员自己输入每个变量的类型这类臃肿的做法也被废弃,自动类型推断必不可少 - 当编译器可以”聪明地”推导出合适的类型的时候,变量类型指定可以忽略。

譬如需要声明一个某种类型的变量,Rust用let x: someType = <some value>来表示;当然对于编译器可以推导出来类型的情况下,类型是可以省略的这样可以少写一些啰嗦的代码,let x = 2就会定义一个整数类型的变量x;比较新的编程语言基本都是这么做的,没有什么新意。

作为一门强类型的语言,任何变量或者表达式必须有唯一的类型,否则编译的时候就会报错。当然Rust支持一种特殊的变量隐藏机制(Shadow),即同一个名字的变量可以重新使用,并设置为一个完全不同的类型;这个时候原来的变量就不能被访问了。如

let var = "something"; //string literal
let var = 1; //changed to int

这种机制从某种程度上来说,反而会使代码变得不太容易理解,如果程序员习惯了C/C++的编程方式的话;同时也会给IDE等工具的解析带来一些挑战;当然这个是仁者见仁智者见智的事情。

类型可变性约束

Rust要求所有定义的变量必须指定是否是可变的;并且作为变量的基本特征强制程序员做合理的选择。 可变的变量用类似let mut varName:Type = value的语法来定义,顾名思义可以在声明之后被重新赋值修改; 而不可变的变量少了一个mut关键字,其定义的变量在初始化一次后续就不能再修改了。

Rust里边同时支持常量类型,用const来声明,像是从C++里借鉴来的。它和可变类型mutable有一些细微的不同: 对于常量类型我们必须使用类型注解,不能声明可变的常量类型(不允许混合constmut),而且常量类型只能被赋值为一个常量表达式, 不能用函数调用的结果或者是其他一些运行时计算出来的值来初始化。 综合来看,Rust的常量类型和C++11中新引入的constexpr行为比较接近。

内置类型

内置类型一般用于提供大部分程序员都要用到的基本数据结构。除了一些其他语言都常见的基本类型(Rust称之为标量类型),Rust也提供了一些相对比较复杂的类型。

基本标量类型包含以下这些基本的类型

  • 整型类型,包括定长的8/16/32/64位的有符号和无符号类型(如u16是无符号16位整型,i32是有符号32位类型), 还支持平台相关的有符号/无符号类型,分别用isizeusize表示
  • 浮点类型,支持单精度(f32)和双精度(f64)类型,这些在数值计算的时候比较关键
  • 布尔类型,和C++中的比较类似,有truefalse两种可能的取值,当然没有C/C++中的那些隐式转换的麻烦
  • 字符类型,支持Unicode

复合类型`

比较新一点的语言都支持一些复杂一点的基本组合类型。

tuple和其它语言的比较类似,用括号语法来声明,基本用法可以看下边这个简单的例子

let tup = (1, 2.2, "something")
let (a, b, c) = tup
let secondElem = tup.1

第一行代码声明一个含有三个不同类型的元素的元组;第二行代码则将元组中的元素逐一取出,和Python的用法比较类似。 除了这种提取方式,元组元素也可以用点语法来访问元素,如上边的第三行代码则用tup.1则取出第二个元素; 比C++11的模板元语法简单多了。

数组则用于表示具有相同类型的元素的集合,如let arr = [1, 2, 3, 4, 5],如果类型不一致则会有编译错误报出。 和C/C++这中的类似,数组元素一般是分配在栈上的,其大小在编译器应该是预先确定的;如果需要可变长的容器,则需要Vector类型。 数组越界的检查默认也包含在语言中了,如果访问越界的下标,默认程序就会崩溃;当然Rust的错误处理机制也有些特殊,容后探讨。

容器类型

Rust支持以下基本的容器类型

  • Vector 该类型用于存储逻辑上的列表类型,其正式名字是Vec,用Vec::new()创建空的向量,因为其是用泛型实现的,我们必须指定类型注解; 即是用 let v: Vec<i32> = Vec::new() 来生成一个新的向量v

  • String作为一个库提供的而不是基本的语言机制;其实现和C++的比较类似,内部也使用一个Vec<u8>来存储数据的,因此考虑到国际化的原因,其操作可能比其它语言中的要复杂一些;幸运的是,这些细节以及被标准库所封装。

  • Hashmap 用于表述逻辑上的哈希关联容器;其提供的API和C++/Java的比较类似,功能上比C++的复杂一些但比Java的更精简一点

函数

作为基本编程要素的函数在Rust中的定义没有什么特别特殊的地方,除了其类型声明是后置风格之外,其返回类型(如果不能被自动推断)用->来声明,比如

//一个返回int类型的函数
fn thisIsAFunction(parA: i32, parB: string) -> int {
    //some implementation
}

函数的实现体本质上是一个block,由一系列的表达式组成(当然表达式也是用分号分隔的),同时它还支持Ruby风格的自动返回最后一个表达式的写法, 仅仅需要最后一个表达式省略分号即可;比如这个简单的函数

fn five() -> i32 {
    5
}

懒惰是伟大程序员的优良品质嘛。由于我们有内置的tuple类型,因此Rust是可以允许有多个返回值的; 比较典型的一个场景是用户错误处理的情况,可以返回一个Result,同时携带错误码和可能的原因;稍后会仔细看一下异常处理的部分。

函数和宏

Rust本身支持语法层面的宏,并且其标准库提供了很多各种各样的宏,譬如最常用的打印函数其实就是一个宏;所有的宏使用!后缀来区分。 println!("The value of x is {}, y is {}", x, y)用于打印出x和y的值;其语法形式非常像一些常见的Java库所支持的格式,可以用大括号来打印对象。

宏是在编译的早期阶段被展开的,和C中的宏原理类似,虽然Rust的语法看起来更简洁一些;但是依然有很多新的语法构造,简单来说可以认为Rust的宏是用macro_rules和模式匹配来实现的。

从可维护的角度来说,应该做好折中因为宏代码往往意味着更难理解和调试。很多时候,需要将宏作为最后一种不得已而为之的措施。 比C中的宏好一点的是,Rust提供了对宏进行调试的方式,可以在其编译器的命令行中加入--pretty expand选项来查看展开的代码。

错误检查机制

现实生活中的软件总是有各种各样的错误需要被正确处理但没有被及早处理就泄漏到了客户现场。 Rust采用的设计思路是,尽早强迫程序员去显示处理并以编译器错误的方式提示程序员。

和Java的关于错误分类的思路类似,Rust也区分可恢复的错误和不可恢复的错误,并提供了相应的语言机制上的支持。 可恢复的错误一般是一些环境的错误,譬如文件找不到或者网络连接失败等情况,实现上可以用重试等策略来尝试自动恢复。 不可恢复的错误往往意味着编程错误或低级bug,这种情况下最好的思路是直接让程序崩溃,并修复代码。

和Java不同的是,Rust里没有异常支持!对于可恢复异常,Rust使用Result<T, E>类型来封装处理结果,而不可恢复异常则提供panic!宏来终止程序继续执行。

不可恢复异常的支持

遇到不可恢复异常的时候,panic!宏会打印错误消息(程序员指定),展开线程栈帧,打印出实际出错的源代码位置。 如果需要打印backtrace信息,则可以在程序运行前设置环境变量RUST_BACKTRACE。如果忘记设置的话,默认的打印输出会给出温馨的提示。

如果不希望展开栈帧而直接暴力终止程序,可以在Cargo.toml中指定

[profile.release]
panic='abort'

可恢复异常

可恢复异常用一个泛型类Result来传递结果,其定义是

enum Result<T, E> {
    Ok(T),
    Err(E)
}

可以使用枚举类型的模式匹配(见后述) 来优雅的解决,譬如这个操作文件的例子

use std::fs::File;

fn main() {
    let f = File::open("hello.txt");

    let f = match f {
        Ok(file) => file,
        Err(error) => {
            panic!("There was a problem opening the file: {:?}", error)
        },
    };
}

Rust支持一种更简洁的方法来简化上述的样板代码let f = File::open("hello.txt").unwrap()则返回正常情况下的返回值,如果有异常则直接调用panic!来终止程序。 还有一种更”偷懒/简洁”的做法是,加上额外的描述字符串 - 大部分情况下出错了我们总想额外打印一些信息,可以用

let f = File::open("hello.text").expect("Unable to open file...")

异常的传递和扩散

这是一个常见的场景,某个API的使用者不想自己去处理异常场景,仅仅想将其传递给自己的调用者去处理,或者程序中有个统一的地方处理异常(通常来说可能不是一个好的主意!)。 最基本的思路是,直接将异常返回的类型签名写出来,显示让调用者处理。

下边这段代码实现读入一个文件,从里边读取某个字符串,如果成功则返回该字符串,期间有任何错误,则传递给调用者。

fn read_username_from_file() -> Result<String io::Error> {
    let f = File::open("hello.txt");
    let mut f = match f {
        Ok(file) => file,
        Err(e) => return Err(e),
    };

    let mut s = String::new();
    match f.read_to_string(&mut s) {
        Ok(_) => Ok(s),
        Err(e) => Err(e),
    }
}

Rust提供了一种更简洁的方式(惯用法) - 用"?"操作符来传递错误,类似的代码可以重写为

fn read_username_from_file() -> Result<String io::Error> {
    let mut f = File::open("hello.txt")?;
    let mut s = String::new();
    f.read_to_string(&mut s)?;
    Ok(s)
}

需要注意到上边的代码使用了block的写法省略return关键字。

如果追求更精简的代码,我们甚至可以用一行代码来完成上述的函数体

let mut s = String::new();
File::open("hello.txt")?.read_to_string(&mut s)?;
Ok(s)

是否有种熟悉的函数式编程的链式写法的味道?

内存访问模型和并发

作为一门面向系统编程的语言,Rust决定了不使用GC,同时基于工程上的原因,让工程师自己来管理内存又显得不符合时代潮流。 Rust采用的策略是让程序员提供一定的指示给编译器,然后由编译器来确保内存的分配和访问总是安全的。

对于Rust程序用而言,理解堆和栈以及对象的生存期/作用域是必须的,虽然编译器在后台做了很多工作。 为了支持其内存安全和高效约束的目标,Rust提供了一些特殊的语言机制,包括其独特的对象唯一所有权的概念和引用语法,其智能指针的概念也比较有特色。

从语法的角度来看,Rust取消了->操作符,因此所有的方法调用都是采用obj.doSth()的方式;这点没什么惊喜,没有了C的后向兼容负担,基本上新的语言都是这么干的。在语言层面上,Rust仍然有引用类型的概念;由于要借助编译器来管理内存,Rust的对象作用域规则有些特殊。

对象的唯一Ownership

默认每个对象都是有唯一的所有权的,这个贯穿在Rust的基本设计规则中

  1. 任何一个值(基本类型或对象)都唯一关联一个变量,这个变量被称为其Owner
  2. 任何一个时间点,同一个值仅仅有一个Owner
  3. 当其Owner离开作用域的时候(无法被程序再次访问),值将会被从内存中释放

举个简单的例子,当我们声明let s = "hello world"的时候,字面量"hello world"的Owner就是s本身;当s离开作用域的时候,对应的字面量空间就会被释放。 作用域的概念和传统的C/C++/Java中的很类似,大部分情况下,是通过大括号来限定作用域的。

比较特殊一点的情况和变量的shadow有关,当一个变量通过shadow的方式重新指向另外一个对象的时候,原来的值因为失去了Owner也应该被编译器悄悄释放了; 当然这里行为仍然是安全的,因为程序没有通过其它办法再访问原来的值。编译器也可以选择在真正碰到作用域结束的时候再释放,然而这些已经属于编译器的实现细节了,应用程序无需关心。 非常优雅的关注点分离设计!

函数调用中的所有权转移

和C/C++中不一样的是,函数调用的时候,参数传递会造成所有权转移即调用者失去了对原来参数的所有权!考虑下边的例子

fn main() {
    let s = String::from("hello");
    do_something(s); //s失去对字符串的所有权!
    let x = 5;
    do_somethingElse(x); //内置类型被拷贝!
}

fn do_something(par: String) {
    //par 拥有外部传入参数的所有权
} //作用域结束的时候,par对应的对象会被释放

fn do_somethingElse(par: i32) {
    // play with par
}

上述例子中,当调用了do_something(s)之后,虽然s还可以访问但已经失去了对应对象的所有权,其行为和C++11/14中的Move很像。第二个例子中x对象却依然可以访问,这里的不同是,Rust对象对分配在栈上的对象默认采用copy方式处理, 所以仅分配在内存堆上的对象被Move,栈上的对象(编译期必须知道大小)默认是被复制过去的。

对于分配于堆上的(大小运行期才知道)对象,Rust也提供了clone方法来(其实是泛型的annotation)执行深度拷贝。

函数返回的时候,默认也会转移所有权,这点和函数调用的参数传递情况类似,只不过是传递/接收参数的顺序反了过来,不再详述。

引用类型

如果默认的转移所有权的方式不符合实际的场景,Rust还提供了引用类型来指示传递过程中,仅仅保留对原来参数的引用而不转移所有权; 概念上和C的指针很想象,只是有很多额外的措施避免滥用指针可能出现的空指针、悬挂指针等复杂问题。

引用类型在语法上用&符号来表示,可以用于修饰标志符,熟悉C/C++的应该不陌生;唯一有点麻烦的是,调用者和函数声明都必须显示声明引用类型, 如下边的例子

fn calculate_lenght(s: &String) -> usize {
    s.len()
}

let s1 = String::from("hello");
let len = calculate_length(&s);
println!("The length of '{}' is {}", s1, len);

默认的引用类型是只读的,因为这个对象是借来的,被调用函数没有所有权;尝试去修改的话,则会被编译器报错拦住。 又是一个精妙的设计,多少粗心的错误可以被精明的编译器拦住。

可修改的引用和安全性

如果实在需要在被调用函数中修改传入的引用参数,那么也是可以声明类型为 &mut SomeType的,只是出于数据安全性的考虑(避免可能的运行期错误), Rust定义了如下规则来保证对象的访问总是安全的;任何可能引起Race Condition的访问模式都尽量被编译器拦截住,这样成功编译的代码,出现运行期错误的可能性被大大降低了。

  1. 引用的对象必须是合法的
  2. 同一个作用域内(对象是可以被程序访问到的),可以有多个只读的引用
  3. 同一个作用域内,如果已经有一个可修改引用,那么不允许存在其它任何引用,即使是只读的也不行
  4. 不同的作用域内,可以有多个可修改的引用;这里因为对对象的修改是相互隔离的,因此不会有意外情况发生;该规则能保证程序逻辑正确的同时,又尽可能给上层程序更多的自由度

上述最后一条规则其实意味着我们可以有意利用它,通过大括号来创建不同的作用域,写出更简洁的代码,比如

let mut aStr = String::from("hello")
{
    let r1 = &mut s;
    //do sth with r1
} //r1 离开作用域

let r2 = &mut s;
//基于r2的修改操作

另外一种常见的指针错误是”悬挂指针”,在传统的C++程序中,当一个指针指向一个不存在的对象的时候,紧接着所有对指针的操作会导致未定义的行为; 由于实际出现错误的地方和真正“制造出悬挂指针”的地方可能相距万里,这类运行期的错误往往会耗费程序员大量宝贵的时间。考虑下边的例子

fn main() {
    let reference_to_nothing = dangle();
}

fn dangle() -> &String {
    let s = String::from("hello");
    &s
}

如果尝试编译上述代码,rust编译器会清晰的报告一个对象生存期错误

error[E0106]: missing lifetime specifier
 --> dangle.rs:5:16
   |
 5 | fn dangle() -> &String {
   |                ^^^^^^^
   |
   = help: this function's return type contains a borrowed value, but there is no
     value for it to be borrowed from
   = help: consider giving it a 'static lifetime

error: aborting due to previous error

对象生存期

在Rust的内部实现中,一个隐含的逻辑是,任何一个引用都关联着一个对于的生存期,大部分情况下生存期都可以由编译器自动推导得到而不需要使用者格外留意。 当具体的实现中期望引用的生存期可以根据某些条件呈现不同的行为的时候,程序员必须提供一些辅助措施告诉编译器这些额外的判断信息。

Rust编译器内部有一个成为BorrowChecker的工具,它在程序编译的过程中会检查是否所有的引用是合法的。 当它无法判断引用的生存期的时候,程序员需要在定义的地方传入一些类似于检查点的生存期指示帮助编译器正常检查。

考虑一个取2个字符串slice长度最大者并将其返回的一个函数

fn longest(x: &str, y:&str) -> &str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

编译这段程序的时候,编译器就会报错说,不知道如何决定返回的引用的生存期,因为它要么是x,要么是y, 却是由程序的运行期的行为来动态决定的,编译器没有办法在编译的过程中做决定。修补这个错误则需要在函数签名中加入生存期标记

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

这样编译器就可以知道其实参数和返回值的生存期是一致的,不会产生意外的非法访问或者Race Condition。这里采用的语法是泛型的语法,后边会详细考察一下Rust的泛型支持。

生存期检查的概念是Rust独有的,其采用的类泛型的语法学习起来也显得不是很清晰易懂;这也许是最迷人也最晦涩的特性,从设计的角度来说, 牺牲一定的简单性来达到安全编程又不损失性能的目标也许是个不错的折中; 既想要高层的抽象,又想要极致的性能,还不想有太多意外的错误是个刀剑上跳舞的极致挑战,这方面Rust做的很不错。

智能指针

默认的引用方式支持生存期检查和对象借用,实质上采用的任然是所有者唯一的模型;实际应用场景中,程序员可能需要选择一个可以被多个所有者共享的对象生存期模型, 一如C++中很常用的基于自动引用计数shared_ptr的样子。

Rust通过标准库的方式提供了额外的对象生存期管理模型,包括

  • Box<T>类型用于表示一个指向单个堆上分配的对象的指针,该指针的大小在编译期间是可知的从而我们可以用它来定义递归的数据结构
  • Deref Trait用于表示一个允许通过解引用来访问其封装的数据的智能指针
  • RefCell<T> 用来支持可以修改某个不可变参数内部隐藏的数据的模式;默认情况下,引用规则不允许这样的操作。这种情况下会产生不安全的代码,需要程序员做一些额外的处理
  • Rc<T>RefCell<T>用于支持环形引用而不引入内存泄露,这个在GC的算法中很常见

细节不一一展开探讨,总体上而言智能指针其实是接管了对象的所有权,并且在智能指针内部做自动的控制;这一思路现代C++的实践是英雄所见略同。

更简洁的并发支持

支持安全而又高效的并发编程是Rust另外一个雄心勃勃的目标。同时Rust又力图做到尽可能的简洁。 从语言实现上来说,Rust采用了和控制内存安全访问以及对象所有权/生存期以及类型系统完全相同的工具来解决并发的问题, 尽管这些机制看起来和并发安全相差甚远。

经由大量的类型系统检查、对象生存期检查;大量的并发编程问题都可以在编译器被捕获,从而编译通过的代码往往就意味着没有并发安全性的问题找上门; 程序员可以放心的重构其代码而不用太担心重构后的代码会破坏并发安全性;因此Rust称之为“无所畏惧的并发”。

Rust的并发编程支持一些流行的并发编程模型

  • 基于消息传递的CSP模型,这也是Golang所采用的并发方式
  • 传统的基于Mutex和对象的所有权来控制共享数据访问的方式 - Rust的类型系统和所有权控制使得其中的挑战降低了不少

从设计上来说,并发支持不是Rust的核心语言的部分,所有的并发机制都是通过标准库来提供的,这也意味着更多扩展的可能;有新的并发访问方式,那就写新的库呗。

模块系统

编程范式和高级特性

从编程范式的角度来看,Rust本身其实支持多种编程范式因为其某种程度上对标的是现代的C++或者Golang这样的竞争对手。

过程式编程

传统的过程式编程风格和基本的C模型比较接近; 其定义结构体的方式和C比较类似,依然是采用struct来组织数据,所不同的是Rust支持“方法”和实现分开定义,通过新的关键字impl来添加新的方法实现。

考虑一个简答的例子,定义个矩形以及对应的area方法来计算其面积

struct Rectangle {
    length: u32,
    width: u32,
}

impl Rectangle {
    fn area(&self) -> u32 {
        self.length * self.width
    }
}

这里的area方法绑定于该Rectangle上,第一个参数总是&self这样编译器可以自动推导出其类型是所绑定的struct对象;因为这里的参数仍然是一个引用, 默认是不能修改结构体的参数,当需要修改时候,可以指定&mut self从而获取一个可修改的引用。这里引用的生存周期模型仍然是适用的。

调用的时候,只需要构造一个结构然后,采用structObj.callMethod(...)语法即可;大概是出于简化语言的考虑,Rust只支持简单的.语法而丢弃了古老的->操作符; ->的使用仅仅限于指定函数的返回类型上,干净清爽了许多。

let rect = Rectangle { length: 50, width: 30 };
println!("The area of rectangle is {}", rect.area())

Rust也支持类似C++中的静态函数的概念,对应的机制Rust称为关联函数,这样的机制对大型代码的组织是很有意义的,可以方便地解决名字冲突的问题。 当定义在impl块里的函数其参数中没有self的时候,Rust会认为其实一个和某个具体的数据结构无关的函数,它和该结构体类在同一个命名空间中。 比如我们前边已经看到的String::from("hello")这样的调用就是将构造方法放置在Stringimpl块里,但是完全没有使用self参数。

只是现代的C++社区因为有更完善的语言层面的命名空间隔离机制,其实已不太推荐这种古老的静态函数组织方式。

面向对象和泛型编程

从形式上来说,Rust不提供对传统的面向对象编程的直接支持,但提供了一些更复杂的面向接口编程的语言级别机制。 这一核心武器就是Rust的Traits。某种程度上说,面向接口编程是面向对象编程最核心的精髓之一; 继承、封装和多态这些基本的武器都可以用面向接口编程的方式来达到。

Rust的泛型编程实现上有很明显的C++的影子,不同的是它通过Traits机制巧妙的将编译器多态和运行期多态统一为一体了。

Traits

Traits概念上来说就是接口,它是Rust支持可扩展程序的基础;它既可以支持编译器多态(类似于C++的模板元但是比模板元更为简单一些),也可以支持基于动态分发技术的运行期多态。 从设计的角度来看,Traits机制受C++的设计哲学影响比较深,同样希望达到零成本的抽象这一至高目标

C++ implementations obey the zero-overhead principle: What you don’t use, you don’t pay for [Stroustrup, 1994]. And further: What you do use, you couldn’t hand code any better.

  • Stroustroup

一个描述Hash函数的Traits定义如下

trait Hash {
    fn hash(&self) -> u64;

    //can have more functions
}

两个实现了该Traits的结构可以定于如下(不一定必须放在同一个源代码文件中)

impl Hash for bool {
    fn hash(&self) -> u64 {
        if *self { 0 } else { 1 }
    }
}

impl Hash for i64 {
    fn hash(&self) -> u64 {
        self as u64
    }
}

和传统的C++中的抽象类或Java中的接口不同的时候,Traits是半开放的,这意味着我们可以打开某个定义好的结构,为其添加新的实现;有点类似Ruby的模块扩展方式。 当然Rust 仍然是静态语言并且是强类型的。C++的模板元虽然可以达到类似的效果,但只支持编译器多态,并且其Concept的支持虽然千呼万唤却一直没有进入语言标准

基于泛型的编译器多态

考虑一个适用上述Traits的例子

fn print_hash<T: Hash>(t: &T) {
    println!("The hash is {}", t.hash())
}

print_hash(&true); // calls with T=bool
print_hash(&12_i64); //calls with T=i64

这里定义了一个打印Hash的泛型函数print_hash,要求对应的类型必须实现了Hash;实际调用的时候,编译器可以做类型检查来判断对应的实际类型是否满足Traits约束; 和C++的Concept非常相像。

此外这种类型约束方式还是可以组合的,当期望泛型类满足多个Traits约束的时候,可以用+将其串起来, 比如 <T: Hash + Eq>则要求泛型类T必须同时实现HashEq才能编译通过。

动态分发的运行期多态

当多态行为依赖于具体运行期才精确得知的条件的时候,泛型就无能为力了。Rust的解决方式是,采用额外的中间层-指针来达到。 比如在GUI编程中,我们经常需要处理界面元素的点击事件,传统的面向对象思路是定义一个Traits,然后在具体的界面元素上添加一个事件监听者列表

trait ClickCallback {
    fn on_click(&self, x: i64, y: i64);
}

struct Button<T: ClickCallback> {
    listeners: Vec<Box<ClickCallback>>;
}

由于结构体的大小必须在编译期确定,因而直接放一个大小不确定的ClickCallback就不能编译通过了;标准库中提供了智能指针来帮我们很优雅地解决了这个问题;因为指针的大小总是确定的。具体到实现上,其原理和C++中的虚函数表非常类似,一个封装了Traits的智能指针(这里是Box)内部结构上类似于一个vtable,其指向一个在运行期动态构造的函数表。在调用的地方,编译器可以自动查找具体实现了对应Traits的结构的函数表,转到正确的调用地址。

函数式编程

函数式编程风格具有更高的抽象层次和更丰富的表达能力,更有利于写出声明式风格的代码。较新的编程语言无一例外都或多或少对函数式编程风格提供支持。 Rust的函数式编程具有明显的Haskell痕迹

枚举类型Enum

Rust的枚举类型和传统的C++/Java中的枚举的概念类似,都可以用来表示取值有固定可能性的数据类型; 通过与泛型的结合,Enum还拥有和Haskell的抽象数据类型ADT相同的扩展能力。

最简单的枚举类型定义可以是如下的样子

enum IpAddrKind {
    V4,
    V6
}

这里每个具体的枚举值都是一个不同的具体值,同时他们的类型是一样的。更复杂一点的情况是,Enum支持每个枚举的值可以有不同的类型构造,如

enum IpAddr {
    V4(u8, u8, u8, u8),
    V6(String),
}

let home = IpAddr::V4(127, 0, 0, 1)
let lo = IpAddr::V6(String::from("::1"))

更一般地,具体的枚举值可以用不同的类型来构造出来;从而我们由此将不同类型的数据聚合在一起形成一个抽象的定义

struct IpAddr4 {
    // 细节省略
}

struct IpAddr6 {
    // 细节省略
}

enum IpAddr {
    V4(IpAddr4),
    V6(IpAddr6)
}

模式匹配

一个Enum中可能封装了不同的数据,当需要对不同的可能的数据做不同的处理的时候,Rust采用模式匹配的方式来提高代码的可读性。 模式匹配是一种特殊的表达式,采用match关键字和一个包含枚举了所有可能的取值以及其处理代码的代码块组成。譬如考虑上面的地址定义,如果需要对不同的地址类型有不同的处理,可以用模式匹配的方式写为

fn handle_address(addr : IpAddr) -> i32 {
    match addr {
        IpAddr::V4 => 1,
        IpAddr::V6 => 2,
    }
}

这里每一个=>对用,分隔开,其左边的部分是某个具体的枚举变量值,右边是对应的处理表达式。当表达式不止一条语句的时候,可以用大括号隔开。

模式匹配必须保证所有的枚举值都必须被处理过;并且处理表达式的类型必须是一样的;否则编译器会报错。 当枚举的可能取值有很多个而处理代码只对其中部分可能值感兴趣,可以用_来表示可以匹配所有之前未匹配到的值。

另外一种特殊的情况是,我们仅仅关心某个枚举值中的一个的时候,match语法依然显得比较啰嗦;Rust提供了特殊的语法来简化代码,如

let some_u8_value = Some(0u8);
match some_u8_value {
    Some(3) => println!("three!")
    _ => (),
}

可以改写为

if let Some(3) = some_u8_value {
    println!("three!")
}

类似的我们也可以像常规的处理一样加上一个else分支来处理其它不匹配的情况。

Option类型

Option是一个封装类型,其概念和Haskell中的Monad或Java8中的Optional的作用比较类似;都是用于表示一种要么存在一个值要没没有值的容器。 它比空指针有优势的地方在于它是一种应用逻辑层的抽象;是用于替代空指针的一个很好的工具。

I call it my billion-dollar mistake. At that time, I was designing the first comprehensive type system for references in an object-oriented language. My goal was to ensure that all use of references should be absolutely safe, with checking performed automatically by the compiler. But I couldn’t resist the temptation to put in a null reference, simply because it was so easy to implement. This has led to innumerable errors, vulnerabilities, and system crashes, which have probably caused a billion dollars of pain and damage in the last forty years.

  • Tony Honare, the inventor of null

Rust中的Option是一种构建于泛型技术上的特殊的enum类型

pub enum Option<T> {
    None,
    Some(T),
}

标准库提供了一些成员函数来实现常见的绑定/链式操作范式

  • fn is_some(&self) -> bool 判断是否有值
  • fn is_none(&self) -> bool 判断是否为空
  • fn unwrap(self, msg: &str) -> T 用于提取内部存储的值,如果不存在则用给定的消息panic
  • fn unwrap(self) -> T 移动内部存储的值如果其存在的话;不存在则panic
  • fn unwrap_or(self, def: T) -> T 存在的话返回其存储的值,否则返回提供的默认值
  • fn unwrap_or_else<F>(self, f: F) -> T where F: FnOnce() -> T 尝试提取值,如果不存在调用给定的函数生层一个值
  • fn map<U, F>(self, f: F) -> Option<U> where F: FnOnce(T) -> U 经典的map操作,将值通过给定的函数转换成另外一个值并封装成新的Option,如果不存在,则也重新封装成目标类型的空值
  • fn map_or<U, F>(self, default: U, f:F) -> U where F: FnOnce(T) -> U 类似于map操作,但返回转换后的类型;如果空则返回给定的默认值
  • fn as_ref(&self) -> Option<&T> 返回引用类型
  • fn as_mut(&mut self) -> Option<&mut T>返回可修改的类型
  • fn iter(&self) -> Iter<T> 返回迭代器类型,可以遍历其值,这里的迭代器总是只能返回一个值
  • fn and<U>(self, optB: Option<U>) -> Option<U> 如果没有值,则返回空,否则返回给定的新的optB,便于链式操作减少逻辑判断

闭包Closure

闭包是另外一个重要的函数式编程工具;Rust采用的语法是比较类似于Ruby,其内部实现上则采用C++的匿名函数模型;即闭包对象其实生成的是匿名的函数对象。 一个最简单的例子

let calculate = |a, b| {
    let mut result = a * 2;
    result += b;
    result
};


assert_eq!(7, calculate(2, 3)); // 2 * 2 + 3 == 7
assert_eq!(13, calculate(4, 5)); // 4 * 2 + 5 == 13

闭包的类型注解约束要比函数定义的要求宽松一些,即不需要指定返回类型也可以;和现代C++的generic lambda特性比较类似; 都是为了方便程序员写出更简洁、干净的代码。如下的代码是完全等价的

fn  add_one_v1   (x: i32) -> i32 { x + 1 }  // a function
let add_one_v2 = |x: i32| -> i32 { x + 1 }; // the full syntax for a closure
let add_one_v3 = |x|             { x + 1 }; // a closure eliding types
let add_one_v4 = |x|               x + 1  ; // without braces

从代码可读性和可维护性的角度来看,最好不用闭包来写太长/太复杂的代码块, 因为随着匿名代码块中逻辑的增加,上下文逻辑变得更加模糊;这个时候,用一个命名良好的子函数反而更清晰便于维护。

软件工程支持 - 工具和方法

Rust提供了成熟的软件工程实践支持;有相对完善的模块文档和官方的gitboook

Creates && Cargo系统

作为一门站在巨人肩上的语言,Rust吸收了已有的一些成熟的包管理系统的经验,并提供了类似的极致来支持更好的协作

  • Creates和其包分发系统有点Hackage的影子,又有点NPM的味道
  • 版本依赖管理上,和Ruby Gems的处理方式也有些像,虽然toml的格式没有Ruby的DSL那么灵活强大

Cargo是一个类似于C++中的CMake的系统,同时还提供了一些创建项目模板的快捷方式,帮助程序员快速创建项目骨架,更快专注于具体的实现而不是构建细节。 可以用它的子命令来

  • 检查依赖,自动升级依赖
  • 增量编译代码并报告错误
  • 根据特定的开关选项执行对应的测试
  • 生成文档
  • 运行项目生成的可执行文件(如果不是编译一个库)
  • 运行benchmark
  • 安装编译好的二进制构建结果
  • 搜索crates中注册的模块

具体功能可以查看其命令行帮助。

IDE和编辑器插件支持

某些程序员更喜欢IDE,另外一些人则更熟悉命令行的Vim/Emacs或者其它轻量级的编辑器。社区目前提供了比较丰富的支持,包括对Eclipse/IntelliJ/Visual Studio的IDE插件, 以及对Atom/Visual Studio Code/Sublime/Vim/Emacs的插件支持;基本上比较主流的编程环境的支持都有了;具体支持程度如何,有待进一步验证;官方的文档看起来非常值得一试。

测试

Rust支持在包中提供单元测试和功能测试。默认的工具会搜索源码目录中的所有单元测试,并自动组织起来运行,同时也提供了一些高级的测试支持。 Rust希望程序员明确的区分这两种测试,并采用不同的约定

  • 所有的单元测试都和被测试的源代码放在一起,并且支持对private方法的测试(当然这个很有争议,个人建议不要测试private)
  • 集成测试被放在专门的test文件夹下边,可以放在多个文件中,Cargo将会为每个文件生成一个crates

cargo test命令可用来执行所有的测试,并且默认是并发执行的;这样开发的反馈周期会更短;也可以用命令来显示要求线性执行 - 传入 --test-threads=1即可。 一些更复杂的特性,如指定某个case的执行,跳过某些特定的case,以及按照某个过滤条件来选择特定的case,忽略case运行过程中的打印输出等特性也被贴心的支持了。

总结

在注重极致性能又强调工程协作和扩展性的系统编程领域,Rust做了比较大胆的尝试,在不引入垃圾收集并保持强类型检查的前提下, 它期望能将C++的零成本抽象推向一个新的高度而又能避免陷入传统C/C++语言指针访问安全性以及复杂的模板元编程等复杂性的泥潭。

它的泛型编程支持和强调值对象唯一所有权的概念和对象生存周期的强制检查使得多线程并发编程变得轻松简单; 加上强类型检查的约束,编译通过的程序往往运行期错误也变得很少,这一来自于Haskell的设计哲学深深地影响着Rust。

从一开始就加入的包管理器机制和对丰富的软件工程工具支持以及对开源社区的热情拥抱,使得Rust一开始就汲取了传统C/C++语言工程化支持不足的一些教训。 中心化的软件仓库以及对流行IDE、编辑器环境的支持使得它可以更好地赢得社区的支持。

于此同时随着更新节奏的加快,基于ISO标准化的C++语言也在通过更快的迭代速度和更短的更新周期对这些新加入的竞争者予以反击; 期望Rust能在系统编程领域掀起新的波澜。

Leave a Comment

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

Loading...