C++/CLI 实战 - 声明 CLR 类型






4.95/5 (37投票s)
摘录自第 1 章。主题包括 (1) 声明 CLR 类型和 (2) 句柄:CLI 中指针的等价物
![]() |
|
这是 Nishant Sivakumar 撰写并由 Manning Publications 出版的《C++/CLI 实战》一书的章节摘录。内容已为 CodeProject 重新排版,可能与印刷版和电子书的布局有所不同。
1.3 声明 CLR 类型
在本节中,我们将探讨声明 CLI(或 CLR)类型的语法、可应用于 CLI 类型的修饰符以及 CLI 类型如何实现继承。C++/CLI 同时支持原生(非托管)和托管类型,并使用一致的语法声明各种类型。原生类型声明和使用方式与标准 C++ 相同。声明 CLI 类型类似于声明原生类型,不同之处在于类声明前会加上一个形容词,指示正在声明的类型。表 1.3 显示了各种 CLI 类型声明的示例。
CLI 类型 | 声明语法 |
引用类型 |
ref class RefClass1
{
void Func(){}
};
ref struct RefClass2
{
void Func(){}
};
|
值类型 |
value class ValClass1
{
void Func(){}
};
value struct ValClass2
{
void Func(){}
};
|
接口类型 |
interface class IType1
{
void Func();
};
interface struct IType2
{
void Func();
};
|
表 1.3 CLI 类型的类型声明语法
C# 开发人员可能会对 `class` 和 `struct` 同时用于引用类型和值类型感到困惑。在 C++/CLI 中,`struct` 和 `class` 可互换使用(正如标准 C++ 中那样),并且它们遵循结构体和类的标准 C++ 可见性规则。在类中,方法默认为 `private`;在结构体中,方法默认为 `public`。在表 1.3 中,`RefClass1::Func` 和 `ValClass1::Func` 都是 `private`,而 `RefClass2::Func` 和 `ValClass2::Func` 都是 `public`。为了清晰起见并与 C# 保持一致,您可能希望仅使用 `ref class` 来声明引用类型,并使用 `value struct` 来声明值类型,而不是混用 `class` 和 `struct` 来声明引用类型和值类型。
接口方法始终为 `public`;将接口声明为结构体等同于将其声明为类。这意味着在生成的 MSIL 中,`IType1::Func` 和 `IType2::Func` 都将是 `public`。C# 开发人员必须牢记以下几点:
- C++/CLI 的值类(或值结构体)与 C# 的结构体相同。
- C++/CLI 的引用类(或引用结构体)与 C# 的类相同。
熟悉旧 MC++ 语法的各位应该还记得这三点:
- 引用类与 `__gc` 类相同。
- 值类与 `__value` 类相同。
- 接口类与 `__interface` 相同。
带空格的关键字您需要注意的一个有趣之处是,C++/CLI 只引入了三个新的保留关键字:`gcnew`、`nullptr` 和 `generic`。所有其他看似新关键字的都是*带空格*(或上下文)关键字。诸如 `ref class`、`for each` 和 `value class` 等语法短语是带空格的关键字,在编译器的词法分析器中被视为单个标记。最大的好处是,任何使用这些新关键字(如 `ref` 或 `each`)的现有代码都能正确编译,因为在 C++ 中,标识符中不允许使用空格。以下代码在 C++/CLI 中是完全有效的: int ref = 0;
int value = ref;
bool each = value == ref;
当然,如果您的现有代码将 `gcnew`、`nullptr` 或 `generic` 用作标识符,C++/CLI 将无法编译,您必须重命名这些标识符。 |
您已经了解了如何声明 CLI 类型。接下来,我们将介绍如何将类型修饰符应用于这些类(或结构体,视情况而定)。
1.3.1 类修饰符
您可以在类上指定 `abstract` 和 `sealed` 修饰符;一个类可以同时标记为 `abstract` 和 `sealed`。但此类不能显式派生自任何基类,并且只能包含 `static` 成员。由于全局函数不符合 CLS 标准,如果您希望代码符合 CLS 标准,则应使用 `abstract sealed` 类配合 `static` 函数,而不是全局函数。
如果您想知道何时以及为何需要使用这些修饰符,请记住,要有效地编写针对 .NET Framework 的代码,您应该能够实现所有受支持的 CLI 范例。CLI 显式支持抽象类、密封类以及既抽象又密封的类。如果 CLI 支持,您也应该能够做到。
与标准 C++ 一样,抽象类只能用作其他类的基类。并不要求类包含抽象方法才能将其声明为抽象类,这为您设计类层次结构提供了额外的灵活性。以下类是抽象的,因为它声明为 `abstract`,尽管它不包含任何抽象方法:
ref class R2 abstract
{
public:
virtual void Func(){}
};
一个有趣的编译器行为是,如果您有一个带有抽象方法的类但没有标记为 `abstract`,例如下面的类,编译器会发出警告 C4570(*类未显式声明为抽象但具有抽象函数*),而不是发出错误:
ref class R1
{
public:
virtual void Func() abstract;
};
在生成的 IL 中,类 R1 被标记为 `abstract`,这意味着如果您尝试实例化该类,您将收到编译器错误(而且应该如此)。当一个类包含抽象方法时,不将其标记为 `abstract` 是不规范的,我强烈建议您为至少有一个方法是抽象的类显式标记为 `abstract`。请注意我在前面的示例中如何将 `abstract` 修饰符应用于类方法;您将在第 2 章中看到更多关于此和其他函数修饰符的内容。
使用 `sealed` 修饰符的语法类似。`sealed` 类不能用作任何其他类的基类——它将类密封起来,禁止进一步派生:
ref class S sealed
{
};
ref class D : S // This won't compile
{ // Error C3246
};
`sealed` 类通常用于您不希望修改某个类的特性(通过派生类)时,因为您希望确保该类的所有实例都以固定的方式运行。由于派生类可以在基类可以使用的任何地方使用,如果您允许类被继承,通过在预期基类实例的地方使用派生类实例,代码用户可能会改变类的预期功能(而您希望该功能保持不变)。例如,考虑一个银行应用程序,它有一个 `CreditCardInfo` 类,用于获取有关持卡人信用卡交易的信息。由于此类的实例会偶尔通过 Internet 传输,所有内部数据都使用强大的加密算法安全地存储。允许继承该类存在风险,一个不明智的程序员可能会忘记正确遵循 `CreditCardInfo` 类实现的加密;因此,派生类的任何实例本质上都不安全。通过将 `CreditCardInfo` 类标记为 `sealed`,可以轻松避免这种意外情况。
使用 `sealed` 类的一个性能优势是,由于编译器知道 `sealed` 类不能有任何派生类,因此它可以使用非虚拟调用来静态解析 `sealed` 类实例上的虚拟成员调用。例如,假设 `CreditCardInfo` 类重写了 `GetHashCode` 方法(它从 `Object` 继承),当您在运行时调用 `GetHashCode` 时,CLR 不必弄清楚调用哪个函数。这是因为不必确定类的多态类型(因为 `CreditCardInfo` 对象只能是 `CreditCardInfo` 对象,它不能是派生类型的对象——不存在派生类型)。它直接调用 `CreditCardInfo` 类定义的 `GetHashCode` 方法。
查看以下 `abstract sealed` 类的示例:
ref class SA abstract sealed
{
public:
static void DoStuff(){}
private:
static int bNumber = 0;
};
如前所述,`abstract sealed` 类不能有实例方法;尝试包含它们会抛出编译器错误 C4693。考虑到 `abstract sealed` 类上的实例方法毫无用处,因为您永远无法创建此类的一个实例,所以这并不令人费解。`abstract sealed` 类不能显式派生自基类,但它隐式派生自 `System::Object`。对于使用过 C# 的各位来说,知道 `abstract sealed` 类与 C# 的 `static` 类相同可能很有意思。
现在我们已经讨论了如何声明 CLI 类型以及如何应用修饰符,接下来让我们看看 CLI 类型如何与继承一起工作。
1.3.2 CLI 类型与继承
继承规则与标准 C++ 相似,但存在一些差异,在使用 C++/CLI 时认识到这些差异很重要。好消息是,大多数差异都是显而易见且自然的,由 CLI 的性质决定。因此,您不会觉得记住它们特别费劲。
引用类型(`ref class` / `struct`)仅支持公共继承,如果您省略访问关键字,则假定为 `public` 继承。
ref class Base
{
};
ref class Derived : Base // implicitly public
{
};
如果您尝试使用 `private` 或 `protected` 继承,您将收到编译器错误 C3628。实现接口时也适用相同的规则;接口必须使用 `public` 继承来实现,如果您省略访问关键字,则假定为 `public`。
interface class IBase
{
};
ref class Derived1 : private IBase {}; //error C3141
ref class Derived2 : protected IBase {}; //error C3141
ref class Derived3 : IBase {}; //public assumed
值类型和继承的规则与引用类型的规则略有不同。值类型只能实现接口;它不能继承自其他值类型或引用类型。这是因为值类型隐式派生自 `System::ValueType`。由于 CLI 类型不支持多重基类,值类型不能有任何其他基类。此外,值类型始终是密封的,不能用作基类。在以下代码片段中,只有 `Derived3` 类可以编译。其他两个类尝试继承自引用类和值类,这两者都不允许:
ref class RefBase {};
value class ValBase {};
interface class IBase {};
value class Derived1 : RefBase {}; //error C3830
value class Derived2 : ValBase {}; //error C3830
value class Derived3 : IBase {};
这些限制适用于值类型,因为值类型的目的是成为简单的类型,没有继承或引用同一性的复杂性,这些可以通过基本的按值复制语义来实现。还要注意,这些限制是由 CLI 强制执行的,而不是由 C++ 编译器强制执行的。C++ 编译器仅遵守 CLI 关于值类型的规则。作为开发人员,您在设计类型时需要牢记这些限制。值类型保持简单是为了让 CLR 能够在运行时进行优化,在那里它们被视为简单的纯旧数据 (POD) 类型,如 `int` 或 `char`,从而使其比引用类型极其高效。
当您想决定一个类是否应该是值类型时,可以遵循一个简单的规则:尝试确定您希望它被视为一个类还是普通数据。如果您希望它被视为一个类,请不要将其设为值类型;但如果您希望它的行为方式与 `int` 或 `char` 相同,那么很可能您的最佳选择是将其声明为值类型。通常,如果您希望它被视为一个类,您会期望它支持虚拟方法、用户定义的构造函数以及复杂数据类型的其他特性。另一方面,如果它只是一个带有某些数据成员的类或结构体,而这些数据成员本身是值类型,如 `int` 或 `char`,那么您可能希望将其设为值类型。
一个需要注意的重要问题是,CLI 类型不支持多重继承。因此,虽然 CLI 类型可以实现任意数量的接口,但它只能有一个直接父类型;如果未指定,则默认假定为 `System::Object`。
接下来,我们将讨论 VC++ 2005 中引入的最重要功能之一:句柄的概念。
1.4 句柄:CLI 中指针的等价物
句柄是 C++/CLI 中引入的一个新概念;它们取代了托管 C++ 中使用的 `__gc` 指针概念。在本章前面,我们讨论了旧语法中普遍存在的指针使用混乱问题。句柄解决了这种混乱。在我看来,句柄的概念为将 C++ 提升为 .NET 编程语言世界的头等公民做出了最大贡献。在本节中,我们将探讨使用句柄的语法。我们还将介绍使用跟踪引用的相关主题。
1.4.1 使用句柄的语法
句柄是对 CLI 堆上托管对象的引用,并用 `^` 标点符号(发音为hat)表示。
注意 | 在本章中,当我提到标点符号时,我指的是从编译器的角度。就语言语法而言,您可以将*标点符号*一词替换为*运算符*,而不会改变其含义。 |
句柄对于 CLI 堆来说就像原生指针对于原生 C++ 堆一样;就像您使用指针处理堆分配的原生对象一样,您使用句柄处理在 CLI 堆上分配的托管对象。请注意,虽然原生指针不一定总是指向原生堆(原生指针可以指向托管堆或非 C++ 分配的内存存储),但托管句柄与托管堆有着紧密的联系。以下代码片段展示了如何声明和使用句柄:
String^ str = "Hello world";
Student^ student = Class::GetStudent("Nish");
student->SelectSubject(150);
在代码中,`str` 是 CLI 堆上 `System::String` 对象的句柄,`student` 是 `Student` 对象的句柄,而 `SelectSubject` 调用 `student` 句柄上的一个方法。
`str` 引用的内存地址不保证保持不变。`String` 对象在垃圾回收周期后可能会被移动,但 `str` 仍将是同一个 `System::String` 对象的引用(除非被程序更改)。句柄在托管对象被移动时能够改变其内部内存地址的能力称为跟踪。
句柄可能看起来与指针非常相似,但在行为方面,它们是完全不同的实体。表 1.4 说明了句柄和指针之间的区别。
句柄 | 指针 |
句柄用 `^` 标点符号表示。 | 指针用 `*` 标点符号表示。 |
句柄是对 CLI 堆上托管对象的引用。 | 指针指向内存地址。 |
句柄在其生命周期内可能引用不同的内存位置,具体取决于 GC 周期和堆压缩。 | 指针是稳定的,垃圾回收周期不会影响它们。 |
句柄跟踪对象,因此如果对象被移动,句柄仍然拥有对该对象的引用。 | 如果原生指针指向的对象被程序移动,则指针不会更新。 |
句柄是类型安全的。 | 指针并非设计用于类型安全。 |
`gcnew` 运算符返回已实例化 CLI 对象的句柄。 | `new` 运算符返回原生堆上已实例化原生对象的指针。 |
不必删除句柄。垃圾回收器最终会清理所有孤立的托管对象。 | 您有责任调用 `delete` 来释放您分配的对象指针;否则,您将面临内存泄漏。 |
句柄不能转换为 `void^` 或从 `void^` 转换。 | 指针可以转换为 `void*` 或从 `void*` 转换。 |
句柄不允许句柄算术运算。 | 指针算术是操作原生数据的流行机制,尤其是在处理数组时。 |
表 1.4 句柄与指针的区别
尽管有这些区别,但通常您会发现,在大多数情况下,您最终会像使用指针一样使用句柄。事实上,`*` 和 `->` 运算符用于解引用句柄(就像使用指针一样)。但了解句柄和指针之间的区别很重要。VC++ 团队成员最初称它们为托管指针、GC 指针和跟踪指针。最终,团队决定称它们为句柄,以避免与指针混淆;在我看来,这是一个明智的决定。
现在我们已经介绍了句柄,是时候介绍相关的跟踪引用概念了。
1.4.2 跟踪引用
正如标准 C++ 支持引用(使用 `&` 标点符号)来补充指针一样,C++/CLI 支持使用 `%` 标点符号的跟踪引用来补充句柄。标准 C++ 引用显然不能用于 CLR 堆上的托管对象,因为其内存地址不保证在任何一段时间内保持不变。必须引入跟踪引用;顾名思义,它跟踪 CLR 堆上的托管对象。即使对象被 GC 移动,跟踪引用仍然持有对其的引用。就像原生引用可以绑定到左值一样,跟踪引用可以绑定到托管左值。有趣的是,由于左值隐式转换为托管左值,跟踪引用也可以绑定到原生指针和类类型。让我们看一个接受 `String^` 参数的函数,然后为它分配一个字符串。第一个版本不如预期工作,调用代码发现传递给函数的 `String` 对象未被更改:
void ChangeString(String^ str)
{
str = "New string";
}
int main(array<System::String^>^ args)
{
String^ str = "Old string";
ChangeString(str);
Console::WriteLine(str);
}
如果您执行此代码片段,您会发现调用 `ChangeString` 后 `str` 仍然包含旧字符串。将 `ChangeString` 更改为:
void ChangeString(String^% str)
{
str = "New string";
}
现在您会发现 `str` 确实被更改了,因为函数接收的是 `String` 对象句柄的跟踪引用,而不是像前一种情况那样接收 `String` 对象。一个通用的定义是,对于任何类型 `T`,`T%` 是类型 `T` 的跟踪引用。C# 开发人员可能会有兴趣知道,在 MSIL 级别上,这等同于将 `String` 作为 C# `ref` 参数传递给 `ChangeString`。因此,每当您想将 CLI 句柄传递给函数,并且您期望句柄本身在函数内被更改时,您就需要将句柄的跟踪引用传递给函数。
在标准 C++ 中,除了用作引用外,`&` 符号还用作一元地址运算符。为了保持一致性,在 C++/CLI 中,一元 `%` 运算符返回其操作数的一个句柄,使得 `%T` 的类型为 `T^`(类型 `T` 的句柄)。如果您计划使用栈语义(我们将在下一章讨论),您会发现自己经常在访问 .NET Framework 库时应用一元 `%` 运算符。这是因为 .NET 库始终需要对象的句柄(因为 C++ 是唯一支持非句柄引用类型的语言);因此,如果您有一个使用栈语义声明的对象,您可以对其应用一元 `%` 运算符来获取一个句柄类型,然后您可以将其传递给库函数。以下代码展示了如何使用一元 `%` 运算符:
Student^ s1 = gcnew Student();
Student% s2 = *s1; // Dereference s1 and assign
// to the tracking reference s2
Student^ s3 = %s2; // Apply unary % on s2 to return a Student^
请注意,`*` 标点符号用于解引用指针和句柄,尽管对称地思考,`^` 标点符号应该用于解引用句柄。也许这是这样设计的,以便我们可以编写处理原生类型和非托管类型的无差别模板/泛型类。
现在您知道如何声明 CLI 类型了;您也知道如何使用 CLI 类型的句柄。为了运用这些技能,您必须理解 CLI 类型是如何实例化的,这将在下一节中进行讨论。