1. 程式人生 > 實用技巧 >Rust之路(3)——資料型別 下篇

Rust之路(3)——資料型別 下篇

【未經書面同意,嚴禁轉載】 --2020-10-14 --

架構是道,資料是術。道可道,非常道;術不名,不成術!道無常形,術卻可循規。

學習與分析資料型別,最基本的方法就是搞清楚其儲存原理,變數和物件資料是在棧、堆、靜態區如何分佈。把資料和資料的表現形式抓住了,就能很快地明白型別的行為、轉換,還有Rust牽扯到所有權和所有權移動、借用。

書接上回!

資料型別上篇依次闡述了整型、浮點型、布林、字元型、元組、指標、陣列和向量。除了向量,其他都屬於基本型別,在賦值、傳參時是複製一份傳遞過去(即值傳遞),而向量是引用傳遞型別,賦值或傳參的時候,傳遞的是儲存在棧上的指標和相關資訊,而資料本身在堆記憶體不動。

本篇關注一下最常用的另外幾種型別:切片、字串(以及文字字串)。

切片Slice

切片其實不是真正的“資料”型別,而指的是別人的一段資料。顧名思義,切片就是從別的序列型別(如陣列、向量等)上面切了一段的資料(當然,這一段還是屬於別人的一部分,並沒有切下)。

但這個切片,在實際程式碼中是沒法用的,資料屬於別人的,由於Rust的所有權概念(下篇講這個),別人的東西不能直接拿來用。而是用另一種形式使用:引用。

現在,可以把引用理解為指標。

切片的型別寫法是 [T],其中T是切片中元素資料的型別,可以是u8、String等等。切片引用的寫法是&[T]。後者在程式碼中更常用。

而切片通常指的就是前文所說的切了一段資料的引用,切片的引用包含1個指標、1個數據長度(切片所切的元素數目)。比如:

let v: Vec<f64> = vec![0.0, 0.707, 1.0, 0.707]; //一個向量
let sv: &[f64] = &v[1..3];  //sv是切片引用,指向v的索引1的元素至索引3的元素(不包括索引3的元素),切片有2個元素

/* &v[1..3]中,中括號內的表示式由兩個整數和一個..操作符構成,..操作符是range生成操作符,產生一個range型別(其實
根據兩個整數是否省略分為4種類型,統稱為range型別)的值。用於序列型別的後面,產生一個子序列。兩個整數形成一個前閉
後開的區間,此例中是[1,3)。如果需要包含右邊的整數索引,可以寫成[1..=3];如果從切片是從開始元素到索引2,則可以
寫成[..3];從索引2到末尾,寫成[2..];取其所有元素,簡寫為[..]。
*/

其記憶體分佈圖為(圖中sv中的數字2就是切片的元素數):

甚至可以把整個序列的引用賦值給一個切片引用:

let v: Vec<f64> = vec![0.0, 0.707, 1.0, 0.707]; //一個向量,資料在堆上
let a: [f64; 4] = [0.0, -0.707, -1.0, -0.707];  //一個數組,分佈在棧上
let sv: &[f64] = &v;  //sv是包含向量v所有元素的切片引用,由向量v的引用轉換得到
let sa: &[f64] = &a;  //sa是包含陣列a所有元素的切片引用,由陣列a的引用轉換得到

在最後兩行,Rust會自動將&Vec<f64>引用和&[f64;4]引用轉換為直接指向資料的切片引用。

其記憶體分佈圖為:

想編寫一個對任何同類型資料序列(無論是儲存在陣列、向量、堆疊或堆中)進行操作的函式時,切片引用都是一個不錯的選擇。例如,這裡有一個函式可以列印一段數字,每行一個:

fn print(n: &[f64]) {
  for elt in n { 
    println!("{}", elt);   } } print(&v); // works on vectors 可以用於vector print(&a); // works on arrays 可以用於陣列

