从C++转向最受欢迎的Rust语言
作者:孟杰,騰訊 TEG后臺(tái)開(kāi)發(fā)工程師
在日常開(kāi)發(fā)過(guò)程中,若長(zhǎng)期使用C++語(yǔ)言,在初次使用Rust的過(guò)程中可能會(huì)碰到一些問(wèn)題。本文嘗試從C++的角度來(lái)說(shuō)明在使用Rust時(shí)需要特別注意的一些地方,特別是其中的思維方式的轉(zhuǎn)變(mind shift)。
不久前 Stackoverflow 網(wǎng)站做了一項(xiàng)有八萬(wàn)多開(kāi)發(fā)人員參與的調(diào)查問(wèn)卷,在“大家最想學(xué)習(xí)的編程語(yǔ)言”選項(xiàng)中,Rust高居第一。
一、賦值的move語(yǔ)義
(一)C++ vs Rust
C++的賦值操作是copy語(yǔ)義,在不考慮優(yōu)化的情況下,從語(yǔ)義的角度理解,賦值后內(nèi)存中的某個(gè)對(duì)象即變成了兩份。修改新的對(duì)象并不會(huì)對(duì)舊對(duì)象產(chǎn)生副作用。
而Rust對(duì)賦值操作有更加精細(xì)的控制,以下兩條:
對(duì)于所有實(shí)現(xiàn)了Copy trait的類(lèi)型來(lái)說(shuō),賦值采用了copy語(yǔ)義。
對(duì)于其它情況,采用move語(yǔ)義。
在Rust中直接使用編譯器來(lái)保證了move語(yǔ)義,確保變量的值被移出后,不能被再使用,如下例:
fn main() {let mut x = 5;let rx0 = &mut x;let rx1 = rx0;println!("test {}", rx0); }會(huì)產(chǎn)生編譯錯(cuò)誤:
error[E0382]: borrow of moved value: `rx0`--> src/main.rs:5:25| 3 | let rx0 = &mut x;| --- move occurs because `rx0` has type `&mut i32`, which does not implement the `Copy` trait 4 | let rx1 = rx0;| --- value moved here 5 | println!("test {}", rx0);| ^^^ value borrowed here after move明確地說(shuō)明了原因:變量在移動(dòng)后又被使用了,在哪兒被使用,以及為什么采用了move語(yǔ)義。
而在C++中,可以通過(guò)禁用class的拷貝構(gòu)造函數(shù)來(lái)達(dá)到禁止變量復(fù)制的目的。如以下代碼是編譯不通過(guò)的:
#include <memory>using namespace std;int main(int argc, const char* argv[]) {auto int_p0 = unique_ptr<int>(new int);auto int_p1 = int_p0;*int_p0 = 5;return 0; }在clang++中會(huì)產(chǎn)生如下錯(cuò)誤:
main.cc:8:10: error: call to implicitly-deleted copy constructor of 'std::__1::unique_ptr<int, std::__1::default_delete<int> >'auto int_p1 = int_p0;^ ~~~~~~ /opt/llvm/clang-10.0.1/bin/../include/c++/v1/memory:2513:3: note: copy constructor is implicitly deleted because 'unique_ptr<int, std::__1::default_delete<int> >' has a user-declared move constructorunique_ptr(unique_ptr&& __u) _NOEXCEPT^但是只需要將錯(cuò)誤行改成如下代碼即可以編譯通過(guò):
auto int_p1 = std::move(int_p0);但后果是,程序在對(duì)*int_p0進(jìn)行賦值時(shí)會(huì)產(chǎn)生coredump。這也是Rust所謂的內(nèi)存安全性,即只要沒(méi)有使用unsafe,編譯器可以發(fā)現(xiàn)內(nèi)存的錯(cuò)誤訪(fǎng)問(wèn),并拒絕通過(guò)編譯。
(二)引用&T與可變引用&mut T
還是上面的例子,如果將其中的可變引用改成非可變引用(默認(rèn)形式的引用),如下代碼:
fn main() {let x = 5;let rx0 = &x;let rx1 = rx0;println!("test {}", rx0); }可以通過(guò)編譯。Rust的文檔中有如下說(shuō)明:
The following traits are implemented for all &T, regardless of the type of its referent:Copy Clone (Note that this will not defer to T’s Clone implementation if it exists!) Deref Borrow Pointer*&mut T references get all of the above except Copy and Clone (to prevent creating multiple simultaneous mutable borrows), plus the following, regardless of the type of its referent:DerefMut BorrowMut&mut T相較于&T少實(shí)現(xiàn)了Copy和Clone。因此,對(duì)于可變引用&mut?T來(lái)說(shuō),賦值采用的是move語(yǔ)義,而對(duì)于普通引用&T來(lái)說(shuō)采用的是copy語(yǔ)義,所以改成普通引用上面的程序就可以編譯通過(guò)了。
這也是為什么可變引用也被稱(chēng)之為獨(dú)占引用,因?yàn)槊看螌?duì)可變引用的賦值,都意味著舊變量的失效,這就確保了全局只會(huì)存在一份可變引用。
Rust在這里體現(xiàn)了語(yǔ)言設(shè)計(jì)的優(yōu)雅:賦值操作的語(yǔ)義委托到了類(lèi)型系統(tǒng),通過(guò)定義基本的機(jī)制同時(shí)約束了自定義類(lèi)型與內(nèi)建類(lèi)型的行為,在編譯期完成檢查,而不是需要開(kāi)發(fā)去記憶各種特例。這在不了解語(yǔ)言的時(shí)候會(huì)產(chǎn)生學(xué)習(xí)曲線(xiàn),但是一旦了解了其套路后(Thinking in Rust), 可以顯著地降低編碼過(guò)程中的心智負(fù)擔(dān)。
二、Option與空指針
(一)enum與match
在C++中,對(duì)于可能存在或不存在的變量,慣常的作法之一是傳入指針 (包括現(xiàn)代C++中智能指針shared_ptr和unique_ptr),在處理時(shí),通過(guò)檢查指針是否為空來(lái)判斷變量是否存在。這是一種非常便利的做法,但是同樣的,此方案在編譯期無(wú)法做更多的檢查,最終檢查的責(zé)任交給了開(kāi)發(fā)。
Rust對(duì)此問(wèn)題主要使用了兩個(gè)機(jī)制:枚舉(enum)和模式匹配(match)。相比較C++的enum, Rust的enum更像是C++的union。是 ADT(algebraic data type)中sum types(tagged union)在Rust中的實(shí)現(xiàn)。在Rust中enum可能包括一組類(lèi)型中的一個(gè),如:
enum Message {Quit,Move {x: i32, y: i32},Write (String), }上面代碼表示,一條消息(Message)可能有三種類(lèi)型: Quit、Move和Write。當(dāng)類(lèi)型為Move或者Write時(shí),還可以帶上自己的特定的數(shù)據(jù)。當(dāng)處理Message時(shí),則會(huì)使用模式匹配機(jī)制取得具體類(lèi)型進(jìn)行處理:
match message {Message::Quit => todo!(),Message::Move { x, y } => todo!(),Message::Write(info) => todo!(), }為了避免在修改了enum的定義后,忘記在match中添加相應(yīng)的處理,match會(huì)在編譯期要求分支必須覆蓋全部可能的情況。如在Message中新加入一項(xiàng):
enum Message {Quit,Move {x: i32, y: i32},Write (String),Send (String), // 新加入 }再編譯時(shí)會(huì)出現(xiàn)以下錯(cuò)誤,提示開(kāi)發(fā)將Send的處理加入match。
--> src/main.rs:9:11| 1 | / enum Message { 2 | | Quit, 3 | | Move { x: i32, y: i32 }, 4 | | Write(String), 5 | | Send(String),| | ---- not covered 6 | | }| |_- `Message` defined here ... 9 | match message {| ^^^^^^^ pattern `Send(_)` not covered|= help: ensure that all possible cases are being handled, possibly by adding wildcards or more match arms= note: the matched value is of type `Message`由此可見(jiàn),在C++中,與其最相似的類(lèi)型其實(shí)是C++17的std::variant,而match機(jī)制類(lèi)似于std::visit。但Rust在這里做得更完善一些,體現(xiàn)在:
相同的子類(lèi)型可以因?yàn)門(mén)ag的不同出現(xiàn)多次,如上面的Write和Send,子類(lèi)型都是String。這是std::variant無(wú)法直接做到的,除非再封裝一個(gè)結(jié)構(gòu)。
match會(huì)要求分支覆蓋enum所有變體,std::visit也會(huì)在編譯期檢查完整的類(lèi)型覆蓋,但其中類(lèi)型會(huì)考慮C++的隱式類(lèi)型轉(zhuǎn)換,使用時(shí)需要小心。
(二)Option
有了上面的預(yù)備知識(shí),現(xiàn)在就可以來(lái)了解在Rust中是如何處理空懸指針的問(wèn)題。先看一下Option的定義:
pub enum Option<T> {/// No valueNone,/// Some value `T`Some(T), }在Rust中,對(duì)于可選的情景,會(huì)定義為該變量類(lèi)型的Option。假設(shè)某函數(shù)提供從磁盤(pán)讀取某個(gè)token,該token可能存在或者不存在,那么該函數(shù)的定義會(huì)是:
struct Token { /*...*/ };fn load_token() -> Option<Token>;在使用的時(shí)候會(huì)采用如下代碼:
let token = load_token(); // 此時(shí) token 的類(lèi)型是 Option<Token>match token {Some(token) => {// 注意這里的 token 是由 Some(token) 這個(gè) pattern 匹配出來(lái)的// 已經(jīng)覆蓋了最外層的 token. 此時(shí) token 的類(lèi)型是 Token, 已經(jīng)// 確保存在。todo!()},None => todo!() }可以看到,對(duì)于返回Option<T>的情形,無(wú)法直接將Option<T>當(dāng)作T來(lái)處理,只能使用模式匹配機(jī)制(match,if let,while let等),將T提取出來(lái)處理。這一步強(qiáng)制的機(jī)制,確保了對(duì)可為空的變量進(jìn)行檢查,避免了對(duì)空懸指針的意外訪(fǎng)問(wèn)。
相較于使用指針來(lái)表達(dá)可選情形,Option<T>的表達(dá)力會(huì)更豐富一些,因?yàn)闆](méi)有強(qiáng)制將T轉(zhuǎn)成T*,保留了移動(dòng)優(yōu)化的可能性;同時(shí),使用專(zhuān)門(mén)的類(lèi)型來(lái)表達(dá)可選,在語(yǔ)義上也理加精確一些。
了解Haskell的同學(xué)可以發(fā)現(xiàn),Option與Maybe如出一轍。事實(shí)上,Rust的類(lèi)型系統(tǒng),很大程度地受到了Haskell的影響,所以很多地方可以看到Haskell的影子也并不奇怪。學(xué)習(xí)Haskell對(duì)理解Rust也會(huì)很有幫助。
最后說(shuō)明一下,在C++17中加入的std::optional實(shí)現(xiàn)了類(lèi)似的功能。從接口上說(shuō)還是像智能指針,使用前需要判斷,否則對(duì)std::nullopt進(jìn)行dereference還是會(huì)產(chǎn)生運(yùn)行時(shí)故障。
三、迭代器Iterator
(一)Iterator在Rust中的地位
Iterator是Rust相對(duì)獨(dú)特的功能。對(duì)于Rust來(lái)說(shuō),采用如下的方式去遍歷數(shù)組是低效的:
let data = vec![1,2,3,4,5];for i in 0..data.len() {println!("{}", data[i]); }因?yàn)橄虬踩缘耐讌f(xié),每次data[i]的操作都會(huì)進(jìn)行邊界檢查,顯然這種檢查是不必要的,在性能敏感的場(chǎng)景中也是不可接受的。因此,在Rust中推薦的做法是:
for v in data {println!("{}", v); }使用迭代器的形式避免了最終取值時(shí)的再一次邊界檢查,同時(shí)也更加簡(jiǎn)潔。由此可見(jiàn),以地道的Rust風(fēng)格來(lái)說(shuō),遍歷數(shù)組應(yīng)該使用迭代器來(lái)完成,而不是通過(guò)遍歷下標(biāo)來(lái)進(jìn)行索引。
對(duì)于現(xiàn)代C++ (C++11)來(lái)說(shuō),也提供了類(lèi)似的語(yǔ)法方式進(jìn)行容器遍歷:
for (auto&& v: data) {// do something for v }(二)取得迭代器的三種形式
對(duì)于可以迭代的對(duì)象,以std::vec::Vec為例,通常會(huì)提供三種方式取得迭代器,如下:
iter():取得元素的引用,即&T,非消耗性。
iter_mut():取得元素的可變引用,即&mut?T,非消耗性。
into_iter():取得元素的所有權(quán),即T,消耗性。
這里消耗性指的是在迭代完成之后,原來(lái)的容器是否還可以繼續(xù)使用。對(duì)于into_iter()來(lái)說(shuō),在迭代過(guò)程中已經(jīng)將容器中的所有元素所有權(quán)全部取得,所以最終容器不再持有任何對(duì)象,也同時(shí)被drop。因此稱(chēng)之為消耗性的。
(三)IntoIterator
對(duì)于一般的迭代形式:
for x in data {}Rust期望data是一個(gè)實(shí)現(xiàn)了Iterator的對(duì)象。否則,會(huì)嘗試使用IntoIterator將data轉(zhuǎn)換成`Iterator`對(duì)象。所以對(duì)于data: Vec<i32>來(lái)說(shuō),實(shí)際展開(kāi)成了如下代碼:
for x in IntoIterator::into_iter(data) { }這里for ... in語(yǔ)句使用IntoIterator::into_iter獲取了目標(biāo)對(duì)象的迭代器。因此,凡是實(shí)現(xiàn)了IntoIterator的類(lèi)型均可以使用for ... in語(yǔ)句進(jìn)行迭代。
以std::vec::Vec為例,分別為Vec<T>、& Vec<T>和&mut Vec<T>實(shí)現(xiàn)了IntoIterator,并分別代理到into_iter()、iter()?和 iter_mut(),以應(yīng)對(duì)上面所說(shuō)的三種不同迭代形式。如下例(清晰起見(jiàn),將類(lèi)型注解加上了):
let mut data: Vec<i32> = Vec::from([1,2,3,4]); // 取得引用 for v: &i32 in &data {} // 取得可變引用 for v: &mut i32 in &mut data {} // 取得所有權(quán) for v: i32 in data {}(四)鏈?zhǔn)秸{(diào)用
在Rust的設(shè)計(jì)中,利用Adapter可以靈活而高效地通過(guò)Iterator來(lái)處理集合。
Adapter在Rust中指的是一類(lèi)函數(shù),它們接收一個(gè)Iterator并且返回一個(gè)Iterator。這樣的接口規(guī)范使用可以通過(guò)鏈?zhǔn)秸{(diào)用的方式組合多個(gè)Adapter完成復(fù)雜的功能。常見(jiàn)的Adapter包括:map、filter以及filter_map等等。
除了Adapter,Rust也提供其它一些函數(shù)用于迭代器的最終處理。比如:
count
用于計(jì)算元素的個(gè)數(shù)。
collect
用于收集迭代器中的元素到某個(gè)實(shí)現(xiàn)了FromIterator的類(lèi)型中去,比如Vec、VecDeque和String等等。
reduce
使用某個(gè)函數(shù)對(duì)集合進(jìn)行規(guī)約。類(lèi)似地,也可以使用fold進(jìn)行有初值的規(guī)約。
可以看到,針對(duì)迭代器,Rust提供了豐富的函數(shù)對(duì)其處理,具體可以參考文檔。此種編碼風(fēng)格,與舊風(fēng)格的C++很不一樣,轉(zhuǎn)到Rust后在需要對(duì)集合進(jìn)行循環(huán)處理的場(chǎng)合,可以有意識(shí)地想想,能不能將邏輯寫(xiě)成迭代器的形式,通常可以得到更加簡(jiǎn)潔的代碼,同時(shí),如前面所說(shuō),也可能獲得性能更高的代碼。
最后提一下,C++社區(qū)也在積極的采納此種代碼風(fēng)格,在C++20中,已經(jīng)將ranges加入標(biāo)準(zhǔn)。其中提供的Range adaptors與Rust的Adapter的概念基本是一樣的。如C++的樣例代碼:
auto const ints = {0,1,2,3,4,5};auto even = [](int i) { return 0 == i % 2; };auto square = [](int i) { return i * i; };// "pipe" syntax of composing the views:for (int i : ints | std::views::filter(even) | std::views::transform(square)) {std::cout << i << ' ';}寫(xiě)成Rust則是:
let ints = vec![0, 1, 2, 3, 4, 5];let even = |i: &i32| 0 == *i % 2;let square = |i: i32| i * i;for i in ints.into_iter().filter(even).map(square) {println!("{}", i);}一、惰性求值-Laziness
最后需要提一下的是,對(duì)于使用鏈?zhǔn)秸{(diào)用的方式將各種Adapter組合的Iterator,其求值是惰性的。即,當(dāng)寫(xiě)下如下代碼時(shí):
let v = vec![0,1,2,3,4,5]; v.iter().map(|i| println!("{}", i));其實(shí)并不會(huì)去調(diào)用println將數(shù)據(jù)輸出。Rust文檔的原文是:
This means that just creating an iterator doesn’t do a whole lot. Nothing really happens until you call next即,只有調(diào)用迭代器的next方法,才會(huì)依次觸發(fā)各級(jí)Iterator的求值。這樣做的好處是:
(一)性能
考慮如下代碼:
let v = vec![0,1,2,3,4,5,6,7,8,9]; let even = |i: &i32| 0 == *i % 2; let square = |i: i32| i * i; v.into_iter().filter(even).map(square).take(2);如果是eager evaluation,前兩個(gè)Adapter,filter(even)和map(square)會(huì)分別先執(zhí)行10次和5次,最后才是take(2)取到最開(kāi)始的兩個(gè)元素。如果這個(gè)數(shù)組的長(zhǎng)度不是10,而100萬(wàn),那么這里浪費(fèi)的空間和時(shí)間將會(huì)是巨大的。同時(shí)也會(huì)影響響應(yīng)時(shí)間,因?yàn)橹挥星懊鎯刹蕉继幚硗戤呏?#xff0c;才會(huì)進(jìn)行到最后一步。
而采用lazy evaluation時(shí),執(zhí)行會(huì)由take(2).next()傳導(dǎo)到map(square)再到filter(even), 最終不論數(shù)組的長(zhǎng)度是多少,都只會(huì)調(diào)用filter(even)3次,map(square)2次。沒(méi)有產(chǎn)生額外的開(kāi)銷(xiāo)。
(二)無(wú)限迭代
惰性求值的另一個(gè)好處是,使得無(wú)限迭代器成為了可能。考慮如下代碼:
let number = 1..; // 這是一個(gè)無(wú)限迭代器 for n in number.filter(even).take(5) {println!("{}", n) }不會(huì)因?yàn)閒ilter(even)的調(diào)用而陷入死循環(huán)。而是按需取用。使用此種方法,可以使用遞推公式實(shí)現(xiàn)數(shù)列的迭代器, 并支持各種Adapter的組合:
pub struct Fib {n0: u64,n1: u64, }impl Default for Fib {fn default() -> Self {Self { n0: 0, n1: 1 }} }impl Iterator for Fib {type Item = u64;fn next(&mut self) -> Option<Self::Item> {let n = self.n0 + self.n1;self.n0 = self.n1;self.n1 = n;Some(self.n0)} }fn main() {let fib = Fib::default();let square = |i: u64| i * i;for n in fib.map(square).take(10) {println!("{}", n);} }五、總結(jié)
本文主要是記錄自己從C++轉(zhuǎn)向Rust碰到的一些問(wèn)題,特別是記錄兩種語(yǔ)言在處理程序設(shè)計(jì)中基礎(chǔ)問(wèn)題的不同套路。這一篇主要介紹了三個(gè)主題:move語(yǔ)義、Option和Iterator。由于筆者寫(xiě)的Rust也不多,所以其中必然會(huì)有很多錯(cuò)誤與不足,發(fā)出來(lái)與大家交流,希望大家包涵并不吝指教。
之后也會(huì)以同樣的形式介紹其它主題,比如當(dāng)前心里還想著要記錄的有:錯(cuò)誤處理、生命周期&借用、interior mutability等。接下來(lái)自己爭(zhēng)取將后面的系列完成。
騰訊程序員今晚7點(diǎn)30分開(kāi)播
總結(jié)
以上是生活随笔為你收集整理的从C++转向最受欢迎的Rust语言的全部?jī)?nèi)容,希望文章能夠幫你解決所遇到的問(wèn)題。
- 上一篇: C++ 学习笔记
- 下一篇: 技术她力量,鹅厂女博士的寻“豹”之旅