Microsoftは、C#のメモリ安全性を高めるため、unsafeキーワードの意味を大きく変える新モデルを発表した。対象は名目上C# 16の機能で、.NET 11でプレビュー公開され、.NET 12で本番導入される予定だ。発表したのは.NETチームのRichard Lander氏で、初期段階ではプロジェクト単位で有効化するオプトイン方式を採る。

今回の変更は、C#をRustへ置き換える話ではない。C#はガベージコレクション、境界チェック、初期化規則などにより、通常の安全なコードでは強い保護を持つ。一方で、ネイティブコードとの連携や性能上の理由から、ポインタや手動メモリ操作に近い処理を扱うunsafeコードは残る。Microsoftが変えようとしているのは、その危険な領域を「どこにあるか」だけでなく、「誰が何を保証しなければならないか」までコード上で追えるようにする点である。

背景には、AI支援コード生成の普及もある。Microsoftは、ソフトウェア生成の速度が人間レビューの速度を上回り得る時代に、メモリ安全性の境界をコンパイラが見える形で扱う必要が増していると説明している。これは単なる文法変更ではなく、レビュー、監査、サプライチェーン確認の粒度を変える設計だ。

AD

unsafeは「中で危険なことをする印」から「呼び出す側の契約」へ変わる

従来のC# 1.0モデルでは、unsafeは主にポインタ機能を使える構文上の領域を作る役割だった。型、メソッド、メソッド内部のブロックなどにunsafeを付けることで、その中でポインタの宣言やデリファレンスなどを扱えるようにする。だが、この指定は必ずしも呼び出し側に明確な義務を伝えるものではなかった。

Microsoftが例に挙げているのがMarshal.ReadByte(IntPtr, int)のようなAPIだ。この種のAPIは、呼び出し側が渡したIntPtrとオフセットが、読み取り可能なメモリ上の1バイトを指していることを前提に動く。ところがIntPtrは形としては整数に見えるため、ポインタ型をシグネチャに出す従来の伝播規則をすり抜けやすい。つまり、実態としてはメモリを直接読む危険な操作でも、呼び出し側に「ここは安全義務を負う場所だ」と強く伝わらない場合があった。

新モデルでは、この構図が変わる。unsafeがメンバーのシグネチャに付くと、それは呼び出し側へ伝播する契約になる。呼び出し側は、そのunsafeメンバーを呼ぶ箇所を内側のunsafe { }ブロックで囲まなければならない。さらに、unsafeメンバーには/// <safety>という文書ブロックを用意し、呼び出し側が満たすべき条件を明示することが推奨される。欠落はアナライザーで検出できるようにする方針だ。

この設計で重要なのは、unsafeが危険を消すわけではないことだ。危険な操作を使う事実は変わらない。変わるのは、危険の所在と責任の流れが、慣習や暗黙知ではなく、コンパイラ診断とドキュメントで追えるようになる点である。安全性はキーワードだけで生まれるのではなく、キーワードがレビュー可能な足場を作る。

C# 16の新モデルはRustに近いが、C#の既定安全性を前提にしている

Microsoftは、新しいunsafeの考え方がRustやSwiftの影響を受けていることを認めている。特にC# 16の方向性は、Rustのunsafe fnunsafe { }に近い。外側のunsafeは呼び出し側への契約を表し、内側のunsafeブロックは実際に危険な操作が行われる範囲を示す。

ただし、C#とRustの安全モデルは同じではない。Rustは借用チェッカーを中心に所有権とライフタイムを厳密に扱う。一方のC#は、ガベージコレクションやランタイムチェックを前提に、安全な通常コードの領域を広く確保している。今回の変更も、C#全体にRust型の借用検査を導入するものではない。Microsoftは、リフレクション経由の呼び出しやライフタイム管理については残る設計領域として明示している。