因為這個函式接受切片引用作為引數,所以可以將它應用於向量或陣列。實際上,Rust在切片上定義了很多方法:例如,sort和reverse方法,都可以運用在向量或陣列上,對其元素進行排序或反轉。

最後再重複一次,真正的切片是母序列的一段資料。由於切片幾乎總是以引用出現,所以通常說的切片,實際上是切片引用&[T]。

字串String 和文字字串(或稱為字串字面量 英文為String Literal)

Rust中,最常用的兩種字串:字串字面量str型別、可變字串String型別。另外還有CString、OsString等,以後用到再說。

字串字面量是指的一串文字,因為它是確定的,不像變數一樣變化,所以在程式中硬編碼到程式檔案內,是靜態的,我們不能移動或改變這串文字。字串字面量用雙引號括起來。特殊字元用反斜槓轉義序列,也可以換行:

let speech = "\"Ouch!\" it's said the well.\n"; //單行文字,雙引號需要轉義,但單引號不需要

//多行文字,Singing後有一個空格和換行符,第二行的前面有多個空行。列印時這些都會保留 println!(
"In the room the women come and go, Singing of Mount Abora");
//末尾有反斜槓轉義,雖然第一行的末尾和第二行前面有多個空格、換行符,但是在and和there中間只輸出一個空格!
println!(
"It was a bright, cold day in April, and \ there were four of us—\ more or less.");

在一些情況下,需要將字串中的反斜槓轉義是一種麻煩的事情(比如在正則表示式和Windows路徑)。用小寫字母“r”標記為原始字串(raw string)。原始字串中的所有反斜槓和空白字元都會逐字包含在字串中。不會進行轉義。但問題是,雙引號就沒法輸入了(因為不存在轉義,所以加反斜槓也沒用),這時候可以在原始字串的開始和結束加“#”號標記:

let default_win_install_path = r"C:\Program Files\Gorillas"; //Windows路徑
let pattern = Regex::new(r"\d+(\.\d+)*");                    //正則表示式

