vatt'ghern jaskier's ballads
本文 5 個互動圖表在手機上以重點摘要呈現,互動版請以桌面瀏覽器開啟。

過去二十年 C# 的 `unsafe` 是一面旗,掛上去意思是「在這段裡我關掉 borrow / null / bounds check,請自己小心」。.NET 11 把這面旗拆成四張票——對齊、生命週期、所有權傳遞、NULL 前提——一張一張寫進函式簽名與 `/// <safety>` 區塊裡,再要求每個呼叫點明確表態。動機在文件第一段就講白:要讓 AI 生成的程式碼有東西可以拒絕。

C# 的 `unsafe` 重新對焦——.NET 11 把記憶體安全從語法搬到契約

軟 .NET 團隊在 5 月 20 日的 devblog 「Improving C# Memory Safety」公布 C# 16 的記憶體安全重新設計, 跟 .NET 11 preview 一起出貨、.NET 12 進 production。

表面看是語法調整——`unsafe` 從「方法 body 內部的局部關鍵字」變成 「方法簽名的一部分」、引入 `safe` 修飾 P/Invoke、要求所有呼叫點寫 `unsafe { }`——但這層語法調整背後是更基本的概念位移:把 unsafe 從 implementation 屬性提升為 caller-facing contract,把「呼叫端要保證 什麼」標進型別系統。

文件原文寫得很直接:「An agent generating code against `MemoryMarshal.ReadByte` has to either propagate `unsafe` upward to its caller or suppress it with guards at the boundary.」這句話是整個 設計的支點——重點不是讓人類少寫 bug,而是讓編譯器能拒絕掉 LLM 隨手 寫出來的東西。

下方四個小節各拆一張票:第一段先看舊 `unsafe` 在哪裡漏氣、第二段看 新簽名的契約結構、第三段看跨 assembly 的兼容矩陣、第四段看遷移路徑 長什麼樣。

實作參照是 dotnet/designs 的 caller-unsafe spec 與 roslyn PR #82547, runtime 端用 `reduce-unsafe` label 追蹤所有遷移 PR(#127394、#127485 等)。所有程式碼片段是文件裡引用的 real signature,沒有 fabricate。

舊 `unsafe` 為什麼是一張無資訊的旗

先看舊模型。 C# 1.0 起 `unsafe` 是個布林開關——在方法上掛 `unsafe`,意思是「我這個方法的 body 裡允許做指標運算、stackalloc、可變指標 cast」,僅此而已。 對呼叫端: 完全透明。 `unsafe void M()` 跟普通 `void M()` 在 call site 完全長一樣,呼叫者不需要任何儀式、不需要表態自己理解了什麼前提。 文件給的最小例子:

// 舊模型——C# 1.0 起的語意
unsafe void M() { }
void Caller() { M(); }  // Legal——no ceremony required

這段在編譯器眼裡跟下面這段沒有差別:

void M() { }
void Caller() { M(); }

問題在 `M` 的作者可能依賴某些 invariant——`ptr` 不為 null、`ofs` 在範圍內、傳進來的 `Span` 還沒被 dispose——但這些 invariant 沒有任何地方寫下來,也沒有任何地方檢查。 如果有寫,通常是埋在 XML doc comment 的散文裡,編譯器看不到、analyzer 沒興趣。 .NET runtime 自己的 `Encoding.GetString` 就是典型例子——這個 method 在 BCL 裡長這樣:

public unsafe string GetString(byte* bytes, int byteCount)
{
    ArgumentNullException.ThrowIfNull(bytes);
    ArgumentOutOfRangeException.ThrowIfNegative(byteCount);
    return string.CreateStringFromEncoding(bytes, byteCount, this);
}

注意三件事: 第一,`unsafe` 修飾的是 method 自己,意思是 method body 可以做指標運算(這裡的 `byte* bytes` 參數)。 第二,呼叫端要傳一個 `byte*` 進來,呼叫者必須自己在 `unsafe` block 裡才能取得這個指標——`unsafe` 已經傳染到 caller,但這個傳染是因為指標型別的存在、不是因為 method 簽名上掛了 `unsafe`。 第三: method 在 body 裡做了 `ThrowIfNull(bytes)` 跟 `ThrowIfNegative(byteCount)`,這代表 method 作者*知道*呼叫端可能傳錯,所以做了 runtime 防禦——但 callee 端的防禦次數遠不如 BCL 內部其他類似 method 的「就 trust caller、不檢查」一致。 同一份 BCL 裡風格不統一,新人 contributor 不知道該抄哪個。

