これまでの unsafe
C# 1.0
C#は当初から、GC、型検査、配列境界チェックなどによって、高いメモリ安全性を提供する言語として設計されています。一方で、OS の API とのやり取りやデバイス制御、高性能なメモリ処理ではポインタが必要になるため、C# 1.0 から unsafe が用意されていました。
unsafe
{
int* ptr = ...;
}
当時の C# では、「ポインタを使う」=「unsafe コンテキストが必要」という対応関係が明確でした。このころは unsafe がキーワードとしての意味を十分に持っていました。
Unsafe クラスの登場
.NET Core の初期、ポインタを直接操作しなくても型変換等を行える System.Runtime.CompilerServices.Unsafe が登場しました。代表的な API が Unsafe.As<TFrom, TTo> です。
ref TTo value = ref Unsafe.As<TFrom, TTo>(ref source);
このコードには T* のような生ポインタが含まれていません。実際には型システムの通常の保証を越えて、ある型の参照を別の型として扱う非常に低レベルで危険な操作であるものの、従来の C# では必ずしも unsafe コンテキストを必要としません。
その結果、次のような状況が生まれました。
- 「ポインタを使う+キャスト」⇒「
unsafeコンテキストが必要」 - 「Unsafe.As を使ってキャスト」⇒「
unsafeコンテキストが不要」
つまり、unsafe なのが実質ポインタを直接使う場合に限られる事態となりました。ここから本来の unsafe の意味を失い始めます。
Span<T> 系クラスも登場
次の大きな転換点が、.NET Core 2.1 で本格導入された Span<T>、ReadOnlySpan<T>、Memory<T>、MemoryMarshal です。これらは、配列や文字列、stackalloc、非マネージドメモリなどを、コピーせずに安全に「範囲」として扱えるようにするために導入されました。
あるメモリの一部分だけを配列として処理したくても、このように余計なアロケーションが生じます。これは String.Substring で有名ですね。
byte[] data = ...;
byte[] slice = data.Skip(100).Take(20).ToArray();
一方、アロケーションを避けるなら、
unsafe
{
fixed (byte* p = data)
{
DoSomethingDangerously(p, data.Length);
}
}
みたいにポインタを使えますが、これはメモリ安全性を失います。
そこで、Span は既存の配列の単なるビューとして機能します。C++ の std::span<T> にかなり近いです。
Span<byte> span = ...;
Span<byte> part = span.Slice(100, 20);
ReadOnlySpan は特に文字列でコピーなしで一部分を取り出す場合に便利です。
問題点
C# はこれまで、低レイヤーなメモリ操作のうち、コンパイラが安全性を確認できるものについては、できるだけ unsafe コンテキストを必要としない方向に進化してきました。一方で、Unsafe や MemoryMarshal のように、ポインタを直接書かなくてもメモリ安全性を破れる API も増えています。
例えば次のようなコードです。
unsafe
{
ref byte start = ref MemoryMarshal.GetReference(span);
ref byte value = ref Unsafe.Add(ref start, index);
}
Unsafe.Add は、index が元の Span<T> の範囲内かどうかを確認しません。範囲外を指す参照を作ることもできますが、現在の C# ではこのコード自体に unsafe コンテキストは必要ありません。
ここで、C# 1.0 から使われてきた unsafe の意味と、実際にメモリ安全性を破ることのできるコードの範囲が一致しなくなってきました。
この問題を解決するために進められているのが、現在 Unsafe Evolution や Unsafe v2 と呼ばれている変更です。
新しい unsafe
現在の C# では、unsafe は主にポインタを扱うためのコンテキストです。
例えば、
unsafe
{
int* ptr = &value;
int result = *ptr;
}
では、ポインタを宣言することも、ポインタを参照することも同じ unsafe コンテキストの中に入ります。
新しいルールでは、ポインタを持っているだけなら必ずしも unsafe とする必要はありません。
int* ptr = &value; // safe
int result = *ptr; // unsafe
この場合、ptr を生成して保持するところまでは通常のコードとして扱い、実際にメモリへアクセスする *ptr の部分だけを unsafe として扱います。
これによって、unsafe は「ポインタを使っている場所」ではなく、「コンパイラがメモリ安全性を保証できない操作を行う場所」という意味に近づきます。
もう1つ大きく変わるのが、メソッドにつける unsafe です。
現在は、
unsafe void Foo()
{
// ポインタなどを使える
}
と書くと、単純にメソッド本体全体が unsafe コンテキストになります。つまり、unsafe ブロックを一々書かずにメソッド全体を unsafe コンテキストにするために使われてきました。
呼び出し側は普通に、
Foo();
と呼ぶことができます。
Unsafe Evolution では、メソッドにつけた unsafe に「このメソッドを安全に呼び出せるかどうかを、コンパイラでは保証できない」という意味に変わり、コンテキストを指定するものではなくなりました。
unsafe void Foo()
{
}
このようなメソッドを呼び出す場合、呼び出し側も、
unsafe
{
Foo();
}
と書く必要があります。
この仕組みは現在 requires-unsafe と呼ばれています。
これによって、メソッド内部だけでは確認できない前提条件を、呼び出し側まで伝播させることができます。
例えば、
public static unsafe /* コンテキスト指定ではない */ int Read(byte* ptr)
{
unsafe // ここでコンテキスト指定
{
return *ptr;
}
}
という API があった場合、ptr が有効なメモリを指しているかどうかは、このメソッドだけでは確認できません。
この requires-unsafe という情報は、アセンブリをまたいでも保持する必要があります。
そこで、requires-unsafe なメンバーをコンパイルすると、コンパイラはモジュールのメタデータに [module:MemorySafetyRules(15)] 属性を、そのメンバーのメタデータ上に [RequiresUnsafe] 属性を生成します。
つまりソースコードでは、
public static unsafe ref TTo As<TFrom, TTo>(ref TFrom source);
と書きますが、別のアセンブリから参照するときには、メタデータに記録された RequiresUnsafeAttribute をコンパイラが認識し、その呼び出しに unsafe コンテキストを要求します。
呼び出し側がその条件を保証する必要があるため、API 自体を unsafe として公開する意味がありますね。
現在の C# では、たとえば Unsafe.As は unsafe コンテキストの外からでも呼び出せますが、この新しいルールは、Unsafe クラスや MemoryMarshal クラス等の API にも適用されます。
public static unsafe ref TTo As<TFrom, TTo>(ref TFrom source);
そのため、呼び出す箇所を unsafe コンテキストに置く必要があります。
unsafe
{
ref B value = ref Unsafe.As<A, B>(ref source);
}
unsafe コンテキストの箇所を最小限にする
大きいメソッドであれば、メソッド全体を unsafe とするのではなく、特定の箇所のみを unsafe で囲むことが推奨されます。
unsafe の範囲が狭ければ、コードレビューでも確認する範囲が分かりやすくなります。100 行あるメソッド全体について安全性を確認するのではなく、実際に保証が必要な数行だけを見ることができます。同様の理由で、unsafe をクラス等の型、static コンストラクタ、ファイナライザなどにつける現在の使い方も見直されています。
int DoSomethingDangerous()
{
// 大量のコード
unsafe
{
DoActualDangerousWork();
}
// 大量のコード
}
また、Unsafe Evolution では一つの式だけを unsafe コンテキストにする unsafe(expression) も提案されています。
ref B value = ref unsafe(Unsafe.As<A, B>(ref source));
P/Invoke 系も safe と unsafe を区別する
例えば、
[LibraryImport("libc")]
internal static partial int getpid();
という宣言があったとしても、C# コンパイラはネイティブ側の実装が本当に宣言どおりに動作するかまでは確認できません。
新しいルールでは、Interop の宣言についても safe または unsafe を明示できるようになります。
[LibraryImport("libc")]
internal static safe partial int getpid();
一方で、呼び出し側がポインタの有効性などを保証する必要がある API であれば、
[LibraryImport("libc")]
internal static unsafe partial nuint strlen(byte* str);
のようになります。
P/Invoke だけでなく、COM Interop や ComWrappers、source-generated COM、JSImport / JSExport なども同じ考え方で整理されています。
<safety> ドキュメントタグの導入
Rust の # Safety セクションとかなり近い考え方で、requires-unsafe な API では、なぜ呼び出し側に unsafe が必要なのかをドキュメントへ書くことも想定されています。
例えば、
/// <safety>
/// ptr must point to at least four readable bytes.
/// </safety>
public static unsafe int ReadInt32(byte* ptr)
{
unsafe
{
return *(int*)ptr;
}
}
のように、呼び出し側が保証する条件を書きます。
requires-unsafe な API であることはアセンブリのメタデータにも保存されます。
そのため、別のライブラリから API を参照した場合でも、C# コンパイラは、「この API は unsafe コンテキストから呼び出す必要がある」という情報を取得できます。nullability の情報がメタデータに残るのと似た仕組みです。
ランタイム側での取り組み
.NET 11 では、こうした変更が BCL にも入り始めています。
例えば Enumerable.Sum の SIMD 実装では、従来、境界チェックを避けるために使われていた MemoryMarshal.GetReference、Vector.LoadUnsafe、Unsafe.Add などを減らし、JIT 自体を改善することで、より通常の Span<T> 操作へ書き換える変更が行われています。
AI によるコード生成も背景にある
Memory Safety in .NET の設計では、AI によるコード生成についても触れられています。
人間が低レベルコードを書く場合はレビューできますが、AI Agent やコード生成ツールが大量のコードを書く場合、「Unsafe はなるべく使わない」といったルールだけでは不十分です。
新しいメモリ安全性ルールを有効にした上で
<AllowUnsafeBlocks>false</AllowUnsafeBlocks>
としておけば、生成されたコードにも同じ制約を適用できます。
おわりに
Unsafe Evolution によって、unsafe は「ポインタを使うためのキーワード」から、unsafe 内のコードの「メモリ安全性はプログラマが保証する」という境界を表すものへ変わるということです。
2026 年 9 月時点では、Unsafe Evolution は .NET 11 の C# コンパイラに Preview 機能として実装されています。C# 15 の正式な言語機能として完成するものではなく、.NET 11 ではその一部を Preview として提供し、今後のリリースでも継続して完成させていく計画です。
私みたいに C# で unsafe を使う人はそうそういないと思いますが(笑)、P/Invoke 等で必要な方はなるべく [LibraryImport]/[GeneratedComInterface] とマーシャラを活用し、自分が書くコードに関しては unsafe をできるだけ避けたほうが得策です。