新モデルで変わる具体項目は多い。まず、型全体にunsafeを付ける形はエラーになる。unsafeの範囲は、メソッド、プロパティ、フィールドといった個別メンバーへ下がる。これにより、型全体が曖昧に危険扱いされるのではなく、どのメンバーがどの義務を呼び出し側へ渡すのかを確認しやすくなる。

また、ポインタ型がシグネチャに含まれるだけでは、新モデル上のunsafe伝播にはならない。重要なのは、ポインタを保持することではなく、デリファレンスのように実際のメモリアクセスを行い、.NETが守ろうとする「アクセス先が生きたメモリである」という前提を破り得る操作だ。この切り分けにより、危険な操作そのものへ注目を集める。

外部関数呼び出しにも明示性が加わる。LibraryImportのような宣言では、コンパイラが安全性を自動で判断できない場合にsafeまたはunsafeを明示する方向だ。例えば、単純なプロセスID取得のように安全な呼び出しはsafe、ネイティブ側がポインタを読むような呼び出しはunsafeとして、API作成者が安全性の性格を表す必要がある。

AD

影響が大きいのは、業務アプリよりライブラリとネイティブ連携だ

Microsoftは、多くのC#開発者はこの変更にほとんど気づかないだろうと説明している。通常の業務アプリケーションではunsafeコードを有効にしていないことが多く、既定の<AllowUnsafeBlocks>falseであるためだ。新モデルを有効にし、なおかつunsafeを許可しない構成では、プロジェクトは新しい厳格なモデルに参加しながら、危険なAPIの呼び出し自体をコンパイル時に防げる。

影響が大きいのは、.NETライブラリ、ネイティブ相互運用、低レベルな性能最適化、ソースジェネレータ、ランタイム寄りのコードを扱う開発者である。これらの領域では、既存コードのunsafeが「実装上必要な囲い」だったのか、「呼び出し側に条件を渡す契約」だったのかを見直す必要が出てくる。

たとえば、あるメソッドが内部でunsafeブロックを使っていても、呼び出し側が満たすべき追加義務をすべてメソッド内部で検査できるなら、そのメソッド自体は安全な表面を維持できる。逆に、呼び出し側だけがメモリの長さ、所有権、寿命を判断できるなら、メソッドのシグネチャにunsafeを付け、/// <safety>で義務を伝播させる必要がある。この判断はコンパイラが完全に代行するものではなく、設計者とレビュアーの責任として残る。

この点で、新モデルは開発者の負担を単に増やすだけではない。レビューの入口が明確になる利点がある。従来は、IntPtrを受け取るAPIや慣習上危険とされるAPIを、知識のあるレビュアーが文脈から読み解く必要があった。C# 16のモデルでは、unsafeキーワードと<safety>文書、内部の// SAFETY:コメントをたどることで、何を信じてメモリへ触れているのかを機械的に探しやすくなる。

これはAI支援開発にも関係する。AIがコードを生成する場合、危険なAPI呼び出しをレビューで見つけるより、コンパイラがunsafeブロック不足として止めるほうが効率的だ。Microsoftは、AllowUnsafeBlocks=trueを設定していなければ、コンパイラがunsafeコードを拒否できる点を強調している。メモリ安全監査の負担を、すべての変更箇所を読む作業から、プロジェクト設定と明示された境界の確認へ寄せる狙いだ。

移行はオプトインから始まるが、.NETランタイム側は先に動く

C# 16の新モデルは、いきなり全プロジェクトへ強制されるわけではない。Microsoftは新しいプロジェクトレベルのプロパティを用意し、初期段階ではこれを有効にしたプロジェクトだけが新しい規則でコンパイルされる形を想定している。プロパティ名は.NET 11プレビューの時点で確定する見込みだ。

ここで重要なのは、新しいプロパティと既存の<AllowUnsafeBlocks>が別の役割を持つことだ。新しいプロパティは、何がunsafeで、どのように呼び出し側へ伝播するかを決める。一方、<AllowUnsafeBlocks>は、ソース中でunsafeキーワードを使えるかどうかを決める。新モデルを有効にし、<AllowUnsafeBlocks>falseのままにすれば、プロジェクトは新しい厳格な規則に参加しながら、unsafeコードを許可しない最も安全な構成になる。

