20 2月 2025

C#コードのC++への移植:スマートポインタ

C#からJavaへのコードトランスレータを開発する際、未使用のオブジェクトを削除する上で問題は発生しません。JavaはC#と十分に類似したガベージコレクションの仕組みを提供しており、クラスを使用した変換後のコードも単純にコンパイルされて動作します。 一方で、C++の場合では事情が異なります。参照を生ポインタにマッピングしても期待通りの結果は得られません。そのような変換後のコードは何も削除しないためです。また、GC環境での作業に慣れているC#開発者は、多くの一時的なオブジェクトを作成するコードを引き続き記述します。

変換後のコードにおいて、オブジェクトの適時に削除を保証するために、以下の3つの選択肢の中から選ばなければなりませんでした:

  1. スマートポインタなどを通じてオブジェクトにリファレンスカウンティングを使用する。
  2. C++用のガベージコレクタ実装を使用する。例えば、Boehm GC
  3. オブジェクトを削除すべきポイントを決定するために静的解析を使用する。

3つ目の選択肢はすぐに除外されました。移植されるライブラリ内のアルゴリズムの複雑さが障害となりました。さらに、静的解析はこれらの変換されたライブラリを使用するクライアントコードにも拡張される必要がありました。

2つ目の選択肢も非現実的でした。ライブラリではなくアプリケーションを移植していたため、ガベージコレクタを導入すると、これらのライブラリを使用するクライアントコードに制約を課すことになります。この方向での実験は失敗と判断されました。

したがって、最後の選択肢であるリファレンスカウンティングを使用したスマートポインタの利用に至りました。これはC++では一般的な手法です。これにより、循環参照の問題を解決するために、強い参照に加えて弱い参照も使用する必要が生じました。

スマートポインタの種類

いくつかのよく知られたタイプのスマートポインタがあります:

  • shared_ptrは最も明白な選択肢のように見えるかもしれませんが、大きな欠点があります。enable_shared_from_thisを使用しても、リファレンスカウンタはヒープ上にオブジェクトとは別に格納されます。リファレンスカウンタのメモリを確保・解放するのは比較的コストのかかる操作です。
  • intrusive_ptrはこの点で優れています。構造体内に未使用の4/8バイトフィールドを持つことは、各一時オブジェクトごとの追加割り当てのオーバーヘッドと比較して悪影響が少ないです。

以下のようなC#コードを考えます:

class Document
{
    private Node node;
    public Document()
    {
        node = new Node(this);
    }
    public void Prepare(Node n) { ... }
}

class Node
{
    private Document document;
    public Node(Document d)
    {
        document = d;
        d.Prepare(this);
    }
}

intrusive_ptrを使用すると、このコードは次のように変換されます:

class Document : public virtual System::Object
{
    intrusive_ptr<Node> node;
public:
    Document()
    {
        node = make_shared_intrusive<Node>(this);
    }
    void Prepare(intrusive_ptr<Node> n) { ... }
};

class Node : public virtual System::Object
{
    intrusive_ptr<Document> document;
public:
    Node(intrusive_ptr<Document> d)
    {
        document = d;
        d->Prepare(this);
    }
};

ここで、3つの問題が明らかになります:

  1. 循環参照を解消する仕組みが必要であり、この場合Node::documentを弱い参照にする必要があります。
  2. thisintrusive_ptrに変換する方法(shared_from_thisに類似)が必要です。代わりにメソッドシグネチャを変更し始めると(例:Document::Prepareintrusive_ptr<Node>ではなくNode*を受け取るようにする)、すでに構築されたオブジェクトやオブジェクトのライフタイム管理に関連する問題が発生します。
  3. コンストラクタ中でthisintrusive_ptrに変換し、その後参照カウントをゼロに減らしても(たとえば、NodeコンストラクタでDocument::Prepareを抜ける際に)、まだ外部参照を持たない部分的に構築されたオブジェクトを即座に削除してはなりません。

最初の問題は手動で対処しました。人間でもどの参照を弱くすべきかを判断するのが難しい場合が多く、明確な答えがないケースもあり、C#コードの修正が必要になることもあります。 たとえば、あるプロジェクトでは「印刷アクション」と「印刷アクションパラメータ」のペアとなるクラスがあり、それぞれのコンストラクタが相方のオブジェクトを作成し双方向の参照を確立していました。これらのいずれかの参照を弱くすると使用シナリオが壊れることが明らかです。最終的に、[CppWeakPtr]属性を使用して、対応するフィールドが強い参照ではなく弱い参照を持つように指示することにしました。

2番目の問題は、intrusive_ptrが生ポインタからの変換を許可すれば簡単に解決されます。Boostの実装はこの機能を提供しています。

