[為你自己學 Rust] 結構(Struct)

[為你自己學 Rust] 結構(Struct)

除了先前介紹過的 Array、Tuple 以及 Vector 之外,在 Rust 裡還有個很常用的資料結構叫做「結構(Struct)」。因為 Struct 這個字本身就是「結構」的意思,為了避免「在 Rust 裡結構是一種資料結構」這樣看起來有點奇怪的句字,以下我就用 Struct 原文就好。

建立 Struct

在 Rust 裡可以使用 struct 關鍵字來定義一個 Sturct:

struct Cat {
    name: String,
    age: u8,
    is_sleeping: bool,
}

Struct 需要給它一個名字,例如 Cat,裡面通常會放一些看起來像屬性的欄位(fields)以及這個欄位的型態。在宣告 Struct 的時候雖然沒有硬性規定要用什麼樣的命名慣例,但如果像這樣寫:

struct dog {
    NAME: String,
    Age: u8,
}

雖然程式不會出錯,但會跳一些警告訊息,Rust 的編譯器會「鼓勵」你應該用駝峰式命名 Struct,而裡面的欄位則是使用蛇式命名。

使用 Struct

定義好 Sturct 之後,就可以透過這個 Struct 來建立東西:

let kitty = Cat {
    name: String::from("Kitty"),
    age: 12,
    is_sleeping: true,
};

看到這裡會不會覺得 Struct 看起來有點像 JavaScript 或其它物件導向程式語言裡的類別(Class)?講到 JavaScript 我就想到在 ES6 之後物件的寫法,如果跟 value 的變數名稱跟物件裡的 key 的名字是一樣的,可以簡化成只要寫一個就好:

const name = "Kitty"
const age = 12
const isSleeping = true

const kitty = {
  name, 
  age, 
  isSleeping
}

比較晚出生的程式語言就是有這種好處,Rust 也借了這個簡寫的設計來用:

let name = String::from("Kitty");
let age = 12;
let is_sleeping = true;

let kitty = Cat {
    name,
    age,
    is_sleeping
};

不過 Rust 就比 JavaScript 嚴謹多了,例如在建立實體的時候如果給的資料型別不對,想都不用想就知道 Rust 一定會用錯誤訊息提醒你,甚至少給了欄位的時候:

let kitty = Cat {
    name: String::from("Kitty"),
    age: 12
};

這裡漏給了 is_sleeping,Rust 不會讓這段程式碼通過編譯:

$ cargo run
error[E0063]: missing field `is_sleeping` in initializer of `Cat`
   |