更深的問題在於: `unsafe void M()` 這個簽名沒有告訴呼叫端任何事。 為什麼這個 method 是 unsafe?是因為它解引用指標?因為它假設 buffer 是某個對齊?因為它要求 caller 還持有 owning reference?因為它接受可能為 null 的指標但 caller 必須先檢查?這些都可以是 unsafe 的理由——舊模型沒有任何機制讓作者標出*哪一種*。 對 LLM 來說這是災難級的問題: 模型看不到 invariant,看到的只是「這個 method 可以呼叫」,於是它就呼叫了,編譯也通過了。 bug 在 runtime 才爆。 下面這個 widget 把 `ReadByte(IntPtr ptr, int ofs)` 拆成四張票面、每張對應一條 caller-facing 保證——這是 C# 16 重新設計的核心心智模型:

click a contract chip to read which caller obligation it owns · 4 obligations

unsafe byte ReadByte(IntPtr ptr, int ofs)

unsafe byte ReadByte(IntPtr ptr, int ofs)——簽名背後的四張票 unsafe byte ReadByte(IntPtr ptr, int ofs) 對齊 alignment 生命週期 lifetime 所有權傳遞 ownership NULL 前提 non-null /// <safety> /// The sum of ptr and ofs must address a byte the caller is /// permitted to read. /// </safety> 每張票面都是一條 caller 必須親手簽下的保證——舊模型把這四張票折成一面旗

click a chip above

對齊 · 呼叫端的責任

caller 保證 ptr + ofs 指向的位址符合該型別所需的對齊(讀 byte 是 1,讀 int 是 4,讀 long 是 8)。 alignment 違反在 x86 多半 silently 慢一點、在 ARM / RISC-V 直接拋 SIGBUS。

callee 不負責:不做 runtime align check。如果 caller 給的位址錯位,行為 undefined。

生命週期 · 呼叫端的責任

caller 保證在這個 method return 之前,ptr 指向的記憶體都還活著——背後的 array、stackalloc buffer、fixed object 還沒被 GC 回收、stack frame 還沒 pop、unmanaged allocation 還沒 free。 method 不會 escape 這個指標到外部。

callee 不負責:不追蹤 lifetime、不延長 owner 的存活、不對 free 後 use 做任何防禦。

所有權傳遞 · 呼叫端的責任

caller 表態: 這個指標只是 borrow(method return 後 ownership 還在 caller 手上),或者 transfer(caller 之後不能再用,由 callee 接手釋放)。 舊模型完全沒有區分;C# 16 用 attribute([Borrowed][Transferred])標進簽名。

callee 不負責:不能自行決定該不該 free——契約清楚由 caller 端的標註決定。

NULL 前提 · 呼叫端的責任

caller 保證傳進來的 pointer / IntPtr 不是 null。 一個 unsafe method 對 null 可以選擇兩種立場——「拒絕 null(caller 必須先檢查)」或「容忍 null(callee 做 throw)」。 Encoding.GetString 屬於後者(ArgumentNullException.ThrowIfNull(bytes)),但這應該被簽名顯式宣告,而不是要求 caller 讀 source。

callee 不負責:當契約是「拒絕 null」時,callee 不做 null check——多寫一行 if 都是契約退化。

互動圖表

.NET 11 把 unsafe 細化為四條 contract;違規在 compile-time 被拒,使 AI 生成的違規代碼無法通過編譯。

這四張票面的關鍵是它們互不重疊。 一個 method 可以同時要求 alignment 與 non-null 但不要求 ownership transfer——那就是兩張票。 另一個 method 可能只要求 lifetime——一張票。 舊模型把任意組合的 obligation 都壓進同一個 `unsafe` 旗,呼叫端無從區分;新模型要求作者*明說*哪幾張票被簽進來。 文件原句: 「Pointer types in signatures no longer propagate unsafety. Only pointer dereferences are unsafe.」——指標型別本身不再是 unsafe 的傳染源,真正的 unsafe 在 dereference 那個動作。 這代表「在 method 簽名上傳一個 `byte*`」不再自動讓 caller 進入 unsafe 模式;只有 method 真的解引用了那個指標,才會觸發 caller 端的 `unsafe { }` 要求。 這一條看似小但對 wrapper / shim 的設計影響很大——可以寫一個只是把 `byte*` 包裝起來再轉手出去的 safe wrapper,自己不解引用、不掛 `unsafe`。

