在上一章節的最後我們介紹到利用元組結構體來建立新的型別的方式,在這個章節我們會繼續介紹更多型別的應用。



型別的別名

我們在上一個章節,學習到可以利用type關鍵字來替特性定義出其關聯型別,而事實上type關鍵字並不一定只能在特性上。我們可以在任意的scope下,使用type關鍵字來定義出一個型別的新別名。舉例來說:

fn main() {
    type Kilometers = i32;

    let x: i32 = 5;
    let y: Kilometers = 5;

    println!("x + y = {}", x + y);
}

以上程式,我們替i32型別定義了一個新別名Kilometers,這個Kilometers並不算是一個新的型別,它很單純地就是代表著i32型別。定義新型別主要是用來代替某些需要使用泛型的型別,其在撰寫時又臭又長的語法。舉例來說:

type Thunk = Box<dyn Fn() + Send + 'static>;

fn main() {
    let f: Thunk = Box::new(|| println!("hi"));
}

fn takes_long_type(f: Thunk) {
    // --snip--
}

以上程式,我們使用新別名Thunk來代替原本的Box<dyn Fn() + Send + 'static>型別。

Result<T, E>列舉也是很常會定義新的別名來代替,因為通常在一個模組下,使用的Err變體所包裹的值都是同一個型別。例如標準函式庫中的std::io模組就會利用type關鍵字替Result<T, E>列舉定義出新的別名:

pub struct Error {
    repr: Repr,
}

pub type Result<T> = result::Result<T, Error>;

別名也支援泛型,而且也可以搭配pub關鍵字,使別名能夠被其它的模組存取。

空型別(Empty Type)

Rust程式語言還有內建一個十分特別的型別,那就是!,稱作「空型別」。有些函數或是方法,在它們被呼叫之後,就會進入無窮迴圈或是使程式直接結束,因此不會有將值回傳出去的機會。Rust程式語言的編譯器擁有型別推論的功能,在進行型別推論時,「空型別」的優先權會低於其它的型別,當同時遇到某個表達式可能會回傳空型別和其它型別時,便會忽略空型別,將這個表達式的回傳值型別推論為其它型別。

舉例來說:

fn panic() -> ! {
    panic!("panic!!");
}

fn main() {
    let status = 5;

    let s = if status == 0 {
        "Hello World!"
    } else {
        panic();
    };
}

以上程式碼中,我們定義了一個會回傳空型別的panic函數,這個函數在呼叫之後就會使程式panic,而沒有機會將值回傳出去。因此程式第8行到第12行的ifelse關鍵字組成的表達式,其回傳值型別會被推論為&str

我們再仔細觀察一下panic函數的實作方式,就會發現一些特別的地方。既然panic函數會回傳「空型別」的值的話,那麼它勢必得在主體中定義回傳的動作,即便這個動作可能不會有被執行的機會,也應該還是要有,否則程式應該不能編譯才對。難道,panic!巨集有回傳值,而且回傳值型別就是「空型別」?賓狗!沒錯,Rust程式語言有些內建的東西會回傳「空型別」,除了讓能程式panic的相關巨集之外,還有loop關鍵字所組成的表達式,在沒有break關鍵字的情況下也會回傳「空型別」。

可是怎麼還是有點奇怪?程式第2行,如果我們要讓panic函數回傳panic!巨集的回傳值的話,不是應該不能把它加上分號嗎?還有程式第12行,我們要讓else關鍵字組成的表達式能夠回傳panic函數的回傳值的話,不是也應該不能把它加上分號嗎?這是算是當程式敘述會回傳「空型別」時的例外用法,也就是可以無視它有沒有分號,只要放在程式區塊的最後一行,編譯器就會自動判斷它需不需要進行回傳,如果函數或是方法有定義要回傳「空型別」就會回傳;如果函數或是方法沒有定義回傳值型別,就不會回傳。

舉例來說:

fn panic() -> &'static str {
    "panic!!";
}

fn main() {
    let status = 5;

    let s = if status == 0 {
        "Hello World!"
    } else {
        panic();
    };
}

以上程式第2行和第11行都必須要去掉分號才可以編譯成功。

再舉一個例子:

fn panic() {
    panic!("panic!!")
}

fn main() {
    panic();
}

以上程式碼,雖然panic!巨集會回傳「空型別」,且panic函數沒有定義回傳值型別,但程式第2行,即便panic!巨集沒有加上分號也可以編譯成功。

動態大小的型別

在先前的章節中,我們學到了動態調度和靜態調度,因而了解到Rust程式語言要替一個變數或是參數來分配記憶體空間時,必須要在程式編譯時期就要知道要分配多少記憶體空間,否則就只能是用動態調度的方式,利用參考或是智慧型指標來存取資料。