.NETランタイムライブラリは新モデルにオプトインする予定だ。これは、ライブラリ側がunsafe契約をメタデータとして持たなければ、呼び出し側のコンパイラが十分に検査できないためである。Microsoftは、RuntimeリポジトリのMemory Safety v2追跡Issueやreduce-unsafeラベルで、ランタイムライブラリからunsafeコードを減らす作業、注釈付け、アナライザーやコード修正の整備を進めている。

ただし、移行期には非対称性が残る。新モデルにオプトインした呼び出し側が、旧モデルのライブラリを使う場合、コンパイラはポインタ型を持つメンバーを互換モードでunsafe扱いする。一方で、旧モデルの呼び出し側が新モデル対応ライブラリを使う場合、新しいunsafeマーカーを解釈できない。そのため、IntPtrを使うようなポインタ非露出の危険APIが、旧モデル側からは従来通り呼べてしまう余地がある。

Microsoftは、移行支援としてdotnet formatの修正機能も計画している。これはunsafe呼び出し箇所をunsafe { }で囲む、型に付いたunsafeをメンバーへ移す、といった機械的な書き換えを行うものだ。ただし、<safety>ブロックに何を書くべきか、呼び出し側の義務をどこで解消できるかまでは自動推論できない。コンパイルを通すための初手にはなっても、移行完了を意味するわけではない。

AD

残る制約は、unsafeを消すことではなく監査可能にすることだ

今回の発表を「C#がメモリ安全言語になる」と読むのは、半分正しく、半分危うい。C#はすでに通常コードではメモリ安全性を強く持つ言語であり、Microsoft自身もその点を前提にしている。新モデルが扱うのは、既存の安全なC#コードではなく、安全性の外側へ出る必要がある領域だ。

新モデルは、ランタイムチェックを追加するものではなく、性能上の新しいコストも導入しない。ポインタが不正なメモリを指していれば、依然として未定義動作やプロセス終了、誤った値の読み取りは起こり得る。unsafeキーワードや<safety>文書は、そうしたリスクを物理的に消す装置ではなく、どの条件を満たせばそのリスクを避けられるかをコードレビューの対象にする装置である。

また、すべての問題がC# 16で片付くわけでもない。Microsoftは、リフレクション経由でunsafe APIを呼び出せる問題を今回の対象外としている。ライフタイムについても、Rustの借用チェッカーのような全面的な仕組みを導入する計画ではなく、ArrayPoolRentReturnのような対象を念頭に、より限定的な所有権モデルを検討している段階だ。

それでも、この変更の実務上の意味は小さくない。メモリ安全性の議論では、C/C++からRustなどへ移行する大きな選択が注目されがちだ。しかし多くの現実のコードベースでは、全面移行ではなく、既存言語の中で危険な境界をどれだけ狭く、明示的に、監査可能にできるかが重要になる。C# 16のunsafe再設計は、まさにその中間地点を狙う変更である。

開発者にとっての読みどころは、「自分のコードにunsafeがあるか」だけではない。依存ライブラリがどの安全モデルでビルドされ、どのAPIが呼び出し側に義務を渡し、どの境界で義務を解消しているかまで見る必要が出てくる。MicrosoftがNuGetで新モデル対応のバッジを検討しているのも、サプライチェーン全体でこの情報を見えるようにするためだ。

C# 16の新しいunsafeは、危険なコードを書くことを簡単にする機能ではない。むしろ、危険なコードを書いたときに、その事実を呼び出し側、レビュアー、監査担当、そしてコンパイラから隠しにくくする機能である。AIがコードを書く量を増やすほど、こうした明示性の価値は増す。安全なコードだけを書く理想と、低レベル処理が残る現実の間に、C#はより読みやすい警戒線を引こうとしている。