結構體(struct)可以將多個不同類型的資料命名並包裝起來,將它們組合成一個有特殊意義的群組,就像是物件導向概念中的物件所擁有的屬性。在這個章節中我們將會比較元組和結構體的差異、學習如何使用結構體,並替結構體定義方法(method)和關聯函數(associated function)。



結構體和我們先前討論過的元組還蠻相像的,都可以儲存不同型別的資料。只不過,結構體會將每個資料命名,讓我們明確知道每個資料欄位(field)的用途,而不必去考慮欄位的順序。定義一個結構體,可以使用struct關鍵字,用法如下:

struct 結構名稱 {
    欄位1名稱: 欄位1的型別,
    欄位2名稱: 欄位2的型別,
    欄位3名稱: 欄位3的型別,
    欄位n名稱: 欄位n的型別,
}

舉例來說:

struct User {
    username: String,
    email: String,
    sign_in_count: u64,
    active: bool,
}

定義好結構體之後,就可以將這個結構體作為一個型別來使用。結構體所產生出來的值,即為這個結構的「實體」(instance),要產生出結構實體,必須要將結構體「實體化」。舉例來說:

struct User {
    username: String,
    email: String,
    sign_in_count: u64,
    active: bool,
}

fn main() {
    let user1 = User {
        email: String::from("someone@example.com"),
        username: String::from("someusername123"),
        active: true,
        sign_in_count: 1,
    };
}

實體化一個結構體和定義一個結構體的時候很像,都需要撰寫結構名稱和這個結構的所有欄位。在實體化結構體的時候,必須直接在:後面指派欄位的值,而非撰寫欄位的型別。以上程式碼的第9行到第14行,透過賦予值給User結構的每個欄位,來建立出一個User結構的實體,並指派給剛被宣告出來的user1變數儲存。

如果要存取結構體的欄位,可以用.語法。舉例來說:

struct User {
    username: String,
    email: String,
    sign_in_count: u64,
    active: bool,
}

fn main() {
    let mut user1 = User {
        email: String::from("someone@example.com"),
        username: String::from("someusername123"),
        active: true,
        sign_in_count: 1,
    };

    user1.email = String::from("anotheremail@example.com");
}

程式第16行,將新的字串結構實體指派給user1email欄位。由於user1變數的值本身需要可以被改變,因此在第9行宣告user1變數的時候要使用mut關鍵字。這邊要注意的是,結構體無法指定其哪些欄位可變、哪些欄位不可變,只要沒有用mut宣告變數,就是都不可變;有用mut宣告變數,就是都可以變。

在實體化出結構體的時候,必須要指派所有欄位的值。可是這樣在使用上很不方便,若有些欄位在實體化的時候所用的值是固定的,每次進行實體化的時候都要寫一樣的程式。Rust的結構體並無法直接定義初始值,因此如果想要讓結構體的某些欄位在實體化時是選填的話,可以透過實作一個建立出該結構體實體的函數來供以後重複使用。

舉例來說:

struct User {
    username: String,
    email: String,
    sign_in_count: u64,
    active: bool,
}

fn build_user(email: String, username: String) -> User {
    User {
        email: email,
        username: username,
        active: true,
        sign_in_count: 1,
    }
}

fn main() {
    let user1 = build_user(String::from("someone@example.com"), String::from("someusername123"));
}

透過build_user函數,每次要實體化出User結構的時候,只要填寫emailusername欄位即可。但以上程式寫起來似乎還是有點麻煩,程式第10行和第11行,分別寫了email: emailusername: username,看起來很冗長。Rust在這裡提供了一個方便的語法糖,當變數名稱和欄位名稱相同的時候,可以省略撰寫欄位名稱,直接填入變數名稱即可。修改後的程式如下:

struct User {
    username: String,
    email: String,
    sign_in_count: u64,
    active: bool,
}

fn build_user(email: String, username: String) -> User {
    User { email, username, active: true, sign_in_count: 1 }
}

fn main() {
    let user1 = build_user(String::from("someone@example.com"), String::from("someusername123"));
}

另外還有一個語法糖稱作「結構更新語法」,可以直接以現有的相同結構實體部份或全部的欄位值來產生新的結構實體。舉例來說:

struct User {
    username: String,
    email: String,
    sign_in_count: u64,
    active: bool,
}

fn build_user(email: String, username: String) -> User {
    User { email, username, active: true, sign_in_count: 1 }
}

fn main() {
    let user1 = build_user(String::from("someone@example.com"), String::from("someusername123"));
    let user2 = User {
        email: String::from("another@example.com"),
        username: String::from("anotherusername567"),
        active: user1.active,
        sign_in_count: user1.sign_in_count,
    };
}

以上程式第17行和第18行可以直接簡化成:

struct User {
    username: String,
    email: String,
    sign_in_count: u64,
    active: bool,
}

fn build_user(email: String, username: String) -> User {
    User { email, username, active: true, sign_in_count: 1 }
}

fn main() {
    let user1 = build_user(String::from("someone@example.com"), String::from("someusername123"));
    let user2 = User {
        email: String::from("another@example.com"),
        username: String::from("anotherusername567"),
        ..user1
    };
}

