使用結構體的程式範例

為了瞭解我們何時會想要使用結構體,讓我們來寫一支計算長方形面積的程式。我們會先從單一變數開始,再慢慢重構成使用結構體。

讓我們用 Cargo 建立一個新的專案 rectangles ,它將接收長方形的長度與寬度,然後計算出長方形的面積。範例 5-8 展示了在我們專案底下 src/main.rs 用其中一種方式寫出來的小程式。

檔案名稱:src/main.rs

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

    println!(
        "長方形的面積為 {} 平方像素。",
        area(width1, height1)
    );
}

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

範例 5-8:使用變數 width 和 height 計算長方形面積

現在使用 cargo run 執行程式:

$ cargo run
   Compiling structs v0.1.0 (file:///projects/structs)
    Finished dev [unoptimized + debuginfo] target(s) in 0.42s
     Running `target/debug/structs`
長方形的面積為 1500 平方像素。

雖然範例 5-8 可以執行並呼叫 area 函式計算出長方形的面積,但我們可以做得更好。寬度與長度是互相關聯的,因為它們在一起剛好定義了一個長方形。

此程式碼的問題在 area 的函式簽名就能看出來:

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

    println!(
        "長方形的面積為 {} 平方像素。",
        area(width1, height1)
    );
}

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

area 函式應該要計算長方形的面積,但是我們寫的函式有兩個參數。參數之間是有關聯的,但是它在我們的程式中沒有表現出來。要是能將寬度與長度組合起來的話,會更容易閱讀與管理。我們可以使用我們在第三章提到的「元組型別」

使用元組重構

範例 5-9 展示了我們的程式用元組的另一種寫法。

檔案名稱:src/main.rs

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

    println!(
        "長方形的面積為 {} 平方像素。",
        area(rect1)
    );
}

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

範例 5-9:使用元組指定長方形的寬度與長度

一方面來說,此程式的確比較好。元組讓我們增加了一些結構,而我們現在只需要傳遞一個引數。但另一方面來說,此版本的閱讀性反而更差。元組無法命名它的元素,所以我們在計算時反而更難讀懂,我們傳得只是元組的索引。

我們在計算面積時,哪個值是寬度還是長度的確不重要。但如果我們要顯示出來的話,這就很重要了!我們會需要記住元組索引 0width 然後元組索引 1height。如果有其他人要維護這段程式碼的話,他就也得知道並記住這件事才行。但事實上是我們很常忘記這樣數值的意義並導致錯誤發生,因為我們無法從程式碼推導出資料的意義。

使用結構體重構:賦予更多意義

我們可以用結構體來為資料命名以賦予其意義。我們可以將元組轉換成一個有整體名稱且內部資料也都有名稱的資料型別,如範例 5-10 所示。

檔案名稱:src/main.rs

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

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

    println!(
        "長方形的面積為 {} 平方像素。",
        area(&rect1)
    );
}

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

範例 5-10:定義 Rectangle 結構體

我們在此定義了一個結構體叫做 Rectangle。在大括號內,我們定義了 widthheight 的欄位,兩者型別皆為 u32。然後在 main 中,我們建立了一個寬度為 30 長度為 50 的 Rectangle 實例。

現在我們的 area 函式使需要一個參數 rectangle,其型別為 Rectangle 結構體實例的不可變借用。如同第四章提到的,我們希望借用結構體而非取走其所有權。這樣一來,main 能保留它的所有權並讓 rect1 繼續使用,這也是為何我們要在要呼叫函式的簽名中使用 &

area 函式能夠存取 Rectangle 中的 widthheight 欄位。我們的 area 函式簽名由可以表達出我們想要做的事情了:使用 widthheight 欄位來計算 Rectangle 的面積。這能表達出寬度與長度之間的關係,並且給了它們容易讀懂的名稱,而不是像元組那樣用索引 01。這樣清楚多了。

使用推導特徵實現更多功能

現在要是能夠在我們除錯程式時能夠印出 Rectangle 的實例並看到它所有的欄位數值就更好了。範例 5-11 嘗試使用我們之前章節提到的 println! 巨集,但是卻無法執行。

