C++/CLI 实战 - 实例化 CLI 类






4.98/5 (27投票s)
这是第一章的摘录,涵盖了 CLI 类的实例化,并讨论了构造函数和赋值运算符。
![]() |
|
这是 Nishant Sivakumar 撰写并由 Manning Publications 出版的《C++/CLI 实战》一书的章节摘录。内容已为 CodeProject 重新排版,可能与印刷版和电子书的布局有所不同。
1.5 实例化 CLI 类
在本节中,您将看到如何使用 `gcnew` 运算符实例化 CLI 类。您还将了解构造函数、复制构造函数和赋值运算符如何与托管类型协同工作。尽管基本概念保持不变,但 CLI 的性质在构造函数和赋值运算符的工作方式上施加了一些行为差异;当您开始编写托管类和库时,理解这些差异非常重要。不过,请不要担心。一旦您看到了托管对象如何与构造函数和赋值运算符协同工作,实例化托管对象和非托管对象之间的差异就会自动变得清晰。
1.5.1 gcnew 运算符
`gcnew` 运算符用于实例化 CLI 对象。它返回对 CLR 堆上新创建对象的句柄。尽管它与 `new` 运算符类似,但存在一些重要区别:`gcnew` 没有数组形式或放置形式,并且不能在全局或特定于类的范围内重载。考虑到内存是由垃圾回收器分配的,放置形式对于 CLI 类型来说并没有太大的意义。同样的原因也不允许您重载 `gcnew` 运算符。`gcnew` 没有数组形式,因为 CLI 数组使用与非托管数组完全不同的语法,我们将在下一章中详细介绍。如果 CLR 无法分配足够的内存来创建对象,则会抛出 `System::OutOfMemoryException`,尽管您遇到这种情况的可能性很小。(如果您确实遇到了 `OutOfMemoryException`,并且您的系统没有运行低虚拟内存,则很可能是由于代码编写不当,例如无限循环不断创建被错误地保留的对象。)下面的代码清单显示了 `gcnew` 关键字用于实例化托管对象(在本例中是 `Student` 对象)的典型用法。
ref class Student
{
...
};
...
Student^ student = gcnew Student();
student->SelectSubject("Math", 97);
C++/CLI 编译器将 `gcnew` 运算符编译为 `newobj` MSIL 指令。`newobj` MSIL 指令创建一个新的 CLI 对象——要么是 CLR 堆上的 `ref` 对象,要么是栈上的 `value` 对象——尽管 C++/CLI 编译器使用不同的机制来处理 `gcnew` 运算符创建 `value` 类型对象的用法(我将在本节稍后介绍)。由于 C++ 中的 `gcnew` 翻译为 MSIL 中的 `newobj`,因此 `gcnew` 的行为在很大程度上取决于 `newobj` MSIL 指令,因此与其相似。事实上,当 `newobj` 无法找到足够的内存来分配所请求的对象时,它会抛出 `System::OutOfMemoryException`。一旦对象在 CLR 堆上分配完毕,就会使用零个或多个参数(取决于使用的构造函数重载)在该对象上调用构造函数。构造函数调用成功完成后,`gcnew` 返回对已实例化对象的句柄。需要注意的是,如果构造函数调用不成功完成,例如在构造函数内部抛出异常,`gcnew` 将不会返回句柄。以下代码片段可以轻松验证这一点。
ref class Student
{
public:
Student()
{
throw gcnew Exception("hello world");
}
};
//...
Student^ student = nullptr; //initialize the handle to nullptr
try
{
student = gcnew Student(); //attempt to create object
}
catch(Exception^)
{
}
if(student == nullptr) //check to see if student is still nullptr
Console::WriteLine("reference not allocated to handle");
不出所料,当执行 `if` 块时,`student` 仍然是 `nullptr`。由于构造函数未执行完毕,CLR 认为对象尚未完全初始化,因此不会将句柄引用推送到堆栈上(如果构造函数成功完成,它就会这样做)。
注意 | C++/CLI 引入了一个名为 `nullptr` 的通用空字面量。这允许您使用相同的字面量 (`nullptr`) 来表示空指针和空句柄值。`nullptr` 会隐式转换为指针或句柄类型;对于指针,它根据标准 C++ 求值为 0;对于句柄,它求值为空引用。您可以在关系、相等和赋值表达式中将 `nullptr` 与指针和句柄一起使用。 |
正如我之前提到的,使用 `gcnew` 实例化 `value` 类型对象所生成的 MSIL 与实例化 `ref` 类型时生成的 MSIL 不同。例如,请考虑以下使用 `gcnew` 实例化 `value` 类型的代码:
value class Marks
{
public:
int Math;
int Physics;
int Chemistry;
};
//...
Marks^ marks = gcnew Marks();
对于这段代码,C++/CLI 编译器使用 `initobj` MSIL 指令在栈上创建一个 `Marks` 对象。然后将此对象装箱为 `Marks^` 对象。我们将在下一节中讨论装箱和拆箱;现在,请注意,除非您的代码上下文要求使用 `gcnew` 来实例化 `value` 类型对象,否则这样做效率低下。必须创建一个栈对象,然后必须将其装箱为引用对象。您不仅创建了两个对象(一个在托管堆栈上,一个在托管堆上),还付出了装箱的成本。创建 `Marks` 类型(或任何 `value` 类型)对象的更有效方法是将其声明在栈上,如下所示:
Marks marks;
您已经看到调用 `gcnew` 如何在正在创建的类型的实例上调用构造函数。在接下来的部分中,我们将更深入地探讨构造函数如何与 CLI 类型协同工作。
1.5.2 构造函数
如果您有一个 `ref` 类,并且没有编写默认构造函数,编译器会为您生成一个。在 MSIL 中,构造函数是一个特殊命名的实例方法,称为 `.ctor`。为您生成的默认构造函数会调用当前类的直接基类的构造函数。如果您没有指定基类,它会调用 `System::Object` 构造函数,因为每个 `ref` 对象都隐式继承自 `System::Object`。例如,考虑以下两个类,它们都没有用户定义的构造函数:
ref class StudentBase
{
};
ref class Student: StudentBase
{
};
`Student` 和 `StudentBase` 都没有用户提供的默认构造函数,但编译器会为它们生成构造函数。您可以使用 `ildasm.exe` (随 .NET Framework 提供的 IL 反汇编器) 等工具来检查生成的 MSIL。如果您这样做,您会发现为 `Student` 生成的构造函数会调用 `StudentBase` 对象的构造函数。
call instance void StudentBase::.ctor()
为 `StudentBase` 生成的构造函数会调用 `System::Object` 构造函数。
call instance void [mscorlib]System.Object::.ctor()
与标准 C++ 一样,如果您有一个构造函数——无论是默认构造函数还是接受一个或多个参数的构造函数——编译器都不会为您生成默认构造函数。除了实例构造函数之外,`ref` 类还支持 `static` 构造函数(在标准 C++ 中不可用)。`static` 构造函数(如果存在)会初始化类的 `static` 成员。静态构造函数不能有参数,必须是 `private`,并且由 CLR 自动调用。在 MSIL 中,`static` 构造函数由一个特殊命名的 `static` 方法表示,称为 `.cctor`。这两个特殊方法名称中都包含 `.` 的一个可能原因是为了避免名称冲突,因为任何 CLI 语言都不允许函数名中包含 `.`。如果您的类中至少有一个 `static` 字段,并且您没有自己提供 `static` 构造函数,编译器会为您生成一个默认的 `static` 构造函数。当您有一个简单的类时,例如以下类,即使您没有指定,生成的 MSIL 也会包含一个 `static` 构造函数。
ref class StudentBase
{
static int number;
};
由于编译器生成的构造函数以及对 `System::Object` 的隐式继承,生成的类看起来更像这样:
ref class StudentBase : System::Object
{
static int number;
StudentBase() : System::Object()
{
}
static StudentBase()
{
}
};
`value` 类型无法声明默认构造函数,因为 CLR 无法保证 `value` 类型上的任何默认构造函数都能被正确调用,尽管 CLR 会自动将成员零初始化。无论如何,`value` 类型应该是一个简单的类型,它体现了值语义,并且不应该需要默认构造函数——甚至析构函数的复杂性。请注意,除了不允许默认构造函数之外,`value` 类型还不能拥有用户定义的析构函数、复制构造函数和复制赋值运算符。
在您得出 `value` 类型毫无用处的结论之前,您需要将 `value` 类型视为 .NET 世界中的 POD 等价物。像使用原始类型(如 `int` 和 `char`)一样使用 `value` 类型,您应该就没问题了。当您需要简单类型,而不需要 `virtual` 函数、构造函数和运算符的复杂性时,`value` 类型是更有效的选择,因为它们在栈上分配。栈访问比访问垃圾回收的 CLR 堆上的对象要快。如果您想知道为什么会这样,与 CLR 堆相比,栈的实现要简单得多。当您考虑到 CLR 堆还内在支持复杂的垃圾回收算法时,就很明显栈对象更有效。
当我说 `value` 类型在某些情况下与引用类型的行为不同时,您可能会感到有些困惑。但是,作为开发人员,您应该能够区分值类型和引用类型之间的概念差异,尤其是在设计复杂的类层次结构时。随着本书的进展,我们将看到更多示例,您应该会更适应这些差异。
由于我们已经讨论了构造函数,接下来我们将讨论复制构造函数。
1.5.3 复制构造函数
复制构造函数 是通过创建另一个对象的副本来实例化对象的构造函数。C++ 编译器会为您的非托管类生成复制构造函数,即使您没有显式地这样做。对于托管类,情况并非如此。请考虑以下代码,它尝试复制构造一个 `ref` 对象:
ref class Student
{
};
int main(array<System::String^>^ args)
{
Student^ s1 = gcnew Student();
Student^ s2 = gcnew Student(s1); <<==(1)
}
如果您用编译器运行它(**1**),您将收到编译器错误 C3673(类没有复制构造函数)。出现此错误的原因是,与标准 C++ 不同,编译器不会为您的类生成默认复制构造函数。至少有一个原因是,所有 `ref` 对象都隐式继承自 `System::Object`,而 `System::Object` 没有复制构造函数。即使编译器尝试为 `ref` 类型生成复制构造函数,它也会失败,因为它无法访问基类复制构造函数(它不存在)。
为了更清楚地说明这一点,请考虑一个具有 `private` 复制构造函数的非托管 C++ 类 `Base`,以及一个派生类 `Derived`(它 `public` 继承自 `Base`)。尝试复制构造 `Derived` 对象将失败,因为基类复制构造函数不可访问。为了演示,让我们编写一个继承自具有 `private` 复制构造函数的基类的类:
class Base
{
public:
Base(){}
private:
Base(const Base&);
};
class Derived : public Base
{
};
int _tmain(int argc, _TCHAR* argv[])
{
Derived d1;
Derived d2(d1); // <-- won't compile
}
由于基对象复制构造函数被声明为 `private`,因此从派生对象无法访问它,所以这段代码无法编译:编译器无法复制构造派生对象。`ref` 类的情况与此代码类似。此外,与非托管 C++ 对象(除非通过指针访问它们,否则它们不是多态的)不同,`ref` 对象是隐式多态的(因为它们总是通过指向 CLR 堆的句柄来访问)。这意味着编译器生成的复制构造函数可能并不总是执行您期望的操作。当您考虑到 `ref` 类型可能包含成员 `ref` 类型时,就会出现一个问题:复制构造函数是为这些成员实现浅拷贝还是深拷贝。VC++ 团队可能认为,对于未定义复制构造函数的类,由编译器自动生成复制构造函数存在太多不确定性。
如果您想为您的类提供复制构造函数支持,您必须显式实现它,这幸运的是一项不难的任务。让我们为 `Student` 类添加一个复制构造函数:
ref class Student
{
public:
Student(){}
Student(const Student^)
{
}
};
这并不难,对吧?请注意,您必须显式地为类添加一个默认的无参数构造函数。这是因为当编译器看到存在另一个构造函数时,它不会生成默认构造函数。此复制构造函数的一个限制是参数必须是 `Student^`,这没关系,除了您可能有一个您想传递给复制构造函数的 `Student` 对象。如果您想知道这是如何可能的,C++/CLI 支持栈语义,我们将在第 3 章中详细介绍。假设您有一个 `Student` 对象 `s1` 而不是 `Student^`,并且您需要使用它来调用复制构造函数:
Student s1;
Student^ s2 = gcnew Student(s1); //error C3073
正如您所见,这段代码无法编译。有两种方法可以解决此问题。一种方法是对 `s1` 对象使用一元 `%` 运算符来获取指向 `Student` 对象的句柄:
Student s1;
Student^ s2 = gcnew Student(%s1);
虽然这可以编译并解决了当前问题,但考虑到您的代码的每个调用者都需要执行相同的操作(如果他们拥有 `Student` 对象而不是 `Student^`),这并不是一个完整的解决方案。另一种解决方案是为复制构造函数提供两个重载,如列表 1.2 所示。
ref class Student
{
//...
public:
Student(){}
Student(String^ str):m_name(str){}
Student(const Student^) <<==(1)
{
}
Student(const Student%) <<==(2)
{
}
};
//...
Student s1;
Student^ s2 = gcnew Student(s1);
列表 1.2 为复制构造函数声明两个重载
这解决了调用者需要正确对象形式的问题,但它带来了另一个问题:代码重复。您可以将公共代码包装在一个 `private` 方法中,并让复制构造函数的两个重载都调用此方法,但这样您就无法利用初始化列表。
最终,这是一个您必须做出的设计选择。**1** 如果您只有接受 `Student^` 的复制构造函数重载,那么当您拥有 `Student` 对象时,您需要使用一元 `%` 运算符;并且 **2** 如果您只有接受 `Student%` 的重载,那么在使用它进行复制构造之前,您需要使用 `*` 运算符解引用 `Student^`。如果您两者都有,您可能会遇到代码重复;避免代码重复(使用两个重载都调用的公共函数)的唯一方法是剥夺您使用初始化列表的能力。
我的建议是使用接受句柄的重载(在上一个示例中,接受 `Student^` 的那个),因为此重载对 C# 等其他 CLI 语言可见(不像另一个重载)——如果您遇到语言互操作情况,这很好。一元 `%` 运算符实际上不会减慢您的代码速度;它只是一个您需要输入的额外字符。我还建议您避免使用两个重载,除非是仅由 C++ 调用者使用的库的特定情况;即使如此,您也必须考虑代码重复的问题。
现在您知道,如果您需要为 `ref` 类型提供复制构造函数支持,您必须自己实现它。因此,在下一节中,您可能会看到同样的情况也适用于复制赋值运算符,这可能并不令人惊讶。
1.5.4 赋值运算符
复制赋值运算符是编译器在标准 C++ 中为非托管类自动生成的运算符,但对于 `ref` 类则不是。原因与决定不自动生成复制构造函数的原因类似。以下代码(使用前面定义的 `Student` 类)将无法编译:
Student s1("Nish");
Student s2;
s2 = s1; // error C2582: 'operator =' function
// is unavailable in 'Student'
定义赋值运算符与您在标准 C++ 中所做的类似,除了类型是托管的:
Student% operator=(const Student% s)
{
m_name = s.m_name;
return *this;
}
请注意,复制赋值运算符只能由 C++ 调用者使用,因为它对 C# 和 VB.NET 等其他语言不可见。另请注意,对于句柄变量,您不需要编写复制赋值运算符,因为句柄值会内在复制。
您应该尝试将您在 C++ 中遵循的许多良好编程实践带到 CLI 世界,除非它们不适用。例如,赋值运算符不处理自赋值。虽然在我们的特定示例中无关紧要,但请考虑列表 1.3 中的情况。
ref class Grades <<==(1)
{
//...
};
ref class Student
{
String^ m_name;
Grades^ m_grades;
public:
Student(){}
Student(String^ str):m_name(str){}
Student% operator=(const Student% s)
{
m_name = s.m_name;
if(m_grades) [#2]
delete m_grades; <<==(2)
m_grades = s.m_grades;
return *this;
}
void SetGrades(Grades^ grades)
{
//...
}
};
列表 1.3 自赋值问题
在前面的列表中,**1** 假设 `Grades` 是一个具有非平凡构造函数和析构函数的类;因此,在 `Student` 类赋值运算符中,在复制 `m_grades` 成员之前,**2** 现有的 `Grades` 对象通过对其调用 `delete` 来显式释放——所有这些都非常高效。让我们假设发生了自赋值:
while(some_condition)
{
// studarr is an array of Student objects
studarr[i++] = studarr[j--]; // self-assignment occurs if i == j
if(some_other_condition)
break;
}
在前面的代码片段中,如果 `i` 等于 `j`,您将得到一个损坏的 `Student` 对象,其 `m_grades` 成员无效。就像您在标准 C++ 中所做的那样,您应该检查自赋值:
Student% operator=(const Student% s)
{
if(%s == this) //<<== Check for self-assignment
{
return *this; //<<== If it is so, return immediately
}
m_name = s.m_name;
if(m_grades)
delete m_grades;
m_grades = s.m_grades;
return *this;
}
我们在本节中涵盖了很多内容——如果您觉得很多信息传递得太快,请不要担心。我们到目前为止讨论的大部分内容都会在这本书中再次出现,最终它们都会让您完全理解。接下来我们将讨论装箱和拆箱,我认为这是许多 .NET 程序员未能正确理解的概念——并产生了不好的后果。