如 使用 C++/WinRT 的 Author API 中所述,创建实现类型的对象时,应使用 winrt::make 系列帮助程序执行此操作。 本主题深入探讨 C++/WinRT 2.0 功能,可帮助诊断直接在堆栈上分配实现类型的对象的错误。
此类错误可能会导致莫名其妙的崩溃或数据损坏问题,而且这类问题往往难以调试且耗时。 因此,这是一项重要功能,值得了解背景。
使用 MyStringable 设置场景
首先,我们先来看一个简单的 IStringable 实现。
struct MyStringable : implements<MyStringable, IStringable>
{
winrt::hstring ToString() const { return L"MyStringable"; }
};
现在,假设你需要从实现中调用需要 IStringable 作为参数的函数。
void Print(IStringable const& stringable)
{
printf("%ls\n", stringable.ToString().c_str());
}
问题在于,我们的 MyStringable 类型 并不是IStringable。
- MyStringable 类型是 IStringable 接口的实现。
- IStringable 类型是投影类型。
Important
了解实现类型和投影类型之间的区别非常重要。 有关基本概念和术语,请务必阅读使用 C++/WinRT 使用 API和使用 C++/WinRT 创作 API。
实现与投影之间的差别可能很微妙,难以把握。 事实上,为了让实现看起来更像投影,该实现为它所实现的每个投影类型都提供了隐式转换。 这并不意味着我们可以简单地做到这一点。
struct MyStringable : implements<MyStringable, IStringable>
{
winrt::hstring ToString() const;
void Call()
{
Print(this);
}
};
相反,我们需要获取引用,以便转换运算符可用作解析调用的候选项。
void Call()
{
Print(*this);
}
这有效。 隐式转换提供从实现类型到投影类型的转换(非常高效),这在许多方案中非常有用。 如果没有这一机制,许多实现类型编写起来会非常繁琐。 如果只使用 winrt::make 函数模板(或 winrt::make_self)来分配实现,那么一切都很好。
IStringable stringable{ winrt::make<MyStringable>() };
C++/WinRT 1.0 的潜在缺陷
不过,隐式转换可能会让你陷入困境。 来看这个毫无帮助的辅助函数。
IStringable MakeStringable()
{
return MyStringable(); // Incorrect.
}
甚至只是这个显然无害的声明。
IStringable stringable{ MyStringable() }; // Also incorrect.
遗憾的是,此类代码 确实 可以在 C++/WinRT 1.0 中通过编译,这是因为存在这种隐式转换。 这个(非常严重的)问题在于,我们可能会返回一种投影类型,它指向某个引用计数对象,而该对象的底层内存位于临时栈上。
下面是使用 C++/WinRT 1.0 编译的其他内容。
MyStringable* stringable{ new MyStringable() }; // Very inadvisable.
原始指针是危险的和劳动密集型的 bug 来源。 如果不需要,请不要使用它们。 C++/WinRT 会尽其所能,使一切高效,而无需强制使用原始指针。 下面是使用 C++/WinRT 1.0 编译的其他内容。
auto stringable{ std::make_shared<MyStringable>(); } // Also very inadvisable.
这是一个多方面的错误。 对于同一对象,我们有两个不同的引用计数。 Windows 运行时(以及在此之前的经典 COM)基于一种内在的引用计数机制,而这种机制与 std::shared_ptr 不兼容。 std::shared_ptr当然有许多有效的应用程序;但是,在共享Windows 运行时(和经典 COM)对象时,这完全没有必要。 最后,它还使用 C++/WinRT 1.0 进行编译。
auto stringable{ std::make_unique<MyStringable>() }; // Highly dubious.
这再次相当可疑。 唯一所有权与 MyStringable 自身引用计数的共享生命周期相对。
使用 C++/WinRT 2.0 的解决方案
使用 C++/WinRT 2.0 时,所有这些尝试直接分配实现类型都会导致编译器错误。 这种错误才是最好的,比那种莫名其妙的运行时错误好得多。
每当需要实现时,只需使用 winrt::make 或 winrt::make_self,如上所示。 现在,如果你忘记这样做,就会看到一个编译器错误,错误信息会提到这一点,并引用一个名为 use_make_function_to_create_this_object 的抽象函数。 它并不完全是一个 static_assert;但已经很接近了。 不过,这是检测描述的所有错误的最可靠方法。
这确实意味着我们需要对实现施加一些轻微的约束。 鉴于我们依赖缺少替代来检测直接分配, winrt::make 函数模板必须以某种方式满足抽象虚拟函数的替代。 它通过从该实现派生出一个提供该重写的 final 类来实现这一点。 有关此过程,需要注意一些事项。
首先,虚拟函数仅在调试版本中存在。 这意味着检测不会影响优化版本中 vtable 的大小。
其次,由于 winrt::make 使用的派生类是 final,这意味着即使你之前选择不将实现类标记为 final,优化器凡是能够推导出的去虚拟化仍都会发生。 因此,这是一个改进。 反过来说,你的实现不能这样final。 同样,这不会造成任何后果,因为实例化类型将始终如此 final。
第三,没有任何东西会阻止你将你的实现中的任何虚函数标记为 final。 当然,C++/WinRT 与经典 COM 和实现(如 WRL)大相径庭,其中有关实现的所有内容往往都是虚拟的。 在 C++/WinRT 中,虚分派仅限于应用程序二进制接口(ABI)(它始终为 final),而你的实现方法则依赖于编译时多态或静态多态。 这样既可以避免不必要的运行时多态,也意味着在你的 C++/WinRT 实现中几乎没有使用虚函数的必要。 这是一件非常好的事情,并带来更可预测的内联优化。
第四,由于 winrt::make 注入派生类,因此实现不能具有专用析构函数。 专用析构函数很受经典 COM 实现的欢迎,因为同样,一切都是虚拟的,并且通常直接处理原始指针,因此很容易意外调用 delete 而不是 Release。 C++/WinRT 特意让你难以直接处理原始指针。 你得 费很大劲,才能在 C++/WinRT 中拿到一个你可能会对其调用 delete 的原始指针。 值语义意味着你正在处理值和引用;很少使用指针。
因此,C++/WinRT 挑战我们先入主的概念,即编写经典 COM 代码意味着什么。 这完全合理,因为 WinRT 不是经典 COM。 经典 COM 是Windows 运行时的汇编语言。 它不应该是你每天编写的代码。 相反,C++/WinRT 让你编写出的代码更像现代 C++,而远不像经典 COM。