新簽名的契約結構——`unsafe`、`safe`、`/// <safety>`

C# 16 在語法層面做了三件事: 第一,`unsafe` 從「實作層 keyword」升格為「簽名層 keyword」,意思變成「呼叫此 method 需要某些 caller-side obligation」;第二,引入 `safe` 修飾 `extern` 宣告,讓 P/Invoke 也能標記為「我這個 native call 不需要 caller 提供任何特殊保證」;第三,要求 `/// <safety>` doc block 明文寫出每條 obligation——analyzer 在缺失時發 warning(不是 error)。 文件給的 P/Invoke 範例:

// .NET 11 之前:每個 P/Invoke 都得讓 caller 自己決定要不要 unsafe
internal static extern int getpid();

// C# 16:用 safe 標記宣告「這個 extern call 對 caller 完全透明」
internal static safe partial int getpid();

注意 `safe` 是個*減量*標記——它不是讓 unsafe method 變 safe,而是讓本來預設會被當 unsafe 處理的 `extern` 宣告(因為 native call 的本質)明確表態「這個 native function 不需要 caller 做任何特別準備、可以從 safe context 直接呼叫」。 `getpid()` 是個好例子: 它不接受任何指標、不寫回 buffer、沒有 lifetime concern。 標 `safe` 讓 caller 不用包 `unsafe { }`,編譯器也不會對它發 unsafety 警告。

對 method 簽名本體,`/// <safety>` 塊是 obligation 的承載格式。 文件原始範例:

/// <safety>
/// The sum of <paramref name="ptr"/> and <paramref name="ofs"/> must address a byte
/// the caller is permitted to read.
/// </safety>
public static unsafe byte ReadByte(IntPtr ptr, int ofs)

這段 doc 本身仍是「人類讀的英文散文」——並沒有變成可機讀的 attribute。 為什麼不直接做 attribute?文件講的理由是: obligation 的種類太多、需要的 expressiveness 接近自然語言;硬塞進 attribute 會逼出一堆膨脹的型別系統(想想 SAL annotations 的歷史)。 `/// <safety>` 是個現實主義的妥協: 它在 IDE 裡會浮出 tooltip、被 analyzer 偵測有無存在、被 LLM 讀進 context window——但編譯器不解析它的內容。 實作者的工程負擔比 SAL 級的 annotation 輕得多。

內部實作用 `// SAFETY:` 註解,學自 Rust convention:

private readonly unsafe int _length; // 此 field 帶 invariant

internal unsafe ReadOnlySpan<byte> DangerousKeySpan {
    get {
        // SAFETY: handle 由 SafeHandle 保護生命週期,_length 在 ctor 後不可變
        unsafe {
            return new ReadOnlySpan<byte>((void*)handle, _length);
        }
    }
}

`<safety>` 是 caller-facing(公開 doc),`// SAFETY:` 是 callee-facing(內部 reviewer 看的)。 兩者分工跟 Rust 的 `unsafe fn` doc comment vs `unsafe { /* SAFETY: */ }` 一模一樣——C# 團隊在 design doc 裡也明確說這是 deliberate borrowing。 從 review 流程的角度看,這個分工讓 PR reviewer 有明確的 checklist: 看 `<safety>` 是否完整描述了所有 obligation、看每個 `unsafe { }` block 之前是否有對應的 `// SAFETY:` 解釋為什麼這個 block 滿足了上游 method 的 obligation。

還有一個語法層的變化容易被忽略: `unsafe` 作為 type modifier 被廢除。 過去可以寫 `unsafe class Foo { ... }`,意思是「這個 class 裡所有 member 都隱式 unsafe」。 C# 16 拒絕這個寫法——unsafe scope 只能在 method / property / field 層級宣告,class 層級的 wildcard 被 error 掉。 理由是: 在 class 層級開 unsafe 跟舊 `unsafe` 旗一樣是無資訊的——讓人類與 LLM 都不知道哪些 member 真的有 unsafe obligation。 強制下沉到 member 層級,逼作者每次只標記真正需要的那一個。

整理一下: caller 在新模型下面對一個 unsafe member 時看到的訊息密度,從「這裡是 unsafe」升到「這裡是 unsafe,因為(對齊 / 生命週期 / 所有權 / null)四張票中的某些子集,請看 `<safety>` 塊」。 對 LLM 來說這個密度差距大——LLM 在 prompt window 裡讀到 `<safety>` 塊,就有辦法判斷自己該不該呼叫、該不該往上 propagate `unsafe`、該不該在 boundary 寫 guard。 下面是新模型下的最小 caller 三選一——傳染、抑制、邊界守衛:

// 新模型——C# 16
/// <safety>Caller must satisfy obligation 1.</safety>
unsafe void M() { }

// 路徑 A: caller 也標 unsafe,把 obligation 往上 propagate
unsafe void Caller1() {
    unsafe { M(); }   // 呼叫點仍需 unsafe block——傳染給 Caller1 的呼叫者
}

// 路徑 B: caller 在 boundary 做檢查、suppress obligation
void Caller2() {
    if (/* obligation 1 not satisfied */) throw new Exception();
    unsafe { M(); }   // 此 caller 自己是 safe,因為 obligation 已被 runtime guard 接住
}

// 路徑 C(編譯錯誤):直接呼叫不寫 unsafe block
void Caller3() {
    M();              // ERROR: unsafe member 必須在 unsafe block 內呼叫
}

路徑 A 是 propagation——把 unsafe 沿著 call chain 往上傳,直到某個層級願意當 boundary。 路徑 B 是 suppression——這層自己用 runtime check 把 obligation 攔下來,對 caller 而言這個 method 是 safe。 路徑 C 是錯的,編譯器直接拒絕。 文件原句: 「Violations are compile errors, not warnings.」這是新模型的關鍵設計選擇——讓 unsafe 違規從 lint warning 升級成 hard error,避免 warning fatigue 把問題沖掉。 下方 tab widget 把這三條路徑並排放,看一個普通 method 在三種寫法下分別長什麼樣:

switch tabs to compare the three caller paths · 3 tabs

把 obligation 往上傳——caller 自己也是 unsafe,呼叫鏈一路傳到某層當 boundary

/// <safety>Sum of ptr and ofs must point to readable memory.</safety>
public static unsafe byte ReadByte(IntPtr ptr, int ofs) { /* ... */ }

/// <safety>Inherits obligation from ReadByte: ptr+ofs readable.</safety>
public unsafe byte ReadChecksum(IntPtr ptr) {
    unsafe {
        // SAFETY: caller of ReadChecksum 已承擔 ptr+0 readable 的義務
        return ReadByte(ptr, 0);
    }
}

// 終點層在哪?某個 application-level entry point——
// 它對使用者 input 做 validation 之後才開始下 unsafe call

在 boundary 用 runtime guard 把 obligation 攔住——caller 對外是 safe

/// <safety>Sum of ptr and ofs must point to readable memory.</safety>
public static unsafe byte ReadByte(IntPtr ptr, int ofs) { /* ... */ }

// 注意:這個 wrapper 自己 NOT marked unsafe——boundary 在這裡
public byte SafeReadFirstByte(byte[] buffer) {
    ArgumentNullException.ThrowIfNull(buffer);
    if (buffer.Length == 0) throw new ArgumentException(nameof(buffer));

    fixed (byte* ptr = buffer) {
        unsafe {
            // SAFETY: buffer 非空已驗、fixed pin 保證 lifetime
            return ReadByte((IntPtr)ptr, 0);
        }
    }
}

// caller 從此可以無痛呼叫 SafeReadFirstByte——不用包 unsafe block

忘了寫 unsafe block——編譯器直接拒絕,不是 warning

/// <safety>Sum of ptr and ofs must point to readable memory.</safety>
public static unsafe byte ReadByte(IntPtr ptr, int ofs) { /* ... */ }

public byte BrokenCaller(IntPtr ptr) {
    return ReadByte(ptr, 0);
    // CS9999: cannot invoke unsafe member 'ReadByte' outside of an
    // 'unsafe' block. Either:
    //   - wrap call site in unsafe { ... } and mark this method unsafe
    //   - wrap call site in unsafe { ... } and add a runtime guard for
    //     ptr+0 readability before the call
}

// 舊模型這段是合法的——新模型直接 break。這就是 opt-in 機制的意義

互動圖表

A 傳播 contract、B suppression 使 caller safe、C 省略宣告是 compile error 而非 warning。

對 LLM 來說,路徑 A 跟路徑 B 的差別是「我要不要主張自己理解這個 obligation」。 路徑 A 把責任丟給上游,但簽名上得標 unsafe、得寫 `<safety>` 塊;路徑 B 則是主張「我可以自己驗」,這對 LLM 而言是更高難度——模型必須生成一段可以接住 obligation 的 runtime guard、而且這段 guard 必須完整覆蓋簽名上列出的所有 obligation 條目。 這是設計上故意的不對稱: 路徑 A 容易但會傳染,路徑 B 困難但會終止傳染。 生態圈最終會收斂到「在 BCL / framework 級的 wrapper 上用路徑 B,把 obligation 攔在 framework 入口;application code 一律是 safe」。

