20 февраля 2025
При разработке транслятора кода C# на Java проблем с удалением неиспользуемых объектов нет: Java предоставляет механизм сборки мусора, в достаточной мере похожий на таковой в C#, и транслированный код, использующий классы, просто собирается и работает.
C++ — иной случай. Очевидно, отображение ссылок на «голые» указатели не приведёт к нужным результатам, поскольку такой транслированный код не будет удалять ничего. А программисты C#, привыкшие к работе в среде с GC, будут продолжать писать код, создающий множество временных объектов.
Для того, чтобы обеспечить своевременное удаление объектов в сконвертированном коде, нам пришлось выбирать из трёх вариантов:
Третий вариант отпал сразу: сложность алгоритмов в портируемых библиотеках оказалась слишком высокой. К тому же, анализировать нужно было бы также и клиентский код, использующий сконвертированные библиотеки.
Второй вариант также выглядел малоприменимым: поскольку мы портируемы библиотеки, а не приложения, использование сборщика мусора накладывало бы соответствующие ограничения на клиентский код, использующий эти библиотеки. Эксперименты, проводившиеся в этом направлении, были признаны неудавшимися.
Таким образом, мы пришли к последнему оставшемуся варианту — к использованию умных указателей с подсчётом ссылок, что является довольно типичным для 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);
}
};
Здесь видны сразу три проблемы:
Node::document
слабой ссылкой.this
в intrusive_ptr
(аналог shared_from_this
). Если вместо этого начать менять сигнатуры (например, заставив Document::Prepare
принимать Node*
вместо intrusive_ptr<Node>
), начнутся проблемы с вызовом тех же методов с передачей уже сконструированных объектов и/или управлением временем жизни объектов.this
в intrusive_ptr
на этапе создания объекта с последующим уменьшением счётчика ссылок до нуля (как это происходит, например, в конструкторе Node
при выходе из Document::Prepare
) не должно приводить к немедленному удалению недоконструированного объекта, на который ещё не существует внешних ссылок.Первый пункт было решено исправлять в ручном режиме, поскольку во многих ситуациях даже человек с трудом может понять, какая из нескольких ссылок должна быть слабой. А в некоторых случаях на этот вопрос в принципе не существует ответа, что требует изменений в коде C#.
Например, в одном из проектов была пара классов «действие печати» и «параметры действия печати», конструктор каждого из которых создавал парный объект и связывал с текущим двусторонними ссылками. Очевидно, превращение одной из этих ссылок в слабую нарушило бы сценарий использования. В итоге было решено использовать атрибут [CppWeakPtr]
, указывающий транслятору, что соответствующее поле должно содержать слабую ссылку вместо сильной.
Вторая проблема легко решается, если intrusive_ptr
допускает преобразование из «голого» указателя, каковым является this
. В реализации из boost такая возможность предусмотрена.
Наконец, третья проблема была решена внедрением в код конструктора локальной переменной-часового, которая увеличивала счётчик ссылок текущего объекта при создании и уменьшала при удалении. Причём уменьшение счётчика ссылок до нуля в коде часового не приводит к удалению защищаемого объекта.
С соответствующими правками код до и после трансляции выглядел примерно так:
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#.
Разрешить данную ситуацию можно двумя способами:
MyContainer<MyClass>
к семантике MyContainer<intrusive_ptr<MyClass>>
:auto a = make_shared_intrusive<MyContainer<intrusive_ptr<MyClass>>>();
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;
}
};
Помимо многословности, растущей экспоненциально с каждым новым параметром-типом, второй вариант плох тем, что каждый контекст, в котором используется MyContainer<T>
, должен знать, является ли T
значимым или ссылочным типом, что во многих случаях нежелательно. Например, когда мы хотим иметь минимально возможное количество включаемых заголовков или и вовсе спрятать информацию о неких внутренних типах.
Кроме того, выбор типа ссылки (сильная или слабая) возможен лишь один раз на контейнер. То есть, становится невозможно иметь одновременно List
сильных ссылок и List
слабых ссылок, хотя в коде конвертируемых продуктов существует необходимость в обоих вариантах.
С учётом этих соображений, было решено портировать MyContainer<MyClass>
в семантике MyContainer<System::SharedPtr<MyClass>>
, либо MyContainer<System::WeakPtr<MyClass>>
для случая слабых ссылок. Поскольку наиболее популярные библиотеки не предоставляют указателей с требуемыми характеристиками, нами были разработаны собственные реализации, получившие названия System::SharedPtr
— сильная ссылка, использующая счётчик ссылок в объекте и System::WeakPtr
— слабая ссылка, использующая счётчик ссылок вне объекта. За создание объектов в стиле std::make_shared
отвечает функция System::MakeObject
.