返回文章列表

Rust 向量與資料結構深入解析

本文探討 Rust 的核心資料結構 `Vec`(向量)的內部機制、最佳化策略以及與切片的關係,同時解析 `Deref` 特徵如何賦予向量類別似繼承的行為。此外,文章也涵蓋了 Rust 中其他重要資料結構如 `HashMap`、`String` 與向量間的關聯,以及如何在 Rust

程式語言 系統程式設計

Rust 的 Vec 作為動態大小陣列,是其最核心的資料結構之一,底層藉由切片實作,並透過 Deref 特徵巧妙地橋接兩者,讓 Vec 擁有切片的操作方法。String 底層也是根據 Vec<u8> 實作,展現了 Vec 的通用性。Rust 提供了多種資料結構,HashMap 便是另一重要型別,適合快速資料檢索,並支援自定義雜湊函式與可雜湊型別。選擇正確的資料結構對於效能至關重要,除了 VecHashMap,Rust 還提供了 VecDequeLinkedListBTreeMap 等,各有其適用場景。理解這些資料結構的特性,才能在開發中做出最佳選擇。

向量(Vectors)

向量可以說是Rust中最重要的資料型別(第二重要的資料型別是 String,它是根據 Vec 實作的)。在Rust中處理資料時,當你需要一個可調整大小的值序列時,你會經常建立向量。如果你來自C++背景,你可能已經聽說過向量這個術語,在很多方面,Rust的向量型別與你在C++中找到的非常相似。向量是一種通用容器,可以用於幾乎任何型別的序列。

深入瞭解 Vec

Vec 繼承了切片的方法,因為我們可以從向量中取得一個切片參考。Rust沒有像物件導向程式設計那樣的繼承,但 Vec 是一種特殊的型別,它同時是 Vec 和切片。例如,讓我們看看標準函式庫中 as_slice() 的實作。

pub fn as_slice(&self) -> &[T] {
    self
}

這段程式碼進行了一種特殊的轉換,通常情況下這種轉換是不可能的。它將 self(在上述程式碼中是 Vec<T>)簡單地傳回為 &[T]。如果你嘗試自己編譯相同的程式碼,它將無法編譯。

Deref 實作

Rust提供了一個名為 Deref(及其可變的配套 DerefMut)的特徵,編譯器可以使用它來隱含地將一種型別強制轉換為另一種型別。一旦為給定的型別實作了這個特徵,該型別也將自動實作被參考型別的所有方法。在 Vec 的情況下,DerefDerefMut 是在Rust標準函式庫中實作的,如下所示。

impl<T, A: Allocator> ops::Deref for Vec<T, A> {
    type Target = [T];
    fn deref(&self) -> &[T] {
        unsafe { slice::from_raw_parts(self.as_ptr(), self.len) }
    }
}

impl<T, A: Allocator> ops::DerefMut for Vec<T, A> {
    fn deref_mut(&mut self) -> &mut [T] {
        unsafe { slice::from_raw_parts_mut(self.as_mut_ptr(), self.len) }
    }
}

在上述程式碼中,對向量的解參考將使其強制轉換為切片,從其原始指標和長度。應該注意的是,這種操作是暫時的——也就是說,切片不能被調整大小,並且在解參考時會提供長度給切片。

包裝向量

Rust中的某些型別只是包裝了一個 Vec,例如 StringString 型別是一個 Vec<u8>,並且使用前面提到的 Deref 特徵解參考為 str

pub struct String {
    vec: Vec<u8>,
}

內容解密:

  • 上述程式碼展示了 String 如何根據 Vec<u8> 實作。
  • 這意味著 String 繼承了 Vec 的許多特性,並且可以被視為一個可增長的位元組緩衝區。

向量的內部最佳化

向量具有一些內部最佳化,以限制過度的分配,例如以區塊方式分配記憶體。此外,在 nightly Rust 中,您可以提供自定義的分配器(在第5章中有更詳細的討論)來實作自己的記憶體分配行為。

使用向量的注意事項

如果由於某種原因,你取了一個向量的切片並調整了向量的大小,切片的大小不會改變。然而,這只有在不安全的程式碼中才有可能,因為借用檢查器不允許你同時借用一個向量的切片和改變向量。以下面的例子來說明:

let mut vec = vec![1, 2, 3];
let slice = vec.as_slice();
vec.resize(10, 0);
println!("{}", slice[0]);

上述程式碼將無法編譯,因為借用檢查器會傳回以下錯誤:

error[E0502]: cannot borrow `vec` as mutable because it is also borrowed as immutable
--> src/main.rs:4:5
|
3 | let slice = vec.as_slice();
| --- immutable borrow occurs here
4 | vec.resize(10, 0);
| ^^^^^^^^^^^^^^^^^ mutable borrow occurs here
5 | println!("{}", slice[0]);
| 
---
-
---
- immutable borrow later used here

內容解密:

  • 這段程式碼試圖同時對同一個向量進行可變和不可變的借用,這是Rust借用檢查器所不允許的。
  • 這種情況下,編譯器會阻止你進行這種可能導致未定義行為的操作。

資料結構:向量與雜湊對映的應用

在程式設計中,選擇適當的資料結構對於程式的效能和可維護性至關重要。Rust 語言提供了多種內建的資料結構,其中 VecHashMap 是最常用的兩種。

向量相關型別

在大多數情況下,Vec 是實作可調整大小的序列的最佳選擇。然而,在某些特定情況下,其他容器型別可能更為合適。

常見向量相關型別

  • VecDeque:一個可以調整大小的雙端佇列,根據 Vec 實作。
  • LinkedList:一個雙向鏈結串列。
  • HashMap:一個雜湊對映,將在下一節中詳細討論。
  • BTreeMap:一個根據 B 樹的對映。
  • HashSet:一個雜湊集合,根據 HashMap 實作。
  • BTreeSet:一個 B 樹集合,根據 BTreeMap 實作。
  • BinaryHeap:一個優先佇列,使用 Vec 內部實作。

這些資料結構都可以在 Rust 的標準函式庫中找到,並且根據具體的需求進行選擇。

雜湊對映

HashMap 是 Rust 中另一種常用的容器型別。當需要快速檢索資料時,HashMap 是一個不錯的選擇。

自定義雜湊函式

Rust 的 HashMap 預設使用 Siphash-1-3 函式進行雜湊運算。然而,在某些情況下,您可能需要使用自定義的雜湊函式。

要使用自定義的雜湊函式,需要實作 std::hash::BuildHasherstd::hash::Hasherstd::default::Default 特性。

use metrohash::MetroBuildHasher;
use std::collections::HashMap;

let mut map = HashMap::<String, String, MetroBuildHasher>::default();
map.insert("hello?".into(), "Hello!".into());
println!("{:?}", map.get("hello?"));

在上述範例中,我們使用了 MetroHash 作為自定義的雜湊函式。

建立可雜湊型別

要將自定義型別用作 HashMap 的鍵,需要實作 std::cmp::Eqstd::hash::Hash 特性。可以使用 #[derive] 屬性自動派生這些特性。

#[derive(Hash, Eq, PartialEq, Debug)]
struct CompoundKey {
    name: String,
    value: i32,
}

在上述範例中,我們定義了一個名為 CompoundKey 的結構體,並使用 #[derive] 屬性自動派生了 HashEqPartialEqDebug 特性。

Rust 型別系統:原始型別、結構體、列舉與別名

Rust 是一種強型別語言,提供了多種方式來建模資料。從最基本的原始型別開始,Rust 提供了多種資料型別,包括整數、浮點數、元組、陣列等,接著是結構體和列舉,用於封裝其他型別,最後是別名,用於重新命名和組合現有的型別。

Rust 中的四類別主要型別

  1. 原始型別:包括字串、陣列、元組和整數型別等。
  2. 結構體:一種複合型別,可以由任意其他型別組合而成,類別似於 C 語言中的結構體。
  3. 列舉:Rust 中的特殊型別,類別似於 C、C++ 和 Java 等語言中的列舉。
  4. 別名:用於建立根據現有型別的新型別定義的語法糖。

使用原始型別

Rust 語言和核心函式庫提供了原始型別,這些型別與其他強型別語言中的原始型別相似,但有一些例外。主要的原始型別包括整數、浮點數、元組和陣列等。

整數型別

整數型別可以根據其符號(有符號或無符號)以及位元長度來識別。有符號整數以 i 開頭,無符號整數以 u 開頭,後面跟著位元數。例如,i8 表示 8 位元有符號整數,u128 表示 128 位元無符號整數。

let value = 0u8;
println!("value={}, length={}", value, std::mem::size_of_val(&value));

let value = 0b1u16;
println!("value={}, length={}", value, std::mem::size_of_val(&value));

let value = 0o2u32;
println!("value={}, length={}", value, std::mem::size_of_val(&value));

let value = 0x3u64;
println!("value={}, length={}", value, std::mem::size_of_val(&value));

