27 марта 2025

Портирование C# кода в C++: Реализация SmartPtr

С самого начала речь шла о портировании нескольких проектов объёмом до нескольких миллионов строк кода. По сути, техническое задание на транслятор сводилось к фразе «чтобы всё это портировалось и нормально работало на C++». Работа людей, ответственных за выпуск продуктов для C++, сводится к тому, чтобы транслировать код, прогонять тесты, готовить релизные пакеты, и так далее. Проблемы, возникающие при этом, как правило, попадают в одну из нескольких категорий:

  1. Код не транслируется в С++ - работа транслятора завершается с ошибкой.
  2. Код транслируется в C++, но не компилируется.
  3. Код компилируется, но не линкуется.
  4. Код линкуется и запускается, но тесты не проходят, либо же происходят падения в рантайме.
  5. Тесты проходят, но при их работе возникают проблемы, не связанные напрямую с функциональностью продукта. Например: утечки памяти, низкая производительность и т. п.

Движение по этому списку идёт сверху вниз — например, не решив проблемы с компиляцией транслированного кода, невозможно проверить его работоспособность и производительность. Таким образом, многие проблемы, существовавшие в течение долгого времени, были обнаружены лишь на поздних стадиях работы над проектом CodePorting.Translator Cs2Cpp.

В начале, когда мы исправляли простые случаи утечки памяти, вызванные циклическими зависимостями между объектами, мы навешивали атрибут CppWeakPtr на поля, получая в итоге поля типов WeakPtr. До тех пор, пока WeakPtr может быть преобразован в SharedPtr вызовом метода lock() или неявно (что удобнее синтаксически), это не вызывает проблем. Далее, однако, нам пришлось также делать слабыми ссылки, содержащиеся в контейнерах, используя специальный синтаксис атрибута CppWeakPtr, и вот тут нас ждала пара неприятных сюрпризов.

Первым звоночком, сообщившим о проблемах с принятым нами подходом, стало то, что с точки зрения C++ MyContainer<SharedPtr<MyClass>> и MyContainer<WeakPtr<MyClass>> — это два разных типа. Соответственно, они не могут быть сохранены в одну и ту же переменную, переданы в один и тот же метод, или возвращены из него, и так далее. Атрибут, предназначенный сугубо для управления способом хранения ссылок в полях объектов, начал появляться во всё более странных контекстах, затрагивая возвращаемые значения, аргументы, локальные переменные, и так далее. Код транслятора, отвечающий за его обработку, становился сложнее день ото дня.

Второй проблемой стало то, чего мы также не предвидели. Для программистов C# оказалось естественным иметь одну ассоциативную коллекцию на объект, содержащую в себе как уникальные ссылки на объекты, принадлежащие текущему и не доступные каким-либо другим способом, так и ссылки на родительские объекты. Это было сделано для оптимизации операций чтения из некоторых файловых форматов, однако для нас это значило, что в одной и той же коллекции могут содержаться как сильные, так и слабые ссылки. Тип указателя перестал быть конечной инстанцией в вопросе о режиме его работы.

Тип ссылки как часть состояния указателя

Очевидно, эти две проблемы не решались в рамках существующей парадигмы, и типы указателей были вновь пересмотрены. Результатом пересмотра подхода стал класс SmartPtr, имеющий метод set_Mode(), принимающий одно из двух значений: SmartPtrMode::Shared и SmartPtrMode::Weak. Те же значения принимают все конструкторы SmartPtr. В итоге каждый экземпляр указателя может находиться в одном из двух состояний:

  1. Сильная ссылка, счётчик ссылок инкапсулирован в объект;
  2. Слабая ссылка, счётчик ссылок находится вне объекта.

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

Полный список функций, поддерживаемых нашим указателем, выглядит так:

  1. Хранение сильной ссылки: управление временем жизни объекта с подсчётом ссылок.
  2. Хранение слабой ссылки на объект.
  3. Семантика intrusive_ptr: любое количество указателей, созданных на один и тот же объект, будут разделять один счётчик ссылок.
  4. Разыменование и оператор «стрелка»: для доступа к объекту, на который указывает указатель.
  5. Полный набор конструкторов и операторов присваивания.
  6. Разделение объекта, на который указывает указатель, и объекта, для которого ведётся подсчёт ссылок (aliasing constructor): поскольку библиотеки наших клиентов работают с документами, то часто бывает ситуация, когда указатель на элемент документа должен держать «живым» весь документ.
  7. Полный набор кастов.
  8. Полный набор операций сравнения.
  9. Присваивание и удаление указателей: работают на неполных типах.
  10. Набор методов для проверки и изменения состояния указателя: режим псевдонима, режим хранения ссылки, число ссылок на объект и т. д.

