首页密报 > 文章列表
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的概念認識的不完整,妳完全寫不出程序,那怕就是很簡單的一段代碼。這逼著程序員必需了解所有的概念才能編碼。但是,另一方面也錶明了這門語言併不適合初學者……


目錄

變量的可變性

變量的所有權

Owner語義帶來的復雜度

引用(借用)和生命周期

引用(借用)

生命周期

閉包與所有權

函數閉包

線程閉包

Rust的智能指針

線程與智能指針

多態和運行時識別

通過Trait多態

嚮下轉型

Trait 重載操作符

小結


變量的可變性

首先,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 移出作用域併調用 <code data-enlighter-language="raw" class="EnlighterJSRAW">drop</code> 方法。佔用的內存被釋放

// 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("haoel@hotmail.com"),

    };

    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)] // 讓結構體可以使用 <code data-enlighter-language="raw" class="EnlighterJSRAW">{:?}</code>的方式輸出

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,

}

//實現結構體 <code data-enlighter-language="raw" class="EnlighterJSRAW">Render</code> 的方法

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 <code data-enlighter-language="raw" class="EnlighterJSRAW">self.next_buffer</code> which is behind a mutable reference

--> /.........../xxx.rs:18:31

|

14 | self.current_buffer = self.next_buffer;

|                          ^^^^^^^^^^^^^^^^ move occurs because <code data-enlighter-language="raw" class="EnlighterJSRAW">self.next_buffer</code> has type <code data-enlighter-language="raw" class="EnlighterJSRAW">Buffer</code>,

                                            which does not implement the <code data-enlighter-language="raw" class="EnlighterJSRAW">Copy</code> 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 中。 我們需要一個“雜耍”的技能。

indy

這個需要動用 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};

    //可以運行,因為 <code data-enlighter-language="raw" class="EnlighterJSRAW">u8</code> 有 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實現了 <code data-enlighter-language="raw" class="EnlighterJSRAW">Copy</code>,所以,這裏還可以訪問

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的概念認識的不完整,妳完全寫不出程序,那怕就是很簡單的一段代碼。我覺得,這種挺好的,逼著程序員必需了解所有的概念才能編碼。但是,另一方面也錶明了這門語言併不適合初學者。

沒有銀彈,任何語言都有些適合的地方和場景。

生成海报
请长按保存图片,将内容分享给更多好友