let value = 4u128;
println!("value={}, length={}", value, std::mem::size_of_val(&value));

整數文字表示法

Rust 支援多種整數文字表示法,包括二進位、八進位、十進位和十六進位。

println!("Binary (base 2) 0b1111_1111={}", 0b1111_1111);
println!("Octal (base 8) 0o1111_1111={}", 0o1111_1111);
println!("Decimal (base 10) 1111_1111={}", 1111_1111);
println!("Hexadecimal (base 16) 0x1111_1111={}", 0x1111_1111);
println!("Byte literal b'A'={}", b'A');

大小型別

usizeisize 是平台依賴的大小型別,通常分別為 32 位元或 64 位元,用於表示大小和索引。

原始型別的算術運算

Rust 中的算術運算預設是檢查過的,不允許未定義的行為,如除以零。

let one = { || 1 }();
let zero = { || 0 }();
println!("{}", one / zero); // 這將導致執行時錯誤:attempt to divide by zero

結構體與列舉

結構體和列舉是 Rust 中用於建立複雜資料型別的兩種主要方式。結構體類別似於 C 語言中的結構體,而列舉則提供了更豐富的功能。

型別別名

型別別名允許開發者為現有的型別建立新的名稱,使得程式碼更易讀和維護。

程式碼解密:

上述程式碼範例展示了 Rust 中不同整數型別的使用方法,以及如何進行算術運算。透過使用不同的進位表示法(如二進位、八進位等),我們可以更靈活地表示整數。此外,Rust 對算術運算的檢查可以避免許多執行時錯誤,提高程式的安全性。

Rust 型別系統概覽

@startuml
skinparam backgroundColor #FEFEFE
skinparam componentStyle rectangle

title Rust 向量與資料結構深入解析

