Rust 的結構體

楔子

結構體是一種自定義的數據類型,它允許我們將多個不同的類型組合成一個整體。下面我們就來學習如何定義和使用結構體,並對比元組與結構體之間的異同。後續我們還會討論如何定義方法和關聯函數,它們可以指定那些與結構體數據相關的行爲。

定義並實例化結構體

結構體與我們之前討論過的元組有些相似,和元組一樣,結構體中的數據可以擁有不同的類型。而和元組不一樣的是,結構體需要給每個數據賦予名字以便清楚地表明它們的意義。正是由於有了這些名字,結構體的使用要比元組更加靈活:你不再需要依賴順序索引來指定或訪問實例中的值。

關鍵字 struct 被用來定義並命名結構體,一個良好的結構體名稱應當能夠反映出自身數據組合的意義。除此之外,我們還需要在隨後的花括號中聲明所有數據的名字及類型,舉個例子:

struct Girl {
    nameString,
    ageu8,
    emailString,
}

爲了使用定義好的結構體,我們需要爲每個字段賦予具體的值來創建結構體實例,可以通過聲明結構體名稱,並使用一對大括號包含鍵值對的方式來創建實例。其中的鍵對應字段的名字,而值則對應我們想要在這些字段中存儲的數據。

let g = Girl {
    nameString::from("古明地覺"),
    age16,
    emailString::from("satori@komeiji.com"),
};

注意:字段的賦值順序和在結構體中的聲明順序並不需要保持一致,換句話說,結構體的定義就像類型的通用模板一樣,當我們將具體的數據填入模板時就創建出了新的實例。

在獲得了結構體實例後,我們可以通過點號來訪問實例中的特定字段,比如你想獲得某個 Girl 的電子郵件地址,那麼可以使用 g.email 來獲取。另外,如果這個結構體的實例是可變的,那麼我們還可以通過點號來修改字段中的值。

struct Girl {
    nameString,
    ageu8,
    emailString,
}

fn main() {
    let mut g = Girl {
        nameString::from("古明地覺"),
        age16,
        emailString::from("satori@komeiji.com"),
    };
    println!("g.email = {}", g.email);
    // g.email = satori@komeiji.com

    g.email = String::from("satori@komeiji123.com");
    println!("g.email = {}", g.email);
    // g.email = satori@komeiji123.com
}

需要注意的是,一旦實例可變,那麼實例中的所有字段也將是可變的。比如代碼中的變量 g 聲明爲 mut,那麼不僅它本身是可變的(可以賦值一個新的結構體實例給它),它內部的字段也是可變的(可以對內部的字段進行修改)。

這和我們之前介紹的數組和元組類似,對於任意一個複合類型的變量來說,不管是重新賦值,還是修改內部的某個元素,都要求變量必須是可變的。

當然結構體實例也如同其它表達式一樣,我們可以在函數體的最後一個表達式中構造結構體實例,來隱式地將這個實例作爲結果返回。

struct Girl {
    nameString,
    ageu8,
    emailString,
}

fn build_girl(nameString, ageu8,
              emailString) -> Girl {
    Girl {
        namename,
        ageage,
        emailemail,
    }
}

fn main() {
    let g = build_girl(
        String::from("古明地覺"),
        16,
        String::from("satori@komeiji.com"),
    );
    println!("{} {} {}", g.name, g.age, g.email);
    // 古明地覺 16 satori@komeiji.com
}

在函數中使用與結構體字段名相同的參數名可以讓代碼更加易於閱讀,但 name, age, email 同時作爲字段名和變量名被書寫了兩次,則顯得有些煩瑣了,特別是當結構體擁有較多字段時,爲此 Rust 提供了一個簡便的寫法。

簡化版的實例化方式

由於上個例子中的參數與結構體字段擁有完全一致的名稱,所以有些囉嗦。而如果你 IDE 比較智能的話,應該會給出提示:

所以我們可以使用名爲字段初始化簡寫(field init shorthand)的語法來重構 build_girl 函數。這種語法不會改變函數的行爲,但卻能讓我們免於在代碼中重複書寫。

fn build_girl(nameString, ageu8,
              emailString) -> Girl {
    Girl { age, name, email }
}

build_girl 函數中使用了相同的參數名與字段名,並採用了字段初始化簡寫的語法進行編寫。注意:這裏順序不要求一致,變量會自動賦給和自己名字相同的字段。如果變量名和結構體字段名不同,那麼在賦值的時候必須指定字段名。

fn build_girl(name_xxxString, age_xxxu8,
              email_xxxString) -> Girl {
    Girl {
        namename_xxx,
        ageage_xxx,
        emailemail_xxx,
    }
}

這裏我們故意在變量名的結尾後面加上了 _xxx,它們和結構體字段不相同,此時必須指定字段名。可能有人想到了 C 語言,那麼下面這種賦值方式可不可以呢?