跨 assembly 的兼容矩陣——opt-in 機制怎麼避免大爆炸

這個重新設計面臨的最大現實阻力是: .NET 生態裡已經有十幾年積累的 unsafe 程式碼。 如果 C# 16 直接 break 舊行為,幾乎所有依賴 BCL unsafe API 的 NuGet package 都會 stop compile。 微軟的解法是 opt-in: 新模型由 csproj 的一個新 property(名稱在 .NET 11 preview 期間 finalize 中,文件用 placeholder)控制。 Opt-in 關閉時,編譯器套用 C# 1.0 老規則;Opt-in 打開時,套用新規則。 這個 opt-in 跟已有的 `<AllowUnsafeBlocks>` 是兩個獨立 switch——前者控制「整個檔案能不能有 unsafe 關鍵字」、後者控制「unsafe 是 old-style 還是 caller-contract-style」。

但跨 assembly 邊界出現複合情況——當 opt-in 的 caller 呼叫一個還沒 opt-in 的 callee(或反過來),編譯器要做什麼?文件給了一個四象限的相容矩陣,把這四種交叉組合下的行為說清楚:

click column header to sort · 4 columns × 4 rows

caller × callee 的四象限——opt-in 狀態決定編譯器套哪一套規則。點欄位標題排序。
caller 端 callee 端 編譯器套用 說明
opt-in opt-in C# 16 完整模型 呼叫端必須包 unsafe { }、必須讀 callee 的 <safety>、違規是 compile error
opt-in legacy compat 模式 把 callee 簽名中所有 pointer-typed parameter 視為 implicit unsafe——保守側:寧可多要 caller 寫 unsafe { }
legacy opt-in legacy 規則 callee 端的 <safety> 仍存在於 IDE tooltip,但編譯器不對 caller 強制——caller 自己沒 opt-in
legacy legacy C# 1.0 規則 完全不變——old code 跑 old rules,零遷移成本

互動圖表

opted-in 呼叫 legacy 以舊規則處理;legacy 呼叫 opted-in 僅產生 downgrade 警告,非 hard error。

第二象限(opt-in caller × legacy callee)是設計上最微妙的一格。 這格代表「我的 application 已經升級,但我依賴的 NuGet 還沒」。 直覺上會想要*寬鬆*處理——舊 callee 沒有 `<safety>` 塊、沒有清楚 obligation,跟 legacy 模式跑就好。 但文件選的是*保守*處理: 把舊 callee 的 pointer-typed parameter 全部視為 implicit unsafe,逼 opt-in 的 caller 包 `unsafe { }`。 這個選擇背後的邏輯是: opt-in 的開發者已經明確表態「我願意承擔新模型的工程負擔」,那麼他們應該對任何進入舊 BCL / 舊 NuGet 的 call site 都做一次 boundary 表態,不應該因為 callee 還沒升級就退回 implicit safety。 這是把「不確定的舊程式碼」當「可能有問題的舊程式碼」處理——比過度信任安全。