//用字母r和#表示的原始字串,r是raw string的首字母
println!(r###"
This raw string started with 'r###"'.
Therefore it does not end until we reach a quote mark ('"') followed immediately by three pound signs ('###'):
"###);

加“#”的原始字串,規則是:字母r,後面是n個#(n≥1),後面跟雙引號引起來的字串,後面是n個#(跟前面的n必須相等):

可變的字串

內容和長度可變的字串是String型別,和大多數語言中的可變字串相似。但是也有很多嚴格不同之處。就像開頭所述,要明白它的行為,需要先摸清它在記憶體是怎麼儲存的。

String儲存一串字元,字元的編碼是UTF-8,而UTF-8是變長的,前篇我們已經詳解了。所以儲存了“abc”的字串長度是3個位元組,而儲存了“a中”的長度是4個位元組,其中中文佔3位元組。

let s1: String = "abc".to_string();
let s2: String = "a中".to_string();
println!("{} {} {} ", s1, s1.capacity(), s1.len()); //輸出 abc 3 3
println!("{} {} {} ", s2, s2.capacity(), s2.len()); //輸出 a中 4 4

亮點來了哈:

如果用len()函式計算字串的長度,結果是位元組數,而不是有多少個字元!!所以,無法把字串簡單的看做字元的陣列,也就不能直接用索引法 s1[1]來表示第2個字元,也不能用for...in 來遍歷字串!!

當然,切片也不建議用,因為s1[1..3]能正確的得出是“bc”,而s2[1..3]則會出錯,因為這個切片是切了“中”字三個位元組當中的前兩個位元組。

這麼神奇嗎?

作為最常用型別,當然不會這麼弱智!辦法是用到String型別的chars方法。s1.chars()返回一個序列型別(型別名稱是Chars),包含了a、b、c三個元素,s2.chars()返回的序列包含a、中兩個字元(序列的型別是前面我們講到的char型別,每個char型別佔4位元組空間)。

所以,索引、迭代、切片的使用方法,都可以用在這個序列上!

String的用法(更多用法可以查參考手冊):

//建立字串
let s1 = "too many pets".to_string();       //用字串字面量的to_string方法
let s2 = format!("{}年齡:{}", "林沖", 29);  //用format!巨集建立,它的使用方法和printnl!巨集相同,但是末尾不加換行

let bits = vec!["阮小二", "阮小五", "阮小七"];
assert_eq!(bits.concat(), "阮小二阮小五阮小七");        //用序列型別的concat方法
assert_eq!(bits.join(", "), "阮小二, 阮小五, 阮小七");  //用序列型別的join方法,可以在各元素中間加間隔符

//比較
assert!("ONE".to_lowercase()  == "one");  //支援比較運算子==、!=、>、<、<=、>和>=

//一些方法
let mut s = String::from("源字串"); //必須宣告為mut,才能改變字串的內容 assert_eq!(s.pop(), Some('o')); //彈出最後一個字元,返回一個Option型別,其中包裝了彈出的值,或者None
s.push("新增"); //追加到末尾
assert!("peanut".contains("nut")); //是否包含文字 assert_eq!("?_?".replace("?", ""), "■_■"); //替換 assert_eq!(" clean\n".trim(), "clean"); //去除收、尾的空格、換行符等等空白字元 for word in "veni, vidi, vici".split(", ") { //分割為字串序列   assert!(word.starts_with("v")); //測試開始字串,ends_with是測試末尾 }

&str型別

&str是一種引用型別,類似切片,引用了字串字面量或者是String的一段,它其實也是一個組合指標:包括引用目標的指標和引用的長度(當然也是以佔用位元組數計量的)。

&str最大的用處是在函式傳參中。因為它可以引用任何字串的任何片段,不管它是字串字面量文字(儲存在可執行檔案中)還是字串(在執行時分配和釋放)。想讓函式允許傳遞任何一種字串的引數時,&str比其他字串型別更適合。

最後,說一下引用和非引用。

引用的實質是指標,在C語言中,如果用指標所指的值,需要解引用;在C++中有引用型別,它掩蓋了解引用的步驟:

// C或C++
int
a = 32; int *ra = &a; //修改a的值 *ra = 64; // 在C++程式碼中 int x = 10; int &r = x; // initialization creates reference implicitly r = 20; // stores 20 in x, r itself still points to x

在Rust中,指標型別其實也需要解引用。使用&運算子顯式建立引用,並使用*運算子顯式解引用:

// 在Rust中
let x = 10;
let r = &x;    // &x 是指向 x 的引用
assert!(*r == 10);    // 顯式地解引用r

但是,在需要的時候,點.運算子為解引用提供了便利,可以隱式解引用其左運算元而不用寫 *,這在面向物件程式碼中大量使用。例如訪問結構體物件的元素或元組中的元素:

let t = (123, "bubble", "測試");
let rt = &t; //元組的引用
assert_eq!(rt.2, "測試"); // 和上面一句相同: assert_eq!((*rt).2, "測試");

struct Anime { age:
i32, bechdel_pass: bool }; let aria = Anime { age: 32, bechdel_pass: true }; let anime_ref = &aria; //結構體的引用 assert_eq!(anime_ref.age, 32); // 和上面一句相同: assert_eq!((*anime_ref).age, 32);

字串在各種語言中都是最常用的,但是其複雜度經常超出普通人的想象。所以,有坑慢慢踩,且行且珍惜!

除了上篇和本篇,還有三種使用者自定義型別:結構體struct、列舉enum和特性trait:struct、enum類似其他語言裡的結構體/類和列舉,但也有很多不同之處,不可混為一談。trait在其他語言中也有出現,但本人不是太瞭解其他語言的trait是什麼......

這三種類型將在後面分別單獨闡述。

基礎的資料型別就到這裡吧,下篇開始進入Rust的核心!