我們可以也可以定義出如以下這樣的結構體:

struct Color(i32, i32, i32);

這種結構體稱作「元組結構體」(tuple struct),說穿了就是將原本的元組多加上結構名稱,也就是能自訂型別名稱的元組,用法也跟一般的元組一樣。將「元組結構體」實體化的語法和一般的結構體類似,就是原本定義出結構體的語法,只不過要把欄位型別改成欄位的值。如下:

struct Color(i32, i32, i32);
struct Point(i32, i32, i32);

let black = Color(0, 0, 0);
let origin = Point(0, 0, 0);
let t = (0, 0, 0);

上面這個例子要注意的是,雖然black變數、origin變數和t變數的元組都是(i32, i32, i32),但由於black變數、origin變數是屬於「元組結構體」型別,只要不是來自於相同的結構體,就是屬於不同的型別。換句話說,black變數、origin變數和t變數都是使用不同的型別。

我們可以定義不包含任何欄位,也不寫任何括號的結構體,這種結構體稱為「類單元結構體」(unit-like struct),也可以簡稱為「單元結構體」(unit struct)。這種結構體常會和其它特性一同使用,以物件導向的觀念來解釋的話就是「沒有物件屬性,只有方法的類別」,舉例來說:

struct Tools;

trait Array {
    fn print_array(a: &[i32]);
}

trait Helper {
    fn show_help();
}

impl Array for Tools {
    fn print_array(a: &[i32]) {
        println!("{a:?}");
    }
}

impl Helper for Tools {
    fn show_help() {
        println!("Struct Tools is a set of functions dealing with common things.");
    }
}


fn main() {
    let a = [1, 2, 3];
    Tools::show_help();
    Tools::print_array(&a);
}

程式第1行定義了沒有任何欄位的結構體Tools。程式執行結果為:

Struct Tools is a set of functions dealing with common things.
[1, 2, 3]

如果現在還不了解traitimpl關鍵字沒有關係,之後會做更深入的介紹,在此只要有個「結構體可以沒有資料欄位」的概念就好。

接著我們來使用結構體練習實際的程式案例:製作出一個可以計算矩型面積的程式。

首先,使用Cargo建立名為rectangles的專案。接著先用最簡單的方式,不使用結構體來撰寫出以下計算矩型面積的程式:

fn main() {
    let width1 = 30;
    let height1 = 50;

    println!("The area of the rectangle is {} square pixels.", area(width1, height1));
}

fn area(width: u32, height: u32) -> u32 {
    width * height
}

area函數可以傳入矩型的長和高,計算並回傳矩型的面積。以上程式執行結果如下:

The area of the rectangle is 1500 square pixels.

這個程式很簡單,但是會有個令人疑惑的地方。我們在前面的章節不是提到直接寫在程式碼的整數是i32型別嗎?為什麼這邊使用u32是可以通過編譯的呢?這個狀況跟之前提到的在使用let關鍵字時明確定義數值型別的方式有點類似,只不過我們現在是將明確定義數值型別的地方放在第一次使用這個數值時。在這個計算面積的例子中,width1height1變數在宣告時並沒有明確定義出數值是用哪個型別,但是它們第一次都被使用在呼叫area函數時代入的u32型別的參數,編譯器就會在編譯階段自動將width1height1變數當作是u32型別。

接著使用之前學到的元組,嘗試改寫這個計算矩型面積的程式:

fn main() {
    let rect1 = (30, 50);

    println!("The area of the rectangle is {} square pixels.", area(rect1));
}

fn area(dimensions: (u32, u32)) -> u32 {
    dimensions.0 * dimensions.1
}

我們將元組(u32, u32)的索引0欄位定義為矩型的長,索引1的欄位定義為矩型的高。這樣的作法雖然可以少寫一些程式碼,卻可能會造成欄位上的誤解,雖然這個例子是計算矩型面積,長寬順序錯了也沒關係。

為了讓程式的可讀性更高,我們選擇使用結構體來改寫程式。如下:

struct Rectangle {
    width: u32,
    height: u32,
}

fn main() {
    let rect1 = Rectangle { width: 30, height: 50 };

    println!("The area of the rectangle is {} square pixels.", area(&rect1));
}

fn area(rectangle: &Rectangle) -> u32 {
    rectangle.width * rectangle.height
}

程式第1行到第4行,定義了Rectangle結構。接著在main函數中建立出Rectangle結構的實體,並以參考的方式傳遞給area函數使用,這樣才不會造成Rectangle結構實體的擁有權被area函數的參數拿走。

如果將我們建立出來的結構實體放進println!巨集中使用會發生什麼事呢?如以下程式:

struct Rectangle {
    width: u32,
    height: u32,
}

fn main() {
    let rect1 = Rectangle { width: 30, height: 50 };

    println!("rect1 is {rect1}");
}

程式第9行會編譯錯誤,因為Rectangle結構並未實作std::fmt套件底下的Display特性。

那如果是跟印出陣列或是元組的方式一樣,在大括號內加上:?呢?程式如下:

