今回の投稿はC#アドベントカレンダー2023の8日目の記事です。テーマはみんな大好き(?)unsafeコードについて。
C#はメモリ安全な言語であり、基本的にメモリの確保や解放に関する処理を書くことはありません。new()したオブジェクトのメモリはマネージドヒープに確保され、不要になったら自動的にGCによって回収されます。
しかし、他言語との相互運用や高いパフォーマンスが必要な場面ではポインタを用いてメモリを直接操作できると便利です。今回はそのような場面で用いられる「unsafe」なコードや、エクストリームなパフォーマンスを求めるC#erに愛用される「Unsafeクラス」について解説していきます。カジュアルなタイトルに反してクソ長い記事になってしまいましたが、お付き合いください。
また、今回も構造体やメモリ周りの知識を前提とした記事になっています。この辺りは以下の記事で触れているので適宜参照していただけると。
メモリとポインタ
C#ではポインタを扱う機会が少ないため馴染みがない方もいるかもしれません。ここではメモリとポインタの概要について簡単に触れておきましょう。
C#に限らず、あらゆるプログラムの中で定義した変数はメモリ上に記憶されます。このメモリには値を格納する領域が直列に並んでおり、これらはアドレスと呼ばれる通し番号で管理されています。このアドレスを格納する変数が「ポインタ」です。CやC++を使ったことがある方にとっては馴染み深い機能でしょう。
ポインタはメモリ上のデータに直接アクセスすることが可能であるため、低レベル寄りの操作を行うには非常に強力です。一方で使い方を間違えれば非常に危険な代物でもあるため、Javaではそもそもポインタが禁止されており、C#でもその使用を厳しく制限しています。そもそもこれらの言語には参照の機能があるため、わざわざポインタを使う必要はありません。
とはいえ、CやC++など他言語との相互運用にはポインタを扱えると便利です。また、UnityのDOTSにおいてはほとんどのメモリをネイティブ側で管理するため、それらを扱うためにポインタを多用することになります。
最適化においてポインタが用いられるケースもまれにありますが、実際にそこまでの最適化が必要になるのはシリアライザなど高いパフォーマンスが求められるライブラリくらいです。普通のコードを書いている限りはパフォーマンス上の理由からunsafeを使うことはありません。
ポインタを使ってみよう
まずは実際にC#でポインタを書いてみましょう。
先ほども書いた通り、C#は安全性の観点からポインタの使用を制限しています。使用する際にはコンパイル時に/unsafeオプション(AllowUnsafeBlocks)をつける必要があり、さらに使用するメソッドや型、またはブロックにunsafeキーワードを追加する必要があります。
using System;
// unsafeブロック内に処理を記述するか、メソッドや型にunsafeをつける必要がある
unsafe
{
int a = 0;
// int型のポインタを定義し、aのアドレスを代入
int* ptr = &a;
// 他の型のポインタにキャストすることも可能
byte* bytePtr = (byte*)ptr;
// aの1バイト目を書き換え
*bytePtr = 0x78;
// メモリの位置を進める
bytePtr++;
// 他の2、3、4バイト目も同様に書き換える
*bytePtr = 0x56;
bytePtr++;
*bytePtr = 0x34;
bytePtr++;
*bytePtr = 0x12;
// 16進数でptrの指す先(a)をConsoleに出力
Console.WriteLine("{0:x}", *ptr);
}
これを実行するとコンソールには「12345678」と表示されます。
書き方はCやC++とほとんど同じです。アドレス取得演算子「&」と間接参照演算子「*」を用いてポインタの取得や値の書き換えを行います。
メモリの位置を移動させたい際には「+」「-」や「++」「–」を使用できます。これらを使うことでポインタの型のサイズ分だけ移動します。例えばbyte*の場合は1バイト、int*の場合は4バイト分だけ移動することになります。
とはいえコードだけ見ても少しわかりづらいので、実際にメモリ上の値がどうなっているかを確認してみましょう。メモリの確認にはSharpLabを使用します。
unsafe
{
int a = 0;
int* ptr = &a;
// 値を書き換える前のaを表示
Inspect.Stack(a);
byte* bytePtr = (byte*)ptr;
*bytePtr = 0x78;
bytePtr++;
*bytePtr = 0x56;
bytePtr++;
*bytePtr = 0x34;
bytePtr++;
*bytePtr = 0x12;
// 値を書き換えた後のaを表示
Inspect.Stack(a);
// 12345678が表示される
Console.WriteLine("{0:x}", *ptr);
}
上が変更前、下が変更後のaの値です。こうして見ると上の処理がわかりやすくなるのではないのでしょうか。++で位置を進めつつ、連続するメモリ上のデータに順次アクセスして値を書き換えてる訳です。
アクセス演算子
メンバーアクセス演算子「->」を使うことで、ポインタの参照先のメンバに簡単にアクセスできます。
unsafe
{
var foo = new Foo();
// fooのアドレスを取得
Foo* ptr = &foo;
// '->'で参照先の構造体のメンバにアクセス可能
// ptr->aは(*ptr).aと同じ
ptr->a = 1;
ptr->b = 2;
Console.WriteLine("a:" + foo.a); // a:1
Console.WriteLine("b:" + foo.b); // b:2
}
// 適当な構造体
public struct Foo
{
public int a;
public int b;
}
配列のように「[ ]」を用いてアクセスすることも可能です。
unsafe
{
var foo = new Foo();
// fooのアドレスを取得
Foo* fooPtr = &foo;
// ポインタをint*にキャスト
int* ptr = (int*)fooPtr;
// '[ ]'で参照先の構造体のメンバにアクセス可能
// ptr[1]は *(ptr + 1)と同じ
ptr[0] = 1;
ptr[1] = 2;
Console.WriteLine("a:" + foo.a); // a:1
Console.WriteLine("b:" + foo.b); // b:2
}
ポインタに使用できる型
ポインタの型には何でも利用できるわけではなく、unmanagedな型に限定されます。これにはintやfloatなどの値型や、それらのみをフィールドに持つ(参照をフィールドに持たない)構造体などが含まれます。
// intなどの値型は普通にポインタにできる
int a = 1;
int* aPtr = &a;
// unmanagedな構造体(全てのフィールドが値型で構成された構造体)もポインタにできる
FooStruct fooStruct = new();
FooStruct fooPtr1 = &fooStruct;
// ポインタのポインタを取得することも可能
int** ptrPtr = &aPtr;
// クラスはポインタにできない
FooClass fooClass = new();
// CS0208: Cannot take the address of, get the size of, or declare a pointer to a managed type ('FooClass')
FooClass* fooPtr2 = &fooClass;
この制限はC#11から緩和され、managedな型のポインタが取得できるようになりました。ただし、警告が表示されることからもわかる通り危険な操作であることは依然変わりません。可能な限り使用しないことが推奨されます。
var fooClass = new FooClass();
// ポインタ化自体は可能になった (ただし警告が表示される)
// CS8500: This takes the address of, gets the size of, or declares a pointer to a managed type ('FooClass')
FooClass* ptr = &fooClass;
汎用ポインタ (void*)
C#においてポインタ型はobject型を継承しないため、objectにキャストしたりなどはできません。その代わりとして、全てのポインタ型はvoid*に代入することができます。
int a;
int* aPtr = &a;
// void*には任意の型のポインタを代入可能
void* ptr = aPtr;
ただし、void*はサイズが不定であるため移動させることができず、「*」で値にアクセスすることもできません。使用する際には特定の型のポインタにキャストして使用します。
IntPtr
ポインタと同様のアドレスを表す構造体として「IntPtr」も存在します。これはプラットフォーム固有のサイズの整数を表す型で、動作的にはvoid*とほぼ同じです。P/Invokeでポインタを受け渡す際などによく使用されます。
また、IntPtrはvoid*と相互に変換が可能です。
unsafe
{
// 適当なIntPtr
IntPtr intPtr1;
// IntPtrをvoid*に変換
void* ptr = intPtr1.ToPointer();
// void*をIntPtrに変換
IntPtr intPtr2 = (IntPtr)ptr;
}
アドレスを固定する
アドレスを取得した変数がGCの管理下にある場合、GCが呼ばれた際にメモリ上の位置が移動される可能性があります。位置が変われば当然ポインタの参照先がおかしくなるため、不正なメモリアクセスを引き起こしてしまいます。
これを回避するためには、ポインタを操作している間だけアドレスを固定しておく必要があります。C#ではアドレスを固定するための構文として「fixed」が用意されています。
unsafe
{
var fooClass = new FooClass();
// ヒープ上のメモリはGCによる移動の可能性がある
// そのためポインタ化する際はfixedでアドレスを固定しておく
fixed (int* ptr = &fooClass.a)
{
*ptr = 10;
}
// 10が表示される
Console.WriteLine(fooClass.a);
}
// 適当なクラス
public class FooClass
{
public int a;
}
または、GCHandleという専用の構造体を用いてインスタンスのアドレスを固定することも可能です。これは広いスコープでアドレスを固定したいときに便利ですが、Allocで獲得したGCHandleは使用後にFreeを呼び出さないとメモリリークするため注意してください。
また、GCHandleはunsafe以外のコードから使用することができます。
using System;
using System.Runtime.InteropServices;
var fooClass = new FooClass();
// GCHandle.Allocでインスタンスのアドレスを固定
GCHandle gcHandle = GCHandle.Alloc(fooClass, GCHandleType.Pinned);
// AddrOfPinnedObjectでポインタを取得
IntPtr ptr = gcHandle.AddrOfPinnedObject();
// 何らかの処理をここで行う
// 使用後はFreeで明示的にメモリを解放する
gcHandle.Free();
// 適当なクラス
public class FooClass
{
public int a;
}
配列/文字列のポインタを取得する
fixedを使用することで配列や文字列のポインタを取得することも可能です。C#においてunmanagedな型の配列はメモリ上に直列に要素が並ぶため、ポインタを用いてアクセスできます。
unsafe
{
var array = new[] {1, 2, 3, 4, 5 };
// 配列の先頭の要素のポインタを取得
fixed (int* ptr = array)
{
for (int i = 0; i < array.Length; i++)
{
Console.WriteLine(ptr[i]);
}
}
}
同様にして文字列のポインタを取得することも可能です。通常stringは書き換えできませんが、ポインタを使用することでstringを不正に書き換えることができてしまいます。が、不具合の原因になるので(特殊なケースを除き)書き換えは行わないでください。
unsafe
{
var str = "abcde";
// 文字列の先頭のポインタを取得
fixed(char* ptr = str)
{
for (int i = 0; i < str.Length; i++)
{
// 無理やり書き換えられてしまう
ptr[i] = '!';
}
}
Console.WriteLine(str); // !!!!!
}
ユーザー定義型のfixed
配列やstringのように、ユーザー定義の型でもfixedで特殊な処理を挟むことができます。これを行いたい場合には対象の型にGetPinnableReferenceというメソッドを追加します。
GetPinnableReferenceはGetEnumeratorやGetAwaiterなどと同じくダックタイピングを採用しているため、特定のインターフェースを継承する必要はありません。
unsafe
{
var pinnable = new PinnableStruct("abcde");
// fixed時にGetPinnableReferenceが呼ばれる
fixed (char* ptr = pinnable)
{
for (int i = 0; i < 5; i++)
{
Console.WriteLine(ptr[i]);
}
}
}
// 中身はstringのラッパー
public readonly struct PinnableStruct
{
public PinnableStruct(string str)
{
this.str = str;
}
readonly string str;
// GetPinnableReferenceでfixed時に固定する参照を取得
public unsafe ref char GetPinnableReference()
{
if (str is null)
{
return ref Unsafe.AsRef<char>(null);
}
fixed (char* ptr = str)
{
// Unsafe.AsRefでrefに変換
return ref Unsafe.AsRef<char>(ptr);
}
}
}
unsafeで使える機能
ポインタ以外にも、unsafeコンテキスト内だけで使える機能がいくつか用意されています。
sizeof(T)
sizeofでunmanagedな型のサイズを取得することができます。プリミティブ型に対するsizeofは通常のコードからも呼び出すことが可能ですが、ユーザー定義の構造体に対してsizeofを呼びたい場合にはunsafeコンテキスト内で行う必要があります。
// プリミティブ型のsizeofはsafeからも取得できる
Console.WriteLine(sizeof(byte)); // 1
Console.WriteLine(sizeof(int)); // 4
unsafe
{
// 定義した構造体のsizeofはunsafe内でのみ使用可能
Console.WriteLine(sizeof(FooStruct)); // 8
}
// 適当な構造体
public struct FooStruct
{
public int a;
public byte b;
public short c;
}
構造体のメモリ配置はアラインメント(メモリレイアウトの調整)が入るため、必ずしも全てのフィールドのサイズの合計と等しくなるわけではありません。基本的にはサイズが4の倍数になるように調整されます。
固定長配列
unsafeコンテキスト内では、固定長の配列を構造体のフィールドとして埋め込むことができるようになっています。固定長配列を定義するには「fixed」ステートメントを使用します。固定長配列にできるのはプリミティブな値型(byte, short, int, etc…)のみで、宣言の際にはサイズを指定する必要があります。(実行時にサイズを変更することはできません。)
// unsafeが必要
public unsafe struct Buffer
{
// 構造体内に固定長配列を埋め込む
public fixed byte fixedBuffer[128];
}
固定長配列にアクセスする際は、通常の配列と同じようにインデクサを使用できます。
unsafe
{
var buffer = new Buffer();
// 通常の配列のように利用できる
buffer.fixedBuffer[1] = 1;
Console.WriteLine(buffer.fixedBuffer[2]);
// 構造体のサイズは1x128=128になる
Console.WriteLine(sizeof(Buffer));
}
public unsafe struct Buffer
{
public fixed byte fixedBuffer[128];
}
固定長配列とは言っていますが実際に配列を使用しているわけではなく、単一のフィールドのみを持つ構造体をコンパイラが生成しています。これをUnsafe.Addを介して操作することで配列のような操作を可能にしているわけです。
上のコードはコンパイル時に以下のような形に展開されます。固定長配列の宣言やアクセスの部分が置き換わっているのがわかるかと思います。
...
[CompilerGenerated]
internal class Program
{
private unsafe static void <Main>$(string[] args)
{
Buffer buffer = default(Buffer);
Unsafe.Add(ref buffer.fixedBuffer.FixedElementField, 1) = 1;
Console.WriteLine(Unsafe.Add(ref buffer.fixedBuffer.FixedElementField, 2));
Console.WriteLine(sizeof(Buffer));
}
}
public struct Buffer
{
[StructLayout(LayoutKind.Sequential, Size = 128)]
[CompilerGenerated]
[UnsafeValueType]
public struct <fixedBuffer>e__FixedBuffer
{
public byte FixedElementField;
}
[FixedBuffer(typeof(byte), 128)]
public <fixedBuffer>e__FixedBuffer fixedBuffer;
}
stackalloc
通常C#では新しく配列を作成するとヒープ上にメモリが確保されますが、パフォーマンス上の理由からこれを避けたい場面もあります。stackallocを用いることでスタック上にメモリを確保することができます。
unsafe
{
int length = 3;
// stackallocでスタック上に配列を確保し、先頭のポインタを取得する
int* arrayPtr = stackalloc int[length];
for (var i = 0; i < length; i++)
{
arrayPtr[i] = i;
}
}
stackallocで確保したメモリはSpan<T>で受け取ることも可能です。この場合はunsafe以外でも使用できます。
int length = 3;
// Span<T>で受け取ることで安全にstackallocを使用できる
Span<int> span = stackalloc int[length];
for (var i = 0; i < length; i++)
{
span[i] = i;
}
関数ポインタ
C#ではデリゲートが用意されているため、C#内であれば関数ポインタに当たる機能は全く必要ありません。しかし、相互運用においてはP/Invokeで関数ポインタを渡したい場面も存在します。
一昔前のC#では関数ポインタを扱う手段がなくIntPtrなどで代用されていましたが、C#9以降では正式に関数ポインタが利用可能になっています。関数ポインタを扱うには「delegate*」という専用のポインタ型を使用します。引数や戻り値はジェネリック型のような「<T1, T2, …>」をdelegate*の後につけることで指定できます。
unsafe
{
// delegate* <T1, T2, ...> 型で関数ポインタを受け取る
// アドレスの取得は通常通り'&'を使用
delegate* <double, double> sin = &Math.Sin;
// 関数のような構文で呼び出しを行える
var result = sin(Math.PI);
}
ポインタ化できるメソッドはstaticのみで、インスタンスメソッドの関数ポインタを取得することはできません。
また、関数ポインタにはmanaged/unmanagedを指定できます。(省略するとmanagedになります。)
P/Invokeでのネイティブライブラリの呼び出しなど、.NETの管轄外の関数を扱う際にはunmanagedを指定します。また、同時に「[ ]」で呼び出し規約を指定することも可能です。
static unsafe class NativeMethods
{
// 関数ポインタを引数に渡すことができる
[DllImport("foo.dll", EntryPoint = "foo")]
static extern string Foo(delegate* unmanaged[Cdecl]<int, int> ptr);
}
関数ポインタは従来のデリゲートを用いた呼び出しと比べるとオーバーヘッドが少なく、効率的にネイティブ側とのやり取りを行うことができます。
Unsafeクラス
ここまでunsafeコードやポインタについて説明してきましたが、C#の言語的な制約上ポインタを使用しても出来ないことはあります。C#には言語的制約を超えたより自由度の高い(そしてより危険な)処理を行うためのクラスとして「Unsafe」クラスが用意されています。
Unsafeクラスの内部はC#ではなくILで実装されています。そのため、通常のC#では不可能な処理をILレベルで行うことができるようになっています。使いこなせれば非常にアグレッシブな最適化を行うことができるので、ハイパフォーマンスなライブラリではよく使われているクラスです。
ただし、使い方によってはunsafe以上に危険であるため、使用側で責任を持って安全性を担保する必要があります。詳細は公式ドキュメントを参照してもらうとして、ここではよく使われる(?)Unsafeクラスのメソッドをいくつか紹介します。
(UnityにUnsafeクラスは含まれないため別途dllを持ってくる必要がありますが、Unsafeの一部の機能はUnity.CollectionsのUnsafeUtilityで代用できます。)
Unsafe.As<T> / Unsafe.As<TFrom, TTo>
Unsafe.Asは「変数を強制的に他の型として解釈させる」メソッドです。これは通常のキャストなどとは異なり、全く無関係の型であろうとノーチェックで素通しします。
これを本当に無関係な型で行うと動作が狂うため絶対に行ってはいけませんが、メモリレイアウトが同じ構造体などであれば一応動作保証はあります。(そもそもこれはポインタでも可能な操作です。)
using System;
using System.Runtime.CompilerServices;
var foo = new Foo();
foo.a = 10;
// Unsafe.Asで強制的にBarに変換
var bar = Unsafe.As<Foo, Bar>(ref foo);
// 10が表示される
Console.WriteLine(bar.x);
// メモリレイアウトが同一な2つの構造体
public struct Foo
{
public int a;
public int b;
}
public struct Bar
{
public int x;
public int y;
}
また、単純なキャストを行う際に型チェックをUnsafe.Asでスキップするという最適化も可能です。安全性は失われますが、キャスト可能だとわかっている状況では高速化に利用できます。
// 適当なインスタンスをobject型に代入
object boxed = new Foo();
// 通常のキャストより高速に変換可能
// ただし、キャスト可能かどうかの確認は実装側の責任になる
var foo = Unsafe.As<Foo>(boxed);
さらに危険な手法として、型構造が全く同じクラスを定義して中身を取り出すということも可能です。ただしこれに関しては全く動作保証がないため、環境やバージョンによっては動作しない可能性もあります。
// 適当にListを作成
var list = new List<int>();
list.Add(1);
list.Add(2);
list.Add(3);
// Unsafe.Asで強制的にListをListViewに変換する
ref var view = ref Unsafe.As<List<int>, ListView<int>>(ref list);
// ListViewを介してListの内部配列を直接操作する(!)
view._items[0] = 100;
// 100が表示される
Console.WriteLine(list[0]);
// List<T>と構造を同じに揃えたクラス
internal sealed class ListView<T>
{
public T[] _items;
public int _size;
public int _version;
}
Unsafe.AsRef<T>
先ほどGetPinnedReferenceの説明でもこっそり使っていましたが、こちらはポインタやref readonly (in)をrefに変換するメソッドです。
unsafe
{
var str = "abcde";
fixed (char* ptr = str)
{
// Unsafe.AsRefでrefに変換
ref var c = ref Unsafe.AsRef<char>(ptr);
}
}
Unsafe.AsPointer<T>
今度はAsRefとは逆に、refで渡された値をポインタに変換するメソッドになります。これらを利用することでrefとポインタを相互に変換できます。
unsafe
{
var i = 10;
// Unsafe.AsPointerでポインタに変換
void* ptr = Unsafe.AsPointer(ref i);
}
Unsafe.Add<T>
固定長配列の解説で少しだけ登場しましたが、こちらは渡された値のアドレスから指定された個数分進めたアドレスの値を取得します。言葉では説明しづらいのでコードを見た方が早いでしょう。
var array = new int[] { 0, 1, 2, 3, 4, 5 };
// arrayの先頭のアドレスから3つ分だけ進めた値を取得する
int i = Unsafe.Add(ref array[0], 3);
// 3が表示される
Console.WriteLine(i);
ポインタでも似たような操作は可能ですが、こちらはsafeコンテキスト内でも行うことができます。また、逆に指定された個数分後ろの値を取得するUnsafe.Subtract<T>も存在します。
まとめ
以上、unsafeについて色々書いてきましたが、いかがだったでしょうか。というかそもそもC#でポインタ使うことがまずないですが、相互運用等の際にはC#側でもポインタを扱う場面もあるにはあるでしょう。あと最適化の最終手段としてUnsafeクラスを扱えるスキルはあっても困らないと思います。基本的にはポインタもUnsafeクラスも使っちゃダメですが。
また、C#の話からはやや外れますが、UnityのDOTSはポインタだらけの魔境なので、細かい実装をやろうとするとunsafe周りの知識は必要になってきます。メモリやらポインタやらがあらゆる所で登場するC#らしくない世界ですが、やろうと思えばいくらでも高速化ができて楽しいのでおすすめです。DOTSはいいぞ。
とまあ実際にunsafeを使う機会は少ないかもしれませんが、必要になったときのためにも是非とも習得していきましょう。この辺りの知識は、他の言語を学ぶ際にもきっと役に立つはずです。