Класс SmartPtr является шаблонным и не содержит виртуальных методов. Он тесно связан с классом System::Object, который осуществляет хранение счётчика ссылок, и работает исключительно с его дочерними классами.

Существуют отступления от типового поведения указателей:

  1. Перемещение (конструктор перемещения, перемещающий оператор присваивания) изменяет не всё состояние, сохраняя тип ссылки - слабая/сильная.
  2. Доступ к объекту по слабой ссылке не требует локинга (создания временной сильной ссылки), так как подход, при котором оператор «стрелка» возвращает временный объект, слишком сильно просаживает производительность на сильных ссылках.

Для сохранения работоспособности старого кода тип SharedPtr стал псевдонимом SmartPtr. Класс WeakPtr теперь наследуется от SmartPtr, не добавляя каких-либо полей, и лишь переопределяет конструкторы, всегда создавая слабые ссылки.

Контейнеры теперь всегда портируются в семантике MyContainer<SmartPtr<MyClass>>, а тип хранимых ссылок выбирается в рантайме. Для контейнеров, написанных вручную на базе структур данных из STL (в первую очередь, контейнеров из пространства имён System), тип ссылки по умолчанию задаётся при помощи кастомного аллокатора, при этом для отдельных элементов контейнера остаётся возможность изменения режима. Для транслированных контейнеров необходимый код переключения режима хранения ссылок генерируется транслятором.

К недостаткам решения относится, прежде всего, сниженная производительность на операциях создания, копирования и удаления указателей, поскольку к обычному подсчёту ссылок добавляется обязательная проверка типа ссылки. Конкретные цифры сильно зависят от структуры теста. В настоящее время ведётся дискуссия о том, чтобы в местах, где тип указателя гарантированно не меняется, генерировать более оптимальный код.

Подготовка кода к трансляции

Наш способ портирования требует, чтобы в исходном коде C# были вручную расставлены атрибуты, отмечающие, в каких случаях ссылки должны быть слабыми. Код, в котором эти атрибуты не были расставлены должным образом, после трансляции будет вызывать утечки памяти и, в некоторых случаях, другие ошибки. Код с расставленными атрибутами выглядит примерно так:

struct S {
    MyClass s; // Сильная ссылка на объёкт

    [CppWeakPtr] 
    MyClass w; // Слабая ссылка на объект

    MyContainer<MyClass> s_s; // Сильная ссылка на контейнер сильных ссылок

    [CppWeakPtr] 
    MyContainer<MyClass> w_s; // Слабая ссылка на контейнер сильных ссылок

    [CppWeakPtr(0)] 
    MyContainer<MyClass> s_w; // Сильная ссылка на контейнер слабых ссылок

    [CppWeakPtr(1)] 
    Dictionary<MyClass, MyClass> s_s_w; // Сильная ссылка на контейнер, в котором ключи хранятся по сильным ссылкам, а значения - по слабым
    
    [CppWeakPtr, CppWeakPtr(0)] 
    Dictionary<MyClass, MyClass> w_w_s; // Слабая ссылка на контейнер, в котором ключи хранятся по слабым ссылкам, а значения - по сильным
}

В некоторых случаях требуется вручную вызвать aliasing конструктор класса SmartPtr или его метод, задающий тип хранимой ссылки. Мы стараемся избегать правок портированного кода, поскольку их приходится внедрять заново после каждого запуска транслятора. Вместо этого мы стараемся держать такой код в исходниках C#. Для этого у нас есть две возможности:

  1. Мы можем объявить в коде C# сервисный метод, который не делает ничего, а при трансляции — заменить его на написанный вручную аналог, выполняющий необходимую операцию:
class Service {
    public static void SetWeak<T>(T arg) {}
}
class Service {
public:
    template <typename T> static void SetWeak(SmartPtr<T> &arg)
    {
        arg.set_Mode(SmartPtrMode::Weak);
    }
};
  1. Мы можем размещать в коде C# специальным образом оформленные комментарии, которые транслятор преобразует в код C++:
class MyClass {
    private Dictionary<string, object> data;
    public void Add(string key, object value)
    {
        data.Add(key, value);
        //CPPCODE: if (key == u"Parent") data->data()[key].set_Mode(SmartPtrMode::Weak);
    }
}

Здесь метод data() в System::Collections::Generic::Dictionary возвращает ссылку на std::unordered_map, лежащую в основе данного контейнета.

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

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

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