错误处理
在现代 C++ 代码中,异常通常用于错误报告(并非总是如此,但它们应该是默认的)。函数应直接返回其结果作为返回值,并在发生故障时引发异常。但是,异常无法跨不同语言和运行时移植;异常处理机制差异很大。即使在 C++ 代码中,异常也可能存在问题,因为不同的编译器可能以不同的方式实现异常。
由于异常在不同语言之间无法很好地工作,因此 Windows 运行时不使用它们;相反,每个函数都会返回一个错误代码 HRESULT,指示成功或失败。如果函数需要返回一个值,则该值将通过 out 参数返回。这是 COM 使用的相同约定。每种语言投影都需要在错误代码和该语言的自然错误处理工具之间进行转换。
捕获和重新抛出异常会产生开销,但即使在涉及用不同语言编写的组件之间交互的简单场景中,其好处也是显而易见的。例如,考虑 C# 组件中的函数调用用 C++/CX 实现的组件中的函数。C++ 代码可以抛出从 Platform::Exception 派生的异常,如果未处理该异常,它将在 ABI 边界被捕获并转换为 HRESULT,然后返回给 C# 代码。然后,CLR 会将错误 HRESULT 转换为托管异常,然后抛出该异常以供 C# 代码捕获。
重要的是,两个组件(C# 组件和 C++ 组件)都可以使用异常自然地处理错误,即使它们使用不同的异常处理机制。目前,知道转换是自动进行的就足够了。
成员函数
我们有意将 Number 类设计得非常简单:GetValue 和 SetValue 都不会抛出异常,因此对其中任何一个的调用都会成功。因此,我们可以使用它们来演示编译器如何将我们的 C++/CX 成员函数转换为真正的 ABI 函数。GetValue 更有趣一些,因为它会返回一个值,让我们来看看它:
int GetValue() {
return _value;
}
编译器将生成具有正确 ABI 签名的新函数;例如,
HRESULT __stdcall __abi_GetValue(int * result) {
// Error handling expressly omitted for exposition purposes
* result = GetValue();
return S_OK;
}
包装函数在参数列表中附加了一个用于返回值的附加输出参数,其实际返回类型更改为 HRESULT。Windows 运行时函数在 x86 上使用 stdcall 调用约定,因此需要 __stdcall 注释。默认情况下,非变量成员函数通常使用 thiscall 调用约定。调用约定注释仅在 x86 上才真正重要;x64 和 ARM 各自都有一个调用约定。
我们将包装函数命名为 __abi_GetValue;这是编译器在其生成的接口上为函数指定的名称;在类中,它使用带有大量下划线的长得多的名称,以确保它不会与任何用户声明的函数或从其他类或接口继承的函数冲突。函数的名称在运行时并不重要,因此函数的名称实际上并不重要。在运行时,函数通过 vtable 查找进行调用,由于编译器正在生成包装函数,因此它知道将哪些函数指针放入 vtable 中。
按照相同的模式为我们的 SetValue 函数生成包装器,不同之处在于没有添加输出参数,因为它不返回任何值。
一个简单的类,没有 C++/CX
通过我们到目前为止所讨论的内容,我们已经有足够的信息在 WRL 的帮助下使用 C++ 实现我们的 Number 类,而无需使用 C++/CX。
使用 C++/CX 时,C++ 编译器将生成组件的 Windows 元数据 (WinMD) 文件和定义所有类型的 DLL。当我们不使用 C++/CX 时,我们需要使用 IDL 来定义需要以元数据结尾的任何内容,并使用 midlrt 从 IDL 生成 C++ 头文件和 Windows 元数据文件。对于那些有 COM 经验的人来说,这应该非常熟悉。
首先,我们需要为我们的 Number 类型定义一个接口。这相当于我们使用 C++/CX 时编译器自动为我们生成的 __INumberPublicNonVirtuals 接口。这里,我们只需将接口命名为 INumber:
[exclusiveto(Number)]
[uuid(5 b197688 - 2 f57 - 4 d01 - 92 cd - a888f10dcd90)]
[version(1.0)]
interface INumber: IInspectable {
HRESULT GetValue([out, retval] INT32 * value);
HRESULT SetValue([ in ] INT32 value);
}
我们的 INumber 接口派生自 IInspectable 接口。这是所有 Windows 运行时接口派生自的基础接口;在 C++/CX 中,每个接口都隐式派生自 IInspectable。
我们还需要在 IDL 文件中定义 Number 类本身,因为它是公共的,因此需要最终出现在元数据文件中:
[activatable(1.0), version(1.0)]
runtimeclass Number {
[default]
interface INumber;
}
此处使用的 activatable 属性指定此类是默认可构造的。对象构造的工作原理将在后续文章中介绍。目前,只需知道此 activatable 属性将在元数据文件中生成所需的元数据,以将 Number 类报告为默认可构造即可。
这就是 IDL 文件中所需的全部内容。如果将此 IDL 文件生成的 WinMD 文件与为我们的 C++/CX 组件生成的文件进行比较,您会发现它们大致相同:接口的名称不同,并且有一些额外的属性应用于 C++/CX 组件中的类型,但除此之外它们是相同的。
class Number: public RuntimeClass < INumber > {
InspectableClass(RuntimeClass_WRLNumberComponent_Number, BaseTrust)
public: Number(): _value(0) {}
virtual HRESULT STDMETHODCALLTYPE GetValue(INT32 * value) override {
* value = _value;
return S_OK;
}
virtual HRESULT STDMETHODCALLTYPE SetValue(INT32 value) override {
_value = value;
return S_OK;
}
private: INT32 _value;
};
RuntimeClass 类模板和 InspectableClass 宏均来自 WRL:它们共同处理实现 Windows 运行时类所需的大量单调重复工作。RuntimeClass 类模板将类将实现的一组接口作为其参数,并提供 IInspectable 接口成员函数的默认实现。InspectableClass 宏将类的名称和类的信任级别作为其参数;这些是实现 IInspectable 接口部分所必需的。
鉴于上述讨论,这两个成员函数的定义符合预期:我们没有直接使用 __stdcall 修饰符,而是使用 STDMETHODCALLTYPE,当使用 Visual C++ 和 Windows 标头时,它会扩展为 __stdcall,但如果您使用的是其他编译器,则可以更改为扩展为其他内容。您也可以使用 STDMETHOD 宏。
最后,由于我们的 Number 类型是默认可构造的,并且默认构造函数是公共的,这意味着其他组件可以创建此类的实例,因此我们需要实现使其他组件能够调用我们类的构造函数所需的逻辑。这涉及实现工厂类并注册工厂,以便我们可以在被要求时返回工厂的实例。这里需要做很多工作,但由于我们只有一个默认构造函数,因此我们只需使用 WRL 中的 ActivatableClass 宏即可:
ActivatableClass(Number)
就是这样!WRL 组件所需的代码量是 C++/CX 组件的三倍多,但这只是一个小而简单的组件。随着事情变得越来越复杂,随着我们使用 Windows 运行时的更多功能,我们将看到基于 WRL 的代码的增长速度和复杂程度都比基于 C++/CX 的代码快得多。