20 февраля 2025

Портирование C# кода в C++: Умные указатели

При разработке транслятора кода C# на Java проблем с удалением неиспользуемых объектов нет: Java предоставляет механизм сборки мусора, в достаточной мере похожий на таковой в C#, и транслированный код, использующий классы, просто собирается и работает.

C++ — иной случай. Очевидно, отображение ссылок на «голые» указатели не приведёт к нужным результатам, поскольку такой транслированный код не будет удалять ничего. А программисты C#, привыкшие к работе в среде с GC, будут продолжать писать код, создающий множество временных объектов.

Для того, чтобы обеспечить своевременное удаление объектов в сконвертированном коде, нам пришлось выбирать из трёх вариантов:

  1. Использовать подсчёт ссылок на объекты — например, через умные указатели;
  2. Использовать реализацию сборщика мусора для C++ — например, Boehm GC;
  3. Использовать статический анализ для определения мест, в которых производится удаление объектов.

Третий вариант отпал сразу: сложность алгоритмов в портируемых библиотеках оказалась слишком высокой. К тому же, анализировать нужно было бы также и клиентский код, использующий сконвертированные библиотеки.

Второй вариант также выглядел малоприменимым: поскольку мы портируемы библиотеки, а не приложения, использование сборщика мусора накладывало бы соответствующие ограничения на клиентский код, использующий эти библиотеки. Эксперименты, проводившиеся в этом направлении, были признаны неудавшимися.

Таким образом, мы пришли к последнему оставшемуся варианту — к использованию умных указателей с подсчётом ссылок, что является довольно типичным для 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);
    }
};

Здесь видны сразу три проблемы:

  1. Необходим способ разорвать циклическую ссылку, сделав в данном случае Node::document слабой ссылкой.
  2. Должен существовать способ преобразования this в intrusive_ptr (аналог shared_from_this). Если вместо этого начать менять сигнатуры (например, заставив Document::Prepare принимать Node* вместо intrusive_ptr<Node>), начнутся проблемы с вызовом тех же методов с передачей уже сконструированных объектов и/или управлением временем жизни объектов.
  3. Преобразование 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#.

Разрешить данную ситуацию можно двумя способами:

  1. Перейдя от семантики MyContainer<MyClass> к семантике MyContainer<intrusive_ptr<MyClass>>:
auto a = make_shared_intrusive<MyContainer<intrusive_ptr<MyClass>>>();
  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;
    }
};

Помимо многословности, растущей экспоненциально с каждым новым параметром-типом, второй вариант плох тем, что каждый контекст, в котором используется MyContainer<T>, должен знать, является ли T значимым или ссылочным типом, что во многих случаях нежелательно. Например, когда мы хотим иметь минимально возможное количество включаемых заголовков или и вовсе спрятать информацию о неких внутренних типах. Кроме того, выбор типа ссылки (сильная или слабая) возможен лишь один раз на контейнер. То есть, становится невозможно иметь одновременно List сильных ссылок и List слабых ссылок, хотя в коде конвертируемых продуктов существует необходимость в обоих вариантах.

С учётом этих соображений, было решено портировать MyContainer<MyClass> в семантике MyContainer<System::SharedPtr<MyClass>>, либо MyContainer<System::WeakPtr<MyClass>> для случая слабых ссылок. Поскольку наиболее популярные библиотеки не предоставляют указателей с требуемыми характеристиками, нами были разработаны собственные реализации, получившие названия System::SharedPtr — сильная ссылка, использующая счётчик ссылок в объекте и System::WeakPtr — слабая ссылка, использующая счётчик ссылок вне объекта. За создание объектов в стиле std::make_shared отвечает функция System::MakeObject.

Связанные новости

Связанные видео

Связанные статьи