16 April 2025

Circular References and Memory Leaks: How to Port C# Code to C++

After the code has been successfully translated and compiled, we often encounter runtime issues, especially related to memory management, which are not typical for the C# environment with its garbage collector. In this article, we will delve into specific memory management problems, such as circular references and premature object deletion, and show how our approach helps to detect and resolve them.

Memory Management Issues

1. Circular Strong References

In C#, the garbage collector can correctly handle circular references by detecting and removing groups of unreachable objects. However, in C++, smart pointers use reference counting. If two objects reference each other with strong references (SharedPtr), their reference counts will never reach zero, even if there are no longer any external references to them from the rest of the program. This leads to a memory leak, as the resources occupied by these objects are never freed.

Consider a typical example:

class Document {
    private Element root;
    public Document()
    {
        root = new Element(this); // Document references Element
    }
}

class Element {
    private Document owner;
    public Element(Document doc)
    {
        owner = doc; // Element references back to Document
    }
}

This code is converted into the following:

class Document : public Object {
    SharedPtr<Element> root;
public:
    Document()
    {
        root = MakeObject<Element>(this);
    }
}

class Element {
    SharedPtr<Document> owner; // Strong reference
public:
    Element(SharedPtr<Document> doc)
    {
        owner = doc;
    }
}

Here, the Document object contains a SharedPtr to Element, and the Element object contains a SharedPtr to Document. A cycle of strong references is created. Even if the variable that initially held the pointer to Document goes out of scope, the reference counts for both objects will remain 1 due to the mutual references. The objects will never be deleted.

This is resolved by setting the CppWeakPtr attribute on one of the fields involved in the cycle, for example, on the Element.owner field. This attribute instructs the translator to use a weak reference WeakPtr for this field, which does not increment the strong reference count.

class Document {
    private Element root;
    public Document()
    {
        root = new Element(this);
    }
}

class Element {
    [CppWeakPtr] private Document owner;
    public Element(Document doc)
    {
        owner = doc;
    }
}

The resulting C++ code:

class Document : public Object {
    SharedPtr<Element> root; // Strong reference
public:
    Document()
    {
        root = MakeObject<Element>(this);
    }
}

class Element {
    WeakPtr<Document> owner; // Now this is a weak reference
public:
    Element(SharedPtr<Document> doc)
    {
        owner = doc;
    }
}

Now Element holds a weak reference to Document, breaking the cycle. When the last external SharedPtr<Document> disappears, the Document object is deleted. This triggers the deletion of the root field (SharedPtr<Element>), which decrements the reference count of Element. If there were no other strong references to Element, it is also deleted.

2. Object Deletion During Construction

This problem occurs if an object is passed via SharedPtr to another object or method during its construction, before a “permanent” strong reference to it is established. In this case, the temporary SharedPtr created during the constructor call might be the only reference. If it is destroyed after the call completes, the reference count reaches zero, leading to an immediate call to the destructor and deletion of the not-yet-fully-constructed object.

Consider an example:

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);
    }
}

The translator outputs the following:

class Document : public Object {
    SharedPtr<Element> root;
public:
    Document()
    {
        ThisProtector guard(this); // Protection against premature deletion
        root = MakeObject<Element>(this);
    }
    void Prepare(SharedPtr<Element> elm)
    {
        ...
    }
}

class Element {
public:
    Element(SharedPtr<Document> doc)
    {
        ThisProtector guard(this); // Protection against premature deletion
        doc->Prepare(this);
    }
}

Upon entering the Document::Prepare method, a temporary SharedPtr object is created, which could then delete the incompletely constructed Element object because no strong references remain to it. As shown in the previous article, this problem is solved by adding a local ThisProtector guard variable to the Element constructor code. The translator does this automatically. The guard object's constructor increments the strong reference count for this by one, and its destructor decrements it again, without causing object deletion.

3. Double Deletion of an Object When a Constructor Throws an Exception

Consider a situation where an object's constructor throws an exception after some of its fields have already been created and initialized, which, in turn, might contain strong references back to the object being constructed.

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;
    }
}

After conversion, we get:

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;
    }
}

After the exception is thrown in the document's constructor and execution exits the constructor code, stack unwinding begins, including the deletion of fields of the incompletely constructed Document object. This, in turn, leads to the deletion of the Element::owner field, which contains a strong reference to the object being deleted. This results in the deletion of an object that is already undergoing deconstruction, leading to various runtime errors.

Setting the CppWeakPtr attribute on the Element.owner field resolves this issue. However, until the attributes are placed, debugging such applications is difficult due to unpredictable terminations. To simplify troubleshooting, there is a special debug build mode where the internal object reference counter is moved to the heap and supplemented with a flag. This flag is set only after the object is fully constructed – at the level of the MakeObject function, after exiting the constructor. If the pointer is destroyed before the flag is set, the object is not deleted.

4. Deleting Chains of Objects

class Node {
    public Node next;
}
class Node : public Object {
public:
    SharedPtr<Node> next;
}

Deleting chains of objects is done recursively, which can lead to stack overflow if the chain is long – several thousand objects or more. This problem is solved by adding a finalizer, translated into a destructor, which deletes the chain through iteration.

Finding Circular References

Fixing the problem of circular references is straightforward – add an attribute to the C# code. The bad news is that the developer responsible for releasing the product for C++ typically does not know which specific reference should be weak, nor that a cycle even exists.

To facilitate the search for cycles, we have developed a set of tools that operate similarly. They rely on two internal mechanisms: a global object registry and the extraction of information about an object's reference fields.

The global registry contains a list of objects currently in existence. The constructor of the System::Object class places a reference to the current object into this registry, and the destructor removes it. Naturally, the registry exists only in a special debug build mode, so as not to affect the performance of the converted code in release mode.

Information about an object's reference fields can be extracted by calling the virtual function GetSharedMembers(), declared at the System::Object level. This function returns a complete list of the smart pointers held in the object's fields and their target objects. In library code, this function is written manually, while in generated code, it is embedded by the translator.

There are several ways to process the information provided by these mechanisms. Switching between them is done by using appropriate translator options and/or preprocessor constants.

  1. When the corresponding function is called, the complete graph of currently existing objects, including information about types, fields, and relationships, is saved to a file. This graph can then be visualized using the graphviz utility. Typically, this file is created after each test to conveniently track leaks.
  2. When the corresponding function is called, a graph of currently existing objects that have circular dependencies – where all references involved are strong – is saved to a file. Thus, the graph contains only relevant information. Objects that have already been analyzed are excluded from analysis in subsequent calls to this function. This makes it much easier to see exactly what leaked from a specific test.
  3. When the corresponding function is called, information about currently existing islands of isolation – sets of objects where all references to them are held by other objects within the same set – is output to the console. Objects referenced by static or local variables are not included in this output. Information about each type of isolation island, i.e., the set of classes creating a typical island, is output only once.
  4. The destructor of the SharedPtr class traverses the references between objects, starting from the object whose lifetime it manages, and outputs information about all detected cycles – all cases where the starting object can be reached again by following strong references.

Another useful debugging tool is checking that after an object's constructor is called by the MakeObject function, the strong reference count for that object is zero. If it is not, this indicates a potential problem – a reference cycle, undefined behavior if an exception is thrown, and so on.

Summary

Despite the fundamental mismatch between the C# and C++ type systems, we managed to build a smart pointer system that allows the converted code to run with behavior close to the original. At the same time, the task was not solved in a fully automatic mode. We have created tools that significantly simplify the search for potential problems.

Related News

Related Videos

Related Articles