在 C 和 Go 裏面是可以的,如果不指定字段名,那麼會將傳遞的變量按照順序分別賦給結構體的每一個字段。但在 Rust 裏面是不可以的,IDE 也給出了提示,Rust 要求構造結構體實例的時候必須指定字段名,除非變量名和字段名一致。比如下面這個例子:

age 變量和結構體的 age 字段名稱一致,那麼 age 變量會賦值給 age 字段,而其它變量和結構體字段的名稱不一致,因此賦值的時候必須指定字段名,並且賦值的時候不用考慮順序。

基於已有結構體實例創建

在很多時候,新創建的結構實例中,除了需要修改的小部分字段,其餘字段的值與某個舊結構體實例完全相同,於是我們可以使用結構體更新語法來快速實現此類新實例的創建。先來看看最直接的創建方法:

struct Girl {
    nameString,
    ageu8,
    emailString,
}

fn main() {
    let g1 = Girl {
        nameString::from("古明地覺"),
        age16,
        emailString::from("satori@komeiji.com"),
    };
    let g2 = Girl {
        nameString::from("古明地覺"),
        age16,
        emailString::from("satori@komeiji123.com"),
    };
}

非常直接,在創建新結構體實例的時候直接初始化每一個字段即可,但問題是新創建的 g2 的 name, age 和已經存在的 g1 是一樣的,我們沒必要重新寫一遍。所以此時可以使用結構體更新語法,來根據 g1 創建 g2,舉個例子。

fn main() {
    let g1 = Girl {
        nameString::from("古明地覺"),
        age16,
        emailString::from("satori@komeiji.com"),
    };
    let g2 = Girl {
        emailString::from("satori@komeiji123.com"),
        ..g1
    };
}

我們只修改 email,因此 email 單獨賦值,剩餘的字段和 g1 保持一致。可以使用 ..g1 來表示剩下的那些還未被顯式賦值的字段,都和給定的結構體實例 g1 一樣擁有相同的值。

並且需要注意,當使用 ..g1 這種形式時,它一定要放在最後面。當然啦,如果你不習慣 Rust 提供的這種語法的話,也可以使用最傳統的方式。

這種做法也是可以的,只不過此時必須要顯式指定字段名。因爲 Rust 規定只有傳遞和字段名相同的變量時,纔可以省略字段名。而 g1.name, g1.age 顯然和字段名不相同,所以此時字段名不可以省略。

元組結構體

除了上面的方式之外,還可以使用另外一種類似於元組的方式定義結構體,這種結構體也被稱作元組結構體。元組結構體同樣擁有用於表明自身含義的名稱,但你無須在聲明時對其字段進行命名,僅保留字段的類型即可。

一般來說,當你想要給元組賦予名字,並使其區別於其它擁有同樣定義的元組時,就可以使用元組結構體。在這種情況下,像常規結構體那樣爲每個字段命名反而顯得有些煩瑣和形式化了。

struct Color(i32, i32, i32);

struct Pointer(i32, i32, i32);

fn main() {
    let black = Color(0, 0, 0);
    let origin = Pointer(0, 0, 0);
}

定義元組結構體時依然使用 struct 關鍵字開頭,並由結構體名稱及元組中的類型組成,以上的代碼中展示了兩個分別叫作 Color 和 Point 的元組結構體定義。

然後基於這兩個結構體,創建了兩個變量 black 和 origin。但要注意它們是不同的類型,因爲它們是不同的元組結構體的實例。我們所定義的每一個結構體都擁有自己的類型,即便結構體中的字段是完全相同的。

例如,一個以 Color 類型作爲參數的函數不能合法地接收 Point 類型的變量,即使它們都是由 3 個 i32 組成的。除此之外,元組結構體實例的行爲就像元組一樣:你可以通過模式匹配將它們解構爲單獨的部分,也可以通過 . 模式用索引來訪問特定字段。

沒有字段的空結構體

也許會出乎你的意料,Rust 允許我們創建沒有任何字段的結構體。因爲這種結構體與空元組十分相似,所以它們也被稱爲空結構體。當你想要在某些類型上實現一個 trait,卻不需要在該類型中存儲任何數據時,空結構體就可以發揮相應的作用。

關於這裏的 trait,後續會詳細介紹。

// 元組結構體
// 裏面只需要指定類型
struct Color();

// 普通的結構體
// 裏面需要同時指定字段名和類型
struct Girl {}

// 但以上兩個結構體都是空結構體
fn main() {
    let color = Color();
    let g = Girl {};
}

如果你有過 Go 的使用經驗的話,你會發現當需要往 channel 裏面發送數據,讓其它 goroutine 解除阻塞的時候,一般也都會發一個空結構體實例進去。因爲空結構體實例的大小是 0,在協調事件通信的時候省內存。

總之當我們需要用一個結構體去做一些事情,但又不需要它存儲數據的時候,就可以使用空結構體。

結構體數據的所有權

上面的結構體定義中,我們使用了自持所有權的 String 類型而不是 &String 和 &str,這是一個有意爲之的選擇。因爲默認情況下,結構體的內部不可以持有其它數據的引用。