最後に、3番目の問題はコンストラクタ内でローカルのRAIIガード変数を導入することで解決されました。このガードは現在のオブジェクトの参照カウントを生成時に増やし、破棄時に減らします。重要なのは、ガード内で参照カウントをゼロに減らしても保護されたオブジェクトは削除されないことです。

これらの変更により、変換前後のコードはおおよそ次のようになります:

class Document
{
    private Node node;
    public Document()
    {
        node = new Node(this);
    }
    public void Prepare(Node n) { ... }
}

class Node
{
    [CppWeakPtr] private Document document;
    public Node(Document d)
    {
        document = d;
        d.Prepare(this);
    }
}
class Document : public virtual System::Object
{
    intrusive_ptr<Node> node;
public:
    Document()
    {
        System::Details::ThisProtector guard(this);
        node = make_shared_intrusive<Node>(this);
    }
    void Prepare(intrusive_ptr<Node> n) { ... }
};

class Node : public virtual System::Object
{
    weak_intrusive_ptr<Document> document;
public:
    Node(intrusive_ptr<Document> d)
    {
        System::Details::ThisProtector guard(this);
        document = d;
        d->Prepare(this);
    }
};

したがって、任意のintrusive_ptrの実装が私たちの要件を満たし、ペアとなるweak_intrusive_ptrクラスによって補完される限り、それは十分です。後者はオブジェクト外のヒープ上に配置されたリファレンスカウンタに依存する必要があります。弱い参照の作成は一時オブジェクトの作成と比較して比較的稀な操作であるため、リファレンスカウンタをオブジェクト内の強いものとオブジェクト外の弱いものに分離することで、実際のコードでのパフォーマンス向上が見られました。

テンプレート

状況は大幅に複雑になります。値型または参照型のどちらでもあり得る型パラメータを持つジェネリッククラスやメソッドのコードを変換する必要があるためです。以下のC#コードを考えてみましょう:

class MyContainer<T>
{
    public T field;
    public void Set(T val)
    {
        field = val;
    }
}
class MyClass {}
struct MyStruct {}

var a = new MyContainer<MyClass>();
var b = new MyContainer<MyStruct>();

直接的な移植アプローチでは、次の結果が得られます:

template <typename T> class MyContainer : public virtual System::Object
{
public:
    T field;
    void Set(T val)
    {
        field = val;
    }
};
class MyClass : public virtual System::Object {};
class MyStruct : public System::Object {};

auto a = make_shared_intrusive<MyContainer<MyClass>>();
auto b = make_shared_intrusive<MyContainer<MyStruct>>();

明らかに、このコードは元の挙動と同じにはなりません。MyContainer<MyClass>をインスタンス化すると、fieldオブジェクトはヒープからMyContainerフィールドに移動し、参照コピーのセマンティクスが破られます。一方で、MyStruct構造体をフィールドに配置することは完全に正しい動きであり、C#の振る舞いと一致しています。

この状況は次の2つの方法で解決できます:

  1. MyContainer<T>のセマンティクスをMyContainer<intrusive_ptr<T>>のセマンティクスに切り替える:
auto a = make_shared_intrusive<MyContainer<intrusive_ptr<MyClass>>>();
  1. 各テンプレートクラスに対して2つの特殊化を作成する:1つは型引数が値型の場合、もう1つは参照型の場合:
template <typename T, bool is_T_reference_type = is_reference_type_v<T>> class MyContainer : public virtual System::Object
{
public:
    T field;
    void Set(T val)
    {
        field = val;
    }
};

template <typename T> class MyContainer<T, true> : public virtual System::Object
{
public:
    intrusive_ptr<T> field;
    void Set(intrusive_ptr<T> val)
    {
        field = val;
    }
};

冗長性が各追加の型パラメータごとに指数関数的に増えることに加え、2番目のアプローチにはMyContainer<T>を使用するすべての文脈がTが値型か参照型かを知る必要があるという欠点があります。これは、特定の内部型に関する情報を隠したり、含めるヘッダファイルの数を最小限にしたい場合などには望ましくありません。 さらに、参照型(強い参照または弱い参照)の選択はコンテナごとに一度しかできません。つまり、変換後の製品コードが両方の形式を必要とする場合でも、強い参照のListと弱い参照のListを同時に持つことは不可能になります。

これらの要素を考慮して、MyContainer<T>MyContainer<intrusive_ptr<T>>または弱い参照の場合にはMyContainer<weak_intrusive_ptr<T>>のセマンティクスを使用して移植することにしました。最も人気のあるライブラリでは必要な特性を持つポインタが提供されていないため、私たちは独自の実装を開発しました。これをSystem::SharedPtr(オブジェクト内リファレンスカウンタを使用する強い参照)およびSystem::WeakPtr(外部リファレンスカウンタを使用する弱い参照)と名付けました。System::MakeObject関数はstd::make_sharedスタイルでオブジェクトを作成する役割を担います。

関連ニュース

関連動画

関連記事