3D グラフィックスなどパフォーマンスが重視されるアプリケーションにおいて、ヒープアロケーションを減らして GC のパフォーマンスを向上させるために C# にできること、できないことを、僕の経験から紹介します。構造体の話がメインです。使い方次第では、C++ に匹敵するパフォーマンスが出せるかもしれません。
注意: この記事は一般のユースケースにおいて class の使用を非推奨とするものでも、struct への置き換えを推奨するものでもありません。非常に多数のオブジェクトを生成する可能性のある状況で、C++ ライクなコーディングを C# 上でどの程度実践できる可能性があるかを検討するためのものです。
C# でできること
struct (構造体) を使う
C# のクラスは参照型なので、クラスのインスタンスを生成しすぎると GC に負担がかかります。一方、C# にはカスタムの値型を定義するための struct という機能があります。これは C++ の class / struct とほぼ同じで、スタックやクラス/配列の中などに直接インスタンスを確保することができます。System.Numerics 名前空間には、これを利用した複素数型 Complex や 3D ベクトル型 Vector3 などが予め定義されています。なお、int 型や float 型なども(名目上は)struct の一種とされており、System 名前空間においてそれぞれ struct Int32, struct Single として定義されています。
関数に ref, out で渡す
C++ でのポインタや参照の用途の一つは、関数から複数の値を返すことです。世の中にはヒープアロケーションなしでは関数から複数の値を返せない言語がたくさんありますが、C# ならできます。そのパラメータが「入出力兼用」なら ref を、「出力専用」なら out を使います。もちろん関数から複数の値を返すには、代わりに構造体や後述のタプルを使ってもできます。また、大きめの構造体に対して戻り値で返却するときのコピーのオーバーヘッドを減らすために使用することもできます。
値型のタプルを使う (C# 7.0 以降)
構造体を定義するほどでもないが、アドホックにいくつかの値の組を使用したいということがあります。そうした場合に対応するため、C# 7.0 ではタプル機能が導入されました。例えば、(double, int) という型は struct System.ValueTuple<double, int> のエイリアスで、double と int の組を値型として保持することができます。なお、C# のタプルは (double d, int i) のように要素ごとに名前を付けることもでき、これは比較的珍しい言語機能といえます。
関数に in で渡す (C# 7.2 以降)
C++ でのポインタや参照の用途の一つに、例え入力専用でも、大きなクラスや構造体をコピーせずに渡すということがあります。C# 7.2 以降では、大きな構造体を in で関数に参照渡しできます。これは C++ の const 参照に似ています。ref や out とは異なり、in で渡された構造体の中身は変更できないので、呼び出し元で in キーワードを付ける必要はありません。
ジェネリックスを使う
値型のインスタンスを object 型や interface 型に変換するとボックス化が発生します。C# のジェネリックスは Java 等とは異なり、ボックス化を最大限回避できるように設計されています。これは、List<int> のような基本的な使い方はもちろんのこと、インターフェイス制約がある場合でもボックス化なしで機能します。例えば
class Container<T> where T : IComparable<T>
{
// IComparable<T>.CompareTo を利用した何か
}
という型があったとします。この T に
struct Node : IComparable<Node>
{
// ...
public int CompareTo(Node other) => // ...
}
という構造体型を代入して Container<Node> という型を作って使っても、ボックス化は発生しません。これは、インターフェイスが「制約」としてのみ機能し、実際にインターフェイス型にキャストされるわけではないからです。構造体は継承できないので、インターフェイスメソッド(この場合 CompareTo )の呼び出し先は T に Node を代入した時点で決まっています。そのため、ランタイムはインターフェイスメソッドの呼び出しを静的な呼び出しに置き換えることができます。
stackalloc / Span<T> を使う (C# 7.2 以降)
小さな配列であれば、stackalloc を使用してスタック上に確保することができます。以前は stackalloc を使用するためには unsafe コード内でポインタを使用しなければなりませんでしたが、その必要はなくなりました。stackalloc で確保されたメモリブロックは
Span<int> a = stackalloc int[8];
のように Span<T> で受け取ることができます。この Span<T> という型は、通常のマネージド配列またはその部分列、スタック上の配列、アンマネージドヒープ上のメモリブロックを統一的に表せる構造体で、これを使用することで、C++ において先頭のポインタと配列の長さを指定するのと同じような柔軟性を得ることができます。なお、Span<T> は List<T> からも生成できますが、内部で配列の再確保が発生すると無効な配列を指している状態になることから、System.Runtime.InteropServices.CollectionsMarshal クラスに隠されています。
おまけ:ポインタを使う
C# は安全性と実行速度のバランスが取れた言語であり、実行速度は可能な限り追求していますが、最優先事項ではありません。しかし、unsafe と宣言されたブロック内ではポインタを使うことができます。C++ がそうであるように、不注意なポインタの使用は複雑怪奇なバグを発生させる可能性があるので、このオプションは最後の手段にしておくほうがいいです。原理的には、ポインタを使えば値型だけで参照ライクなことは全てできるはずです。
C# でできないこと
値型への参照をフィールドとして保存する
たとえば、次のようなことはできません。
struct Node
{
public ref int RefToInt;
}
また、次のようなこともできません。
struct Node
{
public ref Node RefToAnother;
}
これができると、それぞれ、更新が必要なフィールドの参照を取得しておくとか、まとまった大きさのメモリブロックから自分で切り分けて GC に頼らずに参照型っぽいことをできたりするはずです。しかし、C# は C++ とは異なり、有効であると保証できない参照は取得できません。上記のような長寿命な参照が確実に有効であると C# が保証することは難しいので、このような書き方はできません。(ポインタを使えば似たようなことはできるはずですが)
構造体の継承や多態はできない
ジェネリックスのところでも書きましたが、構造体は継承できません。したがって、構造体の継承関係によって機能拡張したり、C++ における
struct Base {
virtual void hello() {
std::cout << "Hello from Base!" << std::endl;
}
};
struct Derived : Base {
virtual void hello() override {
std::cout << "Hello from Derived!" << std::endl;
}
};
void callHello(Base &b) {
b.hello();
}
int main() {
Base b;
Derived d;
callHello(b); // Hello from Base!
callHello(d); // Hello from Derived!
return 0;
}
のように、スタック上に確保したインスタンスから多態性を使用する関数を呼び出すといったことはできません。
まとめ
in や Span<T> あたりを見ていると、「C++ なら簡単にできたことが、C# ではできない」というギャップを埋めるために貪欲に色々取り組んでいるんだなという印象を受けました。当然ながら、参照型でスマートに書くときは C++ よりはるかに使いやすいので、C# は C++ と Java 等の「いいとこ取り」ができる言語なのだと改めて感じました。