Assignment

指派 (Assignment) 是程式語言中最基本的一種語法,亦也是一種最關鍵的邏輯問題,為一種變數 (variable) 之間的轉移手段。指派通常有幾種行為:移動 (move)複製 (copy)參照計數 (reference counted)。為了方便說明,現在就以三種都具備的 Rust 程式語言來表示。

首先以下是 Rust 中的指派語句:


#![allow(unused)]
fn main() {
// 指派語句
let mut a = 20.;
// 修改也是指派語句
a = 10.;
assert_eq!(a, 10.);

// pattern matching 也是指派語句
match 10. {
    a => { assert_eq!(a, 10.); }
    _ => {}
}

// if-let pattern matching 也是指派語句
if let a = 10. {
    assert_eq!(a, 10.);
}

// while-let pattern matching 也是指派語句
let mut iter = [10.].into_iter();
while let Some(&a) = iter.next() {
    assert_eq!(a, 10.);
}

// for-loop 也是指派語句,語意同上
// 所以有兩次指派(迭代物件和本體)
for a in [10.] {
    assert_eq!(a, 10.);
}

// closure capture 預設是取指標
// 但是其生命週期必須比原始資料短
let f1 = |b| { assert_eq!(a, b); };
// 使用 move 關鍵字會使用指派語句
let f2 = move |b| { assert_eq!(a, b); };
// 呼叫函式 / 運算子也是指派語句,同於上面的 b = a
f1(5. + 5.);
f2(10.);
}

Rust 在預設的情況下選擇使用 move 或 copy 來處理指派問題,再加上必須用關鍵字 mut 標示可變變數,因此效能上會有靈活調整的優勢。有趣的是雖然 Rust 是靜態型別的程式語言,但是上面的範例完全不用標明類型,都是自動推導的。

Move

移動算是比較新的概念,而且只發生在語義上。當 a 變數移動到 b 變數時,可視為資料的完全轉移,因此 a 變數就不可使用了。亦即,a 的資料的生命週期 (life cycle) 被「延長」到新的變數,直到變數 b 被刪除,資料才會被刪除。

編譯器在實作時,其實只要把 ab 視為相同的記憶體,就可以達成移動的流程。

在 C++ 中,預設都是複製,另外可以使用 std::move() 函式來達成變數移動。

Copy

複製的方式固然簡單,但是也意味著造成最大的執行成本:時間和空間都不可避免的浪費掉了,特別是對於唯讀 (read-only) 資料。若是程式中可以標明常量 (constant) 或可變 (mutable) 變數,還可以將常量的複製行為移除,稱為 Copy propagation。但若是沒有,複製一整個資料可說是非常重的負擔。

在 Rust 中,只有原始類型 (primitive type) 預設是可以複製的,包含布林 (boolean)、整數 (integer)、浮點數 (float)、字元 (character)、單元 (unit)、指標 (pointer)。至於其他類型 struct、enum、union 則必須實作 Clone trait,以達成可複製的條件。


#![allow(unused)]
fn main() {
#[derive(Clone, PartialEq, Debug)]
struct Point {
    x: f64,
    y: f64,
}

let mut p1 = Point {x: 0., y: 0.};
let p2 = p1.clone();
assert_eq!(p1, p2);
p1.x = 10.;
assert_ne!(p1, p2);
}

上面的程式使用派生巨集 (derive macro),會自動實作複製的行為,但若是其中有欄位 (field) 的類型沒有實作 Clone,就必須手動用實作語法 impl Clone for Point {...} 處理。上面其他的 trait 也是相同的道理,PartialEq 可以實作「等於」與「不等於」運算子,Debug 可以實作運行期間錯誤訊息的格式。達成可複製條件後,就可以手動用 Clone::clone() 這個方法來複製。

若要達成跟原始型別一樣,指派時自動複製,則可以實作一個標記用的 Copy trait,這樣編譯器在解析時會自動呼叫 Clone::clone()


#![allow(unused)]
fn main() {
#[derive(Copy, Clone, PartialEq, Debug)]
struct Point {
    x: f64,
    y: f64,
}

let mut p1 = Point {x: 0., y: 0.};
let p2 = p1;
assert_eq!(p1, p2);
p1.x = 10.;
assert_ne!(p1, p2);
}

Pointer and Reference

在認識參照計數之前,必須先認識指標 (pointer)。指標是指向變數的記憶體位置,是用一段整數來當做代號,可以藉由儲存指標來達成在變數之外存取該變數的數值。

然而,指標顯然也是另外一個變數,自然也可以有自己的指標,在語法上較難處理,也很花時間理解。這時候某些程式語言會推出語義上的指標,來參考原本的數據以免去複製的步驟,稱為參照 (reference)

再者,指標顯然有一些致命缺陷,甚至是可被攻擊的目標:

  • 懸掛指標 (hanged pointer):原始的數值有自己的生命週期,若是在刪除後仍去存取,亦可能取到錯誤的數值。或是創建空指標時沒有指到正確位置就進行讀寫。
  • 記憶體洩漏 (memory leak):存入數值後忘記刪除,就刪除指標,會造成浪費記憶體空間(因為直到程式執行完才會清除)。
  • 堆棧溢出 (stack overflow):指標可以索取連續的記憶體陣列,但是如果檢索的長度超出範圍,會得到錯誤數值。

在 Rust 中,指標與參照是相同的,都俗稱為指標 (pointer)。不能保證生命週期的指標稱為原始指標 (raw pointer),寫作 *T;能保證生命週期的稱為參照 (reference),寫作 &T,跟 C++ 雷同,也是最常用的一種。能保證生命週期的行為也稱為記憶體安全 (memory safe)。記憶體安全也是 Rust 語言的一大賣點,所以預設不能使用原始指標,除非開啟 unsafe 區塊。


