16 4月 2025
コードが正常に変換・コンパイルされた後でも、特にメモリ管理に関連するランタイムの問題に遭遇することがよくある。これらは、ガベージコレクタを持つ C# 環境では典型的ではない問題である。この記事では、循環参照や時期尚早なオブジェクト削除といった特定のメモリ管理問題に焦点を当て、我々のアプローチがこれらの問題を検出し解決するのにどのように役立つかを解説する。
C# では、ガベージコレクタは到達不可能なオブジェクトのグループを検出して削除することで、循環参照を正しく処理できる。しかし、C++ では、スマートポインタは参照カウントを使用する。もし 2 つのオブジェクトが強参照 (SharedPtr
) で相互に参照し合っている場合、プログラムの他の部分からそれらへの外部参照がなくなったとしても、それらの参照カウントは決してゼロにならない。これにより、これらのオブジェクトが占有するリソースが決して解放されず、メモリリークが発生する。
典型的な例を考えてみよう:
class Document {
private Element root;
public Document()
{
root = new Element(this); // Document が Element を参照
}
}
class Element {
private Document owner;
public Element(Document doc)
{
owner = doc; // Element が Document を逆参照
}
}
このコードは以下のように変換される:
class Document : public Object {
SharedPtr<Element> root;
public:
Document()
{
root = MakeObject<Element>(this);
}
}
class Element {
SharedPtr<Document> owner; // 強参照
public:
Element(SharedPtr<Document> doc)
{
owner = doc;
}
}
ここで、Document
オブジェクトは Element
への SharedPtr
を含み、Element
オブジェクトは Document
への SharedPtr
を含む。強参照のサイクルが作成される。最初に Document
へのポインタを保持していた変数がスコープ外に出ても、相互参照のため、両方のオブジェクトの参照カウントは 1 のままである。オブジェクトは決して削除されない。
これは、サイクルに関与するフィールドの 1 つ、例えば Element.owner
フィールドに CppWeakPtr
属性を設定することで解決される。この属性は、トランスレータに対し、このフィールドに弱参照 (WeakPtr
) を使用するよう指示する。弱参照は強参照カウントをインクリメントしない。
class Document {
private Element root;
public Document()
{
root = new Element(this);
}
}
class Element {
[CppWeakPtr] private Document owner;
public Element(Document doc)
{
owner = doc;
}
}
結果として得られる C++ コード:
class Document : public Object {
SharedPtr<Element> root; // 強参照
public:
Document()
{
root = MakeObject<Element>(this);
}
}
class Element {
WeakPtr<Document> owner; // これは弱参照になる
public:
Element(SharedPtr<Document> doc)
{
owner = doc;
}
}
これで Element
は Document
への弱参照を保持し、サイクルが断ち切られる。最後の外部 SharedPtr<Document>
が消滅すると、Document
オブジェクトが削除される。これにより root
フィールド (SharedPtr<Element>
) の削除がトリガーされ、Element
の参照カウントがデクリメントされる。他に Element
への強参照がなければ、それも削除される。
この問題は、オブジェクトがコンストラクションの 途中 で、それに対する「永続的な」強参照が確立される前に、SharedPtr
を介して別のオブジェクトやメソッドに渡される場合に発生する。この場合、コンストラクタ呼び出し中に作成された一時的な SharedPtr
が唯一の参照である可能性がある。呼び出し完了後にそれが破棄されると、参照カウントがゼロになり、まだ完全には構築されていないオブジェクトのデストラクタが即座に呼び出され、削除されてしまう。
例を考えてみよう:
class Document {
private Element root;
public Document()
{
root = new Element(this);
}
public void Prepare(Element elm)
{
...
}
}
class Element {
public Element(Document doc)
{
doc.Prepare(this);
}
}
トランスレータは以下を出力する:
class Document : public Object {
SharedPtr<Element> root;
public:
Document()
{
ThisProtector guard(this); // 時期尚早な削除からの保護
root = MakeObject<Element>(this);
}
void Prepare(SharedPtr<Element> elm)
{
...
}
}
class Element {
public:
Element(SharedPtr<Document> doc)
{
ThisProtector guard(this); // 時期尚早な削除からの保護
doc->Prepare(this);
}
}
Document::Prepare
メソッドに入ると、一時的な SharedPtr
オブジェクトが作成され、その後、不完全に構築された Element
オブジェクトに対する強参照が残っていないため、そのオブジェクトを削除してしまう可能性がある。前の記事で示したように、この問題は Element
コンストラクタのコードにローカル変数 ThisProtector guard
を追加することで解決される。トランスレータはこれを自動的に行う。guard
オブジェクトのコンストラクタは this
の強参照カウントを 1 増やし、そのデストラクタはオブジェクトの削除を引き起こすことなく再びそれを減らす。
オブジェクトのコンストラクタが、そのフィールドの一部がすでに作成・初期化された後で例外をスローする状況を考える。これらのフィールドは、構築中のオブジェクトへの強参照を逆方向に含んでいる可能性がある。
class Document {
private Element root;
public Document()
{
root = new Element(this);
throw new Exception("Failed to construct Document object");
}
}
class Element {
private Document owner;
public Element(Document doc)
{
owner = doc;
}
}
変換後、以下を得る:
class Document : public Object {
SharedPtr<Element> root;
public:
Document()
{
ThisProtector guard(this);
root = MakeObject<Element>(this);
throw Exception(u"Failed to construct Document object");
}
}
class Element {
SharedPtr<Document> owner;
public:
Element(SharedPtr<Document> doc)
{
ThisProtector guard(this);
owner = doc;
}
}
ドキュメントのコンストラクタで例外がスローされ、実行がコンストラクタコードを抜けると、スタックアンワインディングが開始される。これには、不完全に構築された Document
オブジェクトのフィールドの削除も含まれる。これにより、削除中のオブジェクトへの強参照を含む Element::owner
フィールドが削除される。これは、すでにデストラクション中のオブジェクトの削除を引き起こし、さまざまなランタイムエラーにつながる。
Element.owner
フィールドに CppWeakPtr
属性を設定すると、この問題は解決する。しかし、属性が配置されるまで、予測不能な終了のため、このようなアプリケーションのデバッグは困難である。トラブルシューティングを簡素化するために、特別なデバッグビルドモードがある。このモードでは、内部オブジェクト参照カウンタがヒープに移動され、フラグが追加される。このフラグは、オブジェクトが完全に構築された後、つまりコンストラクタを抜けた後の MakeObject
関数のレベルでのみ設定される。フラグが設定される前にポインタが破棄された場合、オブジェクトは削除されない。
class Node {
public Node next;
}
class Node : public Object {
public:
SharedPtr<Node> next;
}
オブジェクトチェーンの削除は再帰的に行われるため、チェーンが長い場合(数千オブジェクト以上)はスタックオーバーフローを引き起こす可能性がある。この問題は、ファイナライザ(デストラクタに変換される)を追加し、反復処理によってチェーンを削除することで解決される。
循環参照の問題を修正するのは簡単である – C# コードに属性を追加するだけである。悪い知らせは、C++ 向けの製品リリースを担当する開発者は、通常、どの特定の参照が弱参照であるべきか、あるいはサイクルが存在することさえ知らないということである。
サイクルの探索を容易にするために、我々は同様に動作する一連のツールを開発した。これらは 2 つの内部メカニズムに依存している:グローバルオブジェクトレジストリと、オブジェクトの参照フィールドに関する情報の抽出である。
グローバルレジストリには、現在存在するオブジェクトのリストが含まれる。System::Object
クラスのコンストラクタは現在のオブジェクトへの参照をこのレジストリに入れ、デストラクタはそれを削除する。当然ながら、このレジストリは特別なデバッグビルドモードでのみ存在し、リリースモードでの変換済みコードのパフォーマンスに影響を与えないようにしている。
オブジェクトの参照フィールドに関する情報は、System::Object
レベルで宣言された仮想関数 GetSharedMembers()
を呼び出すことで抽出できる。この関数は、オブジェクトのフィールドに保持されているスマートポインタとそのターゲットオブジェクトの完全なリストを返す。ライブラリコードでは、この関数は手動で記述されるが、生成されたコードではトランスレータによって埋め込まれる。
これらのメカニズムによって提供される情報を処理するには、いくつかの方法がある。それらの切り替えは、適切なトランスレータオプションやプリプロセッサ定数を使用して行われる。
SharedPtr
クラスのデストラクタは、それが管理するオブジェクトのライフタイムから開始して、オブジェクト間の参照をたどり、検出されたすべてのサイクル(開始オブジェクトに強参照をたどることで再び到達できるすべてのケース)に関する情報を出力する。もう 1 つの有用なデバッグツールは、MakeObject
関数によってオブジェクトのコンストラクタが呼び出された後、そのオブジェクトの強参照カウントがゼロであることを確認することである。ゼロでない場合、これは潜在的な問題(参照サイクル、例外がスローされた場合の未定義の動作など)を示している。
C# と C++ の型システムの根本的な不一致にもかかわらず、我々は変換されたコードが元の動作に近い振る舞いで実行できるようにするスマートポインタシステムを構築することに成功した。同時に、タスクは完全に自動モードで解決されたわけではない。我々は、潜在的な問題の探索を大幅に簡素化するツールを作成した。