所有型別的超集合 T

在 Rust 裡,T 不是「只有 owned type」,而是「所有型別的超集合」,包含 &T&mut T,甚至 &&&T

這個設計是 零成本抽象(zero-cost abstraction) 的核心之一

初學者直覺的錯誤理解

很多人在剛學 Rust 泛型時,腦中會自然形成這樣的分類:

類別T&T&mut T
直覺想像擁有資料不可變借用可變借用
範例i32&i32&mut i32

於是會得出一個 看起來很合理但完全錯誤 的結論:

  • T = 所有 owned types
  • &T = 所有 shared borrow
  • &mut T = 所有 mutable borrow
  • 三者是互斥的集合

這種模型在 C / C++ 的心智模型裡非常自然,但在 Rust 裡是錯的

Rust 泛型的真實模型:T 是「超集合」

在 Rust 裡,泛型的實際意義是 代表「任何可能的型別」,不管它是不是 reference。

也就是:

泛型型別實際包含的型別(部分)
Ti32, &i32, &mut i32, &&i32, &mut &mut i32, ...
&T&i32, &&i32, &&mut i32, ...
&mut T&mut i32, &mut &i32, &mut &mut i32, ...
  • T 包含 &T&mut T
  • &T&mut T 彼此不重疊
  • 這三者都是 無限集合

編譯器錯誤 (impl overlap)

看看這個例子:

trait Trait {}

impl<T> Trait for T {}
impl<T> Trait for &T {}     // ❌
impl<T> Trait for &mut T {} // ❌

編譯器錯誤不是因為 Rust 小氣,而是因為:

  • impl<T> Trait for T 已經涵蓋了所有型別
  • &T&mut T 本來就屬於 T
  • 這會造成 impl overlap

但這樣寫就完全沒問題:

trait Trait {}

impl<T> Trait for &T {}     // ✅
impl<T> Trait for &mut T {} // ✅

效能角度思考:為什麼 T 要包含 reference

如果 Rust 的 T 只代表 owned types,那麼:

  • 每個函數都要寫三個版本
  • 或引入 runtime dispatch

Rust 選擇的是 用型別系統在編譯期解決,而不是 runtime 判斷

也就是 Rust 把 aliasing、mutability、lifetime 全部放進型別系統

泛型測試

要對泛型進行測試,就得對編譯器整體性的綁住手腳,避免編譯器最佳化影響我們的測試

也就是我們要確定,Rust 的泛型與借用設計,在實際會用到的情形之下,不會造成 runtime 成本

在這之下才能測試不同寫法的差,例如 T, &T, &mut T

考慮一個 process<T> 如下:

fn process<T>(value: T) -> u64 {
    let v = std::hint::black_box(value);
    let addr = (&v as *const T as usize) as u64;
    std::hint::black_box(addr)
}

std::hint::black_box 是專門用來做效能分析的工具,告訴編譯器不能理解、推導與提前最佳化輸入值

也就是告訴編譯器說,(1) value 不能假設是常數 (2) 不能假設沒人用,black_box 裡面可能會用,所以編譯器會 保存

但這個保存可能放在 CPU 暫存器 (register) 或是放在棧 (stack),也就是這個值不能被最佳化掉

而第二步 &v 告訴編譯器我需要變數的地址,並且我要轉成一個整數來用,因此 v 不能只放在暫存器上,還得放到 Stack 上

第三步 black_box(addr) 也是說,拿出來的地址也不能亂丟,不能被合併摺疊與提前計算

其中轉 u64 的原因,是完全避免編譯器可能對 usize 的特殊推理,把 usize 當成記憶體位址

因此這整個流程會操作到:

  • 呼叫一個 function
  • 強迫建立一個 local variable
  • 強迫他有 address
  • 強迫他不能被最佳化
  • 再做一次黑盒運算

在此之下去改變泛型引用,才能合理的比較之間的差別與成本

Rust 範例

use std::time::Instant;

#[inline(always)]
fn process<T>(value: T) -> u64 {
    let v = std::hint::black_box(value);
    let addr = (&v as *const T as usize) as u64;
    std::hint::black_box(addr)
}

#[inline(always)]
fn process_mut<T>(value: &mut T) -> u64 {
    // 對 &mut 我們額外做一個「可觀察的寫入」:交換一次位元(針對 u64)
    // 但因為是泛型 T,我們不能直接改;所以示範用 u64 專用版本比較更公平
    let addr = (value as *mut T as usize) as u64;
    std::hint::black_box(addr)
}

#[inline(always)]
fn process_u64_mut(x: &mut u64) -> u64 {
    // 真的寫一次,避免「&mut 其實沒用到」而被刪掉
    *x = x.wrapping_mul(6364136223846793005u64).wrapping_add(1);
    std::hint::black_box(*x)
}

fn bench_owned(n: usize) -> u64 {
    let mut acc = 0u64;
    for i in 0..n {
        // owned: T = u64
        acc = acc.wrapping_add(process(i as u64));
    }
    acc
}

fn bench_ref(n: usize) -> u64 {
    let mut acc = 0u64;
    let x = 123456789u64;
    for _ in 0..n {
        // shared: T = &u64
        acc = acc.wrapping_add(process(&x));
    }
    acc
}

fn bench_mut(n: usize) -> u64 {
    let mut acc = 0u64;
    let mut x = 123456789u64;
    for _ in 0..n {
        // mutable: 對照 C 的 int* 修改
        acc = acc.wrapping_add(process_u64_mut(&mut x));
    }
    acc
}

