Rust语言的编程范式
总是有很多很多人来问我对Rust语言怎么看的问题,在各种地方被at,其实,我不是很想表达我的想法。因为在不同的角度,你会看到不同的东西。编程语言这个东西,老实说很难评价,在学术上来说,Lisp就是很好的语言,然而在工程使用的时候,你会发现Lisp没什么人用,而Javascript或是PHP这样在学术很糟糕设计的语言反而成了主流,你觉得C++很反人类,在我看来,C++有很多不错的设计,而且对于了解编程语言和编译器的和原理非常有帮助。但是C++也很危险,所以,出现在像Java或Go 语言来改善它,Rust本质上也是在改善C++的。他们各自都有各自的长处和优势。
因为各个语言都有好有不好,因此,我不想用别的语言来说Rust的问题,或是把Rust吹成朵花以打压别的语言,写成这样的文章,是很没有营养的事。本文主要想通过Rust的语言设计来看看编程中的一些挑战,尤其是Rust重要的一些编程范式,这样反而更有意义一些,因为这样你才可能一通百通。
这篇文章的篇幅比较长,而且有很多代码,信息量可能会非常大,所以,在读本文前,你需要有如下的知识准备:
- 你对C++语言的一些特性和问题比较熟悉。尤其是:指针、引用、右值move、内存对象管理、泛型编程、智能指针……
- 当然,你还要略懂Rust,不懂也没太大关系,但本文不会是Rust的教程文章,可以参看“Rust的官方教程”(中文版)
因为本文太长,所以,我有必要写上 TL;DR ——
Java 与 Rust 在改善C/C++上走了完全不同的两条路,他们主要改善的问题就是C/C++ Safety的问题。所谓C/C++编程安全上的问题,主要是:内存的管理、数据在共享中出现的“野指针”、“野引用”的问题。
- 对于这些问题,Java用引用垃圾回收再加上强大的VM字节码技术可以进行各种像反射、字节码修改的黑魔法。
- 而Rust不玩垃圾回收,也不玩VM,所以,作为静态语言的它,只能在编译器上下工夫。如果要让编译器能够在编译时检查出一些安全问题,那么就需要程序员在编程上与Rust语言有一些约定了,其中最大的一个约定规则就是变量的所有权问题,并且还要在代码上“去糖”,比如让程序员说明一些共享引用的生命周期。
- Rust的这些所有权的约定造成了很大的编程上的麻烦,写Rust的程序时,基本上来说,你的程序再也不要想可能轻轻松松能编译通过了。而且,在面对一些场景的代码编写时,如:函数式的闭包,多线程的不变数据的共享,多态……开始变得有些复杂,并会让你有种找不到北的感觉。
- Rust的Trait很像Java的接口,通过Trait可以实现C++的拷贝构造、重载操作符、多态等操作……
- 学习Rust的学习曲线并不平,用Rust写程序,基本上来说,一旦编译通过,代码运行起来是安全的,bug也是很少的。
如果你对Rust的概念认识的不完整,你完全写不出程序,那怕就是很简单的一段代码。这逼着程序员必需了解所有的概念才能编码。但是,另一方面也表明了这门语言并不适合初学者……
目录
变量的可变性
首先,Rust里的变量声明默认是“不可变的”,如果你声明一个变量 let x = 5;
变量 x
是不可变的,也就是说,x = y + 10;
编译器会报错的。如果你要变量的话,你需要使用 mut
关键词,也就是要声明成 let mut x = 5;
表示这是一个可以改变的变量。这个是比较有趣的,因为其它主流语言在声明变量时默认是可变的,而Rust则是要反过来。这可以理解,不可变的通常来说会有更好的稳定性,而可变的会代来不稳定性。所以,Rust应该是想成为更为安全的语言,所以,默认是 immutable 的变量。当然,Rust同样有 const
修饰的常量。于是,Rust可以玩出这么些东西来:
- 常量:
const LEN:u32 = 1024;
其中的LEN
就是一个u32
的整型常量(无符号32位整型),是编译时用到的。 - 可变的变量:
let mut x = 5;
这个就跟其它语言的类似, 在运行时用到。 - 不可变的变量:
let x= 5;
对这种变量,你无论修改它,但是,你可以使用let x = x + 10;
这样的方式来重新定义一个新的x
。这个在Rust里叫 Shadowing ,第二个x
把第一个x
给遮蔽了。
不可变的变量对于程序的稳定运行是有帮助的,这是一种编程“契约”,当处理契约为不可变的变量时,程序就可以稳定很多,尤其是多线程的环境下,因为不可变意味着只读不写,其他好处是,与易变对象相比,它们更易于理解和推理,并提供更高的安全性。有了这样的“契约”后,编译器也很容易在编译时查错了。这就是Rust语言的编译器的编译期可以帮你检查很多编程上的问题。
对于标识不可变的变量,在 C/C++中我们用const
,在Java中使用 final
,在 C#中使用 readonly
,Scala用 val
……(在Javascript 和Python这样的动态语言中,原始类型基本都是不可变的,而自定义类型是可变的)。
对于Rust的Shadowing,我个人觉得是比较危险的,在我的职业生涯中,这种使用同名变量(在嵌套的scope环境下)带来的bug还是很不好找的。一般来说,每个变量都应该有他最合适的名字,最好不要重名。
变量的所有权
这个是Rust这个语言中比较强调的一个概念。其实,在我们的编程中,很多情况下,都是把一个对象(变量)传递过来传递过去,在传递的过程中,传的是一份复本,还是这个对象本身,也就是所谓的“传值还是传引用”的被程序员问得最多的问题。
- 传递副本(传值)。把一个对象的复本传到一个函数中,或是放到一个数据结构容器中,可能需要出现复制的操作,这个复制对于一个对象来说,需要深度复制才安全,否则就会出现各种问题。而深度复制就会导致性能问题。
- 传递对象本身(传引用)。传引用也就是不需要考虑对象的复制成本,但是需要考虑对象在传递后,会多个变量所引用的问题。比如:我们把一个对象的引用传给一个List或其它的一个函数,这意味着,大家对同一个对象都有控制权,如果有一个人释放了这个对象,那边其它人就遭殃了,所以,一般会采用引用计数的方式来共享一个对象。引用除了共享的问题外,还有作用域的问题,比如:你从一个函数的栈内存中返回一个对象的引用给调用者,调用者就会收到一个被释放了个引用对象(因为函数结束后栈被清了)。
这些东西在任何一个编程语言中都是必需要解决的问题,要足够灵活到让程序员可以根据自己的需要来写程序。
在C++中,如果你要传递一个对象,有这么几种方式:
- 引用或指针。也就是不建复本,完全共享,于是,但是会出现悬挂指针(Dangling Pointer)又叫野指针的问题,也就是一个指针或引用指向一块废弃的内存。为了解决这个问题,C++的解决方案是使用
share_ptr
这样的托管类来管理共享时的引用计数。 - 传递复本,传递一个拷贝,需要重载对象的“拷贝构造函数”和“赋值构造函数”。
- 移动Move。C++中,为了解决一些临时对象的构造的开销,可以使用Move操作,把一个对象的所有权移动到给另外一个对象,这个解决了C++中在传递对象时的会产生很多临时对象来影响性能的情况。
C++的这些个“神操作”,可以让你非常灵活地在各种情况下传递对象,但是也提升整体语言的复杂度。而Java直接把C/C++的指针给废了,用了更为安全的引用 ,然后为了解决多个引用共享同一个内存,内置了引用计数和垃圾回收,于是整个复杂度大大降低。对于Java要传对象的复本的话,需要定义一个通过自己构造自己的构造函数,或是通过prototype设计模式的 clone()
方法来进行,如果你要让Java解除引用,需要明显的把引用变量赋成 null
。总之,无论什么语言都需要这对象的传递这个事做好,不然,无法提供相对比较灵活编程方法。
在Rust中,Rust强化了“所有权”的概念,下面是Rust的所有者的三大铁律:
- Rust 中的每一个值都有一个被称为其 所有者(owner)的变量。
- 值有且只有一个所有者。
- 当所有者(变量)离开作用域,这个值将被丢弃。
这意味着什么?
如果你需要传递一个对象的复本,你需要给这个对象实现 Copy
trait ,trait 怎么翻译我也不知道,你可以认为是一个对象的一些特别的接口(可以用于一些对像操作上的约定,比如:Copy
用于复制(类型于C++的拷贝构造和赋值操作符重载),Display
用于输出(类似于Java的 toString()
),还有 Drop
和操作符重载等等,当然,也可以是对象的方法,或是用于多态的接口定义,后面会讲)。
对于内建的整型、布尔型、浮点型、字符型、多元组都被实现了 Copy
所以,在进行传递的时候,会进行memcpy
这样的复制(bit-wise式的浅拷贝)。而对于对象来说,则不行,在Rust的编程范式中,需要使用的是 Clone
trait。
于是,Copy
和 Clone
这两个相似而又不一样的概念就出来了,Copy
主要是给内建类型,或是由内建类型全是支持 Copy
的对象,而 Clone
则是给程序员自己复制对象的。嗯,这就是浅拷贝和深拷贝的差别,Copy
告诉编译器,我这个对象可以进行 bit-wise的复制,而 Clone
则是指深度拷贝。
像 String
这样的内部需要在堆上分布内存的数据结构,是没有实现Copy
的(因为内部是一个指针,所以,语义上是深拷贝,浅拷贝会招至各种bug和crash),需要复制的话,必需手动的调用其 clone()
方法,如果不这样的的话,当在进行函数参数传递,或是变量传递的时候,所有权一下就转移了,而之前的变量什么也不是了(这里编译器会帮你做检查有没有使用到所有权被转走的变量)。这个相当于C++的Move语义。
参看下面的示例,你可能对Rust自动转移所有权会有更好的了解(代码中有注释了,我就不多说了)。
// takes_ownership 取得调用函数传入参数的所有权,因为不返回,所以变量进来了就出不去了
fn takes_ownership(some_string: String) {
println!("{}", some_string);
} // 这里,some_string 移出作用域并调用 drop
方法。占用的内存被释放
// gives_ownership 将返回值移动给调用它的函数
fn gives_ownership() -> String {
let some_string = String::from("hello"); // some_string 进入作用域.
some_string // 返回 some_string 并移出给调用的函数
}
// takes_and_gives_back 将传入字符串并返回该值
fn takes_and_gives_back(mut a_string: String) -> String {
a_string.push_str(", world");
a_string // 返回 a_string 将所有权移出给调用的函数
}
fn main()
{
// gives_ownership 将返回值移给 s1
let s1 = gives_ownership();
// 所有权转给了 takes_ownership 函数, s1 不可用了
takes_ownership(s1);
// 如果编译下面的代码,会出现s1不可用的错误
// println!("s1= {}", s1);
// ^^ value borrowed here after move
let s2 = String::from("hello");// 声明s2
// s2 被移动到 takes_and_gives_back 中, 它也将返回值移给 s3。
// 而 s2 则不可用了。
let s3 = takes_and_gives_back(s2);
//如果编译下面的代码,会出现可不可用的错误
//println!("s2={}, s3={}", s2, s3);
// ^^ value borrowed here after move
println!("s3={}", s3);
}
这样的 Move 的方式,在性能上和安全性上都是非常有效的,而Rust的编译器会帮你检查出使用了所有权被move走的变量的错误。而且,我们还可以从函数栈上返回对象了,如下所示:
fn new_person() -> Person { let person = Person { name : String::from("Hao Chen"), age : 44, sex : Sex::Male, email: String::from("[email protected]"), }; return person; } fn main() { let p = new_person(); }
因为对象是Move走的,所以,在函数上 new_person()
上返回的 Person
对象是Move 语言,被Move到了 main()
函数中来,这样就没有性能上的问题了。而在C++中,我们需要把对象的Move函数给写出来才能做到。因为,C++默认是调用拷贝构造函数的,而不是Move的。
Owner语义带来的复杂度
Owner + Move 的语义也会带来一些复杂度。首先,如果有一个结构体,我们把其中的成员 Move 掉了,会怎么样。参看如下的代码:
#[derive(Debug)] // 让结构体可以使用 {:?}
的方式输出
struct Person {
name :String,
email:String,
}
let _name = p.name; // 把结构体 Person::name Move掉
println!("{} {}", _name, p.email); //其它成员可以正常访问
println!("{:?}", p); //编译出错 "value borrowed here after partial move"
p.name = "Hao Chen".to_string(); // Person::name又有了。
println!("{:?}", p); //可以正常的编译了
上面这个示例,我们可以看到,结构体中的成员是可以被Move掉的,Move掉的结构实例会成为一个部分的未初始化的结构,如果需要访问整个结构体的成员,会出现编译问题。但是后面把 Person::name补上后,又可以愉快地工作了。
下面我们再看一个更复杂的示例——这个示例模拟动画渲染的场景,我们需要有两个buffer,一个是正在显示的,另一个是下一帧要显示的。
struct Buffer {
buffer : String,
}
struct Render {
current_buffer : Buffer,
next_buffer : Buffer,
}
//实现结构体 Render
的方法
impl Render {
//实现 update_buffer() 方法,
//更新buffer,把 next 更新到 current 中,再更新 next
fn update_buffer(& mut self, buf : String) {
self.current_buffer = self.next_buffer;
self.next_buffer = Buffer{ buffer: buf};
}
}
上面这段代码,我们写下来没什么问题,但是 Rust 编译不会让我们编译通过。它会告诉我们如下的错误:
error[E0507]: cannot move out ofself.next_buffer
which is behind a mutable reference --> /.........../xxx.rs:18:31 | 14 | self.current_buffer = self.next_buffer; | ^^^^^^^^^^^^^^^^ move occurs becauseself.next_buffer
has typeBuffer
, which does not implement theCopy
trait
编译器会提示你,Buffer
没有 Copy trait 方法。但是,如果你实现了 Copy 方法后,你又不能享受 Move 带来的性能上快乐了。于是,到这里,你开始进退两难了,完全不知道取舍了。
- Rust编译器不让我们在成员方法中把成员Move走,因为
self
引用就不完整了。 - Rust要我们实现
Copy
Trait,但是我们不想要拷贝,因为我们就是想把next_buffer
move 到current_buffer
中
我们想要同时 Move 两个变量,参数 buf
move 到 next_buffer
的同时,还要把 next_buffer
里的东西 move 到 current_buffer
中。 我们需要一个“杂耍”的技能。
这个需要动用 std::mem::replace(&dest, src)
函数了, 这个函数技把 src
的值 move 到 dest
中,然后把 dest
再返回出来(这其中使用了 unsafe 的一些底层骚操作才能完成)。Anyway,最终是这样实现的:
use std::mem::replace fn update_buffer(& mut self, buf : String) { self.current_buffer = replace(&mut self.next_buffer, Buffer{buffer : buf}); }
不知道你觉得这样“杂耍”的代码看上去怎么以样?我觉得可读性下降一个数量级。
引用(借用)和生命周期
下面,我们来讲讲引用,因为把对象的所有权 Move 走了的情况,在一些时候肯定不合适,比如,我有一个 compare(s1: Student, s2: Student) -> bool
我想比较两个学生的平均份成绩, 我不想传复本,因为太慢,我也不想把所有权交进去,因为只是想计算其中的数据。这个时候,传引用就是一个比较好的选择,Rust同样支持传引用。只需要把上面的函数声明改成:compare(s1 :&Student, s2 : &Student) -> bool
就可以了,在调用的时候,compare (&s1, &s2);
与C++一致。在Rust中,这也叫“借用”(嗯,Rust发明出来的这些新术语,在语义上感觉让人更容易理解了,当然,也增加了学习的复杂度了)
引用(借用)
另外,如果你要修改这个引用对象,就需要使用“可变引用”,如:foo( s : &mut Student)
以及 foo( &mut s);
另外,为了避免一些数据竞争需要进行数据同步的事,Rust严格规定了——在任意时刻,要么只能有一个可变引用,要么只能有多个不可变引用。
这些严格的规定会导致程序员失去编程的灵活性,不熟悉Rust的程序员可能会在一些编译错误下会很崩溃,但是你的代码的稳定性也会提高,bug率也会降低。
另外,Rust为了解决“野引用”的问题,也就是说,有多个变量引用到一个对象上,还不能使用额外的引用计数来增加程序运行的复杂度。那么,Rust就要管理程序中引用的生命周期了,而且还是要在编译期管理,如果发现有引用的生命周期有问题的,就要报错。比如:
let r; { let x = 10; r = &x; } println!("r = {}",r );
上面的这段代码,程序员肉眼就能看到 x
的作用域比 r
小,所以导致 r
在 println()
的时候 r
引用的 x
已经没有了。这个代码在C++中可以正常编译而且可以执行,虽然最后可以打出“内嵌作用域”的 x
的值,但其实这个值已经是有问题的了。而在 Rust 语言中,编译器会给出一个编译错误,告诉你,“x
dropped here while still borrowed”,这个真是太棒了。
但是这中编译时检查的技术对于目前的编译器来说,只在程序变得稍微复杂一点,编译器的“失效引用”检查就不那么容易了。比如下面这个代码:
fn order_string(s1 : &str, s2 : &str) -> (&str, &str) { if s1.len() < s2.len() { return (s1, s2); } return (s2, s1); } let str1 = String::from("long long long long string"); let str2 = "short string"; let (long_str, short_str) = order_string(str1.as_str(), str2); println!(" long={} nshort={} ", long_str, short_str);
我们有两个字符串,str1
和 str2
我们想通过函数 order_string()
把这两个字串符返回成 long_str
和 short_str
这样方便后面的代码进行处理。这是一段很常见的处理代码的示例。然而,你会发现,这段代码编译不过。编译器会告诉你,order_string()
返回的 引用类型 &str
需要一个 lifetime的参数 – “ expected lifetime parameter”。这是因为Rust编译无法通过观察静态代码分析返回的两个引用返回值,到底是(s1, s2)
还是 (s2, s1)
,因为这是运行时决定的。所以,返回值的两个参数的引用没法确定其生命周期到底是跟 s1
还是跟 s2
,这个时候,编译器就不知道了。
生命周期
如果你的代码是下面这个样子,编程器可以自己推导出来,函数 foo()
的参数和返回值都是一个引用,他们的生命周期是一样的,所以,也就可以编译通过。
fn foo (s: &mut String) -> &String { s.push_str("coolshell"); s } let mut s = "hello, ".to_string(); println!("{}", foo(&mut s))
而对于传入多个引用,返回值可能是任一引用,这个时候编译器就犯糊涂了,因为不知道运行时的事,所以,就需要程序员来标注了。
fn long_string<'c>(s1 : &'c str, s2 : &'c str) -> (&'c str, &'c str) { if s1.len() > s2.len() { return (s1, s2); } return (s2, s1); }
上述的Rust的标注语法,用个单引号加一个任意字符