這麼做的原因也很簡單,假設結構體實例存儲了變量 a 的引用,但某個時刻變量 a 離開了作用域,那麼相應的內存會被回收,而該結構體實例再通過引用訪問的時候就會報錯,因爲可能會訪問非法的內存。所以我們希望這個結構體實例擁有自身全部數據的所有權,而在這種情形下,只要結構體是有效的,那麼它攜帶的數據也全部都是有效的。

struct Girl {
    name: &String,
    age: u8,
    email: &str,
}

這段代碼沒辦法通過檢查,Rust 會在編譯過程中報錯,提示我們應該指定生命週期:

正如上面說的那樣,如果結構體實例的內部持有某個變量的引用,那麼當結構體實例存活時,變量也必須存活,否則該結構體就有可能訪問非法的內存。

所以默認情況下,結構體內部不能持有引用,如果想持有,那麼必須指定生命週期。通過生命週期來保證結構體實例中引用的數據的壽命不短於實例本身,從而讓結構體實例在自己的有效期內都能合法訪問引用的數據。

生命週期是 Rust 中的一個獨有的概念,非常重要,我們後面說,目前就先使用 String 吧。

使用結構體的示例程序

爲了能夠了解結構體的使用時機,讓我們來編寫一個計算矩形面積的程序,並給出多個方案,看看哪種方案最好。

fn get_area1(widthu32, heightu32) -> u32 {
    width * height
}

fn get_area2(dimension(u32, u32)) -> u32 {
    dimension.0 * dimension.1
}

struct Rectangle {
    widthu32,
    heightu32,
}
fn get_area3(rectangle&Rectangle) -> u32 {
    rectangle.width * rectangle.height
}

以上三個函數都可以計算矩形的面積,那麼哪種最好呢?

首先矩形的長和寬是互相關聯的兩個數據,但第一個函數卻有着兩個不同的參數,並且沒有任何一點能夠表明這兩個參數存在關聯。

第二個函數要求將長和寬組合成一個元組傳過來,它的效果稍微要好一些,使得輸入的參數結構化了。但與此同時程序也變得難以閱讀了,因爲元組並不會給出其中元素的名字,我們可能會對使用索引獲取的值產生困惑和混淆。

在計算面積時,混淆寬度和高度的使用似乎沒有什麼問題,但當我們需要將這個矩形繪製到屏幕上時,這樣的混淆就會出問題了。我們必須牢牢地記住,元素的索引 0 對應了寬度 width,而索引 1 則對應了高度 height。由於沒有在代碼裏表明數據的意義,我們總是會因爲忘記或弄混這些不同含義的值而導致各種程序錯誤。

於是便有了第三個函數,它接收一個結構體的引用。使用結構體無疑是最好的方式,我們會分別給結構體本身及它的每個字段賦予名字,而無須使用類似於元組索引的 0 或 1,這樣就更加清晰了。

但要注意的是,get_area3 接收的是結構體的引用,而且是不可變引用。正如我們之前提到的,在函數簽名和調用過程中使用 & 是因爲我們希望借用結構體,而不是獲取它的所有權,這樣調用方在函數執行完畢後還可以繼續使用它。

通過派生 trait 增加實用功能

需要說明的是,結構體實例默認是不可以打印的。

我們知道宏 println! 可以執行多種不同的文本格式化命令,而作爲默認選項,格式化文本中的花括號會告知 println! 使用名爲 Display 的格式化方法:這類輸出可以直接被展示給終端用戶。我們目前接觸過的所有基礎類型都默認實現了 Display,因爲當你想要給用戶展示類似 1、3.14 這種基礎類型時沒有太多可供選擇的方式。

但對於結構體而言,println! 則無法確定應該使用什麼樣的格式化內容:在輸出的時候需要逗號嗎?需要打印花括號嗎?所有的字段都要被展示嗎?正是由於這種不確定性,Rust 沒有爲結構體提供默認的 Display 實現。

那如果像元組那樣使用 {:?} 這種形式可以嗎?我們來試一下。

我們看到也不行,但提示我們原因是 Rectangle 沒有實現 Debug 這個 trait,那麼如何實現呢?

#[derive(Debug)]
struct Rectangle {
    widthu32,
    heightu32,
}

fn main () {
    let rect = Rectangle{
        width30,
        height50
    };
    println!("{:?}", rect);
    println!("{:#?}", rect);
    /*
    area = Rectangle { width: 30, height: 50 }
    area = Rectangle {
    width: 30,
    height: 50,
    }
    */    
}

以上就成功輸出了,和元組一樣只能使用 {:?} 和 {:#?} 來打印,但是需要添加註解來派生 Debug trait。實際上,Rust 提供了許多可以通過 derive 註解來派生的 trait,它們可以爲自定義的類型增加許多有用的功能。

這裏的 trait 到底是啥,後續會詳細說,目前先知道有這麼東西、以及怎麼讓結構體實例能夠打印即可。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/mT2wBm8yeVRU8SujwzmhjQ