檔案名稱:src/main.rs

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

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

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

範例 5-11:嘗試印出 Rectangle 實例

當我們編譯此程式碼時,我們會得到以下錯誤訊息:

error[E0277]: `Rectangle` doesn't implement `std::fmt::Display`

println! 巨集預設可以做各式各樣的格式化,大括號告訴 println! 要使用 Display 特徵的格式化方式:其輸出結果是用來給最終使用者使用的。我們目前遇過的基本型別預設都會實作 Display,因為它們也只有一種顯示方式(像是 1)能夠給使用者。但是對結構體來說 println! 要怎麼格式化輸出結果就會有點不明確了,因為顯示的方式就很有多種。是要加上頓號嗎?是要印出大括號嗎?所有的欄位都要顯示出來嗎?基於這些不確定因素,Rust 不會去猜我們要的是什麼,所以結構體預設並沒有 Display 的實作。

如果我們繼續閱讀錯誤訊息,我們會得到一些有幫助的資訊:

   = help: the trait `std::fmt::Display` is not implemented for `Rectangle`
   = note: in format strings you may be able to use `{:?}` (or {:#?} for pretty-print) instead

讓我們來試試看吧!println! 巨集的呼叫方式現在看起來應該會像這樣 println!("rect1 is {:?}", rect1);。在 println! 內加上 :? 這樣的標記指的是我們想要使用 Debug 特徵來作為輸出格式方式。Debug 特徵讓我們能印出對開發者有幫助的資訊,好讓我們在除錯程式時可以看到它的數值。

但是要是編譯這樣的程式的話,哎呀!我們卻還是會得到錯誤:

error[E0277]: `Rectangle` doesn't implement `std::fmt::Debug`

不過同樣地,編譯器又給了我們有用的資訊:

   = help: the trait `std::fmt::Debug` is not implemented for `Rectangle`
   = note: add `#[derive(Debug)]` or manually implement `std::fmt::Debug`

Rust 的確有印出除錯資訊的功能,但是我們要針對我們的結構體顯式實作出來才會有對應的功能。為此我們可以在結構體前加上 #[derive(Debug)],如範例 5-12 所示。

檔案名稱:src/main.rs

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

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

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

範例 5-12:加上推導(derive) Debug 特徵的標記並印出 Rectangle 實例的格式化資訊

現在當我們執行程式,我們不會在得到錯誤了,而且我們可以看到格式化後的輸出結果:

$ cargo run
   Compiling structs v0.1.0 (file:///projects/structs)
    Finished dev [unoptimized + debuginfo] target(s) in 0.48s
     Running `target/debug/structs`
rect1 is Rectangle { width: 30, height: 50 }

漂亮!雖然這不是非常好看的輸出格式,但是它的確顯示了實例中所有的欄位數值,這對我們除錯時會非常有用。不過如果我們的結構體非常龐大的話,我們會希望輸出格式可以比較好閱讀。為此我們可以在 println! 的字串使用 {:#?} 而非 {:?}。當我們使用 {:#?} 風格的話,輸出結果會長得像這樣:

$ cargo run
   Compiling structs v0.1.0 (file:///projects/structs)
    Finished dev [unoptimized + debuginfo] target(s) in 0.48s
     Running `target/debug/structs`
rect1 is Rectangle {
    width: 30,
    height: 50,
}

Rust 提供了一些特徵並讓我們可以用 derive 標記來為自定型別加入些實用的功能。這類的特徵與其行為都列在附錄 C。我們會在第十章介紹如何定義自定特徵,且如何實作這些特徵的自訂行為。

我們的函式 area 最後就非常清楚明白了,它只會計算長方形的面積。這樣的行為要是能夠緊貼著我們的 Rectangle 結構體,因為這樣一來它就不會相容於其他型別。讓我們看看我們如何繼續重構我們的程式碼,接下來我們可以將函式 area 轉換為 Rectangle 型別的方法method) 。