
2026/05/22 1:54
C# のメモリ安全性の改善
RSS: https://news.ycombinator.com/rss
要約▶
Japanese Translation:
C# 16 は、メモリ安全性の主要な転換をもたらしており、「unsafe」キーワードを暗黙的な許可から、開発者の明確な義務を定義する明示的でレビュー可能な契約へと変えました。この変更により、ポインタ型と安全性は厳密に分離され、ポインタ自体が自動的に unsafe アクセスを付与することはなくなり、明示的にデ参照されるか、新たに追加された
safe キーワード(コンパイラが明示的な選択を要求する文脈のために導入)が付加されない限り那樣ではありません。このモデルの下では、囲う unsafe { } ブロックなしで unsafe メンバーを呼び出すと、警告ではなくコンパイルエラーが発生します。また、呼び出し側の義務を定義するために安全性ドキュメント(/// <safety> ブロック)の記述が必要です。この機能は .NET 11 でオプトインのプロパティを通じたプレビューとして提供され、将来的にはデフォルトになる可能性があります。機械的な書き換えについては dotnet format ツールによる対応が可能ですが、リフレクションや隠れた依存関係を含む複雑なシナリオでは依然として手動での介入が必要です。結局のところ、チームはこれらの厳格なコンパイラチェックを満たすため、一般プロダクション導入の前にinterop呼び出しを積極的に更新し、重要なロジックをラップする必要があります。本文
C# 16 と unsafe キーワードの刷新:メモリ安全性の飛躍的向上について
はじめに
C# では、メモリ安全性を大幅に強化するための工程が進行中です。
unsafe キーワードは、呼び出し元に安全を保証するために満たすべき義務があることを明示するため、再設計されています。その責務は、新しいセーフティ(安全性)コメントスタイルを用いて文書化されます。
- 範囲の拡大: かつてポインタだけをマークするものから、コンパイラが安全性を検証できない手法でメモリと相互作用するコード全体をカバーする範囲へと拡大しました。
- 強制カプセル化: コンパイラは、
キーワードを使用して非安全な操作をカプセル化するのを強制します。これにより、セーフティ契約や仮定が黙示的ではなく、可視化されレビュー可能になります。unsafe - 実装スケジュール:
- 新モデルと構文は .NET 11 プレビュー版で先行実装として登場。
- .NET 12 で正式にリリースされる予定です。
- 初期段階ではオプトイン(オプション)方式ですが、その後のリリースではデフォルトの設定になる可能性があります。
- テンプレートの更新: nullability(null 許容性)の参照型と同様に、新しいモデルを有効にするためのテンプレートも更新されています。
C# 1.0 の unsafe とその背景
C# 1.0 では、
unsafe キーワードはタイプ、メソッド、および内部メソッドブロックへの不安全なコンテキストを設定する方法として導入されました。
- 従来の仕様:
- 開発者は最も都合の良いスコープを選択できるように設計されていましたが、非安全なコンテキストにはポインタ機能へのアクセス権が与えられました。
- メソッドが
でマークされている場合、その署名と実装でこれらの機能が使用できます。unsafe - システムライブラリ(例:
やSystem.Runtime.CompilerServices.Unsafe
)も「unsafe」コンテキストに明示的に含まれていました。Marshal
- C# 16 の方向性:
- Rust や Swift が
を再活用・拡張してきたような厳格で伝播指向の意味を採用しました。unsafe
Unsafe.NET ランタイムライブラリを含めて
Marshal` のメンバーにも一貫して unsafe な性質を適用します(Rust の実装に最も類似)。および- 「unsafe は単なる構文マーカーではなく、コンパイラが検証できない一種の契約であり、熟練した開発者が意識し、遵守する必要があるものへと変化しました」。
- Rust や Swift が
C# はすでにデフォルトで unsafe なコードをブロックしています。新しいモデルが有効になっても、unsafe API を使用しない開発者にとっては大きな変化はありません。しかし、デフォルトでブロックされる領域は大幅に拡大します。
背景と目的:
- メモリ安全性は、数年間にわたり業界や政府において優先事項として高まってきました。
- AI 支援によるコード生成の登場により、ソフトウェア生産が人間によるレビューのペースを超えて急速に拡大しています。これに対応すため、新しいモデルは可視化されレビュー可能でコンパイラによって強制される強力なガードレールを確立します。
セーフティと構造
より詳細なセーフティメカニズムについては、「.NET とは何で、なぜ選ぶのか」を参照してください。
セーフティの定義:
- 言語とランタイムの組み合わせによって強制される強制力。
- 変数はライブオブジェクトを参照するか null かスコープ外である。
- メモリはデフォルトで自動初期化され、新しいオブジェクトは未初期化のメモリを使用しない。
- 境界チェック: 無効なインデックスへのアクセスが未定義のメモリの読取(オフバイワンエラーなど)を許容せず、代わりに
を発生させる。IndexOutOfRangeException
C# は通常 safe なコードに対して強いセーフティ強制を提供します。新しいモデルは、開発者と AI エージェントが unsafe なコードにおける安全の境界を正確にマークすることを可能にします。
- unsafe の理由:
- ネイティブコードとの相互運用性
- パフォーマンス(場合による)
- 役割の明確化: 言語自体は通常、unsafe なコードの作成を支援することはできません;その役割は、unsafe なコードがどこで使用されているか、安全なコードに戻る方法かを明確にすることです。
プログラミングの安全性を理解する別の見方:
- 道路設計: 対向車線への進入禁止(黄色・白線)や、健全なコンプライアンスがない場合でも機能する構造的分離(バリヤ)。
- メモリ分野特有の事故: すべてのアプリケーションはギガバイト単位の変数メモリへのアクセスが可能です。任意のメモリの書き込みまたは読み取りは**未定義の振る舞い(UB)**を引き起こし、最も一般的なセキュリティバグの原因となります。
モデルの概要
.NET プログラムは、すべてのメモリアクセスがライブメモリ(割り当てられ、初期化され、アクセス時点で使用可能なメモリ)をターゲットにするというコア不変条件を守ることを期待します。
- 安全なコード: コンパイラルールとランタイムチェックの組み合わせにより、迷いのあるアクセスは不可能になります(構造的に守る)。
- unsafe なコード: 不変条件を違反する可能性がある操作です(割り当てられていないメモリまたは初期化されていないメモリへのアクセスなど)。
リスクに対する解決策: 意図的に透明性の高い層状メカニズム。これにより call graph(呼び出しグラフ)を通じて unsafety が推進され、各層が次の層を可能にします:
- Inner unsafe { } ブロック:
- すべて unsafe な操作(unsafe メンバーへの呼び出し、ポインタの参照解除など)は、内部
ブロック内に表示する必要があります。unsafe { }
- すべて unsafe な操作(unsafe メンバーへの呼び出し、ポインタの参照解除など)は、内部
- 伝播 (Propagation):
- 内部ブロックの義務を呼び出し元に再公開するには、囲むメソッドの署名に
を追加します。unsafe - これにより、安全なメソッド、unsafe なメソッド、および境界メソッドで呼び出しグラフが分離されます。開発者は任意の数の上流で連鎖的に伝播させることができます。
- 内部ブロックの義務を呼び出し元に再公開するには、囲むメソッドの署名に
- セーフティドキュメント:
- 各 unsafe メンバーは
ブロックを持つべきです。これは形式的契約です。/// <safety> - アナライザがその欠如をフラグ立てます。
- 各 unsafe メンバーは
- 境界での抑制 (Suppression at the boundary):
- 内部 unsafe ブロックを含みながら署名に
をマークしないメソッドは、安全なコードと unsafe なコードの境界です。unsafe - ランタイムガード、静的推論、または上流 API から得られた文書化された不変条件を通じて解除します。
- 内部 unsafe ブロックを含みながら署名に
重要な原則: 各層を順番にステップして価値を得なければなりません。半分の作業をするだけで半分以下の価値しか得られません。これにより、call graph を通じて他者がレビューし潜在的に改善できる接続された推論ラインを持つことができます。
C# 16 における変更点(C# 1.0 のルールから)
C# 1.0 はポインタ機能などをまとめて「ポインタ機能」として unsafe の下にグループ化していました。新しいモデルはより選択的です。主な変更点は以下の通りです:
- unsafe タイプ修飾子: エラーを発生させます。unsafe スコープはタイプから個別のメソッド、プロパティ、フィールドへと移動し、その契約が見え見えになり最小限に特定されます。
- 静的コンストラクタまたはファイナライザーへの適用: 許可されません(署名マーカーは何ものにも伝播できないため)。
- new() ジェネリック制約: パラメータなしの安全なコンストラクタだけを一致させます。パラメータなしコンストラクタが unsafe なタイプは
を満たすことができません。new() - 新しい safe キーワード: デクレレーションが安全であることを証人する開発者への許可(コンパイラが選択を明示的に要求する場所)。現在、
宣言だけです。extern - メンバー上の unsafe: 危険なコンテキストを確立し、もはや「不安全なタイプ」を意味しません。内部 unsafe ブロックが unsafe コールサイトを必要とします。
- ポインタタイプへの伝播の停止: 署名でのポインタタイプはもはや unsafety を伝播しません(独自に
パラメータは呼び出し元に unsafety を伝播しません)。新しいコードではbyte*
の代わりに typed pointers(例:IntPtr
,byte*
)を好みます。void*
プロジェクトレベルのオプトイン
C# 16 セーフティモデルには 2 つのプロジェククト(プロジェクト)レベルスイッチがあります。それらは独立しており、異なる目的を担います。
スイッチの詳細
- 新しいオプトインプロパティ (
プレビューで最終名が決定):.NET 11- オフ: レガシー C# 1.0 のルールが引き続き支配されます。
- オン: 新しい呼び出し元 unsafe ルールが適用されます(何が unsafe なカウントされ、どのように伝播するかを決定)。
- 既存の
プロパティ:<AllowUnsafeBlocks>- すべての C# バージョンでデフォルトは
です。false - プロジェクトソース内の
キーワードの出現をゲートします(メソッド署名、内部ブロック、フィールドなど)。unsafe - 別のプロジェクトから unsafe API を呼び出す場合もカウントされます;呼び出しサイトには内部
ブロックが必要です。unsafe { }
- すべての C# バージョンでデフォルトは
スイッチ組み合わせの影響
| 設定 | 状態 | 説明 |
|---|---|---|
新プロパティオン + オフ(デフォルト) | 最も安全 | 新モデルに参加しますが、unsafe なコードを許可しません。 などの呼び出しはエラーになります。 |
新プロパティオン + オン | 安全な開発環境 | 新モデルに参加し、unsafe なコードを許可します。 |
新プロパティオフ + オフ | レガシーモード(禁止) | レガシーモデルが引き続き適用されますが、ポインタタイプを使用できません。 |
新プロパティオフ + オン | レガシーモード(許可) | レガシーモデルが引き続き適用されます。ポインタタイプを使用できます。 |
移行支援: 機械的再書き換えを行う
修正ツールが発売予定です。ただし、修正ツールは安全性義務を推論したりdotnet formatブロックを書いたりすることはできません;それは開発者の仕事です。<safety>
エージェントへの影響:
- 新モデルでは、コンパイラが unsafety の責任を負います。
を設定していない場合、コンパイラは安全ではないコードそのものをコンパイルを拒否します。AllowUnsafeBlocks=true- メモリ安全性監査は、すべての diff を検査することからチェックプロジェクトプロパティ 1 つに縮小されます。
クロス言語比較:伝播
C#、Rust、Swift の間の違いは微細ですが重要です。
- C# 16: unsafe キーワードがメンバー上に現れる場合のみ unsafety を伝播します(ポインタタイプや他の unsafe タイプ付きパラメータは独自に伝播しません)。
- Rust: 通常の
のfn
パラメータは何も伝播しません。*const u8
は unsafe ブロック内にある safe fn を超えており、デフォルトが unsafe で safe が個別のデクレレーションをオプトアウトする形は Swift のunsafe fn
に類似しています。@safe - Swift: 例外です。署名内に
タイプが出現するだけで、デクレレーション自体を@unsafe
にし、明示的な@unsafe
属性とは別にします。このインプリシティブ(暗黙的)モデルは、opt-out (@unsafe
) の必要性をもたらします。@safe
LibraryImport の例: 各 LibraryImport パシャルメソッドは safe または unsafe とマークする必要があります:
[LibraryImport("libc")] internal static safe partial int getpid(); // 安全な呼び出し [LibraryImport("libc", StringMarshalling = StringMarshalling.Utf8)] internal static unsafe partial nint strlen(byte* str); // unsafe な呼び出し
getpid はパラメータなしで primitives を返すため safe であり、strlen は生ポインタを収容するため unsafety を伝播します。両方の修飾子を省略するのはコンパイラエラーです。
セーフティドキュメント(コメントスタイル)
「unsafe」を文字通りに解釈することは簡単ですが、誤解を招きます。「安全無効化」を意味します。safe なコードはコンパイラに知られたセーフティモデルに準拠しているのに対し、unsafe なコードにはありません。知る重みが開発者にあります。それは専用セーフティドキュメントを読むことで始まります。
コメントスタイルの役割
- 署名上の
ブロック: 形式的呼び出し元契約です。/// <safety> - ボディ内の
コメント: 内部的な注記であり、unsafe な操作が依存するものを名前付けます。// SAFETY:
教訓: unsafe API の形状を知るのは正しいコードを書くために必要ですが十分ではありません。unsafe なコードを書くことにはセーフティグラスが必要です。
Rust セーフティコメントの例
Rust は確立された標準的な例です。Clippy はセーフティブロックがない unsafe 関数に対して
missing_safety_doc lint をトリップします。
// unsafe Rust 関数、as_bytes_mut: /// Converts a mutable string slice to a mutable byte slice. /// /// # Safety /// /// The caller must ensure that the content of the slice is valid UTF-8 /// before the borrow ends and the underlying `str` is used. /// /// Use of a `str` whose contents are not valid UTF-8 is undefined behavior. /// pub unsafe fn as_bytes_mut(&mut self) -> &mut [u8] { // SAFETY: ... (internal note) unsafe { &mut *(self as *mut str as *mut [u8]) } }
C# セーフティコメントの例
以下の ReadByte モックアップを参照:
/// <summary>Reads a single byte from unmanaged memory.</summary> /// <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) { try { byte* addr = (byte*)ptr; unsafe { // SAFETY: relies on caller obligation. return addr[ofs]; } } catch (NullReferenceException) { throw new AccessViolationException(); } }
セーフティガード
ドキュメントは義務を名前付けます。ガードはそれらを解除します。このパターンは最も unsafe 境界で重要です。
Rust セーフティガード例
str.split_at の例:
pub fn split_at(&self, mid: usize) -> (&str, &str) { // is_char_boundary checks that the index is in [0, .len()] if self.is_char_boundary(mid) { // SAFETY: just checked that `mid` is on a char boundary. unsafe { (self.get_unchecked(0..mid), self.get_unchecked(mid..self.len())) } } else { slice_error_fail(self, 0, mid) // パニック(コンパイラエラー回避のため) } }
C# セーフティガード例
同じ境界パターンが C# で適用されます:
public void CopyTo(int sourceIndex, char[] destination, int destinationIndex, int count) { ArgumentNullException.ThrowIfNull(destination); ArgumentOutOfRangeException.ThrowIfNegative(count); // ... 他のチェック ... unsafe { // SAFETY: 上記の境界チェックにより、`count` キャラクタが安全であることが保証される。 Buffer.Memmove(...); } }
各
ThrowIf* コールはメモリ安全性ガードであり、生 Buffer.Memmove が仮定する不変条件を支えます:
: 欠如すると UB。ThrowIfNull(destination)
: 欠如すると out-of-range コピーが UB。ThrowIfNegative(count)- インデックスの符号チェック:欠如すると UB。
Unsafe フィールド
フィールドは、声明されたタイプが保持する不変条件とダウンストリームコードが依存する不変条件の間にあるギャップがある場合に unsafe な必要があります。unsafety は、型システムが見ることと外部のタイプの約束との間に住んでいます。
ネイティブポインタ保持フィールドの例
public class NativeBuffer : IDisposable { /// <safety> /// Must be null or point to a buffer of Length bytes. /// </safety> private unsafe byte* _ptr; public void Dispose() { unsafe { // SAFETY: ... Free accepts both null and valid pointers. NativeMemory.Free(_ptr); _ptr = null; } } }
ジェネリック配列ラッパーの例
設計ドキュメントは簡易版を与えます:ジェネリッククラスは常に
T[] を含んでいる必要がある Array フィールドを持ちます。C# 型システムは任意の配列をそのフィールドに割り当てを許可しますが、クラスは常に正確な T[] を約束しています。unsafety はそこにあるギャップです。
public class ArrayWrapper<T> { /// <safety> /// Must always hold a value whose runtime type is T[]. /// </safety> private readonly unsafe Array _array; public T GetItem(int index) { unsafe { // SAFETY: _array is always a T[] per the field's <safety> block var typedArray = Unsafe.As<T[]>(_array); return typedArray[index]; } } }
パターンは NativeBuffer と同じです:文書化された不変を持つ unsafe フィールド、境界でそれを解除する unsafe ブロック、safe-callable パブリック表面。
マイグレーション事例の walkthrough
モデルを理解するための最良の方法は、既存のコードをそれに移行することです。
NativeMemory の例
新モデル下での 2 つの NativeMemory メソッドの外観:
public static void* Alloc(nuint byteCount); // safe (returns pointer, holding it is not unsafe) /// <safety> /// The caller must ensure: /// - ptr was returned by Alloc(...) and has not already been freed. /// - No live pointer/span aliases the storage at this call time. /// </safety> public static unsafe void Free(void* ptr); // unsafe (has preconditions)
非対称性の意図:
は safe になります。ポインタを保持する自体は unsafe ではありません;unsafety は最終的な解除参照にあり、呼び出し元がラップします。Alloc
は unsafe remians(残存)因为 it carries real preconditions(pointer must be valid and not freed);theFree
block makes those obligations visible.<safety>
バイナリ配布とコンパチモード
.NET ライブラリは頻繁にバイナリとして配布されます。C# 16 unsafe は新しいコンパイラエラーに大きく依存します。新モデルへのオプトインでは、注釈作業は完了しています。
非対称な互換性
旧モデルでコンパイルされたプロジェクトが新モデルで作成されたパッケージを消費し、その逆も起こります:
- オプトイン側 (caller) vs レガリー側 (callee):
- オプトイン側のプロジェクトはレガシーパッケージに対して compat-mode ルールを強制します。
- callee 署名内の任意のポインタタイプは呼び出しサイトで囲む
ブロックを要求します。unsafe { }
- レガシー側 (caller) vs オプトイン側 (callee):
- レガリープロジェクトはオプトインパッケージを普通のアセンブリとして扱い、新しい診断の対象になりません。
理由: オプトイン側がセーフティ保証を負うため、compat モードはそれを静かに劣化させないことを保持します。
残り設計領域
C# 16 では扱えなかったいくつかの設計側面があります。今後のバージョンで扱う可能性があります。
- リフレクション:
を通じて unsafe API を呼び出すことができ(囲む unsafe ブロックなし)、リフレクション書き込みは unsafe フィールドの文書化された不変条件を違反できます。MethodInfo.Invoke - ライフタイム: Rust は borrow checker でライフタイムを扱います;C# は GC と ref ベースの所有権の一部をカバーします。より強いライフタイム強制の主要なユースケースは
、特にArrayPool
とRent
メソッドです(「使用後自由」違反)。Return
AI 支援(AI Enablement)
モデルはエージェントが無視できない 2 つのものを追加します:
- 安全/unsafe/境界メソッドに分割された call graph。
- 囲む unsafe ブロックなしに unsafe コールを拒絶するコンパイラ。
アナライザも
<safety> ドキュメントの欠如のための警告に貢献します。これらは、エージェントが生成できるコードを狭めると同時にビルドを幸せに保ちます(特に TreatWarningsAsErrors が設定されている場合)。
エージェントがモデルを侵犯する 2 つの主要な方法
- コンパイルしないコードを生成する。
- プロジェクトを旧モデルに戻したり、AllowUnsafeBlocks を有効にする。(これは
やTreatWarningsAsErrors
を無効にしたい場合と同じ)。IsAotCompatible
両カテゴリーはコードレビューや git 履歴で容易に検出できます。新モデルへの移行もエージェントに適しています。
Rust(
unsafe fn, unsafe {})に確立されたパターンは C# 型のコードに綺麗にマッピングします。最も高価なパターンマッチはセーフティドキュメントの構造とイディオムです;その移行側が最も困難にスキル化され得ます。
結論
新しいモデルは、unsafe コードを使用するコードの上に opt-in の壊れ変更を一連レイヤーします:
- メンバー署名上の unsafe が呼び出し元向け契約を定義し、
- 各 unsafe メンバーへの呼び出しには内部 unsafe ブロックが必要で、
- 各 unsafe メンバーは
ブロックを持つべきです。/// <safety>
我々は C# が型およびメモリセーフティ強制を選択・記載された一連言語の中にあり得る未来を思い描いています。このモデル変更により、C#、Rust、Swift はより共通なセーフティボキャブulario ワークフローを持ちます。チームは完全なサプライチェーン視点を依存関係(C# まで下ろすか、app レイヤー C# を system レイヤー Rust の上で)採用します。
新しいモデルは C# をほぼそのまま保ちながら、開発者がほとんど触れない unsafe パターンを微調整し、言語全体の安全能力と姿勢を大幅に改善します。我々はこの機能が、この新しいコーディング時代における開発者の信頼を高めるためにできる最も高いレバレッジの変更の一つであると信じています。
カテゴリ・トピック
| カテゴリ | トピック |
|---|---|
| Author | Richard Lander は .NET チームのプログラムマネージャーです。メモリ制限のある Docker コンテナ、Arm ハードウェア(Raspberry Pi など)、GPIO プログラミングおよび IoT シナリオでの .NET の機能向上を担当しています。新しい .NET ランタイム機能と機能を定義する設計チームの一員でもあります。 趣味: Dune と Doctor Who が好き。カナダとニュージーランドで育ちました。 |
| Contributors | Andy Gocke, Egor Bogatov, Fred Silberberg, Jan Jones, Jan Kotas, Julien Couvreur, Mads Torgersen, Rich Lander, Tanner Gooding ほか |