【图书介绍】《Rust编程与项目实战》-CSDN博客
《Rust编程与项目实战》(朱文伟,李建英)【摘要 书评 试读】- 京东图书 (jd.com)
Rust到底值不值得学,之一 -CSDN博客
1.2.2 引用和借用
如果每次都发生所有权的转移,程序的编写就会变得异常复杂。因此,Rust和其他编程语言类似,提供了引用(References)的方式来操作。获取变量的引用称为借用。类似于你借别人的东西来使用,但是这个东西的所有者不是你。引用不会发生所有权的转移。引用类似于C语言中的指针,指向一块已经存在的数据:
let mut x = 5;
let y = &x;
上例中,y是对变量x的引用,并且没有标注mut,所以是只读引用。写法跟C语言中获取指针的方式类似,就是一个&符号。y此时具有了变量x的一些权限,所以也称为“借用”,本例中因为只借用了读的功能,没有借用写的功能,所以称“一些”。当然也可以借用写的功能,我们后面会再举例。
借用(Borrowing)看起来跟引用是一回事,但“借用”这个词更主要对应的是前面所说的所有权“转移”的概念,转移之后,原来的变量就无效了。而借用之后,原来的变量还有效,或者部分有效,比如只被借用了写权限。在函数参数中,使用引用的方式让函数临时获得数据的访问权,也是典型的借用。事实上,这种方式才是最常用到借用的地方:
fn main() {
fn sum_vec(v: &Vec) -> i32
{ return v.iter().fold(0, |a, &b| a + b); }
let v1 = vec![1, 2, 3];
let s1 = sum_vec(v1);
println!("{}", s1);
}
先别管我们使用到的令人困惑的关键字和函数名,那些系统学习之后都不算什么。在函数sum_vec的参数中,我们就使用了借用。顺便还见识了Rust中函数的嵌套写法,当然现在新兴的语言,包括C++11之后的版本,都已经支持这种写法,这在函数式(Functional Programming Paradigm,注意不是函数化Functionalization)编程中是很重要的支持。
引用和借用的概念与C/C++语言中所使用的非常类似,尽管名称不同,主要的区别在于对引用的管理理念,Rust对引用的管理规则如下:
(1)对于一块内存,同时只能有一个可写引用存在。
(2)对于一块内存,同时可以有多个只读引用存在。
(3)对于一块内存,在有一个可写引用存在的时候,不能有其他引用存在,无论只读或者 可写。
引用的原始对象必须在引用存在的生命期一直有效,比如:
let mut x = 5;
let y = &mut x;
let z = &mut x;
println!("{} {} {}", x,y,z);
上面的代码会产生编译错误,因为y已经是可写的引用,而同时存在一个可写的引用z,违反了Rust对引用的管理规则。如果把z变量这一行和后面显示z的部分去掉呢?去掉之后是可以编译通过的,但仍然需要注意,y此时是可写的指针,“借用”了x的写权限。所以x此时只有读的权限,不能再对x进行赋值。因为它已经被“借用走”(Borrowed)了。
这些复杂的规则看起来就跟前面见过的所有权转移一样,似乎极大地限制了程序员的自由度。但这些都是在强迫你,让你成为一位更优秀的程序员,产出更高质量的代码,将Bug消灭在萌芽期。
1.2.3 生命期
通常一个变量的生命期(Lifetime)就是它的作用域。但在引用和借用出现后,这个问题变得复杂了。熟悉C语言的程序员都碰到过数据失效了而指针依然存在的情况,俗称“悬挂指针”。Java为了解决这个问题,干脆取消了指针,并且最终以引用计数器作为内存管理的主要模式。这种情况出现最多的场景,是在某个函数中使用了变量或者申请了内存,并将其引用作为返回值传递到调用者的时候。比如这段C语言代码:
int *getSomeData(){
int c=32767;
return &c;
}
变量c位于栈上,是一个局部变量,当函数返回指针的时候,指针在这个函数的调用者中依然存在,但变量c已经被回收了。在新版本的编译器中,这种情况也会被警告,但可以编译成功。而在Rust中,这种情况是不允许编译通过的,比如下面的类似代码:
fn somestr() -> &str {
let result = String::from("a demo string");
result.as_str()
}
直接使用方法返回值(或者变量),之后没有分号,即将其作为返回值处理,不用像C语言一样要使用return result.as_str()语句返回值。编译的时候会报错“result变量没有足够长的生命期”:
error[E0597]: `result` does not live long enough --> src/main.rs:3:5 | 3 | result.as_str() | ^^^^^^ does not live long enough 4 | } | - borrowed value only lives until here
如果仅仅是这样断然地禁止返回悬挂引用,也就“不过如此”了。事实上,更复杂的问题在于,如果数据源来自函数的参数,参数本身就是引用的情况。比如下面的Rust代码:
fn longest(x: &str, y: &str) -> &str {
if x.len() > y.len() { x }
else { y }
}
上面这个函数接受两个字符串的引用,比较其长度,将长的那个字符串作为结果返回调用者。这种返回值的方式一定让你印象深刻。虽然示例简单,但不可否认,这种需求是很正当的。大量的应用场景都需要函数独立于外,处理固定的内存数据,进入和返回的都只是指向内存的指针。当然,尽管合理,但是上面的代码是无法编译通过的,报错是“丢失生命期指定”:
error[E0106]: missing lifetime specifier --> src/main.rs:1:33 | 1 | fn longest(x: &str, y: &str) -> &str { | ^ expected lifetime parameter | = help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from `x` or `y`
Rust引入了生命期的概念,从而保证返回值与给定的参数具有相同的生命期。这既保证了程序的灵活性,又不会造成内存泄露,同时还不会把维护内存安全的责任推给不可靠的人为因素。
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x }
else { y }
}
上面的代码添加了生命期指定。在函数名之后首先声明了生命期a,语法样式跟泛型的类型说明部分实际是一样的,都放在尖括号“<>”中。生命期名称之前附加一个单引号“'”。随后的两个引用参数x/y以及作为返回值的字符串引用都直接在&符号之后标注了生命期'a。这表示,这几个引用具有相同的生命期。
当然,在这个例子中,x/y是调用的参数,是外面传递进来的,所以完整的含义应当是:返回的引用值,同参数x/y一样具有相同的生命期。因此,从调用者的角度来看,当x/y指向的内存超出作用域销毁之后,所获得的函数返回值也同时被销毁。
有一个特殊的生命期'static,用于表示Rust中的全局量或者静态量,专门表示这种引用具有贯穿于整个程序运行时的生命期长度。比如,Rust中通常用字面量赋值的字符串实际都是'static,因为这些字面量实际在编译程序的时候,就放置到了数据区并一直存在,贯穿程序始终:
let s = "I have a static lifetime.";
1.2.4 编译时检查和运行时开销
通过前面的几个例子,我们对Rust的编译器rustc有了一个初步了解,丰富、详尽的编译错误输出对于排查源码中的错误帮助很大。实际上远不止于此。Rust的编译器包含着Rust语言的另一个核心思想,那就是尽量在编译阶段就暴露出程序的设计错误,而不让这些错误带到生产环境中,从而付出昂贵的代价。
这也是Rust学习曲线陡峭的原因之一,很多在其他语言中可以编译通过的代码,在Rust中都无法编译通过(排除语法错误)。这种更严格的编译时检查,很容易让初学者手足无措。
带来的优点也是显而易见的,除刚才提过的不让程序Bug带入生产环境外,错误能在编译阶段就消除掉,无须在运行时进行更多不必要的错误检查,这也将大大地减少程序在运行时的消耗。这个消耗包括编译所生成的代码体积和运行时检查所损耗的CPU资源两个方面。比如,Rust中有多种不同功能的智能指针,以常见的Box和Rc为例,前者提供基本的指针功能,后者提供类似Java语言一样,基于引用统计的自动垃圾回收机制。(请注意,这里并不是做语言学习,所以请关注在Rust的设计理念上,先别在意具体的关键字和语法。)
如果在程序中使用Box指针的话,当变量x被赋值给变量y,所有权同时被转移,变量x就不再可用了,这个在开始介绍所有权时就见到了:
let x = Box::new(1);
let y = x; // x从此无效了
与此规则对应的所有操作在程序的编译器都可以做出检查,从而判断是否有错误存在。但毕竟我们也有其他的需求,比如希望同时有多个指针指向同一块存储区域。这时就需要使用Rc指针。
let five = Rc::new(5);
let five1 = five.clone(); // 此时five/five1都是有效的
但显然,使用Rc指针的时候我们无法在编译过程中发现可能的错误。并且,Rc指针类似于Java,当对一块内存的所有引用都失效之后,系统会释放这部分内存。而这个过程都需要在程序执行的过程中,有对应的管理代码不停地工作,以保证跟踪内存的引用和内存的释放(垃圾回收)。这就产生了运行时开销。
为了对运行时开销能够更精确地掌控,Rust在语言层面增加了许多选择,这些选择在其他语言中本来是不需要的。但一个经验丰富的程序员,可以充分利用这些不同的选择,写出高品质的代码。比如Rc指针并不支持多线程,因为其中的引用计数器操作不是原子级的,所以Rust还提供了Arc用于多线程环境。当然,原子级的操作在运行时需要额外的开销。
与Rust语言的编译设计相映成趣的是Go语言,Go语言提供非常快速的编译过程,从而提供流畅的开发体验,让Go语言易于学习和使用。但Go语言的编译质量早就为人所诟病。
当然,更极端的例子是Python、JS等脚本型的语言,脚本语言完全无须编译。虽然执行效率方面这些年来随着计算机性能的提升已经不是严重问题,但大多错误几乎都只能通过代码的执行来发现,使得脚本语言在商业软件开发中占有率一直不高,更别说操作系统这一类的底层软件了。
总结一下这一部分,Rust提供高级语言所具有的一些特征,比如自动的运行时垃圾回收机制。但同时也提供并且倾向于开发人员通过精细的设计,在开发和程序编译过程中就完成内存的设计和管理,从而及早发现错误,降低运行时开销,提高最终的代码质量。
1.2.5 有限的面向对象特征
面向对象是现代开发语言的基本能力,但Rust只提供了有限的面向对象支持。笔者衷心地认为这是一件好事,笔者一直认为现在很多程序员往往为了面向对象而去进行面向对象开发,把原本很简单的事情做得过于复杂,使得代码量和运行开销高企不下,开发效率和执行效率完全失控。
Linus Torvalds曾经在那场著名的辩论中直呼C++是“糟糕程序员的垃圾语言”,有兴趣的读者可以去看原文:Re: [RFC] Convert builin-mailinfo.c to use The Better String Library。
在Rust中没有直接提供“类”(Class)的概念,希望使用“对象”的程序员可以直接在结构(Struct)和枚举(Enum)类型上附加函数方法,比如:
// 声明一个“圆”结构类型
struct Circle { x: f64, y: f64, radius: f64, }
// 为结构实现一个方法
area impl Circle {
fn area(&self) -> f64 { std::f64::consts::PI * (self.radius * self.radius) } }
fn main() {
let c = Circle { x: 0.0, y: 0.0, radius: 2.0 };
println!("{}", c.area()); // 调用结构的内置方法计算圆的面积
}
看起来跟Go处理对象的方法很像,其实在面向对象方面,Go语言的理念也是高举了“简化”的大旗。
Rust也没有我们习惯了的构造函数和析构函数。上面代码中对Circle对象的初始化语句如下:
let c = Circle { x: 0.0, y: 0.0, radius: 2.0 };
就是直接对成员变量赋值。这是因为Rust推崇“明确化”(Being Explicit)的代码方式,也就是所有要执行的代码应当清晰地在代码中体现出来,而不是隐藏在一些容易忘记、容易出错的构造函数之后。
与“简化对象”相反,Rust对面向对象中“接口”(Java中的接口,或者C++中的多重继承)的概念做了发扬,贯穿在了Rust类型管理的方方面面。
当然笔者这样说有点不贴切,其实应当先忘记“接口”的概念,从头理解Rust中的trait,因为trait和接口只是在技术实现上有些类似,但在应用理念上还是很有区别的。本质上说,trait也是实现多个对象中共性的方法,比如:
trait HasArea { //求取对象的面积
fn area(&self) -> f64; }
随后多个对象都可以实现这个trait,从而都具有这个方法:
struct Circle { //定义一个“圆”对象
x: f64, y: f64, radius: f64, }
impl HasArea for Circle {
fn area(&self) -> f64 { std::f64::consts::PI * (self.radius * self.radius) }
}
struct Square { //定义一个“方形”对象
x: f64, y: f64, side: f64, }
impl HasArea for Square { fn area(&self) -> f64 { self.side * self.side }
}
在Rust中,通过泛型的帮助,根据数据类型实现的不同trait会把类型分为不同的功能和用途。比如,具有Send trait的类型,才可以安全地在多个线程间传递从而共享数据。
比如,具有Copy trait的类型,说明数据保存在栈(Stack)上,数据的复制(赋值给其他变量)不会产生所有权的转移(参考前面所有权的例子)。还有刚才讲过Rust中没有析构函数,但如果有一些数据并没有被Rust所管理,需要自己去释放,则可以为自己定义的对象实现一个Drop trait,在其中的drop方法中释放自己申请的内存:
impl Drop for CustomSmartPointer {
fn drop(&mut self) { println!("Dropping CustomSmartPointer with data `{}`!", self.data); }
}
其他面向对象的编程特征,比如泛型、重载,与其他语言并没有很大的区别,这里不再额外介绍。这些相比较其他面向对象语言并不算丰富的语法工具,是保留了面向对象开发模式最精华的部分,并不会对业务的描述造成什么障碍,反而会让建模工作更为简洁、务实,尽可能不造成代码上的晦涩和运行时的低效。
早期出现的开发语言,比如C、Java,本身并没有附加官方的管理工具,比如包管理、测试管理、编译管理。
在编程语言的发展过程中,因为开发工作的需求,往往会出现多个有影响力的工具。在C/C++方面,常见的编译管理工具有Makefile、CMake、AutoMake等,包管理工具往往与系统包管理工具结合在一起,常见的有APT、YUM、Aptitude、Dnf、HomeBrew。Java的情况也非常类似。
新近风靡的语言,比如Python,pip工具占了大部分市场,Node.js则是NPM用户最多。Go语言的同名管理工具就更不用说了。这些现象跟语言本身的官方支持密不可分。
Rust也由官方直接发布了Cargo工具,功能涵盖版本升级、项目管理、包管理、测试管理、编译管理等多方面。
大多数初学者的Rust之旅就是由执行cargo new helloworld开始的。
开发语言的综合管理工具对于构建大型的软件项目必不可少。相信在Cargo的帮助下,可以让学习者快速学以致用,把一些项目迁移至Rust能轻松不少。
1.2.6 扩展库支持
一门语言能否被大量用户支持,与语言所提供的扩展库功能密不可分。笔者就见到不少程序员学习Python的原因是,Python能够更好地支持PyTorch、TensorFlow等机器学习工具包。Rust通过Crate(可以翻译为扩展箱)机制支持自己的扩展包,而且通过内置的Cargo工具可以直接使用大量的官方预置扩展包和社区共享的扩展包。此外,Rust还可以通过FFI接口(Foreign Function Interface)直接调用其他语言编写的函数库或者共享Rust函数供其他语言调用。比如,我们在Rust中调用C++写的Snappy压缩、解压功能包。Snappy官方网站为https://google.github.io/snappy/。因为使用了libc扩展库,需要在Cargo.toml中设置库依赖:
[dependencies] libc = "0.2.0"
编译的时候,rustc会自动链接libc库和宏定义指明的Snappy压缩解压库。把Rust中定义的函数共享给C语言调用也很类似,请看Rust的代码:
extern crate libc;
use libc::uint32_t; #[no_mangle]
pub extern fn add(a: uint32_t, b: uint32_t) -> uint32_t { a + b }
上面的代码需要设置Cargo.toml文件的lib参数:
[lib] crate-type =["cdylib"]
从而让rustc将项目编译为.dylib动态链接库文件(macOS)或者.so动态链接库文件(Linux)。对应的C语言代码如下:
#include extern "C" uint32_t
add(uint32_t, uint32_t);
int main(){ uint32_t sum = add(5, 5);
return 0;
}
C代码编译的时候,记着使用-l参数链接Rust生成的动态链接库。
综上所述,迁移至Rust完全不用担心扩展库的限制,也完全不用担心同现有软件资源之间的互动和共享。可以从一个小的项目作为切入点,边学边用,在享受Rust安全可靠的同时,逐渐达成软件架构的迁移。
1.2.7 Rust是一种可以进行底层开发的高级语言
现在流行的开发语言很多,但能够进行操作系统底层开发的选择项并没有几个。除传统的C、新近的Go外,Rust是另一个不错的选择。要做到这一点,除Rust是真正的二进制编译外,Rust还具有非常小并且可控的“脚印”(Footprint)。这代表Rust可以做到在完全没有自己的运行时库支持下运行。
作为新兴的开发语言,Rust在函数式编程、网络编程、多线程、消息同步、锁、测试代码、异常处理等方面都有不俗表现,但本书这里不展开介绍。建议在学习Rust的过程中,根据所选教程的组织结构来逐步了解。在企业应用中,Web框架和ORM是最常用的组件,但这应当说是Rust当前的一个短板。因为毕竟Rust是一个新兴的生态系统,尽管选择很多,但尚没有重量级的选手出现。在性能和规模化的应用方面还有待市场验证。
总之,Rust首先包含长期软件工程中对于高频Bug的经验总结,从而开创性地提出了大量全新编程理念。不同于很多新式语言给予开发者更多的便利和自由,Rust更苛刻地对待程序员的开发工作。尽管在易用方面Rust也下了不少功夫,但相对于繁复的规则,这些努力很容易被忽视。而这些“成长的代价”保证了更高品质的开发输出。比如,自2004年以来,微软安全响应中心(Microsoft Security Response Center,MSRC)已对所有报告过的微软安全漏洞进行了分类。根据其提供的数据,所有微软年度补丁中约有70%是针对内存安全漏洞的修复程序。恐怕没有人再继续做延伸统计,比如这些安全漏洞造成了多少经济损失。所以,甚至已有传闻微软正在探索使用Rust编程语言作为C、C++和其他语言的替代方案,以此来改善应用程序的安全状况。
Rust并不适合初学者,只有经历过大量实践磨炼,甚至被安全漏洞痛苦折磨的资深开发者,才更能理解Rust的价值。自由还是安全,终要有所取舍。