第2章 變量和類型
2.1 變量聲明
Rust的變量必須先聲明后使用。對于局部變量,最常見的聲明語法為:
let variable : i32 = 100;
與傳統的C/C++語言相比,Rust的變量聲明語法不同。這樣設計主要有以下幾個方面的考慮。
1.語法分析更容易
從語法分析的角度來說,Rust的變量聲明語法比C/C++語言的簡單,局部變量聲明一定是以關鍵字let開頭,類型一定是跟在冒號:的后面。語法歧義更少,語法分析器更容易編寫。
2.方便引入類型推導功能
Rust的變量聲明的一個重要特點是:要聲明的變量前置,對它的類型描述后置。這也是吸取了其他語言的教訓后的結果。因為在變量聲明語句中,最重要的是變量本身,而類型其實是個附屬的額外描述,并非必不可少的部分。如果我們可以通過上下文環境由編譯器自動分析出這個變量的類型,那么這個類型描述完全可以省略不寫。Rust一開始的設計就考慮了類型自動推導功能,因此類型后置的語法更合適。
3.模式解構
let語句不光是局部變量聲明語句,而且具有pattern destructure(模式解構)的功能。關于“模式解構”的內容在后面的章節會詳細描述。
實際上,包括C++ / C# / Java等傳統編程語言都開始逐步引入這種聲明語法,目的是相似的。
Rust中聲明變量缺省是“只讀”的,比如如下程序:
fn main() { let x = 5; x = 10; }
會得到“re-assignment of immutable variable `x`”這樣的編譯錯誤。
如果我們需要讓變量是可寫的,那么需要使用mut關鍵字:
let mut x = 5; // mut x: i32 x = 10;
此時,變量x才是可讀寫的。
實際上,let語句在此處引入了一個模式解構,我們不能把let mut視為一個組合,而應該將mut x視為一個組合。
mut x是一個“模式”,我們還可以用這種方式同時聲明多個變量:
let (mut a, mut b) = (1, 2); let Point { x: ref a, y: ref b} = p;
其中,賦值號左邊的部分是一個“模式”,第一行代碼是對tuple的模式解構,第二行代碼是對結構體的模式解構。所以,在Rust中,一般把聲明的局部變量并初始化的語句稱為“變量綁定”,強調的是“綁定”的含義,與C/C++中的“賦值初始化”語句有所區別。
Rust中,每個變量必須被合理初始化之后才能被使用。使用未初始化變量這樣的錯誤,在Rust中是不可能出現的(利用unsafe做hack除外)。如下這個簡單的程序,也不能編譯通過:
fn main() { let x: i32; println!("{}", x); }
錯誤信息為:
error: use of possibly uninitialized variable: `x`
編譯器會幫我們做一個執行路徑的靜態分析,確保變量在使用前一定被初始化:
fn test(condition: bool) { let x: i32; // 聲明 x,不必使用 mut 修飾 if condition { x = 1; // 初始化 x,不需要 x 是 mut 的,因為這是初始化,不是修改 println!("{}", x); } // 如果條件不滿足,x 沒有被初始化 // 但是沒關系,只要這里不使用 x 就沒事 }
類型沒有“默認構造函數”,變量沒有“默認值”。對于let x: i32;如果沒有顯式賦值,它就沒有被初始化,不要想當然地以為它的值是0。
Rust里的合法標識符(包括變量名、函數名、trait名等)必須由數字、字母、下劃線組成,且不能以數字開頭。這個規定和許多現有的編程語言是一樣的。Rust將來會允許其他Unicode字符做標識符,只是目前這個功能的優先級不高,還沒有最終定下來。另外還有一個raw identifier功能,可以提供一個特殊語法,如r#self,讓用戶可以以關鍵字作為普通標識符。這只是為了應付某些特殊情況時迫不得已的做法。
Rust里面的下劃線是一個特殊的標識符,在編譯器內部它是被特殊處理的。它跟其他標識符有許多重要區別。比如,以下代碼就編譯不過:
fn main() { let _ = "hello"; println!("{}", _); }
我們不能在表達式中使用下劃線來作為普通變量使用。下劃線表達的含義是“忽略這個變量綁定,后面不會再用到了”。在后面講析構的時候,還會提到這一點。
2.1.1 變量遮蔽
Rust允許在同一個代碼塊中聲明同樣名字的變量。如果這樣做,后面聲明的變量會將前面聲明的變量“遮蔽”(Shadowing)起來。
fn main() { let x = "hello"; println!("x is {}", x); let x = 5; println!("x is {}", x); }
上面這個程序是可以編譯通過的。請注意第5行的代碼,它不是x=5;,它前面有一個let關鍵字。如果沒有這個let關鍵字,這條語句就是對x的重新綁定(重新賦值)。而有了這個let關鍵字,就是又聲明了一個新的變量,只是它的名字恰巧與前面一個變量相同而已。
但是這兩個x代表的內存空間完全不同,類型也完全不同,它們實際上是兩個不同的變量。從第5行開始,一直到這個代碼塊結束,我們沒有任何辦法再去訪問前一個x變量,因為它的名字已經被遮蔽了。
變量遮蔽在某些情況下非常有用,比如,我們需要在同一個函數內部把一個變量轉換為另一個類型的變量,但又不想給它們起不同的名字。再比如,在同一個函數內部,需要修改一個變量綁定的可變性。例如,我們對一個可變數組執行初始化,希望此時它是可讀寫的,但是初始化完成后,我們希望它是只讀的。可以這樣做:
// 注意:這段代碼只是演示變量遮蔽功能,并不是Vec類型的最佳初始化方法 fn main() { let mut v = Vec::new(); // v 必須是mut修飾,因為我們需要對它寫入數據
v.push(1); v.push(2); v.push(3); let v = v; // 從這里往下,v成了只讀變量,可讀寫變量v已經被遮蔽,無法再訪問 for i in &v { println!("{}", i); } }
反過來,如果一個變量是不可變的,我們也可以通過變量遮蔽創建一個新的、可變的同名變量。
fn main() { let v = Vec::new(); let mut v = v; v.push(1); println!("{:? }", v); }
請注意,這個過程是符合“內存安全”的。“內存安全”的概念一直是Rust關注的重點,我們將在第二部分詳細講述。在上面這個示例中,我們需要理解的是,一個“不可變綁定”依然是一個“變量”。雖然我們沒辦法通過這個“變量綁定”修改變量的值,但是我們重新使用“可變綁定”之后,還是有機會修改的。這樣做并不會產生內存安全問題,因為我們對這塊內存擁有完整的所有權,且此時沒有任何其他引用指向這個變量,對這個變量的修改是完全合法的。Rust的可變性控制規則與其他語言不一樣。更多內容請參閱本書第二部分內存安全。
實際上,傳統編程語言C/C++中也存在類似的功能,只不過它們只允許嵌套的區域內部的變量出現遮蔽。而Rust在這方面放得稍微寬一點,同一個語句塊內部聲明的變量也可以發生遮蔽。
2.1.2 類型推導
Rust的類型推導功能是比較強大的。它不僅可以從變量聲明的當前語句中獲取信息進行推導,而且還能通過上下文信息進行推導。
fn main() { // 沒有明確標出變量的類型,但是通過字面量的后綴, // 編譯器知道elem的類型為u8 let elem = 5u8; // 創建一個動態數組,數組內包含的是什么元素類型可以不寫 let mut vec = Vec::new(); vec.push(elem); // 到后面調用了push函數,通過elem變量的類型, // 編譯器可以推導出vec的實際類型是 Vec<u8> println!("{:? }", vec); }
我們甚至還可以只寫一部分類型,剩下的部分讓編譯器去推導,比如下面的這個程序,我們只知道players變量是Vec動態數組類型,但是里面包含什么元素類型并不清楚,可以在尖括號中用下劃線來代替:
fn main() {
let player_scores = [
("Jack", 20), ("Jane", 23), ("Jill", 18), ("John", 19),
];
// players 是動態數組,內部成員的類型沒有指定,交給編譯器自動推導
let players : Vec<_> = player_scores
.iter()
.map(|&(player, _score)| {
player
})
.collect();
println!("{:? }", players);
}
自動類型推導和“動態類型系統”是兩碼事。Rust依然是靜態類型的。一個變量的類型必須在編譯階段確定,且無法更改,只是某些時候不需要在源碼中顯式寫出來而已。這只是編譯器給我們提供的一個輔助工具。
Rust只允許“局部變量/全局變量”實現類型推導,而函數簽名等場景下是不允許的,這是故意這樣設計的。這是因為局部變量只有局部的影響,全局變量必須當場初始化而函數簽名具有全局性影響。函數簽名如果使用自動類型推導,可能導致某個調用的地方使用方式發生變化,它的參數、返回值類型就發生了變化,進而導致遠處另一個地方的編譯錯誤,這是設計者不希望看到的情況。
2.1.3 類型別名
我們可以用type關鍵字給同一個類型起個別名(type alias)。示例如下:
type Age = u32; fn grow(age: Age, year: u32) -> Age { age + year } fn main() { let x : Age = 20; println!("20 years later: {}", grow(x, 20)); }
類型別名還可以用在泛型場景,比如:
type Double<T> = (T, Vec<T>); // 小括號包圍的是一個 tuple,請參見后文中的復合數據類型
那么以后使用Double<i32>的時候,就等同于(i32, Vec<i32>),可以簡化代碼。
2.1.4 靜態變量
Rust中可以用static關鍵字聲明靜態變量。如下所示:
static GLOBAL: i32 = 0;
與let語句一樣,static語句同樣也是一個模式匹配。與let語句不同的是,用static聲明的變量的生命周期是整個程序,從啟動到退出。static變量的生命周期永遠是’static,它占用的內存空間也不會在執行過程中回收。這也是Rust中唯一的聲明全局變量的方法。
由于Rust非常注重內存安全,因此全局變量的使用有許多限制。這些限制都是為了防止程序員寫出不安全的代碼:
? 全局變量必須在聲明的時候馬上初始化;
? 全局變量的初始化必須是編譯期可確定的常量,不能包括執行期才能確定的表達式、語句和函數調用;
? 帶有mut修飾的全局變量,在使用的時候必須使用unsafe關鍵字。
示例如下:
fn main() { //局部變量聲明,可以留待后面初始化,只要保證使用前已經初始化即可 let x; let y = 1_i32; x = 2_i32; println!("{} {}", x, y); //全局變量必須聲明的時候初始化,因為全局變量可以寫到函數外面,被任意一個函數使用 static G1 : i32 = 3; println!("{}", G1); //可變全局變量無論讀寫都必須用 unsafe修飾 static mut G2 : i32 = 4; unsafe { G2 = 5; println!("{}", G2); } //全局變量的內存不是分配在當前函數棧上,函數退出的時候,并不會銷毀全局變量占用的內存空間,程序 退出才會回收 }
Rust禁止在聲明static變量的時候調用普通函數,或者利用語句塊調用其他非const代碼:
// 這樣是允許的 static array : [i32; 3] = [1,2,3]; // 這樣是不允許的 static vec : Vec<i32> = { let mut v = Vec::new(); v.push(1); v };
調用const fn是允許的:
#![feature(const_fn)] fn main() { use std::sync::atomic::AtomicBool; static FLAG: AtomicBool = AtomicBool::new(true); }
因為const fn是編譯期執行的。這個功能在編寫本書的時候目前還沒有stable,因此需要使用nightly版本并打開feature gate才能使用。
Rust不允許用戶在main函數之前或者之后執行自己的代碼。所以,比較復雜的static變量的初始化一般需要使用lazy方式,在第一次使用的時候初始化。在Rust中,如果用戶需要使用比較復雜的全局變量初始化,推薦使用lazy_static庫。
2.1.5 常量
在Rust中還可以用const關鍵字做聲明。如下所示:
const GLOBAL: i32 = 0;
使用const聲明的是常量,而不是變量。因此一定不允許使用mut關鍵字修飾這個變量綁定,這是語法錯誤。常量的初始化表達式也一定要是一個編譯期常量,不能是運行期的值。它與static變量的最大區別在于:編譯器并不一定會給const常量分配內存空間,在編譯過程中,它很可能會被內聯優化。因此,用戶千萬不要用hack的方式,通過unsafe代碼去修改常量的值,這么做是沒有意義的。以const聲明一個常量,也不具備類似let語句的模式匹配功能。
2.2 基本數據類型
2.2.1 bool
布爾類型(bool)代表的是“是”和“否”的二值邏輯。它有兩個值:true和false。一般用在邏輯表達式中,可以執行“與”“或”“非”等運算。
fn main() { let x = true; let y: bool = !x; // 取反運算 let z = x && y; // 邏輯與,帶短路功能 println!("{}", z); let z = x || y; // 邏輯或,帶短路功能 println!("{}", z); let z = x & y; // 按位與,不帶短路功能 println!("{}", z); let z = x | y; // 按位或,不帶短路功能 println!("{}", z);
let z = x ^ y; // 按位異或,不帶短路功能
println!("{}", z);
}
一些比較運算表達式的類型就是bool類型:
fn logical_op(x: i32, y: i32) { let z : bool = x < y; println!("{}", z); }
bool類型表達式可以用在if/while等表達式中,作為條件表達式。比如:
if a >= b { ... } else { ... }
2.2.2 char
字符類型由char表示。它可以描述任何一個符合unicode標準的字符值。在代碼中,單個的字符字面量用單引號包圍。
let love = '?'; // 可以直接嵌入任何unicode字符
字符類型字面量也可以使用轉義符:
let c1 = '\n'; // 換行符
let c2 = '\x7f'; // 8 bit字符變量
let c3 = '\u{7FFF}'; // unicode字符
因為char類型的設計目的是描述任意一個unicode字符,因此它占據的內存空間不是1個字節,而是4個字節。
對于ASCII字符其實只需占用一個字節的空間,因此Rust提供了單字節字符字面量來表示ASCII字符。我們可以使用一個字母b在字符或者字符串前面,代表這個字面量存儲在u8類型數組中,這樣占用空間比char型數組要小一些。示例如下:
let x :u8 = 1; let y :u8 = b'A'; let s :&[u8;5] = b"hello"; let r :&[u8;14] = br#"hello \n world"#;
2.2.3 整數類型
Rust有許多的數字類型,主要分為整數類型和浮點數類型。本節講解整數類型。各種整數類型之間的主要區分特征是:有符號/無符號,占據空間大小。具體見表2-1。
表2-1

所謂有符號/無符號,指的是如何理解內存空間中的bit表達的含義。如果一個變量是有符號類型,那么它的最高位的那一個bit就是“符號位”,表示該數為正值還是負值。如果一個變量是無符號類型,那么它的最高位和其他位一樣,表示該數的大小。比如對于一個byte大小(8 bits)的數據來說,如果存的是無符號數,那么它的表達范圍是0~255,如果存的是有符號數,那么它的表達范圍是-128~127。
關于各個整數類型所占據的空間大小,在名字中就已經表現得很明確了,Rust原生支持了從8位到128位的整數。需要特別關注的是isize和usize類型。它們占據的空間是不定的,與指針占據的空間一致,與所在的平臺相關。如果是32位系統上,則是32位大小;如果是64位系統上,則是64位大小。在C++中與它們相對應的類似類型是int_ptr和uint_ptr。Rust的這一策略與C語言不同,C語言標準中對許多類型的大小并沒有做強制規定,比如int、long、double等類型,在不同平臺上都可能是不同的大小,這給許多程序員帶來了不必要的麻煩。相反,在語言標準中規定好各個類型的大小,讓編譯器針對不同平臺做適配,生成不同的代碼,是更合理的選擇。
數字類型的字面量表示可以有許多方式:
let var1 : i32 = 32; // 十進制表示 let var2 : i32 = 0xFF; // 以0x開頭代表十六進制表示 let var3 : i32 = 0o55; // 以0o開頭代表八進制表示 let var4 : i32 = 0b1001; // 以0b開頭代表二進制表示
注意!在C/C++/JavaScript語言中以0開頭的數字代表八進制坑過不少人,Rust中設計不一樣。
在所有的數字字面量中,可以在任意地方添加任意的下劃線,以方便閱讀:
let var5 = 0x_1234_ABCD; // 使用下劃線分割數字,不影響語義,但是極大地提升了閱讀體驗。
字面量后面可以跟后綴,可代表該數字的具體類型,從而省略掉顯示類型標記:
let var6 = 123usize; // i6變量是usize類型 let var7 = 0x_ff_u8; // i7變量是u8類型 let var8 = 32; // 不寫類型,默認為 i32 類型
在Rust中,我們可以為任何一個類型添加方法,整型也不例外。比如在標準庫中,整數類型有一個方法是pow,它可以計算n次冪,于是我們可以這么使用:
let x : i32 = 9; println!("9 power 3 = {}", x.pow(3));
同理,我們甚至可以不使用變量,直接對整型字面量調用函數:
fn main() { println!("9 power 3 = {}", 9_i32.pow(3)); }
我們可以看到這是非常方便的設計。
對于整數類型,如果Rust編譯器通過上下文無法分析出該變量的具體類型,則自動默認為i32類型。比如:
fn main() { let x = 10; let y = x * x; println!("{}", y); }
在此例中,編譯器只知道x是一個整數,但是具體是i8 i16 i32或者u8 u16 u32等,并沒有足夠的信息判斷,這些都是有可能的。在這種情況下,編譯器就默認把x當成i32類型處理。這么做的好處是,很多時候,我們不想在每個地方都明確地指定數字類型,這么做很麻煩。給編譯器指定一個在信息不足情況下的“缺省”類型會更方便一點。
2.2.4 整數溢出
在整數的算術運算中,有一個比較頭疼的事情是“溢出”。在C語言中,對于無符號類型,算術運算永遠不會overflow,如果超過表示范圍,則自動舍棄高位數據。對于有符號類型,如果發生了overflow,標準規定這是undefined behavior,也就是說隨便怎么處理都可以。
未定義行為有利于編譯器做一些更激進的性能優化,但是這樣的規定有可能導致在程序員不知情的某些極端場景下,產生詭異的bug。
Rust的設計思路更傾向于預防bug,而不是無條件地壓榨效率,Rust設計者希望能盡量減少“未定義行為”。比如徹底杜絕“Segment Fault”這種內存錯誤是Rust的一個重要設計目標。當然還有其他許多種類的bug,即便是無法完全解決,我們也希望能盡量避免。整數溢出就是這樣的一種bug。
Rust在這個問題上選擇的處理方式為:默認情況下,在debug模式下編譯器會自動插入整數溢出檢查,一旦發生溢出,則會引發panic;在release模式下,不檢查整數溢出,而是采用自動舍棄高位的方式。示例如下:
fn arithmetic(m: i8, n: i8) {
// 加法運算,有溢出風險
println!("{}", m + n);
}
fn main() {
let m : i8 = 120;
let n : i8 = 120;
arithmetic(m, n);
}
如果我們編譯debug版本:
rustc test.rs
執行這個程序,結果為:
thread 'main' panicked at 'attempt to add with overflow', test.rs:3:20
note: Run with `RUST_BACKTRACE=1` for a backtrace.
可以看到,程序執行時發生了panic。有關panic的詳細解釋,需要參見第18章,此處無須深入細節。
如果編譯一個優化后的版本,加上-O選項:
rustc -O test.rs
執行時沒有錯誤,而是使用了自動截斷策略:
$ ./test -16
Rust編譯器還提供了一個獨立的編譯開關供我們使用,通過這個開關,可以設置溢出時的處理策略:
$ rustc -C overflow-checks=no test.rs
“-C overflow-checks=”可以寫“yes”或者“no”,打開或者關閉溢出檢查。如果我們用上面這個命令編譯,執行可見:
$ ./test -16
雖然它還是debug版本,但我們依然有辦法關閉溢出檢查。
如果在某些場景下,用戶確實需要更精細地自主控制整數溢出的行為,可以調用標準庫中的checked_*、saturating_*和wrapping_*系列函數。
fn main() { let i = 100_i8; println!("checked {:?}", i.checked_add(i)); println!("saturating {:?}", i.saturating_add(i)); println!("wrapping {:?}", i.wrapping_add(i)); }
輸出結果為:
checked None saturating 127 wrapping -56
可以看到:checked_*系列函數返回的類型是Option<_>,當出現溢出的時候,返回值是None; saturating_*系列函數返回類型是整數,如果溢出,則給出該類型可表示范圍的“最大/最小”值;wrapping_*系列函數則是直接拋棄已經溢出的最高位,將剩下的部分返回。在對安全性要求非常高的情況下,強烈建議用戶盡量使用這幾個方法替代默認的算術運算符來做數學運算,這樣表意更清晰。在Rust標準庫中就大量使用了這幾個方法,而不是簡單地使用算術運算符,值得大家參考。
在很多情況下,整數溢出應該被處理為截斷,即丟棄最高位。為了方便用戶,標準庫還提供了一個叫作std::num::Wrapping<T>的類型。它重載了基本的運算符,可以被當成普通整數使用。凡是被它包裹起來的整數,任何時候出現溢出都是截斷行為。常見使用示例如下:
use std::num::Wrapping; fn main() { let big = Wrapping(std::u32::MAX); let sum = big + Wrapping(2_u32); println!("{}", sum.0); }
不論用什么編譯選項,上述代碼都不會觸發panic,任何情況下執行結果都是一致的。
標準庫中還提供了許多有用的方法,在此不一一贅述,請大家參考標準API文檔。
2.2.5 浮點類型
Rust提供了基于IEEE 754-2008標準的浮點類型。按占據空間大小區分,分別為f32和f64,其使用方法與整型差別不大。浮點數字面量表示方式有如下幾種:
let f1 = 123.0f64; // type f64
let f2 = 0.1f64; // type f64
let f3 = 0.1f32; // type f32
let f4 = 12E+99_f64; // type f64 科學計數法
let f5 : f64 = 2.; // type f64
與整數類型相比,Rust的浮點數類型相對復雜得多。浮點數的麻煩之處在于:它不僅可以表達正常的數值,還可以表達不正常的數值。
在標準庫中,有一個std::num::FpCategory枚舉,表示了浮點數可能的狀態:
enum FpCategory { Nan, Infinite, Zero, Subnormal, Normal, }
其中Zero表示0值、Normal表示正常狀態的浮點數。其他幾個就需要特別解釋一下了。
在IEEE 754標準中,規定了浮點數的二進制表達方式:x = (-1)^s * (1 + M) *2^e。其中s是符號位,M是尾數,e是指數。尾數M是一個[0, 1)范圍內的二進制表示的小數。以32位浮點為例,如果只有normal形式的話,0表示為所有位數全0,則最小的非零正數將是尾數最后一位為1的數字,就是(1+2^(-23))*2^(-127),而次小的數字為(1+2^(-22))*2^(-127),這兩個數字的差距為2^(-23)*2^(-127)= 2^(-150),然而最小的數字和0之間的差距有(1+2^(-23))*2^(-127),約等于2^(-127),也就是說,數字在漸漸減少到0的過程中突然降到了0。為了減少0與最小數字和最小數字與次小數字之間步長的突然下跌,subnormal規定:當指數位全0的時候,指數表示為-126而不是-127(和指數為最低位為1一致)。然而公式改成(-1)^s * M * 2^e, M不再+1,這樣最小的數字就變成2^(-23)*2^(-126),次小的數字變成2^(-22)*2^(-126),每兩個相鄰subnormal數字之差都是2^(-23)*2^(-126),避免了突然降到0。在這種狀態下,這個浮點數就處于了Subnormal狀態,處于這種狀態下的浮點數表示精度比Normal狀態下的精度低一點。我們用一個示例來演示一下什么是Subnormal狀態的浮點數:
fn main() { // 變量 small 初始化為一個非常小的浮點數 let mut small = std::f32::EPSILON; // 不斷循環,讓 small 越來越趨近于 0,直到最后等于0的狀態 while small > 0.0 { small = small / 2.0; println!("{} {:?}", small, small.classify()); } }
編譯,執行,發現循環幾十次之后,數值就小到了無法在32bit范圍內合理表達的程度,最終收斂到了0,在后面表示非常小的數值的時候,浮點數就已經進入了Subnormal狀態。
Infinite和Nan是帶來更多麻煩的特殊狀態。Infinite代表的是“無窮大”, Nan代表的是“不是數字”(not a number)。
什么情況會產生“無窮大”和“不是數字”呢?舉例說明:
fn main() { let x = 1.0f32 / 0.0; let y = 0.0f32 / 0.0; println!("{} {}", x, y); }
編譯執行,打印出來的結果分別為inf NaN。非0數除以0值,得到的是inf,0除以0得到的是NaN。
對inf做一些數學運算的時候,它的結果可能與你期望的不一致:
fn main() { let inf = std::f32::INFINITY; println!("{} {} {}", inf * 0.0, 1.0 / inf, inf / inf); }
編譯執行,結果為:
NaN 0 NaN
NaN這個特殊值有個特殊的麻煩,主要問題還在于它不具備“全序”的特點。示例如下:
fn main() { let nan = std::f32::NAN; println!("{} {} {}", nan < nan, nan > nan, nan == nan); }
編譯執行,輸出結果為:
false false false
這就很麻煩了,一個數字可以不等于自己。因為NaN的存在,浮點數是不具備“全序關系”(total order)的。關于“全序”和“偏序”的問題,本節就不展開講解了,后面講到trait的時候,再給大家介紹PartialOrd和Ord這兩個trait。
2.2.6 指針類型
無GC的編程語言,如C、C++以及Rust,對數據的組織操作有更多的自由度,具體表現為:
? 同一個類型,某些時候可以指定它在棧上,某些時候可以指定它在堆上。內存分配方式可以取決于使用方式,與類型本身無關。
? 既可以直接訪問數據,也可以通過指針間接訪問數據。可以針對任何一個對象取得指向它的指針。
? 既可以在復合數據類型中直接嵌入別的類型的實體,也可以使用指針,間接指向別的類型。
? 甚至可能在復合數據類型末尾嵌入不定長數據構造出不定長的復合數據類型。
Rust里面也有指針類型,而且不止一種指針類型。常見的幾種指針類型見表2-2。
表2-2

除此之外,在標準庫中還有一種封裝起來的可以當作指針使用的類型,叫“智能指針”(smart pointer)。常見的智能指針見表2-3。
表2-3

有關這幾種指針的使用方法和設計原理,請參見本書第二部分。
2.2.7 類型轉換
Rust對不同類型之間的轉換控制得非常嚴格。即便是下面這樣的程序,也會出現編譯錯誤:
fn main() { let var1 : i8 = 41; let var2 : i16 = var1; }
編譯結果為mismatched types! i8類型的變量竟然無法向i16類型的變量賦值!這可能對很多用戶來說都是一個意外。
Rust提供了一個關鍵字as,專門用于這樣的類型轉換:
fn main() { let var1 : i8 = 41; let var2 : i16 = var1 as i16; }
也就是說,Rust設計者希望在發生類型轉換的時候不是偷偷摸摸進行的,而是顯式地標記出來,防止隱藏的bug。雖然在許多時候會讓代碼顯得不那么精簡,但這也算是一種合理的折中。
as關鍵字也不是隨便可以用的,它只允許編譯器認為合理的類型轉換。任意類型轉換是不允許的:
let a = "some string";
let b = a as u32; // 編譯錯誤
有些時候,甚至需要連續寫多個as才能轉成功,比如&i32類型就不能直接轉換為*mut i32類型,必須像下面這樣寫才可以:
fn main() { let i = 42; // 先轉為 *const i32,再轉為 *mut i32 let p = &i as *const i32 as *mut i32; println!("{:p}", p); }
as表達式允許的類型轉換如表2-4所示。對于表達式e as U, e是表達式,U是要轉換的目標類型,表2-4中所示的類型轉換是允許的。
表2-4

如果需要更復雜的類型轉換,一般是使用標準庫的From Into等trait,請參見第26章。
2.3 復合數據類型
復合數據類型可以在其他類型的基礎上形成更復雜的組合關系。
本章介紹tuple、struct、enum等幾種復合數據類型。數組留到第6章介紹。
2.3.1 tuple
tuple指的是“元組”類型,它通過圓括號包含一組表達式構成。tuple內的元素沒有名字。tuple是把幾個類型組合到一起的最簡單的方式。比如:
let a = (1i32, false); // 元組中包含兩個元素,第一個是i32類型,第二個是bool類型 let b = ("a", (1i32, 2i32)); // 元組中包含兩個元素,第二個元素本身也是元組,它又包含了兩個元素
如果元組中只包含一個元素,應該在后面添加一個逗號,以區分括號表達式和元組:
let a = (0, ); // a是一個元組,它有一個元素 let b = (0); // b是一個括號表達式,它是i32類型
訪問元組內部元素有兩種方法,一種是“模式匹配”(pattern destructuring),另外一種是“數字索引”:
let p = (1i32, 2i32); let (a, b) = p; let x = p.0; let y = p.1; println!("{} {} {} {}", a, b, x, y);
在第7章中會對“模式匹配”做詳細解釋。
元組內部也可以一個元素都沒有。這個類型單獨有一個名字,叫unit(單元類型):
let empty : () = ();
可以說,unit類型是Rust中最簡單的類型之一,也是占用空間最小的類型之一。空元組和空結構體struct Foo;一樣,都是占用0內存空間。
fn main() { println!("size of i8 {}" , std::mem::size_of::<i8>()); println!("size of char {}" , std::mem::size_of::<char>()); println!("size of '()' {}" , std::mem::size_of::<()>()); }
上面的程序中,std::mem::size_of函數可以計算一個類型所占用的內存空間。可以看到,i8類型占用1 byte, char類型占用4 bytes,空元組占用0 byte。
與C++中的空類型不同,Rust中存在實打實的0大小的類型。在C++標準中,有明確的規定,是這么說的:
Complete objects and member subobjects of class type shall have nonzero size.
class Empty {}; Empty emp; assert(sizeof(emp) ! = 0);
2.3.2 struct
結構體(struct)與元組類似,也可以把多個類型組合到一起,作為新的類型。區別在于,它的每個元素都有自己的名字。舉個例子:
struct Point { x: i32, y: i32, }
每個元素之間采用逗號分開,最后一個逗號可以省略不寫。類型依舊跟在冒號后面,但是不能使用自動類型推導功能,必須顯式指定。struct類型的初始化語法類似于json的語法,使用“成員-冒號-值”的格式。
fn main() { let p = Point { x: 0, y: 0}; println!("Point is at {} {}", p.x, p.y); }
有些時候,Rust允許struct類型的初始化使用一種簡化的寫法。如果有局部變量名字和成員變量名字恰好一致,那么可以省略掉重復的冒號初始化:
fn main() { // 剛好局部變量名字和結構體成員名字一致 let x = 10; let y = 20; // 下面是簡略寫法,等同于 Point { x: x, y: y },同名字的相對應 let p = Point { x, y }; println!("Point is at {} {}", p.x, p.y); }
訪問結構體內部的元素,也是使用“點”加變量名的方式。當然,我們也可以使用“模式匹配”功能:
fn main() { let p = Point { x: 0, y: 0}; // 聲明了px 和 py,分別綁定到成員 x 和成員 y let Point { x : px, y : py } = p; println!("Point is at {} {}", px, py); // 同理,在模式匹配的時候,如果新的變量名剛好和成員名字相同,可以使用簡寫方式 let Point { x, y } = p; println!("Point is at {} {}", x, y); }
Rust設計了一個語法糖,允許用一種簡化的語法賦值使用另外一個struct的部分成員。比如:
struct Point3d { x: i32, y: i32, z: i32, } fn default() -> Point3d { Point3d { x: 0, y: 0, z: 0 }
} // 可以使用default()函數初始化其他的元素 // ..expr 這樣的語法,只能放在初始化表達式中,所有成員的最后最多只能有一個 let origin = Point3d { x: 5, ..default()}; let point = Point3d { z: 1, x: 2, ..origin };
如前所說,與tuple類似,struct內部成員也可以是空:
//以下三種都可以,內部可以沒有成員
struct Foo1;
struct Foo2();
struct Foo3{}
2.3.3 tuple struct
Rust有一種數據類型叫作tuple struct,它就像是tuple和struct的混合。區別在于,tuple struct有名字,而它們的成員沒有名字:
struct Color(i32, i32, i32); struct Point(i32, i32, i32);
它們可以被想象成這樣的結構體:
struct Color{ 0: i32, 1: i32, 2: i32, } struct Point { 0: i32, 1: i32, 2: i32, }
因為這兩個類型都有自己的名字,雖然它們的內部結構是一樣的,但是它們是完全不同的兩個類型。有時候我們不需要特別關心結構體內部成員的名字,可以采用這種語法。
tuple、struct、struct tuple起的作用都是把幾個不同類型的成員打包組合成一個類型。它們的區別如表2-5所示。
表2-5

它們除了在取名上有這些區別外,沒有其他區別。它們有一致的內存對齊策略、一致的占用空間規則,也有類似的語法。從下面這個例子可以看出它們的語法是很一致的:
// define struct
struct T1 { v: i32 } // define tuple struct struct T2(i32); fn main() { let v1 = T1 { v: 1 }; let v2 = T2(1); // init tuple struct let v3 = T2 { 0: 1 }; // init tuple struct let i1 = v1.v; let i2 = v2.0; let i3 = v3.0; }
tuple struct有一個特別有用的場景,那就是當它只包含一個元素的時候,就是所謂的newtype idiom。因為它實際上讓我們非常方便地在一個類型的基礎上創建了一個新的類型。舉例如下:
fn main() {
struct Inches(i32);
fn f1(value : Inches) {}
fn f2(value : i32) {}
let v : i32 = 0;
f1(v); // 編譯不通過,'mismatched types'
f2(v);
}
以上程序編譯不通過,因為Inches類型和i32是不同的類型,函數調用參數不匹配。
但是,如果我們把以上程序改一下,使用type alias(類型別名)實現,那么就可以編譯通過了:
fn type_alias() { type I = i32; fn f1(v : I) {} fn f2(v : i32) {} let v : i32 = 0; f1(v); f2(v); }
從上面的講解可以看出,通過關鍵字type,我們可以創建一個新的類型名稱,但是這個類型不是全新的類型,而只是一個具體類型的別名。在編譯器看來,這個別名與原先的具體類型是一模一樣的。而使用tuple struct做包裝,則是創造了一個全新的類型,它跟被包裝的類型不能發生隱式類型轉換,可以具有不同的方法,滿足不同的trait,完全按需而定。
2.3.4 enum
如果說tuple、struct、tuple struct在Rust中代表的是多個類型的“與”關系,那么enum類型在Rust中代表的就是多個類型的“或”關系。
與C/C++中的枚舉相比,Rust中的enum要強大得多,它可以為每個成員指定附屬的類型信息。比如說我們可以定義這樣的類型,它內部可能是一個i32型整數,或者是f32型浮點數:
enum Number { Int(i32), Float(f32), }
Rust的enum中的每個元素的定義語法與struct的定義語法類似。可以像空結構體一樣,不指定它的類型;也可以像tuple struct一樣,用圓括號加無名成員;還可以像正常結構體一樣,用大括號加帶名字的成員。
用enum把這些類型包含到一起之后,就組成了一個新的類型。
要使用enum,一般要用到“模式匹配”。模式匹配是很重要的一部分,用第7章來詳細講解。這里我們給出一個用match語句讀取enum內部數據的示例:
enum Number { Int(i32), Float(f32), } fn read_num(num: &Number) { match num { // 如果匹配到了 Number::Int 這個成員,那么value的類型就是 i32 &Number::Int(value) => println!("Integer {}", value), // 如果匹配到了 Number::Float 這個成員,那么value的類型就是 f32 &Number::Float(value) => println!("Float {}", value), } } fn main() { let n: Number = Number::Int(10); read_num(&n); }
Rust的enum與C/C++的enum和union都不一樣。它是一種更安全的類型,可以被稱為“tagged union”。從C語言的視角來看Rust的enum類型,重寫上面這段代碼,它的語義類似這樣:
#include <stdio.h> #include <stdint.h> // C 語言模擬 Rust 的 enum struct Number { enum {Int, Float} tag;
union { int32_t int_value; float float_value; } value; }; void read_num(struct Number * num) { switch(num->tag) { case Int: printf("Integer %d", num->value.int_value); break; case Float: printf("Float %f", num->value.float_value); break; default: printf("data error"); break; } } int main() { struct Number n = { tag : Int, value: { int_value: 10} }; read_num(&n); return 0; }
Rust的enum類型的變量需要區分它里面的數據究竟是哪種變體,所以它包含了一個內部的“tag標記”來描述當前變量屬于哪種類型。這個標記對用戶是不可見的,通過恰當的語法設計,保證標記與類型始終是匹配的,以防止用戶錯誤地使用內部數據。如果我們用C語言來模擬,就需要程序員自己來保證讀寫的時候標記和數據類型是匹配的,編譯器無法自動檢查。當然,上面這個模擬只是為了通俗地解釋Rust的enum類型的基本工作原理,在實際中,enum的內存布局未必是這個樣子,編譯器有許多優化,可以保證語義正確的同時減少內存使用,并加快執行速度。如果是在FFI場景下,要保證Rust里面的enum的內存布局和C語言兼容的話,可以給這個enum添加一個#[repr(C, Int)]屬性標簽(目前這個設計已經通過,但是還未在編譯器中實現)。
我們可以試著把前面定義的Number類型占用的內存空間大小打印出來看看:
fn main() { // 使用了泛型函數的調用語法,請參考第21章泛型 println!("Size of Number: {}", std::mem::size_of::<Number>()); println!("Size of i32: {}", std::mem::size_of::<i32>()); println!("Size of f32: {}", std::mem::size_of::<f32>()); }
編譯執行可見:
Size of Number: 8 Size of i32: 4 Size of f32: 4
Number里面要么存儲的是i32,要么存儲的是f32,它存儲數據需要的空間應該是max(sizeof(i32), sizeof(f32))= max(4 byte, 4 byte)= 4 byte。而它總共占用的內存是8 byte,多出來的4 byte就是用于保存類型標記的。之所以用4 byte,是為了內存對齊。
Rust里面也支持union類型,這個類型與C語言中的union完全一致。但在Rust里面,讀取它內部的值被認為是unsafe行為,一般情況下我們不使用這種類型。它存在的主要目的是為了方便與C語言進行交互。
在Rust中,enum和struct為內部成員創建了新的名字空間。如果要訪問內部成員,可以使用::符號。因此,不同的enum中重名的元素也不會互相沖突。例如在下面的程序中,兩個枚舉內部都有Move這個成員,但是它們不會有沖突。
enum Message { Quit, ChangeColor(i32, i32, i32), Move { x: i32, y: i32 }, Write(String), } let x: Message = Message::Move { x: 3, y: 4 }; enum BoardGameTurn { Move { squares: i32 }, Pass, } let y: BoardGameTurn = BoardGameTurn::Move { squares: 1 };
我們也可以手動指定每個變體自己的標記值:
fn main() { enum Animal { dog = 1, cat = 200, tiger, } let x = Animal::tiger as isize; println!("{}", x); }
Rust標準庫中有一個極其常用的enum類型Option<T>,它的定義如下:
enum Option<T> { None, Some(T), }
由于它實在是太常用,標準庫將Option以及它的成員Some、None都加入到了Prelude中,用戶甚至不需要use語句聲明就可以直接使用。它表示的含義是“要么存在、要么不存在”。比如Option<i32>表達的意思就是“可以是一個i32類型的值,或者沒有任何值”。
Rust的enum實際上是一種代數類型系統(Algebraic Data Type, ADT),本書第8章簡要介紹什么是ADT。enum內部的variant只是一個名字而已,恰好我們還可以將這個名字作為類型構造器使用。意思是說,我們可以把enum內部的variant當成一個函數使用,示例如下:
fn main() { let arr = [1,2,3,4,5]; // 請注意這里的map函數 let v: Vec<Option<&i32>> = arr.iter().map(Some).collect(); println!("{:? }", v); }
有關迭代器的知識,請各位讀者參考第24章的內容。在這里想說明的問題是,Some可以當成函數作為參數傳遞給map。這里的Some其實是作為一個函數來使用的,它輸入的是&i32類型,輸出為Option<&i32>類型。可以用如下方式證明Some確實是一個函數類型,我們把Some初始化給一個unit變量,產生一個編譯錯誤:
fn main() { let _ : () = Some; }
編譯錯誤是這樣寫的:
error[E0308]: mismatched types --> test.rs:3:18 | 3 | let _ : () = Some; | ^^^^ expected (), found fn item | = note: expected type `()` found type `fn(_) -> std::option::Option<_> {std::option::Option<_>::Some}`
可見,enum內部的variant的類型確實是函數類型。
2.3.5 類型遞歸定義
Rust里面的復合數據類型是允許遞歸定義的。比如struct里面嵌套同樣的struct類型,但是直接嵌套是不行的。示例如下:
struct Recursive { data: i32, rec: Recursive, }
使用rustc --crate-type=lib test.rs命令編譯,可以看到如下編譯錯誤:
error[E0072]: recursive type `Recursive` has infinite size --> test.rs:2:1 | 2 | struct Recursive { | ^^^^^^^^^^^^^^^^ recursive type has infinite size
3 | data: i32, 4 | rec: Recursive, | -------------- recursive without indirection | = help: insert indirection (e.g., a `Box`, `Rc`, or `&`) at some point to make `Recursive` representable
以上編譯錯誤寫得非常人性化,不僅寫清楚了錯誤原因,還給出了可能的修復辦法。Rust是允許用戶手工控制內存布局的語言。直接使用類型遞歸定義的問題在于,當編譯器計算Recursive這個類型大小的時候:
size_of::<Recursive>() == 4 + size_of::<Recursive>()
這個方程在實數范圍內無解。
解決辦法很簡單,用指針間接引用就可以了,因為指針的大小是固定的,比如:
struct Recursive { data: i32, rec: Box<Recursive>, }
我們把產生了遞歸的那個成員類型改為了指針,這個類型就非常合理了。
- AngularJS Testing Cookbook
- Android項目開發入門教程
- C# Programming Cookbook
- Building a Recommendation Engine with Scala
- Learning Firefox OS Application Development
- 機器學習與R語言實戰
- C語言從入門到精通
- JBoss:Developer's Guide
- Python 3 數據分析與機器學習實戰
- 微信小程序開發實戰:設計·運營·變現(圖解案例版)
- 分布式數據庫原理、架構與實踐
- Yii2 By Example
- C++服務器開發精髓
- 自己動手構建編程語言:如何設計編譯器、解釋器和DSL
- 中小企業網站建設與管理(靜態篇)