EnriqueMark
隨筆

rust 概念

2026-07 → 2026-09 更新 繁體中文

rust本身比較有意思的地方是,他是一個強型別-記憶體安全的語言,所以說很多東西都會規定的很死,通過一定的動態犧牲,帶來了較大的安全性和穩健性。

比如變數上,rust這邊的變數預設就是常量,需要 let mut 額外宣告的才是可變的(mutable)。而還有關於記憶體的明確操作,關於字串這裡就能看出來:

// 这是一个指向字符串视图的指针,是纯粹只读的
let s1: &str = "hello";
// 这才是实际的字符串对象,可以修改
let s2: String = String::from("hello");

rust有一個關鍵的概念叫做「所有權」,這可以說是這個語言的核心。但是,所有權這個詞可能導致誤解,尤其是它的字面意思——所有。實際上這個所有對應的是一個「責任」,用責任來理解反而會更容易點。本質上,這個機制的目的就是為了能更好的處理記憶體問題:所有權歸根究底說的就是,在一段作用域結束時,誰擁有釋放記憶體的責任——當然是該變數的所有者負責釋放。不過這裡「負責」指的並不是我們要手動操作釋放(如果還要手寫 free 就沒有什麼特性優勢可說了),指的是「釋放的時機跟著所有者走」,編譯器會自動在所有者的作用域結束前插入釋放程式碼,因此不需要任何手動操作。只要責任轉移是明確的,釋放當然也是明確的。

這個機制的關鍵,就是為了確保記憶體在該釋放的時候釋放。rust下的任何值,在同一時刻都只能有一個所有者。原則也很簡單:只要這個所有者的作用域結束,它再繼續佔據記憶體空間根本沒什麼意義,就應該立即釋放。所有權機制的單一性確保了釋放的時機可以非常緊密,而且每一處佔用在編譯期就能確定的清清楚楚。這直接使得任何一處記憶體空間都可以在該釋放的時候釋放——既然都在編譯時就規定清楚了,當然不再需要執行時有一個單獨的GC來盯著。編譯期就能讓記憶體問題暴露的清清楚楚,同時還避免了GC帶來的效能開銷。

如何清楚呢,我們從傳參和賦值情況發生時來看,此時會有兩種情況:首先是所有權轉移,這裡背後發生的操作是 move 代表著的就是所有者的轉換,也就是把這個釋放的責任轉移出去;其次則是 copy ,這裡沒有發生所有權轉移,而是直接複製了一個新值(也是新的所有者),同時原值也仍然有效。從所有權的角度看,copy 在建立新所有者的同時,也帶來了新的所有權,它和舊的是並存;move 則不然,舊的所有者會失去所有權,因此是二者存其一。

當然,這裡會有些特殊的存在,比如擁有堆資源的物件,就沒辦法直接常態去copy ,預設只會是轉移,必須要手動觸發clone,才會建立一個新的值與所有者。我相信瞭解淺/深複製的人應該都會比較容易理解這部分,當物件擁有堆資源時,這個物件就會同時有指標和它所指向的堆記憶體空間的佔用。因此,為了避免複製之後,一個堆資源有兩個可使用它的所有者這種情況,原所有者就必須交出所有權。而 clone 則會讓記憶體空間內直接複製一整組出來,換言之就是深複製。但是這裡要注意,rust裡面的clone不必然繫結到深複製(例如Rc型別),具體取決於被操作型別自身的定義。但大部分情況下都是深複製。

理清了所有權之後,就能更好地理解它的相關延展,比如拿走和借用:

fn consume(s: String) {
    println!("{}", s);
}

fn main() {
    let a = String::from("hello");
    consume(a);
    // println!("{}", a); // error:a 已经被 move
}

這裡展現了一個初看會讓人困惑的操作,為什麼會報錯?按其它語言的習慣,比如在ts裡,物件或者說值(本質也是物件)是可以被所有人重複使用的,沒理由說賦值給另一個之後再用就報錯。但在rust這裡,是隻有所有者才有許可權操作——一旦這個所有者失去所有權,它就沒了相應的操作資格。而此處的s: String 就意味著所有權轉移,當a將自身作為元素傳入函式時,也即意味著將自身的所有權轉移給了s。s手中接過了原先屬於a的「回收責任」,這裡所發生的就是 move 操作。在此之後,a就無法再使用,因此該變數失效;因為它的責任/所有權已經被編譯器標記為轉移給了s,在s的作用域跑完了之後,這個值的記憶體也會被即時釋放——consume 函式跑完之後,賦值給它的 hello 也會直接在作用域結束時被清理掉。