package "Rust 記憶體管理" {
    package "所有權系統" {
        component [Owner] as owner
        component [Borrower &T] as borrow
        component [Mutable &mut T] as mutborrow
    }

    package "生命週期" {
        component [Lifetime 'a] as lifetime
        component [Static 'static] as static_lt
    }

    package "智慧指標" {
        component [Box<T>] as box
        component [Rc<T>] as rc
        component [Arc<T>] as arc
        component [RefCell<T>] as refcell
    }
}

package "記憶體區域" {
    component [Stack] as stack
    component [Heap] as heap
}

owner --> borrow : 不可變借用
owner --> mutborrow : 可變借用
owner --> lifetime : 生命週期標註
box --> heap : 堆積分配
rc --> heap : 引用計數
arc --> heap : 原子引用計數
stack --> owner : 棧上分配

note right of owner
  每個值只有一個所有者
  所有者離開作用域時值被釋放
end note

@enduml

此圖示展示了 Rust 型別系統的主要組成部分,包括原始型別、結構體、列舉和型別別名。透過這種視覺化的方式,我們可以更直觀地理解 Rust 中不同的資料建模方式之間的關係。

圖表內容解密:

此 Plantuml 圖表清晰地展示了 Rust 型別系統的架構。從根節點「Rust 型別系統」出發,分支出四個主要的子節點,分別代表四類別主要的型別。每個子節點進一步展開,展示了該型別的具體內容或子型別。這種圖表有助於讀者快速掌握 Rust 型別系統的全貌。

Rust 資料結構深入解析

Rust 語言提供了多種資料結構以滿足不同需求,從基本的原始型別到複雜的結構體和列舉。本文將探討 Rust 中的資料結構,包括原始型別、元組、結構體等,並提供實用的範例和最佳實踐。

4.5.1 原始型別的算術運算

Rust 的原始型別提供了多種方法來處理算術運算。例如,當需要安全地處理除以零的情況時,可以使用 checked_div() 方法,該方法傳回一個 Option

assert_eq!((100i32).checked_div(1i32), Some(100i32));
assert_eq!((100i32).checked_div(0i32), None);

對於整數、浮點數等純量型別,Rust 提供了一系列方法來執行基本的算術運算,如除法、乘法、加法和減法,並支援 checked、unchecked、overflowing 和 wrapping 等形式。

使用 wrapping 形式實作模組化算術

當需要與 C、C++、Java、C# 等語言的行為相容時,可以使用 wrapping 形式的方法,它執行模組化算術並與 C 相當的操作相容。需要注意的是,在 C 中,有符號整數的溢位是未定義的。以下是 Rust 中模組化算術的範例:

assert_eq!(0xffu8.wrapping_add(1), 0);
assert_eq!(0xffffffffu32.wrapping_add(1), 0);
assert_eq!(0u32.wrapping_sub(1), 0xffffffff);
assert_eq!(0x80000000u32.wrapping_mul(2), 0);

每個原始型別的完整算術函式列表可以在 Rust 檔案中找到,例如 i32 的檔案位於 https://doc.rust-lang.org/std/primitive.i32.html

4.5.2 使用元組

Rust 的元組與其他語言中的元組類別似,是一種固定長度的序列,可以包含不同型別的值。元組在 Rust 中不是反射性的;與陣列不同,不能對元組進行迭代、切片或在執行時確定其元件的型別。元組本質上是 Rust 中的一種語法糖,雖然有用,但功能有限。

元組的基本操作

考慮以下元組的範例:

let tuple = (1, 2, 3);

要存取元組中的個別元素,可以透過位置索引,從 0 開始:

println!("tuple = ({}, {}, {})", tuple.0, tuple.1, tuple.2);

也可以使用 match 陳述式,它提供了臨時的解構,只要有模式比對即可(模式比對在第 8 章中有更詳細的討論):

match tuple {
    (one, two, three) => println!("{}, {}, {}", one, two, three),
}

此外,可以使用以下語法將元組解構為其各個部分,這會將值從元組中移出:

let (one, two, three) = tuple;
println!("{}, {}, {}", one, two, three);

元組的最佳實踐

在我的經驗中,元組最常見的用途是從函式傳回多個值。例如,考慮以下簡潔的 swap() 函式:

fn swap<A, B>(a: A, b: B) -> (B, A) {
    (b, a)
}

fn main() {
    let a = 1;
    let b = 2;
    println!("{:?}", swap(a, b));
}

提示: 建議不要建立超過 12 個引數的元組,儘管元組的長度沒有嚴格的上限。標準函式庫只為最多 12 個元素的元組提供了特徵實作。

4.5.3 使用結構體

結構體是 Rust 中的主要構建塊。它們是複合資料型別,可以包含任何集合型別和值。它們在本質上與 C 結構體或物件導向語言中的類別相似。它們可以以類別似於 C++ 中的範本或 Java、C# 或 TypeScript 中的泛型的方式進行泛型組合(泛型在第 8 章中有更詳細的討論)。

結構體的使用場景

當需要以下功能時,應使用結構體:

  • 提供有狀態的函式(即對內部狀態進行操作的函式或方法)
  • 控制對內部狀態的存取(即私有變數)
  • 將狀態封裝在 API 後面

不強制使用結構體;如果願意,可以只使用函式編寫 API,類別似於 C API。此外,結構體僅在定義實作時需要,而不是用於指定介面。這與 C++、Java 和 C# 等物件導向語言不同。

結構體的基本形式

最簡單的結構體形式是空結構體:

struct EmptyStruct {}
struct AnotherEmptyStruct;

空結構體(或單位結構體)是您偶爾會遇到的東西。另一種結構體形式是元組結構體,它看起來像這樣:

struct TupleStruct(String);
let tuple_struct = TupleStruct("string value".into());
println!("{}", tuple_struct.0);

元組結構體是一種特殊的結構體,其行為類別似於元組。元組結構體與常規結構體之間的主要區別在於,在元組結構體中,值沒有名稱,只有型別。請注意,元組結構體在宣告末尾有一個分號(;),這對於常規結構體不是必需的(除非是空宣告)。元組結構體在某些情況下很方便,因為它們允許您省略欄位名稱(從而減少原始碼中的字元數),但它們也會造成歧義。

常規結構體

典型的結構體具有名稱和型別的元素列表,如下所示:

struct TypicalStruct {
    name: String,
    value: String,
    number: i32,
}

結構體中的每個元素預設具有模組可見性。這意味著在當前模組範圍內,結構體內的值是可存取的。可見性可以在每個元素的基礎上設定:

pub struct MixedVisibilityStruct {
    pub name: String,
    pub(crate) value: String,
    pub(super) number: i32,
}

大多數情況下,不需要使結構體元素公開。結構體內的元素可以被該結構體元素公開範圍內的任何程式碼存取和修改。預設可見性(相當於 pub(self))允許同一模組內的任何程式碼存取和修改結構體內的元素。

結構體的可見性和存取控制

瞭解結構體的可見性和存取控制對於編寫安全且可維護的程式碼至關重要。透過適當地設定元素的可見性,可以控制對內部狀態的存取,從而提高程式碼的安全性和可維護性。