struct Rectangle {
    width: u32,
    height: u32,
}

fn main() {
    let rect1 = Rectangle { width: 30, height: 50 };

    println!("rect1 is {rect1:?}");
}

程式第9行還是會編譯錯誤,因為Rectangle結構並未實作std::fmt套件底下的Debug特性。Debug特性顧名思義是用來給程式開發者查看值的內容到底是什麼,我們可以自行實作將結構體轉成字串的方式,或是採用Rust程式語言內建的語法糖#[derive(Debug)]來快速讓我們的結構體實作Debug特性。只要在想要使用內建的方式實作Debug特性的結構體定義程式上,加上一行#[derive(Debug)]即可。如下:

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

fn main() {
    let rect1 = Rectangle { width: 30, height: 50 };

    println!("rect1 is {rect1:?}");
}

程式執行結果為:

rect1 is Rectangle { width: 30, height: 50 }

加上#[derive(Debug)]的結構體就可以透過:?語法來印出。

學會如何檢查結構實體的欄位名稱和值之後,我們要來開始替結構體撰寫「方法」(method)啦!「方法」類似「函數」,也是一樣使用fn關鍵字來定義,只不過原先定義函數參數的地方,第一個參數必須要是&self,用來表示結構實體自身。使用fn關鍵字定義結構體的方法,並不是寫在struct關鍵字的區塊,而是要使用另外一個關鍵字impl。舉例來說:

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn area(&self) -> u32 {
        self.width * self.height
    }
}

fn main() {
    let rect1 = Rectangle { width: 30, height: 50 };

    println!("The area of the rectangle is {} square pixels.", rect1.area());
}

以上程式第7行到第11行,使用impl來定義並實作Rectangle結構的area方法,area方法會利用Rectangle結構實體自身的widthheight欄位來計算並回傳面積。在impl關鍵字所組成的程式區塊中,self即為結構實體本身,將self代入參數的話會造成結構實體的擁有權被參數拿走,因此必須要使用&self來傳遞。

impl中使用fn關鍵字不一定要將第一個參數設為&self,如此一來定義出來的就是這個結構體關聯的函數(associated function)。舉例來說:

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn area(&self) -> u32 {
        self.width * self.height
    }

    fn new(width: u32, height: u32) -> Rectangle {
        Rectangle { width, height }
    }
}

fn main() {
    let rect1 = Rectangle::new(30, 50);

    println!("The area of the rectangle is {} square pixels.", rect1.area());
}

以上程式第12行到第14行,我們定義了一個Rectangle結構的關聯函數new,可以透過new函數的參數來將矩型的長寬值傳入,並建立出一個Rectangle結構的實體。在呼叫結構體的函數時,不需要實體化結構體,只要使用結構體的名稱,再接上::語法與要呼叫的函數名稱;而在呼叫結構體的方法時,必須要先將結構體實體化,再用結構實體接上.與要呼叫的方法名稱。

如果結構實體的方法會改變結構實體本身的值,在用fn關鍵字定義方法的時候,第一個參數必須改為&mut self。舉例來說:

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn area(&self) -> u32 {
        self.width * self.height
    }

    fn set_width(&mut self, width: u32) {
        self.width = width;
    }

    fn new(width: u32, height: u32) -> Rectangle {
        Rectangle { width, height }
    }
}

fn main() {
    let mut rect1 = Rectangle::new(30, 50);

    rect1.set_width(10);

    println!("The area of the rectangle is {} square pixels.", rect1.area());
}

以上程式第12行到第14行,我們定義了一個Rectangle結構的方法set_width,這個set_width方法可以設定Rectangle結構實體的width欄位。程式第27行,呼叫了rect1結構實體的set_width方法,因此rect1結構實體的width欄位的值會發生改變。

同一個結構體可以使用多個impl關鍵字來定義其方法和關聯函數,非常自由,也可以利用多個impl關鍵字來替結構體實作出不同的特性。

稍微把我們剛才寫的Rectangle結構體擴充一些功能,程式如下:

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn square(size: u32) -> Rectangle {
        Rectangle { width: size, height: size }
    }
    fn new(width: u32, height: u32) -> Rectangle {
        Rectangle { width, height }
    }
}

impl Rectangle {
    fn area(&self) -> u32 {
        self.width * self.height
    }

    fn can_hold(&self, other: &Rectangle) -> bool {
        self.width > other.width && self.height > other.height
    }
}

impl Rectangle {
    fn set_width(&mut self, width: u32) {
        self.width = width;
    }

    fn set_height(&mut self, height: u32) {
        self.height = height;
    }
}

fn main() {
    let rect1 = Rectangle::new(30, 50);

    println!("The area of the rectangle is {} square pixels.", rect1.area());
}

總結

結構體讓我們可以建立出有特別意義的自訂型別,資料的群組化與各種相關操作的連結使我們的程式碼變得更容易讀寫。在下一章節中,將會介紹另外一種也是能讓程式碼更好撰寫、更容易閱讀的型別種類──列舉(enum)。

下一章:列舉和型樣匹配