那問題來了,這麼一搞,我後面還要用 hello 咋辦?你給我全清理掉了,那豈不是每次都要給一個新的?這就輪到借用出場了:

// 注意下面的 &
fn consume(s: &str) {
    println!("{}", s);
}   

let a = String::from("hello");
consume(&a);
consume(&a);
println!("main 里 a 仍然可用: {}", a);    // ✅ 全部正常 

我們開場就說過 &str 是一個只讀的字串檢視,而&在這裡發揮的作用就是「借」,a的所有權在其中沒有發生任何轉移——自然清理的責任也沒有發生轉移——所以,s全程都只收到了檢視,看完就看完了。而這就保證了 consume 之後,a仍然是可用的。直到a的作用域結束之後,它所屬的這部分記憶體才會得到釋放。

上述借用屬於「不可變借用」,除此之外還有一種借用是可變借用,它一次只能借給一個物件,且還要用 mut 顯式的宣告出來。

let mut x = 5; // 显式声明,这是个能够可变借用的变量
let y = &mut x; // x 被可变借用给 y
let z = &mut x; // ❌ 报错!编译器直接拒绝编译!

我們從責任的角度就能很順利地理解這一點。它無非指的就是,釋放的作用域仍然跟著所有者(因為責任仍然在所有者這裡),借用者即便有許可權修改,它的作用域結束了,這部分記憶體也不會被釋放,而是借用結束,回到所有者手中。並且rust在這裡有個嚴格的機制,那就是一旦借出去,所有者自己的操作範圍也會被凍結。如果你借的是隻讀,那麼你自己就不能再改,防止借用者讀到錯誤內容,這是「寫凍結」;如果你借出去的是可變的許可權,那麼這期間你自己就是「完全凍結」,不可讀也不可寫。

而借用者的生命週期是個值得一說的東西。早期的rust有個毛病,那就是借用者的臨時許可權會一直帶到大括號結束才結束。這就導致瞭如果我只是想臨時借出去,就得人為寫一個括號出來,防止所有者一直被凍死,完全無法使用:

fn main() {
    let mut x = 5;
    
    let y = &mut x; // 1. 可变借用开始,x 进入「完全冻结」
    *y += 1;        // 2. y 的工作其实在这里就彻底【结束】了
                    // 3. 但在老版本 Rust 里,y 的生命必须强行续到大括号结束
                    
    println!("{}", x); 
    // ❌ 报错!老编译器认为 y 还没死,x 依然处于「完全冻结」状态,不准读!
} // 4. 老版本中 y 在这里才算真正死亡

// 以前不得不这么写:
let mut x = 5;
{
    let y = &mut x;
    *y += 1;
} // 强行加个大括号让 y 死亡,从而解冻 x
println!("{}", x); // 现在可以读了

而後rust推出了NLL,即Non-Lexical Lifetimes(非詞法生命週期),使得借用者可以在其最後一次被使用完畢之後就直接歸還,解除所有者的凍結。這裡的詞法說的就是以前要跟著花括號(這就是個詞法)走,現在則不需要(跟著使用走)了,其實沒什麼難理解的。就拿上面的例子來說,原先的歸還發生在最後的 }這裡,而現在版本的則是在 *y += 1 這一步就會歸還。只要它沒有在其他地方被繼續使用,現在版本的編譯器就會將其視為合法狀態。

隨著 rust在ai開發中的優勢 繼續說。rust難學的點上,還存在著一個容易讓人混淆的部分,就是雙冒號(::)和點(.)的區別。這個概念在其他語言下是混用的,但在rust的哲學之下就需要明確區分。前者意味著名稱空間導航,即針對於「結構」的索引,即「不需要具體值就存在的東西」,一般的抽象藍圖,除非在藍圖裡就預定義好常量的值,否則預設取到的就是成員本身;而後者是對具體「值」的呼叫,即「必須有具體值才存在的東西」,對藍圖的實際實現。

struct Point {
    x: i32,
    y: i32,
}