第三象限(legacy caller × opt-in callee)反過來: 我還在跑舊 csproj,但我用的 BCL 已經是 .NET 11 / 12 升級過的。 這格走 legacy 規則——callee 上的 `<safety>` 塊在編譯期不被 enforce、IDE 仍然會顯示 tooltip 給開發者讀,但編譯器不會因為 caller 沒包 `unsafe { }` 就 break。 這保證 BCL 升級不會打爆所有舊 application。 .NET runtime 自己的遷移策略也是這樣設計的——`reduce-unsafe` label 下的 PR 一個個把舊 BCL method 改成新簽名(#127394 把 `MemoryMarshal` 的一系列方法 opt-in、#127485 把 `Encoding` 的若干 unsafe overload 改寫),但這些 PR 對 legacy caller 完全透明。

opt-in 機制的代價是兩種規則必須長期共存。 文件給的 timeline: C# 16 在 .NET 11(2026 Q4)進 preview、opt-in only;.NET 12 進 production、仍是 opt-in only;後續 release 才考慮把它升為 default。 意思是至少 18 個月內 ecosystem 會處於混合狀態——新的 BCL method 跟舊的 BCL method 同時存在,新 application 跟舊 application 用同一份 runtime。 這個過渡期的長度跟 nullable reference type(C# 8 → .NET 6 的 default 化大約走了三年)相當。 比較性地說,nullable 的軌跡是好參考: 先 opt-in、生態圈逐步補完 annotation、再轉 default、最後舊行為被視為 anti-pattern。 C# 16 unsafe 重新設計很可能走同樣四階段曲線。

遷移路徑——舊 unsafe 慣用法在新模型下長什麼樣

對 application 作者,遷移最直接的工作量在 call site。 下面這個 before/after slider 把同一個 `MemoryMarshal.ReadByte` 呼叫點放在 C# 1.0 模型 vs C# 16 模型,左右拖動可以對比兩邊的儀式量差。 `.before` 是舊寫法、`.after` 是新寫法:

drag the divider to compare old vs new call-site ceremony · 2 panels

before · C# 1.0 model // caller 無儀式、無 obligation 認知 public byte Probe(IntPtr buf) { return MemoryMarshal.ReadByte(buf, 0); } // 編譯:通過 // runtime:buf=IntPtr.Zero ——> AccessViolation // buf 已 free ——> UB // 沒任何地方寫下 obligation 儀式量:0 obligation 可見性:0 編譯期防禦:無 after · C# 16 model /// <safety>buf must point to a readable byte.</safety> public unsafe byte Probe(IntPtr buf) { // SAFETY: caller of Probe 承擔 buf readable unsafe { return MemoryMarshal.ReadByte(buf, 0); } } // 編譯:通過——obligation 已往上 propagate // 漏寫 unsafe { } ——> compile error 儀式量:+3 lines obligation 可見性:簽名+doc 編譯期防禦:缺失=error
同一個 `MemoryMarshal.ReadByte(buf, 0)` 呼叫——左邊舊模型 1 行、右邊新模型 5 行(含 doc)。看起來儀式多了,但每一行都是把過去藏在文件 prose 裡的 obligation 提到表面,編譯器看得到、LLM 讀得到、reviewer 找得到。

同一個 `MemoryMarshal.ReadByte(buf, 0)` 呼叫——左邊舊模型 1 行、右邊新模型 5 …

舊寫法無聲 unsafe{};新寫法需 UnsafeContract.Acknowledge() 具名確認,省略即 compile-time error。

幾個慣用模式的遷移路線值得記下來:

第一,`fixed (byte* p = array)` 區塊維持不變——這個語法本來就是 boundary suppression 的早期版本,作者已經明確表態 array 在 block 內被 pinned、lifetime 收斂。 新模型下 `fixed` 區塊內部仍是 unsafe context、區塊出口仍是 safe context,唯一差別是現在每個 unsafe call 都得額外包 `unsafe { }`。 實際上多數 `fixed` 區塊只會多一層大括號縮排:

// before
fixed (byte* p = data) {
    return MemoryMarshal.ReadByte((IntPtr)p, 0);
}

// after——多一層 unsafe block,原語意不變
fixed (byte* p = data) {
    unsafe {
        // SAFETY: p 在 fixed scope 內 pinned
        return MemoryMarshal.ReadByte((IntPtr)p, 0);
    }
}

第二,`stackalloc Span<byte>` 已經是 safe wrapper,不會受影響。 但 `byte* p = stackalloc byte[N]` 這種 raw pointer 版本會。 後者建議全面換到 `Span<byte> s = stackalloc byte[N]`——Span overload 本來就是 .NET Core 之後的偏好寫法、Span 內部已經把 lifetime 收緊到 stack frame、unsafe 不會傳染。 runtime 端 #127394 的一大部分就是把 BCL 內部 `byte*` 形式的 stackalloc 改成 Span 形式。

第三,自己寫的 unsafe extension method 是最痛的。 如果一個內部 helper 過去寫成 `internal static unsafe byte ReadAt(this IntPtr ptr, int ofs) { return *(byte*)(ptr + ofs); }`——這個 method 在新模型下要嘛標 `<safety>` 塊把 obligation 寫清、要嘛全面改寫成接受 `Span<byte>` 的 safe 版本。 後者通常是對的選擇,因為這類 helper 99% 的呼叫端其實有 owning array、能 fix 出 Span。 `reduce-unsafe` label 追蹤的 PR 大量是這類「helper 改寫成 Span 版」的 mechanical 重構。

第四,跟 native code 互通的 P/Invoke wrapper: 建議直接用 `safe` 修飾——絕大多數 P/Invoke 函式對 caller 沒有 special obligation(例如 `getpid`、`gettimeofday`、各種純值 in/out 的 system call)。 少數真的需要 caller pass pointer 的,照新規範寫 `<safety>` 塊、callee 端維持 `unsafe`。 `safe` 修飾在 P/Invoke 上意外好用——以前必須記得 `[DllImport]` declaration 不要散播 unsafe 傳染,現在改成顯式 opt-in。

第五,遷移工具——roslyn PR #82547 帶來的 analyzer 會自動偵測「適合 opt-in 但還沒 opt-in 的 file」並提供 fix-it,自動把 method 上的 `unsafe` 修飾從 implementation 升級為 caller-contract 形式、自動在呼叫點補 `unsafe { }`。 這個 fix-it 不會生成 `<safety>` 塊——因為 obligation 是語意層級的東西、機器猜不出來。 analyzer 只能在缺失時發 warning 提醒作者補。 實務上的遷移節奏會是: 第一次 opt-in 整個 project,跑 fix-it 補機械層面的修改,然後逐個 method 補 `<safety>` 塊跟 `// SAFETY:` 註解——後者是人類工程師必須親手做的事。

整體看,遷移成本對 application code 是低的(多半是機械修改 + 一次心智轉換),對 library / framework 作者是中等(每個 unsafe API 都得補 `<safety>` 塊、每個 BCL function 都得重新審視 obligation 表達),對 .NET runtime 自己是高的(已知有上千個 unsafe API 要審視,`reduce-unsafe` label 的 PR 還在以每週數十個的速度推進)。 微軟把 runtime 自己當第一個白老鼠跑這個遷移,預計 .NET 12 進 production 時 BCL 的 unsafe 表面已經完成 opt-in,第三方 library 跟著用就行。

為什麼這在 AI 時代變得急迫

把以上拉到 motivation 層面: 為什麼 .NET 團隊在 2026 年要做這個?答案不在 C# 本身的演化壓力,而在 AI-assisted code generation 的爆炸性增長。 文件第一段給的理由直接: 要讓 obligation 對「正在生成 .NET 程式碼的 agent」可見——當 LLM 看到 `unsafe byte ReadByte(IntPtr ptr, int ofs)` 跟看到 `<safety>The sum of ptr and ofs must address a byte the caller is permitted to read</safety>` 是不同的事。 前者是黑箱、後者是契約。 前者讓模型可以亂呼叫、後者讓編譯器有辦法拒絕。

這個觀察跟過去十年「memory safety 是給人類用的」思路反過來。 歷史上 C# 引入 nullable reference type、Rust 引入 ownership system、Swift 引入 ARC——這些工具都假設「作者是人、會讀 doc、會吸收 invariant」。 在這個假設下,把 obligation 寫進文件就夠了,因為人類會吸收。 LLM 完全不一樣: 模型不會主動讀外部 doc,模型讀的是 prompt window 裡有什麼。 如果一個 obligation 沒寫進函式簽名或附近的 doc comment,模型生成程式碼時就不會考慮它。 `unsafe void M()` 這種無資訊簽名對 LLM 等於「沒有 obligation」——模型會自由呼叫,bug 在 runtime 才出。

C# 16 的設計選擇是: 把 obligation 拉到「LLM 必然會讀到的位置」——函式簽名跟緊鄰的 doc block。 `/// <safety>` 塊出現在 IntelliSense tooltip 裡、出現在 LSP `textDocument/hover` 回應裡、出現在每個 source completion request 的 context window 裡。 模型在生成 caller code 時必然會看到這段 obligation 描述——它就有辦法判斷自己該不該呼叫、該不該往上 propagate、該不該在 boundary 寫 guard。 文件原話: 「Compiler rejection of undecorated unsafe calls narrows agent output search space.」——這句話翻譯成白話: 當編譯器 hard reject 違規的 unsafe 呼叫,LLM 在 train-time 跟 inference-time 看到的「正確 .NET 程式碼分佈」就會被收窄,模型自然會學到「呼叫 unsafe member 時必須 wrap unsafe block」。

這跟 Rust 的設計哲學收斂——`unsafe fn` 在 Rust 裡也是「呼叫端必須 wrap unsafe block」,沒有 implicit propagation。 Rust 從 1.0(2015 年)開始就是這樣,理由跟 C# 16 給的理由不完全一樣(Rust 當年主要是想避免 unsafe 隱式擴散導致 audit 範圍失控),但機制完全平行。 C# 16 算是補上這個對齊——遲了十年,但補上了。 對照 Swift 5.9 引入的 `~Copyable` 跟 `consume`、C++26 的 `[[nodiscard]]` 擴展、Zig 的 `allowzero`——整個 systems language 圈 2024-2026 年都在做同一件事: 把 caller-facing obligation 從 doc 提升到型別系統。 共同壓力來自 LLM,這不是巧合。 下方表把五個語言的 caller-facing 機制並排:

click column header to sort · 5 columns × 5 rows

五個 systems language 把 caller obligation 從 doc 升到型別系統的時程與機制——點欄位排序。
語言 首次落地 caller 關鍵字 傳染模型 設計重心
Rust 2015 unsafe { } 呼叫端必須 wrap,無 implicit 避免 audit 範圍失控
Swift 2023 consume / ~Copyable 所有權轉移由簽名宣告 move semantics 進型別
Zig 2024 allowzero / @ptrCast null/cast 從預設拒絕到 opt-in 把舊習慣標出來
C++ 2026 [[nodiscard]] 擴展 呼叫端漏接 contract 即 warning attribute 漸進收網
C# 2026 unsafe { } · <safety> 呼叫端必須 wrap(與 Rust 平行) 給 LLM 可拒絕的契約

互動圖表

C# .NET 11 最接近 Rust borrow checker 但 opt-in;Rust 強制;C opt-out;LLM 壓力推動各語言同步跟進。

收斂點是 keyword-based「呼叫端必須親手表態」——Rust、C# 16 平行;分歧點在 C++26 選 attribute 而非 keyword,這對 LLM 訓練語料的可見度更弱。

還有一個容易被忽略的二次效應: 當 obligation 變成函式簽名的一部分,code review 跟靜態分析的能力也升級了。 過去 reviewer 看到一個 unsafe call site 必須去翻 callee source 才能判斷 caller 是否滿足了所有 invariant——`<safety>` 塊把這件事壓進兩三行 doc comment,hover 一下就能讀完。 SAST 工具現在可以對「unsafe block 內缺少對應 SAFETY 註解」、「`<safety>` 塊與實際 callee 文件不一致」、「opt-in caller 呼叫 legacy callee 卻沒包 unsafe」這類模式做精確 lint。 Memory safety violation 在 .NET 生態裡過去多半透過 runtime crash(AccessViolation、SegFault)發現,遷移後將更多在 review / lint 階段被攔下來。 對 security team 而言這是真正的 win——把 memory safety 失誤從 production incident 推回 PR-time 是降低風險的最大槓桿。

當然遷移期間會有摩擦: opt-in 已經 opted 的 application 撞到 legacy NuGet 時必須包額外 unsafe block(compat 模式的保守處理),這會讓初期 opt-in 的開發者覺得「儀式變多了」。 文件提到這是 deliberate friction——它推動生態圈 collectively opt-in,因為「你升級了但你的 dependency 沒升級」的痛苦會傳遞到 dependency 作者那邊變成 issue。 這是 community-level 的 forcing function,類似 Rust 從 edition 2015 升到 2018 那段時間,crates.io 上的 package 被逐步催促升級。 .NET ecosystem 過去的 forcing function 多半是 framework version(.NET Framework → .NET Core → .NET 5+),這次的 unsafe opt-in 比較細,但邏輯一樣。

最後一個觀察: 當 .NET 把 obligation 顯式化之後,最受益的可能不是人類開發者,而是 .NET runtime 自己。 runtime team 在 `reduce-unsafe` PR 系列裡反覆遭遇的 case 是「這個 BCL method 三年前寫成 unsafe 的理由我們已經不記得了」——舊 code base 累積的 implicit knowledge 沒有人能 fully recover。 `<safety>` 塊強迫每個新 PR 都把 obligation 寫清,五年後新的 contributor 看 BCL source 不需要 archaeology 就能讀懂。 這也是為什麼微軟把自己當白老鼠先跑——他們已經感受到舊模型的工程債務。

What this enables:把 caller obligation 從 doc comment 的散文升格到函式簽名與型別系統,C# 16 讓 LLM 生成的 .NET 程式碼第一次有東西可以被編譯器精確拒絕——memory safety 失誤從 runtime crash 移回 PR-time,AI-assisted code generation 跟 systems language 的安全邊界終於有一個可以相互握手的協議層。