27 三月 2025

SmartPtr实现: 如何将C#代码移植到C++

从一开始,任务就涉及移植包含多达数百万行代码的多个项目。本质上,对转换器的技术规范可以归结为一句话:“确保所有这些代码都能在 C++ 中正确移植和运行”。负责发布 C++ 产品的人员的工作包括翻译代码、运行测试、准备发布包等等。遇到的问题通常分为以下几类:

  1. 代码无法转换为 C++ —— 转换器报错终止。
  2. 代码能转换为 C++,但无法编译。
  3. 代码能编译,但无法链接。
  4. 代码能链接并运行,但测试失败或出现运行时崩溃。
  5. 测试通过,但在执行过程中出现与产品功能不直接相关的问题。例如:内存泄漏、性能不佳等。

处理这个列表是自上而下的 —— 例如,在解决转换后代码的编译问题之前,无法验证其功能和性能。因此,许多长期存在的问题仅在 CodePorting.Translator Cs2Cpp 项目工作的后期阶段才被发现。

最初,在修复由对象间循环依赖引起的简单内存泄漏时,我们对字段应用了 CppWeakPtr 特性,使得字段类型变为 WeakPtr。只要 WeakPtr 可以通过调用 lock() 方法或隐式地(这在语法上更方便)转换为 SharedPtr,这并不会引起问题。然而,后来我们还需要使用 CppWeakPtr 特性的特殊语法,使容器内包含的引用变为弱引用,这导致了一些令人不快的意外。

我们采用的方法遇到的第一个麻烦迹象是,从 C++ 的角度来看,MyContainer<SharedPtr<MyClass>>MyContainer<WeakPtr<MyClass>> 是两种不同的类型。因此,它们不能存储在同一个变量中,不能传递给同一个方法,不能从方法返回等等。这个原本仅用于管理对象字段中引用存储方式的特性,开始出现在越来越奇怪的上下文中,影响了返回值、参数、局部变量等。负责处理它的转换器代码变得日益复杂。

第二个问题也是我们未曾预料到的。对于 C# 程序员来说,每个对象拥有一个关联集合是很自然的事情,该集合既包含当前对象拥有且无法通过其他方式访问的对象的唯一引用,也包含对父对象的引用。这样做是为了优化从某些文件格式的读取操作,但对我们来说,这意味着同一个集合可能同时包含强引用和弱引用。指针类型不再是其操作模式的最终决定因素。

引用类型作为指针状态的一部分

显然,这两个问题在现有范式内无法解决,指针类型需要重新考虑。这种重新审视的结果是 SmartPtr 类,它提供了一个 set_Mode() 方法,接受两个值之一:SmartPtrMode::SharedSmartPtrMode::Weak。所有 SmartPtr 构造函数都接受相同的值。因此,每个指针实例可以存在于以下两种状态之一:

  1. 强引用:引用计数器封装在对象内部;
  2. 弱引用:引用计数器位于对象外部。

模式之间的切换可以在运行时随时发生。只有当至少存在一个对该对象的弱引用时,才会创建弱引用计数器。

我们的指针支持的完整功能列表如下:

  1. 存储强引用:通过引用计数管理对象生命周期。
  2. 存储对象的弱引用
  3. intrusive_ptr 语义:为同一对象创建的任意数量的指针将共享一个引用计数器。
  4. 解引用和箭头运算符 (->):用于访问所指向的对象。
  5. 全套构造函数和赋值运算符
  6. 分离指向的对象和被引用计数的对象(别名构造函数):由于我们客户的库处理文档,通常需要指向文档元素的指针来保持整个文档的存活。
  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; // 对容器的弱引用,其中键通过弱引用存储,值通过强引用存储
}

在某些情况下,需要手动调用 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);
    }
}

这里,System::Collections::Generic::Dictionary 中的 data() 方法返回对该容器底层 std::unordered_map 的引用。

相关新闻

相关视频

相关文章