impl Point {
	// 关联函数(没有 self),这个类比的话很像class中的静态方法,可以在非实例化的情况下直接使用
    fn new(x: i32, y: i32) -> Point {    
        Point { x, y }
    }
    // 方法(有 self),这意味着必须先有了值才能去使用这个方法
    fn distance(&self) -> f64 {          
        ((self.x * self.x + self.y * self.y) as f64).sqrt()
    }
}

let p = Point::new(3, 5);   // :: 从类型 Point 取关联函数 new  ✅
p.x                          // .  从实例 p 取字段            ✅
p.distance()                 // .  在实例 p 上调方法          ✅
Point::x                     // ❌ 非法,x 是字段不是关联项

這個地方需要明確防止一個誤會,那就是雙冒號取的是型別成員,而不是型別內的欄位,因為只有「成員」才有路徑身份,而「欄位」是沒有的。

Point::new(3, 5)        // new 是关联函数,有路径身份 ✅
Point::ORIGIN_Y         // ORIGIN_Y 是关联常量,有路径身份 ✅
Point::y                // y 是字段,没有路径身份,处处非法 ❌

而這裡就要順帶引出一個概念,impl乍看之下很像是類,但實際上並不是。核心點就在於rust從根本上ban了繼承,因此這個地方的impl只是一個「給型別附加方法」的結構體,而 &self從字面意思上就指向這個型別Point自身。

因此,上面說的像靜態方法的「關聯函式」,乍一看很像是類的建構函式,畢竟new的時候返回了一個Point例項。但實際上並非如此。跟一般類的建構函式規定只能有一個不同,fn new在整個 impl 裡面,跟其他方法相比是完全平等的。這就意味著寫幾個都行,也意味著它根本不是一個「建構函式」,只是普通的靜態(鑑於rust裡沒有類這一說,因此這裡是類比意義上)工廠方法。

而關於原先OOP的其他概念,rust仍舊有所留存,比如封裝,就通過 pub 這個保留關鍵詞來指定暴露想要的介面,而相較於其它語言需要明確宣告 private,它在型別內的屬性是預設私有的。

mod geometry {
    pub struct Point {
        x: i32,        // 私有字段
        pub y: i32,    // 公开字段
    }
}

// 在模块外面:
let p = geometry::Point { ... };
p.y    // ✅ 能访问,因为 y 是 pub
p.x    // ❌ 编译错误:x 是私有的,模块外碰不到

而 trait 則是一個比較重要的概念,直接等價於其他語言中的 interface ,即介面(契約)的概念。本質上是對行為結構的抽象定義,也是rust中DI能順利施行的重要因素——通過介面定義好行為結構後,就可以使後續的組合面向介面而非具體實現程式設計。但rust的trait本身可以帶預設實現(看上去有點像類了,但這個顯然跟剛才的一樣,是預定義好的方法)。

trait Animal {
    fn speak(&self) -> String;
    fn greet(&self) -> String {           // 默认实现
        format!("我说:{}", self.speak())  // 复用 speak,谁实现 Animal 都白捡这个 greet
    }
}

而這玩意兒最強也最獨特的地方在於給標準庫,或者說任何其他已有的型別加實現。

trait Describe {
    fn describe(&self) -> String;
}
impl Describe for i32 {                            // 给标准库的 i32 加行为!
    fn describe(&self) -> String { format!("我是数字 {}", self) }
}

42.describe()    // ✅ "我是数字 42"

其次則是可以明確的區分編譯時和執行時,這裡稍微有點複雜,涉及到不同語言彼此之間對於多型的派發策略,即到底是編譯期間就直接定死,還是執行時去查一個虛擬表來確定此泛型指向什麼具體的函式——大部分帶 GC 的語言對於介面上的此類泛型傳入處理,預設都是動態派發的,還沒法選,因此有一個查表開銷和執行時的混合問題;而rust在這裡明確的通過 T 和 dyn 的區分,或者說,通過設計了一套「泛型 和 trait object」的兩條路,將兩者顯式化了。這本身就是rust的設計哲學的再一次體現,那就是「將隱含的東西顯現化」,無論是通過所有權概念體現的記憶體控制,還是顯式邊界,此處只是又一次貫徹。

// 静态派发(编译期多态):泛型 + trait 约束
// 此时的 T 最终是永久单态化的,即编译时定了什么,最终就是什么,不然报错,不放到运行时才决定
fn make_speak<T: Animal>(a: T) -> String {
    a.speak()                    // T 是任何实现了 Animal 的类型,编译期定死,零开销
}

