16 April 2025
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.
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.
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.
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.
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.
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.
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.
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.