fn main() {
    // 迴圈次數:你可以依照機器快慢調整(先從 50_000_000 試)
    let n = 50_000_000usize;

    // warm up(讓 CPU 進入穩態,避免第一次測被冷啟動影響)
    let _ = bench_owned(1_000_000);
    let _ = bench_ref(1_000_000);
    let _ = bench_mut(1_000_000);

    let t0 = Instant::now();
    let a0 = bench_owned(n);
    let d0 = t0.elapsed();

    let t1 = Instant::now();
    let a1 = bench_ref(n);
    let d1 = t1.elapsed();

    let t2 = Instant::now();
    let a2 = bench_mut(n);
    let d2 = t2.elapsed();

    println!("N = {n}");
    println!("owned (T=u64)        : {:?}  acc={}", d0, a0);
    println!("shared (T=&u64)      : {:?}  acc={}", d1, a1);
    println!("mutable (&mut u64)   : {:?}  acc={}", d2, a2);
}

/*
cd examples/lifetime/generics_t
cargo run --release

N = 50000000
owned (T=u64)        : 24.5691ms  acc=12992747882800000000
shared (T=&u64)      : 25.7718ms  acc=12992747882800000000
mutable (&mut u64)   : 40.1917ms  acc=14108795302277110464
*/

C 的對照範例

在 C 也能實作這三類的效果,也能看出時間落差

#include <stdint.h>
#include <stdio.h>
#include <time.h>

#if defined(__GNUC__) || defined(__clang__)
  #define NOINLINE __attribute__((noinline))
  static inline void black_box_u64(uint64_t x) {
      asm volatile("" : : "r"(x) : "memory");
  }
  static inline void black_box_ptr(const void* p) {
      asm volatile("" : : "r"(p) : "memory");
  }
#else
  #define NOINLINE
  static inline void black_box_u64(uint64_t x) { (void)x; }
  static inline void black_box_ptr(const void* p) { (void)p; }
#endif

static inline uint64_t now_ns(void) {
#if defined(CLOCK_MONOTONIC)
    struct timespec ts;
    clock_gettime(CLOCK_MONOTONIC, &ts);
    return (uint64_t)ts.tv_sec * 1000000000ull + (uint64_t)ts.tv_nsec;
#else
    return 0;
#endif
}

// 對照 Rust: owned (傳值)
static inline uint64_t process_u64(uint64_t value) {
    black_box_u64(value);
    // 用位址當作「確實使用過」的觀察點:取 local 的位址
    uint64_t addr = (uint64_t)(uintptr_t)&value;
    black_box_u64(addr);
    return addr;
}

// 對照 Rust: shared borrow (&T) => const pointer
static inline uint64_t process_u64_ref(const uint64_t* value) {
    black_box_ptr(value);
    uint64_t addr = (uint64_t)(uintptr_t)value;
    black_box_u64(addr);
    return addr;
}

// 對照 Rust: mutable borrow (&mut T) => mutable pointer + 寫入
static inline uint64_t process_u64_mut(uint64_t* value) {
    // 真正寫一次,避免被當成沒用
    *value = (*value * 6364136223846793005ull) + 1ull;
    black_box_ptr(value);
    black_box_u64(*value);
    return *value;
}

static uint64_t bench_owned(size_t n) {
    uint64_t acc = 0;
    for (size_t i = 0; i < n; i++) {
        acc += process_u64((uint64_t)i);
    }
    return acc;
}

static uint64_t bench_ref(size_t n) {
    uint64_t acc = 0;
    const uint64_t x = 123456789ull;
    for (size_t i = 0; i < n; i++) {
        acc += process_u64_ref(&x);
    }
    return acc;
}

static uint64_t bench_mut(size_t n) {
    uint64_t acc = 0;
    uint64_t x = 123456789ull;
    for (size_t i = 0; i < n; i++) {
        acc += process_u64_mut(&x);
    }
    return acc;
}

int main(void) {
    const size_t n = 50000000;

    // warm up
    (void)bench_owned(1000000);
    (void)bench_ref(1000000);
    (void)bench_mut(1000000);

    uint64_t t0 = now_ns();
    uint64_t a0 = bench_owned(n);
    uint64_t d0 = now_ns() - t0;

    uint64_t t1 = now_ns();
    uint64_t a1 = bench_ref(n);
    uint64_t d1 = now_ns() - t1;

    uint64_t t2 = now_ns();
    uint64_t a2 = bench_mut(n);
    uint64_t d2 = now_ns() - t2;

    printf("N = %zu\n", n);
    printf("owned (u64)      : %llu ns  acc=%llu\n",
           (unsigned long long)d0, (unsigned long long)a0);
    printf("shared (const*)  : %llu ns  acc=%llu\n",
           (unsigned long long)d1, (unsigned long long)a1);
    printf("mutable (*mut)   : %llu ns  acc=%llu\n",
           (unsigned long long)d2, (unsigned long long)a2);

    return 0;
}

/*
cd examples/lifetime/generics_t
gcc -O3 -march=native -flto -DNDEBUG test.c -o test
./test

N = 50000000
owned (u64)      : 8863900 ns  acc=4272108245200000000
shared (const*)  : 14346100 ns  acc=4272108245600000000
mutable (*mut)   : 43464200 ns  acc=14108795302277110464
*/

從結果看:

範例CRustRust / C 倍數說明
owned8.86 ms24.57 ms≈ 2.8×C 版本在此情境下被高度最佳化,接近「空迴圈」
shared14.35 ms25.77 ms≈ 1.8×C shared 仍可被合併,Rust 保留更多實際工作
mutable43.46 ms40.19 ms≈ 0.9×兩邊皆有真實寫入,效能同一量級、結果最可信
  • mutable case 中,Rust 與 C 的效能幾乎一致,顯示 Rust 泛型與借用模型本身沒有額外 runtime 成本
  • owned / shared 的差距,主要反映編譯器是否能將迴圈最佳化成近乎空操作