#![allow(unused)]
fn main() {
// 原始數值,標注類型 f64 以清楚說明
let a: f64 = 10.;
// 取址,指標的類型標註為 &T / &mut T
let b: &f64 = &a;
// 手動解開指標,將會使用指派
// 這邊 f64 有實作 Copy trait
// 若沒實作 Copy trait,會把所有權移動出來!
println!("{}", *b);
// 指標會實作指向類型能夠使用的 trait
// 如以下範例,Display trait 會自動呼叫
println!("{}", b);
// 若要查看指標位置,可以使用 std::fmt::Pointer trait
println!("{:p}", b);
}

原始指標操作簡單,但是較「危險」。參照在 Rust 則是採取所有權 (ownership) 系統。


#![allow(unused)]
fn main() {
let mut a = 10.;
{
  // b 借走所有權,不可使用 a
  let b = &mut a;
  // 修改記憶體
  *b = 20.;
}
// b 生命週期提前結束,所有權還給 a
assert_eq!(a, 20.);
}

在上面的程式碼中,Rust 可以使用一個大括弧 {} 在執行區創造出一個範圍 (scope),這個範圍跟函式、判斷式等 runtime 要素是一樣的。當使用 let 關鍵字指派一個新的變數,若變數沒有被移動,則都會在括弧關閉的地方被刪除。此概念在 C / C++ 中也有。

當使用 &a / &mut a 運算子,表示對變數 a 索取參照,若有關鍵字 mut,則代表這個指標可以修改變數,前提是變數 a 也是可變的。索取指標會造成所有權被借走,導致 a 不能使用,這在編譯期間就會檢查,並且指出原始資料與指標之間的關係。值得注意的是,這邊 b 不用加上 mut 標示為可變,因為指標僅修改指向的內容,而非自身。

Rust 中呼叫方法 (method) 時會自動轉換自身(用關鍵字 self 代表)的參照 self / &self / &mut self,這樣就不用手動取址或解開引用。另外實作 std::convert::AsRef trait 可以讓類型在取址時也能將自己的指標相容成其他類型,如 String 相容於 &strVec<T> 相容於 &[T],並且還有可變的版本 std::convert::AsMut

所有權系統是為了保障執行緒安全 (thread safe),因此參照只能在單一執行緒使用,如果要在多執行緒使用,比如說平行處理,則必須使用參照計數。

Reference Counted

參照計數的概念在於,所有的資料都存於 heap(詳見記憶體管理),當新增「變數」(Python 稱為「名稱 (name)」)時,就會增加計數器;當參照的數量變成 0 時,就會刪除資料。例如 Java、JavaScript 或 Python 這種只有參照計數系統的程式語言,就是用模擬 stack 的方式移除資料的引用。不過,完全以參照計數為主的程式語言便需要一套垃圾收集 (garbage collection) 系統,移除長久未使用的變數,它們可能在載入的過程中遺留下來,造成另類的記憶體洩漏。

在 Rust 中,std::rc::Rcstd::sync::Arc 是標準庫 (standard library) 提供的參照計數器,Rc 是單執行緒使用的,可以應用於在容器之間分享資料;Arc 則是多執行緒使用的,可以在不同執行緒間分享資料。若沒有多執行緒用途,Rc 存取的速度會較好。

另外得注意的是,Rc::new()Arc::new() 會移動資料到內部,因此一開始就要用參照計數器的話就必須將資料包裝起來。而解除時是使用 Arc::try_unwrapRc::try_unwrap,可以直接將內部資料移動出來,不過必須在計數器為 1 的情況下。


#![allow(unused)]
fn main() {
use std::{
    sync::Arc,
    rc::Rc,
};

// 建立資料
let a = Arc::new(0);
// 增加引用
let b = a.clone();
// 檢查計數器
assert_eq!(Arc::strong_count(&a), 2);

let a = Rc::new(0);
let b = a.clone();
assert_eq!(Rc::strong_count(&a), 2);
}

至於跨執行緒分享資料時,若需要修改該資料,會造成資料競爭 (data racing),產生不同步的情況,因此 Arc 無法像 Rc 一樣輕鬆的改變 heap 上的數值。為了解決資料競爭,可以加上一個互斥鎖 (mutex lock),使用一個原子化 (atomic) 的布林值(通常是 u8)來鎖住資料,執行緒必須檢查鎖是否開啟,等到開啟後才可以鎖住並修改資料。Python 中就有一個全部變數共用的鎖,稱為全域直譯器鎖 (Global Interpreter Lock, GIL),因此其實現新執行緒的方式便是再開一個解譯器,避免資料競爭。


#![allow(unused)]
fn main() {
use std::sync::{Arc, Mutex};

let a = Arc::new(Mutex::new(0));
*a.lock().unwrap() = 10;
assert_eq!(*a.lock().unwrap(), 10);
}

不過對於原始類型,原子化類型 std::sync::atomic::Atomic* 應該更容易使用,而完全不需要互斥鎖。因為在支援的平台上,處理器可以自動排程寫入狀態,以避免底層的資料競爭,不過浮點數是不支援的。

除了普通持有資料的計數外,還有可懸掛空值的弱參照 (weak reference),因此前者相對之下稱為強參照 (strong reference)。弱參照持有的原始指標可以修改,類似於 mut &Tmut &mut T,可以改變指向,而強參照持有原始資料的指標,因此只能固定。Rc::downgrade()Arc::downgrade() 可以將強參照降級為弱參照,不過由於弱參照會有空值,使用數值時都需要重新檢查,較花時間。