總是有很多很多人來問我對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 of `self.next_buffer` which is behind a mutable reference
--> /.........../xxx.rs:18:31
|
14 | self.current_buffer = self.next_buffer;
| ^^^^^^^^^^^^^^^^ move occurs because `self.next_buffer` has type `Buffer`,
which does not implement the `Copy` 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的標註語法,用個單引號加一個任意字符串來標註('static
除外,這是一個關鍵詞,表示生命週期跟整個程序一樣長),然後,說明返回的那兩個引用的生命週期跟 s1
和 s2
的生命週期相同,這個標註的目的就是把運行時的事變成了編譯時的事。於是程序就可以編譯通過了。(注:你也不要以爲你可以用這個技術亂寫生命週期,這只是一種“去語法糖操作”,是幫助編譯器理解其中的生命週期,如果違反實際生命週期,編譯器也是會拒絕編譯的)
這裏有兩個說明,
- 只要你玩引用,生命週期標識就會來了。
- Rust編譯器不知道運行時會發生什麼事,所以,需要你來標註聲明
我感覺,你現在開始有點頭暈了吧?接下來,我們讓你再暈一下。比如:如果你要在結構體中玩引用,那必需要爲引用聲明生命週期,如下所示:
// 引用 ref1 和 ref2 的生命週期與結構體一致
struct Test <'life> {
ref_int : &'life i32,
ref_str : &'life str,
}
其中,生命週期標識 'life
定義在結構體上,被使用於其成員引用上。意思是聲明規則——“結構體的生命週期 <= 成員引用的生命週期”
然後,如果你要給這個結構實現兩個 set
方法,你也得帶上 lifetime 標識。
imp<'life> Test<'life> {
fn set_string(&mut self, s : &'life str) {
self.ref_str = s;
}
fn set_int(&mut self, i : &'life i32) {
self.ref_int = i;
}
}
在上面的這個示例中,生命週期變量 'life
聲明在 impl
上,用於結構體和其方法的入參上。 意思是聲明規則——“結構體方法的“引用參數”的生命週期 >= 結構體的生命週期”
有了這些個生命週期的標識規則後,Rust就可以愉快地檢查這些規則說明,並編譯代碼了。
閉包與所有權
這種所有權和引用的嚴格區分和管理,會影響到很多地方,下面我們來看一下函數閉包中的這些東西的傳遞。函數閉包又叫Closure,是函數式編程中一個不可或缺的東西,又被稱爲lambda表達式,基本上所有的高級語言都會支持。在 Rust 語言中,其閉包函數的表示是用兩根豎線(| |)中間加傳如參數進行定義。如下所示:
// 定義了一個 x + y 操作的 lambda f(x, y) = x + y;
let plus = |x: i32, y:i32| x + y;
// 定義另一個lambda g(x) = f(x, 5)
let plus_five = |x| plus(x, 5);
//輸出
println!("plus_five(10)={}", plus_five(10) );
函數閉包
但是一旦加上了上述的所有權這些東西后,問題就會變得複雜開來。參看下面的代碼。
struct Person {
name : String,
age : u8,
}
fn main() {
let p = Person{ name: "Hao Chen".to_string(), age : 44};
//可以運行,因爲 `u8` 有 Copy Trait
let age = |p : Person| p.age;
// String 沒有Copy Trait,所以,這裏所有權就 Move 走了
let name = |p : Person | p.name;
println! ("name={}, age={}" , name(p), age(p));
}
上面的代碼無法編譯通過,因爲Rust編譯器發現在調用 name(p)
的時候,p
的所有權被移走了。然後,我們想想,改成引用的版本,如下所示:
let age = |p : &Person| p.age;
let name = |p : &Person | &p.name;
println! ("name={}, age={}" , name(&p), age(&p));
你會現在還是無法編譯,報錯中說:cannot infer an appropriate lifetime for borrow expression due to conflicting requirements
error[E0495]: cannot infer an appropriate lifetime for borrow expression due to conflicting requirements
--> src/main.rs:11:31
|
11 | let name = |p : &Person | &p.name;
| ^^^^^^^
然後你開始嘗試加 lifetime,用盡各種Rust的騷操作(官方Github上的 #issue 58052),然後,還是無法讓你的程序可以編譯通過。最後,上StackOverflow 裏尋找幫助,得到下面的正確寫法(這個可能跟這個bug有關係:#issue 41078 )。但是這樣的寫法,已經讓簡潔的代碼變得面目全非。
//下面的聲明可以正確譯
let name: for<'a> fn(&'a Person) -> &'a String = |p: &Person| &p.name;
上面的這種lifetime的標識也是很奇葩,通過定義一個函數類型來做相關的標註,但是這個函數類型,需要用到 for<'a>
關鍵字。你可能會很confuse這個關鍵字不是用來做循環的嗎?嗯,Rust這種重用關鍵字的作法,我個人覺得帶來了很多不必要的複雜度。總之,這樣的聲明代碼,我覺得基本不會有人能想得到的——“去語法糖操作太嚴重了,絕大多數人絕對hold不住”!
最後,我們再來看另一個問題,下面的代碼無法編譯通過:
let s = String::from("coolshell");
let take_str = || s;
println!("{}", s); //ERROR
println!("{}", take_str()); // OK
Rust的編譯器會告訴你,take_str
把 s
的所有權給拿走了(因爲需要作成返回值)。所以,後面的輸出語句就用不到了。這裏意味着:
- 對於內建的類型,都實現了
Copy
的 trait,那麼閉包執行的是 “借用” - 對於沒有實現
Copy
的trait,在閉包中可以調用其方法,是“借用”,但是不能當成返回值,當成返回值了就是“移動”。
雖然有了這些“通常情況下是借用的潛規則”,但是還是不能滿足一些情況,所以,還要讓程序員可以定義 move
的“明規則”。下面的代碼,一個有 move 一個沒有move,他們的差別也不一樣。
//-----------借用的情況-----------
let mut num = 5;
{
let mut add_num = |x: i32| num += x;
add_num(5);
}
println!("num={}", num); //輸出 10
//-----------Move的情況-----------
let mut num = 5;
{
// 把 num(5)所有權給 move 到了 add_num 中,
// 使用其成爲閉包中的局部變量。
let mut add_num = move |x: i32| num += x;
add_num(5);
println!("num(move)={}", num); //輸出10
}
//因爲i32實現了 `Copy`,所以,這裏還可以訪問
println!("num(move)={}", num); //輸出5
真是有點頭大了,int這樣的類型,因爲實現了Copy Trait,所以,所有權被移走後,意味着,在內嵌塊中的num
和外層的 num
是兩個完全不相干的變量。但是你在讀代碼的時候,你的大腦可能並不會讓你這麼想,因爲裏面的那個num又沒有被聲明過,應該是外層的。我個人覺得這是Rust 各種“按下葫蘆起了瓢”的現象。
線程閉包
通過上面的示例,我們可以看到, move
關鍵詞,可以把閉包外使用到的變量給移動到閉包內,成爲閉包內的一個局部變量。這種方式,在多線程的方式下可以讓線程運行地更爲的安全。參看如下代碼:
let name = "CoolShell".to_string();
let t = thread::spawn(move || {
println!("Hello, {}", name);
});
println!("wait {:?}", t.join());
首先,線程 thread::spawn()
裏的閉包函數是不能帶參數的,因爲是閉包,所以可以使用這個可見範圍內的變量,但是,問題來了,因爲是另一個線程,所以,這代表其和其它線程(如:主線程)開始共享數據了,所以,在Rust下,要求把使用到的變量給 Move 到線程內,這就保證了安全的問題—— name
在編程中永遠不會失效,而且不會被別人改了。
你可能會有一些疑問,你會質疑到
- 一方面,這個
name
變量又沒有聲明成mut
這意味着不變,沒必要使用move語義也是安全的。 - 另一方面,如果我想把這個
name
傳遞到多個線程裏呢?
嗯,是的,但是Rust的線程必需是 move的,不管是不是可變的,不然編譯不過去。如果你想把一個變量傳到多個線程中,你得創建變量的複本,也就是調用 clone()
方法。
let name = "CoolShell".to_string();
let name1 = name.clone();
let t1 = thread::spawn(move || {
println!("Hello, {}", name.clone());
})
let t2 = thread::spawn(move || {
println!("Hello, {}", name1.clone());
});
println!("wait t1={:?}, t2={:?}", t1.join(), t2.join());
然後,你說,這種clone的方式成本不是很高?設想,如果我要用多線程對一個很大的數組做統計,這種clone的方式完全喫不消。嗯,是的。這個時候,需要使用另一個技術,智能指針了。
Rust的智能指針
如果你看到這裏還不暈的話,那麼,我的文章還算成功(如果暈的話,請告訴我,我會進行改善)。接下來我們來講講Rust的智能指針和多態。
因爲有些內存需要分配在Heap(堆)上,而不是Stack(堆)上,Stack上的內存一般是編譯時決定的,所以,編譯器需要知道你的數組、結構構、枚舉等這些數據類型的長度,沒有長度是無法編譯的,而且長度也不能太大,Stack上的內存大小是有限,太大的內存會有StackOverflow的錯誤。所以,對於更大的內存或是動態的內存分配需要分配在Heap上。學過C/C++的同學對於這個概念不會陌生。
Rust 作爲一個內存安全的語言,這個堆上分配的內存也是需要管理的。在C中,需要程序員自己管理,而在C++中,一般使用 RAII 的機制(面向對象的代理模式),一種通過分配在Stack上的對象來管理Heap上的內存的技術。在C++中,這種技術的實現叫“智能指針”(Smart Pointer)。
在C++11中,會有三種智能指針(這三種指針是什麼我就不多說了):
unique_ptr
。獨佔內存,不共享。在Rust中是:std::boxed::Box
shared_ptr
。以引用計數的方式共享內存。在Rust中是:std::rc::Rc
weak_ptr
。不以引用計數的方式共享內存。在Rust中是:std::rc::Weak
對於獨佔的 Box
不多說了,這裏重點說一下共享的 Rc
和 Weak
:
- 對於Rust的 Rc 來說,Rc指針內會有一個
strong_count
的引用持計數,一旦引用計數爲0後,內存就自動釋放了。 - 需要共享內存的時候,需要調用實例的
clone()
方法。如:let another = rc.clone()
克隆的時候,只會增加引用計數,不會作深度複製(個人覺得Clone的語義在這裏被踐踏了) - 有這種共享的引用計數,就意味着有多線程的問題,所以,如果需要使用線程安全的智能指針,則需要使用
std::sync::Arc
- 可以使用
Rc::downgrade(&rc)
後,會變成 Weak 指針,Weak指針增加的是weak_count
的引用計數,內存釋放時不會檢查它是否爲 0。
我們簡單的來看個示例:
use std::rc::Rc;
use std::rc::Weak
//聲明兩個未初始化的指針變量
let weak : Weak;
let strong : Rc;
{
let five = Rc::new(5); //局部變量
strong = five.clone(); //進行強引用
weak = Rc::downgrade(&five); //對局部變量進行弱引用
}
//此時,five已析構,所以 Rc::strong_count(&strong)=1, Rc::weak_count(&strong)=1
//如果調用 drop(strong),那個整個內存就釋放了
//drop(strong);
//如果要訪問弱引用的值,需要把弱引用 upgrade 成強引用,才能安全的使用
match weak_five.upgrade() {
Some(r) => println!("{}", r),
None => println!("None"),
}
上面這個示例比較簡單,其中主要展示了,指針共享的東西。因爲指針是共享的,所以,對於強引用來說,最後的那個人把引用給釋放了,是安全的。但是對於弱引用來說,這就是一個坑了,你們強引用的人有Ownership,但是我們弱引用沒有,你們把內存釋放了,我怎麼知道?
於是,在弱引用需要使用內存的時候需要“升級”成強引用 ,但是這個升級可能會不成功,因爲內存可能已經被別人清空了。所以,這個操作會返回一個 Option
的枚舉值,Option::Some(T)
表示成功了,而 Option::None
則表示失改了。你會說,這麼麻煩,我們爲什麼還要 Weak
? 這是因爲強引用的 Rc
會有循環引用的問題……(學過C++的都應該知道)
另外,如果你要修改 Rc
裏的值,Rust 會給你兩個方法,一個是 get_mut()
,一個是 make_mut()
,這兩個方法都有副作用或是限制。
get_mut()
需要做一個“唯一引用”的檢查,也就是沒有任何的共享才能修改
//修改引用的變量 - get_mut 會返回一個Option對象
//但是需要注意,僅當(只有一個強引用 && 沒有弱引用)爲真才能修改
if let Some(val) = Rc::get_mut(&mut strong) {
*val = 555;
}
make_mut()
則是會把當前的引用給clone出來,再也不共享了, 是一份全新的。
//此處可以修改,但是是以 clone 的方式,也就是讓strong這個指針獨立出來了。
*Rc::make_mut(&mut strong) = 555;
如果不這樣做,就會出現很多內存不安全的情況。這些小細節一定要注意,不然你的代碼怎麼運作的你會一臉蒙逼的。
嗯,如果你想更快樂地使用智能指針,這裏還有個選擇 – Cell
和 RefCell
,它們彌補了 Rust 所有權機制在靈活性上和某些場景下的不足。他們提供了 set()
/get()
以及 borrow()
/borrow_mut()
的方法,讓你的程序更靈活,而不會被限制得死死的。參看下面的示例。
use std::cell::Cell;
use std::cell::RefCell
let x = Cell::new(1);
let y = &x; //引用(借用)
let z = &x; //引用(借用)
x.set(2); // 可以進行修改,x,y,z全都改了
y.set(3);
z.set(4);
println!("x={} y={} z={}", x.get(), y.get(), z.get());
let x = RefCell::new(vec![1,2,3,4]);
{
println!("{:?}", *x.borrow())
}
{
let mut my_ref = x.borrow_mut();
my_ref.push(1);
}
println!("{:?}", *x.borrow());
通過上面的示例你可以看到你可以比較方便地更爲正常的使用智能指針了。然而,需要注意的是Cell
和 RefCell
不是線程安全的。在多線程下,需要使用Mutex進行互斥。
線程與智能指針
現在,我們回來來解決前面那還沒有解決的問題,就是——我想在多個線程中共享一個只讀的數據,比如:一個很大的數組,我開多個線程進行並行統計。我們肯定不能對這個大數組進行clone,但也不能把這個大數組move到一個線程中。根據上述的智能指針的邏輯,我們可以通過智指指針來完成這個事,下面是一個例程:
const TOTAL_SIZE:usize = 100 * 1000; //數組長度
const NTHREAD:usize = 6; //線程數
let data : Vec<i32> = (1..(TOTAL_SIZE+1) as i32).collect(); //初始化一個數據從1到n數組
let arc_data = Arc::new(data); //data 的所有權轉給了 ar_data
let result = Arc::new(AtomicU64::new(0)); //收集結果的數組(原子操作)
let mut thread_handlers = vec![]; // 用於收集線程句柄
for i in 0..NTHREAD {
// clone Arc 準備move到線程中,只增加引用計數,不會深拷貝內部數據
let test_data = arc_data.clone();
let res = result.clone();
thread_handlers.push(
thread::spawn(move || {
let id = i;
//找到自己的分區
let chunk_size = TOTAL_SIZE / NTHREAD + 1;
let start = id * chunk_size;
let end = std::cmp::min(start + chunk_size, TOTAL_SIZE);
//進行求和運算
let mut sum = 0;
for i in start..end {
sum += test_data[i];
}
//原子操作
res.fetch_add(sum as u64, Ordering::SeqCst);
println!("id={}, sum={}", id, sum );
}
));
}
//等所有的線程執行完
for th in thread_handlers {
th.join().expect("The sender thread panic!!!");
}
//輸出結果
println!("result = {}",result.load(Ordering::SeqCst));
上面的這個例程,是用多線程的方式來並行計算一個大的數組的和,每個線程都會計算自己的那一部分。上面的代碼中,
- 需要向每個線程傳入一個只讀的數組,我們用
Arc
智能指針把這個數組包了一層。 - 需要向每個線程傳入一個變量用於數據數據,我們用
Arc<AtomicU64>
包了一層。 - 注意:
Arc
所包的對象是不可變的,所以,如果要可變的,那要麼用原子對象,或是用Mutex/Cell對象再包一層。
這一些都是爲了要解決“線程的Move語義後還要共享問題”。
多態和運行時識別
通過Trait多態
多態是抽象和解耦的關鍵,所以,一個高級的語言是必需實現多態的。在C++中,多態是通過虛函數表來實現的(參看《C++的虛函數表》),Rust也很類似,不過,在編程範式上,更像Java的接口的方式。其通過借用於Erlang的Trait對象的方式來完成。參看下面的代碼:
struct Rectangle {
width : u32,
height : u32,
}
struct Circle {
x : u32,
y : u32,
radius : u32,
}
trait IShape {
fn area(&self) -> f32;
fn to_string(&self) -> String;
}
我們有兩個類,一個是“長方形”,一個是“圓形”, 還有一個 IShape
的trait 對象(原諒我用了Java的命名方式),其中有兩個方法:求面積的 area()
和 轉字符串的 to_string()
。下面相關的實現:
impl IShape for Rectangle {
fn area(&self) -> f32 { (self.height * self.width) as f32 }
fn to_string(&self) ->String {
format!("Rectangle -> width={} height={} area={}",
self.width, self.height, self.area())
}
}
use std::f64::consts::PI;
impl IShape for Circle {
fn area(&self) -> f32 { (self.radius * self.radius) as f32 * PI as f32}
fn to_string(&self) -> String {
format!("Circle -> x={}, y={}, area={}",
self.x, self.y, self.area())
}
}
於是,我們就可以有下面的多態的使用方式了(我們使用獨佔的智能指針類 Box
):
use std::vec::Vec;
let rect = Box::new( Rectangle { width: 4, height: 6});
let circle = Box::new( Circle { x: 0, y:0, radius: 5});
let mut v : Vec<Box> = Vec::new();
v.push(rect);
v.push(circle);
for i in v.iter() {
println!("area={}", i.area() );
println!("{}", i.to_string() );
}
向下轉型
但是,在C++中,多態的類型是抽象類型,我們還想把其轉成實際的具體類型,在C++中叫運行進實別RTTI,需要使用像 type_id
或是 dynamic_cast
這兩個技術。在Rust中,轉型是使用 ‘as
‘ 關鍵字,然而,這是編譯時識別,不是運行時。那麼,在Rust中是怎麼做呢?
嗯,這裏需要使用 Rust 的 std::any::Any
這個東西,這個東西就可以使用 downcast_ref
這個東西來進行具體類型的轉換。於是我們要對現有的代碼進行改造。
首先,先得讓 IShape
繼承於 Any
,並增加一個 as_any()
的轉型接口。
use std::any::Any;
trait IShape : Any + 'static {
fn as_any(&self) -> &dyn Any;
…… …… ……
}
然後,在具體類中實現這個接口:
impl IShape for Rectangle {
fn as_any(&self) -> &dyn Any { self }
…… …… ……
}
impl IShape for Circle {
fn as_any(&self) -> &dyn Any { self }
…… …… ……
}
於是,我們就可以進行運行時的向下轉型了:
let mut v : Vec<Box<dyn IShape>> = Vec::new();
v.push(rect);
v.push(circle);
for i in v.iter() {
if let Some(s) = i.as_any().downcast_ref::<Rectangle>() {
println!("downcast - Rectangle w={}, h={}", s.width, s.height);
}else if let Some(s) = i.as_any().downcast_ref::<Circle>() {
println!("downcast - Circle x={}, y={}, r={}", s.x, s.y, s.radius);
}else{
println!("invaild type");
}
}
Trait 重載操作符
操作符重載對進行泛行編程是非常有幫助的,如果所有的對象都可以進行大於,小於,等於這親的比較操作,那麼就可以直接放到一個標準的數組排序的的算法中去了。在Rust中,在 std::ops
下有全載的操作符重載的Trait,在std::cmp
下則是比較操作的操作符。我們下面來看一個示例:
假如我們有一個“員工”對象,我們想要按員工的薪水排序,如果我們想要使用Vec::sort()
方法,我們就需要實現這個對象的各種“比較”方法。這些方法在 std::cmp
內—— 其中有四個Trait :Ord
、PartialOrd
、Eq
和 PartialEq
。其中,Ord
依賴於 PartialOrd
和 Eq
,而Eq
依賴於PartialEq
,這意味着你需要實現所有的Trait,而Eq
這個Trait 是沒有方法的,所以,其實現如下:
use std::cmp::{Ord, PartialOrd, PartialEq, Ordering};
#[derive(Debug)]
struct Employee {
name : String,
salary : i32,
}
impl Ord for Employee {
fn cmp(&self, rhs: &Self) -> Ordering {
self.salary.cmp(&rhs.salary)
}
}
impl PartialOrd for Employee {
fn partial_cmp(&self, rhs: &Self) -> Option<Ordering> {
Some(self.cmp(rhs))
}
}
impl Eq for Employee {
}
impl PartialEq for Employee {
fn eq(&self, rhs: &Self) -> bool {
self.salary == rhs.salary
}
}
於是,我們就可以進行如下的操作了:
let mut v = vec![
Employee {name : String::from("Bob"), salary: 2048},
Employee {name : String::from("Alice"), salary: 3208},
Employee {name : String::from("Tom"), salary: 2359},
Employee {name : String::from("Jack"), salary: 4865},
Employee {name : String::from("Marray"), salary: 3743},
Employee {name : String::from("Hao"), salary: 2964},
Employee {name : String::from("Chen"), salary: 4197},
];
//用for-loop找出薪水最多的人
let mut e = &v[0];
for i in 0..v.len() {
if *e < v[i] {
e = &v[i];
}
}
println!("max = {:?}", e);
//使用標準的方法
println!("min = {:?}", v.iter().min().unwrap());
println!("max = {:?}", v.iter().max().unwrap());
//使用標準的排序方法
v.sort();
println!("{:?}", v);
小結
現在我們來小結一下:
- 在Rust的中,最重要的概念就是“不可變”和“所有權”以及“Trait”這三個概念。
- 在所有權概念上,Rust喜歡move所有權,如果需要借用則需要使用引用。
- Move所有權會導致一些編程上的複雜度,尤其是需要同時move兩個變量時。
- 引用(借用)的問題是生命週期的問題,一些時候需要程序員來標註生命週期。
- 在函數式的閉包和多線程下,這些所有權又出現了各種麻煩事。
- 使用智能指針可以解決所有權和借用帶來的複雜度,但帶來其它的問題。
- 最後介紹了Rust的Trait對象完成多態和函數重載的玩法。
Rust是一個比較嚴格的編程語言,它會嚴格檢查你程序中的:
- 變量是否是可變的
- 變量的所有權是否被移走了
- 引用的生命週期是否完整
- 對象是否需要實現一些Trait
這些東西都會導致失去編譯的靈活性,並在一些時候需要“去糖”,導致,你在使用Rust會有諸多的不適應,程序編譯不過的挫敗感也是令人沮喪的。在初學Rust的時候,我想自己寫一個單向鏈表,結果,費盡心力,才得以完成。也就是說,如果你對Rust的概念認識的不完整,你完全寫不出程序,那怕就是很簡單的一段代碼。我覺得,這種挺好的,逼着程序員必需瞭解所有的概念才能編碼。但是,另一方面也表明了這門語言並不適合初學者。
沒有銀彈,任何語言都有些適合的地方和場景。
(全文完)
《Rust語言的編程範式》的相關評論
-
ersee說道:
皓哥,關於shadowing是有適用範圍的,比如說函數從外面拿到一個String,我現在只想要這個String的長度而不需要它的內容,shadowing可以免去起新名字.
fn Foo(s:String) {
let s=s.len();
}
而注意到let s其實是重新綁定了變量(新s和舊s類型不同),而不是直接s=s.len(),這樣編譯是不會通過的.-
陳皓說道:
嗯,Rust的規則太多了……
-
DennisThink說道:
看完文章以後,我覺得Rust比C++難多了。在C++11以後,只要用好shared_ptr,unique_ptr和weak_ptr,在性能和安全性上就問題不大了。
看了Rust的語法以後,感覺要想寫出一個冒泡排序也要好久。不太明白爲什麼Rust不直接禁止掉Shadowing,直接編譯報錯就好了?新語言有沒有舊代碼的負擔。
個人建議,僅供參考。
-
ersee說道:
Shadowing不是讓濫用的啊…Shadowing的時候最好你自己清楚自己在幹什麼,新舊同名變量最好不是同種類型的
-
-
-
fatgrammer說道:
Rust最詭異的算是 ‘a 這個寫法, 出自OCaml. 其他的語法主要借鑑Scala, 語義借鑑Cpp. 有時間有興趣可以兼學習.
-
陳皓說道:
爲了編譯時檢查,所以,只能讓程序來幫編譯器了。這是“去語法糖”操作。
-
secondwtq說道:
其實我感覺 shadowing 也是受 OCaml 影響的 … 這東西在 OCaml 代碼裏很常用
-
-
Xidorn Quan說道:
關於裏面提到的多線程問題,需要move本質上是因爲標準庫裏的 thread::spawn 要求閉包是 ‘static 的,因爲無法在編譯時驗證線程的生命期。但在多線程計算時,線程的生命期通常都是有限的,因爲你需要它的結果,這時就可以通過crossbeam或者rayon裏提供的scoped thread來無開銷地在多線程之間共享數據而不需要使用Arc。
-
陳皓說道:
嗯。是的,但一般說編程範式的事都是說標準庫的事。擴展的不能算。
-
Xidorn Quan說道:
但Rust相對來說有較小的標準庫和更好的包管理,所以很多常用庫也應該算作範式的一部分吧,畢竟是Rust的設計使得這樣一些功能可以在庫裏被安全地包裝出來。
-
-
-
Xidorn Quan說道:
注意到這裏甚至沒有提到Arc……C++裏的shared_ptr其實應該對應到Rust的Arc,因爲shared_ptr是線程安全的,Rc不是(跨線程使用會導致無法通過編譯)。Rc只是Arc的單線程版本(不使用原子計數)。另外多線程下也不能使用Cell和RefCell,只能使用Atomic*和Mutex/RwLock等同步原語。
-
陳皓說道:
Rc和Arc是提了,Cell和RefCell的線程安全問題也提了。
-
-
土豆太大說道:
關於 Clone::clone() 語義的問題,實際上是因類型而定的,而非泛泛的淺拷貝/深拷貝概念(Rust 裏就沒有這倆概念),所以個人認爲不存在“踐踏” Clone 語義的問題。
-
陳皓說道:
我覺得我理解的clone就是字面上的語議,就是全部clone出來,不存在共享。
-
土豆太大說道:
這裏是各類型自己定義“什麼叫 clone 自身”。對於 Copy 類型, clone 就是 memcpy ;對於 String ,就是弄出來一個一毛一樣的新 String ;對於帶所有權的智能指針類型,就要根據自身的語義分別判斷(Box 的 clone 是透傳到裏面的類型的 clone 的行爲,Rc/Arc 的 clone 就是加引用計數);可以看出來這裏面並沒有一個統一的範式。至於 clone 的名字,估計是當時設計的時候也想不出來什麼更好的了,至少沒有淺拷貝/深拷貝這倆定義不清語義混亂的概念,已經是一大進步了。
-
-
-
kkk說道:
自認爲高級編程語言是提高生產效率,降低程序員心智負擔,能快速造出產品產生價值創造收益的。很難想象這麼多規則下來,還有人能樂在其中構建產品,尤其是經過 C++ 折磨過的人,當然對語言本身就有興趣的人除外。據說 Rust 編譯也很慢,算是繼承了 C++ 了 ;)
-
土豆太大說道:
“高級”在很多方向上具有不同的含義:Haskell 、SQL 、Prolog 都很“高級”,但它們的目標完全不同。從這個意義上說,不存在普適的“高級”定義。(而且作爲一個 Rust 吹子我其實不覺得 Rust 很高級……)
-
-
universeroc說道:
關於 Rust 學習曲線陡峭這個事情我倒覺得是好事情,從術業有專攻的角度來看,編程並不是一件容易的事情。尤其是所謂入門簡單的語言最後都會給你狠狠的一記耳光,在你覺得自己入門了,可以大展身手的時候。Rust 從一開始就你放下這種自負的幻覺,專注於掌握語言的所有細節,否則寸步難行一點不誇張。定位於全新系統開發語言,追求穩定高效,門檻高一點是好事,畢竟不是隨便搞搞就能去做系統開發的,系統知識要求高。當然當前流行的 JavaScript、python 甚至 Go 都是以簡單易上手而獲得大量用戶,我只能說 Rust 再走一條自己的路
-
陳皓說道:
嗯,好壞都是相對的,這一邊有了,另一邊就沒有了。當你說編程並不容易,我也可以說編程也沒什麼神祕的事,當你說,編譯需要了解這些變量所有權的東西,我也可以說,編程本就不應該要去關心這些。你可以說門檻高的東西容易培養高手,而我也可以說這樣的東西在傳播上都會遇到問題,最終曲高和寡……任何事情都是trade-off,這一面有多光亮,另一面就有多黑暗……
看看Rust的那些語法糖的操作,還有爲了要同時move兩個對象用unsafe搞出來的之類的騷操作,其實都是讓這門語言複雜度攀升的原因,這些東西,我並不覺得有多好!
-
tom不喫鹹魚說道:
嗯,好壞都是相對的,主要還是要看取捨。
-