// 动态派发(运行时多态):trait object,&代表引用,一个指向a的指针的指针
// 这里会根据运行时决定,此处的 dyn Animal 取决于运行时到底给入什么
fn make_speak_dyn(a: &dyn Animal) -> String {
    a.speak()                    // 运行时才知道具体是 Dog 还是 Cat,查 vtable
}

// 注意 vec![...] 只是个演示用的list构造,模拟的是运行时给入多个类型的情况
let zoo: Vec<Box<dyn Animal>> = vec![Box::new(Dog), Box::new(Cat)];
for animal in &zoo {
    println!("{}", animal.speak());   // 同一个循环,Dog 喵 Cat 各说各的 —— 经典多态
}

然後另一個則是trait最不同於interface的點,那就是它可以寫成空的。

// Send / Sync 大致长这样(简化):
trait Send {}     // 空的!没有任何方法
trait Sync {}

這裡的 Send 和 Sync ,或者說本質上這種空的 trait 是一個會自動連鎖傳播的標記,可以在編譯期就給所屬行為打上一個標籤。這種標籤使得編寫者可以顯式控制和宣告特定操作,從而在該操作不該發生的地方在編譯期就攔截。比如Rc標準庫,他的用途是建立索引,讓一份資料被多個所有者共享,而其內部會維護一個所有者計數器——對這東西的修改會存在併發競爭問題。那麼此時 Send 標籤就能發揮作用。

Rc 的編寫者只需要顯式宣告此處是 !Send 的,而跨執行緒操作庫的 thread::spawn 再予以配合,要求只有 Send 標籤的操作才能夠通過。那麼通過這種跨方法約定,就可以直接將危險操作攔截在編譯器,而不至於到了執行時才暴露。這個操作往廣了說可以非常靈活,比如併發,複製,只要能加標籤同時要在另一個地方設定門檻時,都可以用類似機制。從編譯上就讓不符合門檻的操作直接報錯,絕對不讓你到執行時才出問題。只要一方設定了標籤,那麼實現了這個介面的子物件都會自動被傳播到。這種自動傳播的標記叫做auto trait,而 Send / Sync 是其中最常見的兩個。

上面那個自動傳播機制是 rust 的特色,其他語言沒有,但除此之外的其他自定義空殼標籤就沒有這個傳播機制了。比如我寫了個表現,但是後來忘了設定查票者或者給下游蓋章,那這一套就無效了。換言之,這套標籤機制的有效性取決於編寫者的設計和對自身程式碼的邊界把控能力。如果毫無設計,該出的問題還是會出現。但如果這樣,rust 最額外的優勢在哪裡呢?核心點在於「編譯期」保證。rust 的空 trait 一旦加上,後續沒有那就是沒有,直接報錯,但類似的比如 java,就得到執行期才能暴露問題。

// ① 定义标签(空壳)
trait A {}

// ② 给某个类型盖章
struct Dog;
impl A for Dog {}          // 为 Dog 装上 A

struct Cat;
// Cat 不盖章

// ③ 下游:在函数签名里"查票"—— <T: A> 就是那道卡
fn need_ticket<T: A>(x: T) {
    // 能进到这个函数体里的 x,类型必然带 A。编译器已经替你保证了。
}

// ④ 消费方尝试
need_ticket(Dog);   // ✅ Dog 有 A,过
need_ticket(Cat);   // ❌ Cat 没 A,编译报错:the trait `A` is not implemented for `Cat`

fn need_ticket<T: A>(x: T) {}              // 写法1:泛型参数上直接标
fn need_ticket<T>(x: T) where T: A {}      // 写法2:where 子句,复杂约束时更清爽
fn need_ticket(x: impl A) {}               // 写法3:impl Trait 语法,最简洁

rust 這裡比ts的優勢在於,這個標籤沒辦法被下游輕易的用 as 斷言蓋過去。如果非得寫,必須顯式宣告,把該物件的型別明確指定到標籤上,代價很明確,這意味著下游全部都知道你加了這個標籤(因為是空的,所以不影響行為,但會明確打上章,而很難暗度陳倉);而 as 斷言就只是一句臨時斷言,並未根本上改變原先物件的型別。其次則是,在 rust 這裡 A {}的主語是A,而ts那裡則是{}。ts的型別本身對結構體負責,換言之,空的{}結構可以對應到任何一個物件,而不是標籤的語義意涵。