在這邊我們來介紹一個相關的特性──Sized。只要是可以在編譯階段就知道其記憶體空間的型別,都會自動實作Sized特性。在使用泛型做靜態調度的時候,為了確保泛型型別參數實際所代表的型別是真的能夠在程式編譯階段就知道其記憶體空間,Rust程式語言其實會自動對泛型型別參數做限制,使它們實際所代表的型別至少要有實作Sized特性。

舉例來說:

fn generic<T>(t: T) {
    // --snip--
}

其實就等於:

fn generic<T: Sized>(t: T) {
    // --snip--
}

如果我們要讓泛型型別參數不要有實作Sized特性的限制,可以用T: ?Sized這樣的寫法來定義。當然,若編譯器無法保證T泛型型別參數所代表的型別是可以在編譯階段就知道大小的型別的話,就無法使用靜態調度了。

fn generic<T: ?Sized>(t: T) {
    // --snip--
}

如以上這段程式,參數t: T會編譯錯誤。

常數泛型(Const Generics)

<>語法中可以使用const關鍵字來建立可以傳入基本資料型別的參數。舉個應用例子來說明:

use std::fmt::Debug;

fn print_array<T: Debug, const N: usize>(array: [T; N]) {
    println!("{array:?}, size = {N}");
}

fn main() {
    let arr = [1, 2, 3];
    print_array(arr);

    let arr = ["hello", "world"];
    print_array(arr);
}

以上程式中,print_array函數有泛型,且有常數泛型參數N,用以表示其第一個參數的陣列型別的長度。

雖然常數泛型可以這樣用,但幫助不大。它最有用的地方是在於它可以在編譯階段的時候去檢查常數泛型的數值是否符合某個條件,如下程式:

#![allow(incomplete_features)]
#![feature(generic_const_exprs)]

use std::{
    error::Error,
    fs::File,
    io::{self, Read},
};

// Types used for type-level operations
pub struct Assert<const CHECK: bool>;
pub trait IsTrue {}
impl IsTrue for Assert<true> {}

fn handler<const BUFFER_SIZE: usize>(reader: &mut dyn Read) -> Result<(), io::Error>
where
    Assert<{ BUFFER_SIZE % 4 == 0 }>: IsTrue,
    Assert<{ BUFFER_SIZE >= 4 }>: IsTrue, {
    let mut buffer = [0u8; BUFFER_SIZE];

    let c = reader.read(&mut buffer)?;

    // do something

    Ok(())
}

fn main() -> Result<(), Box<dyn Error>> {
    handler::<4096>(&mut File::open("Cargo.toml")?)?;

    // handler::<4097>(&mut File::open("Cargo.toml")?)?; // compilation error

    Ok(())
}

以上程式,handler函數可以利用常數泛型參數來設定緩衝空間的大小,並且透過第17行和第18行的程式碼將這個緩衝空間大小的數值限制為大於等於4的4的倍數,如此一來便可以讓如程式第31行的handler函數呼叫發生編譯錯誤。

不過問題來了,我們何不直接把緩衝空間的大小透過函數參數來傳入呢?如以下程式:

use std::{
    error::Error,
    fs::File,
    io::{self, Read},
};

fn handler(reader: &mut dyn Read, buffer_size: usize) -> Result<(), io::Error> {
    assert!(buffer_size % 4 == 0 && buffer_size >= 4);

    let mut buffer = vec![0u8; buffer_size];

    let c = reader.read(&mut buffer)?;

    // do something

    Ok(())
}

fn main() -> Result<(), Box<dyn Error>> {
    handler(&mut File::open("Cargo.toml")?, 4096)?;

    handler(&mut File::open("Cargo.toml")?, 4097)?; // panic

    Ok(())
}

以上程式雖然也可以運作,但有兩個顯而易見的缺點:

  1. 從函數參數傳入的緩衝空間大小是在執行階段才會知道真實數值,因此無法被用來建立堆疊空間,只能夠從堆積動態分配空間,堆積的效能是不如堆疊的。
  2. 無法在編譯階段就阻擋不符合規範的緩衝空間大小。

如何?至此我們就清楚知道常數泛型好用的地方了!

結論

在這個章節我們學會了使用泛型的別名和空型別,也了解到泛型型別參數其實會自動有「型別必須實作Sized特性」的限制。至此我們已經將99%的Rust程式語言的觀念和規則都學完了,在下一章節,我們就來使用Rust程式語言實作出一個支援多執行緒的Web伺服器吧!

下一章:建立多執行緒的Web伺服器