23 |     let kitty = Cat {
   |                 ^^^ missing `is_sleeping`

Rust 編譯器會很直白的提醒你有缺東西,我很喜歡 Rust 這種有話就直說的設計。

接下來你就可以像在 JavaScript 裡操作物件一樣的手法來使用它了:

println!("{}", kitty.name);
println!("{}", kitty.age);
println!("{}", kitty.is_sleeping);

透過 . 就可以取用設定的值了,而且你只能透過這個方式取得,不像 JavaScript 還可以用 kitty["age"] 這種方式取值。

But...又是這個 But,在上面的例子裡的 Cat Struct 定義了 3 個欄位,但實際在用的時候根本沒用到 .is_sleeping,執行的時候程式不會錯,但 Rust 編譯器也會再出來給你一點建議:

$ cargo run
warning: field `is_sleeping` is never read
  |
1 | struct Cat {
  |        --- field in this struct
...
4 |     is_sleeping: bool,
  |     ^^^^^^^^^^^
  |
  = note: `#[warn(dead_code)]` on by default

看到這個警告,你的第一個想法也許是「Rust 你也管太寬了吧」,但你有沒想過什麼情況下欄位定義了卻沒用到?通常是一開始在設計的時候想的比較遠,想要先把欄位開一開,或是本來有在用的欄位後來因為需求變更而不再使用卻也沒有(也不敢)刪掉,不管是哪個情況都表示這個欄位的確是多餘、不需要了。

針對這點 Rust 會好意提醒你,但如果你覺得它有點煩,或是你也不確定這個欄位之後還要不要用,可以加上 #[allow(dead_code)] 的寫法,告訴 Rust 說這裡雖然有目前用不到的欄位,你先不要管我:

#[allow(dead_code)]
struct Cat {
    name: String,
    age: u8,
    is_sleeping: bool,
}

我個人是不建議啦,但這樣就可以暫時把這個警告關掉。

如果想要修改欄位的值的話,就像 JavaScript 一樣透過 . 配合欄位名稱來改就行了:

kitty.age = 20;

執行之後就會看到錯誤訊息:

$ cargo run
error[E0594]: cannot assign to `kitty.age`, as `kitty` is not declared as mutable
   |
29 |     kitty.age = 20;
   |     ^^^^^^^^^^^^^^ cannot assign

沒錯,let 宣告出來的變數預設是不能修改的(immutable),但我想你學到這裡大概也知道該怎麼做了,就只要加個 mut 給它就能搞定了:

let mut kitty = Cat {
    name,
    age,
    is_sleeping
};

定義方法

在 Struct 裡除了可以定義欄位外,也能定義方法。JavaScript 或其它程式語言如果要幫類別定義方法,通常都是寫在類別裡面,但在 Rust 則是另外透過 impl 關鍵字寫在別的地方,impl 是 implementation 的縮寫:

struct Cat {
    name: String,
    age: u8,
    is_sleeping: bool,
}

impl Cat {
    fn greeting(&self) {
        println!("Hello, my name is {}", self.name);
    }
}

其中在 greeting 方法裡的 &self 是個固定的寫法,你不能把它換成別的參數名稱(換掉的話會是別的意思)。&self 是指對自己這個實體的引用,所以在函數裡面的 self.name 指的就是自己這個物件身上的 name 欄位。有了 impl 的加持,所有透過 Cat 所產生出來的實體都會有這個方法可以用:

kitty.greeting() // 印出 Hello, my name is Kitty

雖然在 impl 裡的 greeting() 的函數簽名看起來是 1 個參數,但實際上在使用它的時候不需要代任何引數給它,它自己就會把自己帶進 &self 裡。

因為 & 引用預設是 immutable 的,所以在這裡不能透過 self 修改欄位的值。如果想要可修改,就是使用 &mut 方式來參照:

impl Cat {
    fn greeting(&self) {
        println!("Hello, my name is {}", self.name);
    }

    fn set_age(&mut self, age: u8) {
        self.age = age;
    }
}

同樣的,這個 set_age() 的函數簽名看起來是 2 個參數,但實際上在用的時候,只要給 1 個引數就好:

kitty.set_age(20);

也就是說,如果像是這樣的實體方法的話,第一個參數就是自己本身,剩下的參數才是執行的時候帶進去的。這個 &self 的設計跟程式語言 Python 的 self 設計有點像。

那麼如果沒有給任何參數呢?像這樣:

impl Cat {
    fn run() {
        println!("Go Go Power Rangers");
    }
}

這樣寫的話,這個方法就不是綁在實體上,而是綁在 Struct,所以就必須透過這個 Struct 來呼叫了:

Cat::run()  // 印出 Go Go Power Rangers

同樣的,如果想要讓它可以帶參數:

impl Cat {
    fn count(list: &[u8]) -> u8 {
        list.iter().sum()
    }
}

這樣在呼叫函數的時候就能帶參數進去了:

let resul = Cat::count(&[10, 20, 30]);
println!("{}", resul);  // 印出 60

也就是說,如果想要寫出類似實體方法的話,第一個參數就固定是 &self&mut self;如果想要寫出類似類別方法,第一個參數就不要放 &self

看到這裡,大概知道 Struct 是怎麼回事之後,再看看下面這行:

String::from("Hello Kitty");

這不就是我們前面用來建立字串的方法嗎?是的,但你現在看著它,能不能猜的出來它的原始碼是怎麼實作的?如果去翻原始碼就會發現:

// 檔案 string.rs
pub struct String {
    vec: Vec<u8>,
}

pub 是指公開的存取權限,這個我們在後續章節還會再詳細介紹,但你看看 String 不就是一個 Struct 嗎?再順著原始碼往下看,就能看到這段:

impl String {
  // ... 略 ...
  impl From<&str> for String {
      /// Converts a `&str` into a [`String`].
      ///
      /// The result is allocated on the heap.
      #[inline]
      fn from(s: &str) -> String {
          s.to_owned()
      }
  }
}

這個 impl .. for 的寫法我們目前還沒介紹到,但大概能猜到就是幫 String 這個 Struct 實作了一個 from() 函數,並且帶入一個 &str 字串切片。

有沒有發現我們正在一步一步的把拼圖拼出來,越到後面應該就越能看到完全的樣子了。