发布日期 : 5/31/2005 | 更新日期 : 5/31/2005
Stanley B. Lippman
Microsoft Corporation
适用于:
C++/CLI 第二版
ISO-C++
摘要 :C++/CLI代表 ISO-C++标准语言的一个动态编程范型扩展。本文列举了 V1 版本语言的功能,以及它们在 V2 版本语言中的对应功能(如果存在);并指出了不存在相应功能的那些构造。
本页内容
简介
1. 语言关键字
2. 托管类型
3.类或接口中的成员声明
4 值类型及其行为
5. 语言变化概要
附录:推动修订版语言设计
致谢
简介
C++/CLI代表 ISO-C++标准语言的一个动态编程泛型扩展 (dynamic programming paradigm extension)。在原版语言设计 (V1) 中有许多显著的弱点,我们觉得在修订版语言设计 (V2) 中已经修正了这些弱点。本文列举了 V1 版本语言的功能和它们在 V2 版本中的对应功能(如果存在);并指出了其对应功能不存在的构造。对于有兴趣的读者,可以查看附录中提供新语言设计的扩展原理。另外,一个源代码级别的转换工具 (mscfront) 正在开发中,而且可能在 C++/CLI的发布版中提供给希望将 V1 代码自动移植到新语言设计的人。
本文分为五个章节加一个附录。第一节讨论语言关键字的主要问题,特别是双下划线的移除以及与上下文相关和由空格分隔的关键字。第二节着眼于托管类型的变化 — 特别是托管引用类型和数组。还可以在这里找到有关确定性终结语义 (deterministic finalization) 的详细讨论。关于类成员的变化,例如属性、索引属性和操作符,是第三节的重点。第四节着眼于 CLI 枚举、内部和钉住指针的语法变化。它也讨论了许多可观的语义变化,例如隐式装箱的引入、CLI枚举的变化,和对值类中默认构造函数的支持的移除。第五节有点像大杂烩 — 乱七八糟的杂项。讨论了类型转换符号、字符串字符的行为和参数数组。
1. 语言关键字
原版到修订版语言设计的一个重要转换是在所有关键字中去掉双下划线。举例来说,一个属性现在被声明为 property 而不是 __property。在原版语言设计中使用双下划线前缀的两个主要原因是:
这是提供符合 ISO-C++标准的本地扩展的一致性方法。原版语言设计的一个主要目标就是不引入与标准语言的不兼容性,例如新的关键字和标记。这个原因很大程度上也推动了对声明托管引用类型的对象的指针语法的选择。
双下划线的使用,除了兼容性方面的原因之外,也是一个不会对有旧代码基础的用户造成影响的合理保证。这是原版语言设计的第二主要目标。
这样的话,为什么我们移除双下划线(并且引入了一些新的标记)?不是的,这并不代表我们不再考虑和标准保持一致!
我们继续致力于和标准一致。尽管如此,我们意识到对 CLI动态对象模型的支持表现出了一种全新的强大的编程范型。我们在原版语言设计上的经验以及设计与发展 C++ 语言本身的经验使我们确信,对这个新范型的支持需要它自己的高级关键字和标记。我们想提供一个该新范型的一流表达方式,整合它并且支持标准语言。我们希望您会感受到修订版语言设计提供了对这两种截然不同的对象模型的一流的编程体验。
类似的,我们很关心最小化这些新的关键字的对现有代码可能造成的冲击。这是用与上下文相关和由空格分隔的关键字来解决的。在我们着眼于实际语言语法的修订之前,让我们试试搞清楚这两个特别关键字的特点。
一个与上下文相关的关键字在特定的程序上下文中有特殊的含义。例如,在通常的程序中,sealed 是一个普通标识符。但是,在一个托管引用类类型的声明部分,它就是类声明上下文中的一个关键字。这使得在语言中引入一个新的关键字的潜在影响降到最低程度,我们认为,这对已经拥有代码基的用户非常重要。同时,它允许新功能的使用者获得一流的新增语言功能的体验 — 我们认为在原版语言设计中缺少这些因素。我们将在 2.1.2节中看到 sealed 用法的示例。
一个由空格分隔的关键字是与上下文相关关键字的特例。它在字面上将一个与上下文相关的修饰符和一个现存的关键字配对,用空格分隔。这个配对作为一个单独的单位,例如 value class(示例参见 1.1 节),而不是两个单独的关键字。基于现实的因素,这意味着一个重新定义 value 的宏,如下所示:
#ifndef __cplusplus_cli
#define value
不会在一个类声明中去掉 value。如果确实要这么做的话,必须重新定义单元对,编写如下代码:
#ifndef __cplusplus_cli
#define value class class
考虑到现实的因素,这是十分必要的。否则,现存的 #define 可能转换由空格分隔的关键字的与上下文相关的关键字部分。
2. 托管类型
声明托管类型和创建以及使用这些类型的对象的语法已经大加修改,以提高 ISO-C++类型系统内的集成性。这些更改在后面的小节中详述。委托的讨论延后到 2.3节以用类中的事件成员表述它们 — 这是第 2 节的主题。(有关更加详细的跟踪引用语法介绍的内幕和设计上的主要转变的讨论,请参见附录A:推动修订版语言设计。)
2.1 声明一个托管类类型
在原版语言定义中,一个引用类类型以 __gc关键字开头。在修订版语言中,__gc关键字被两个由空格分隔的关键字 ref class或者 ref struct之一替代。struct或者 class的选择只是指明在类型体中开头未标记部分声明的其成员的公共(对于 struct)或者私有(对于 class)默认访问级别。
类似地,在原版语言定义中,一个 value 类类型以 __value 关键字开头。在修订版语言中,__value 关键字被两个由空格分隔的关键字 value class或者 value struct之一代替。
在原版语言设计中,一个接口类型是用关键字 __interface指明的。在修订版语言中,它被 interface class替代。
例如,下列类声明对
// 原版语法
public __gc class Block { ... }; // 引用类
public __value class Vector { ... }; // 值类
public __interface IMyFile { ... }; // 接口类
在修订版语言设计下等价的声明如下:
// 修订版语法
public ref class Block { ... };
public value class Vector { ... };
public interface class IMyFile { ... };
选择 ref(对于引用类型)而不是 gc(对于垃圾收集类型)是为了便于更好地暗示这个类型的本质。
2.1.1 指定一个类为抽象类型
在原版语言定义中,关键字 __abstract放在类型关键字之前(__gc之前或者之后)以指明该类尚未完成,而且此类的对象不能在程序中创建:
public __gc __abstract class Shape {};
public __gc __abstract class Shape2D: public Shape {};
在修订版语言设计中,abstract 与上下文相关的关键字被限定在类名之后,类体、基类派生列表或者分号之前。
public ref class Shape abstract {};
public ref class Shape2D abstract : public Shape{};
当然,语义没有变化。
2.1.2 指定一个类为密封类型
在原版语言定义中,关键字 __sealed放在 class 关键字之前(__gc之前或者之后)以指明类的对象不能从以下类继承:
public __gc __sealed class String {};
在 V2语言设计中,与上下文相关的抽象关键字限定在类名之后,类体、基类派生列表或者分号之前(您可以声明一个继承类并密封它。举例来说,String类隐式派生自 Object)。密封一个类的好处是允许静态(即在编译时)解析这个密封引用类对象的所有的虚函数调用。这是因为密封指示符保证了 String 跟踪句柄不能指向一个可能重载被调用的虚方法实例的派生类。
public ref class String sealed {};
也可以将一个类既声明为抽象类也声明为密封类。这是一种被称为静态类的特殊情况。这在CLI文档中描述如下:
同时为抽象和密封的类型只能有静态成员,并且以一些语言中调用命名空间一样的方式服务。
例如,以下是一个使用 V1语法的抽象密封类的声明
public __gc __sealed __abstract class State
{
public:
static State();
static bool inParamList();
private:
static bool ms_inParam;
};
而以下是在修订版语言设计中的声明:
public ref class State abstract sealed
{
public:
static State();
static bool inParamList();
private:
static bool ms_inParam;
};
2.1.3 CLI 继承 : 指定基类
在 CLI对象模型中,只支持公有方式的单继承。但是,在原始语言定义中仍然保留了ISO-C++对基类的默认解释,而无需访问关键字指定私有派生。这意味着每一个 CLI继承声明必须用一个 public关键字来代替默认的解释。很多用户认为编译器似乎过于严谨。
// V1:错误:默认为私有派生
__gc class My : File{};
在修订版语言定义中,CLI继承定义缺少访问关键字时,默认是以公有的方式派生。这样,公有访问关键字就不再必要,而是可选的。虽然这个改变不需要对 V1的代码做任何的修改,出于完整性考虑我仍将这个变化列出。
// V2:正确:默认是公有性派生
ref class My : File{};
2.2 一个 CLI 的引用类对象的声明
在原版语言定义中,一个引用类类型对象是使用 ISO-C++指针语法声明的,在星号左边使用可选的 __gc关键字。例如,以下是 V1语法下多种引用类类型对象的声明:
public __gc class Form1 : public System::Windows::Forms::Form {
private:
System::ComponentModel::Container __gc *components;
Button __gc *button1;
DataGrid __gc *myDataGrid;
DataSet __gc *myDataSet;
void PrintValues( Array* myArr )
{
System::Collections::IEnumerator* myEnumerator =
myArr->GetEnumerator();
Array *localArray = myArr->Copy();
// ...
}
};
在修订版语言设计中,引用类类型的对象用一个新的声明性符号(^)声明,正式的表述为跟踪句柄,不正式的表述为帽子。(跟踪这个形容词强调了引用类型对象位于 CLI堆中,因此可以透明地在垃圾回收堆的压缩过程中移动它的位置。一个跟踪句柄在运行时被透明地更新。两个类似的概念:(a)跟踪引用(%) 和 (b)内部指针(interior_ptr<>),在第4.4.3节讨论。
声明语法不再重用 ISO-C++指针语法有两个主要原因:
指针语法的使用不允许重载的操作符直接应用于引用对象;而必须通过其内部名称调用操作符,例如 rV1->op_Addition (rV2) 而不是更加直观的 rV2+Rv2。
有许多指针操作,例如类型强制转换和指针算术对于位于垃圾回收堆上的对象无效。我们认为一个跟踪句柄的概念最好符合一个 CLI 引用类型的本性。
对一个跟踪句柄使用 __gc修饰符是不必要的,而且是不被支持的。对象本身的用法并未变化,它仍旧通过指针成员选择操作符 (->) 访问成员。例如,以下是上面的 V1文字转换到新语言语法的结果:
public ref class Form1: public System::Windows::Forms::Form{
private:
System::ComponentModel::Container^ components;
Button^ button1;
DataGrid^ myDataGrid;
DataSet^ myDataSet;
void PrintValues( Array^ myArr )
{
System::Collections::IEnumerator^ myEnumerator =
myArr->GetEnumerator();
Array ^localArray = myArr->Copy();
// ...
}
};
2.2.1 在 CLI 堆上动态分配对象
在原版语言设计中,现有的在本机堆和托管堆上分配的两种 new表达式很大程度上是透明的。在几乎所有的情况下,编译器能够从上下文正确地确定所需的是本机堆还是托管堆。例如:
Button *button1 = new Button; // OK: 托管堆
int *pi1 = new int; // OK: 本机堆
Int32 *pi2 = new Int32; // OK: 托管堆
在上下文堆分配并非所期望的实例时,可以用 __gc或者 __nogc关键字指引编译器。在修订版语言中,使用新引入的 gcnew关键字来显示两个 new 表达式的不同本质。例如,上面三个声明在修订版语言中如下所示:
Button^ button1 = gcnew Button; // OK: 托管堆
int * pi1 = new int; // OK: 本机堆
interior_ptr<Int32> pi2 = gcnew Int32; // OK: 托管堆
(在第 3 节中讨论 interior_ptr的更多细节。通常,它表示一个对象的地址,这个对象可能(但不必)位于托管堆上。如果指向的对象确实位于托管堆上,那么它在对象被重新定位时被透明地更新。)
以下是前面一节中声明的 Form1成员 V1版本的初始化:
void InitializeComponent()
{
components = new System::ComponentModel::Container();
button1 = new System::Windows::Forms::Button();
myDataGrid = new DataGrid();
button1->Click +=
new System::EventHandler(this, &Form1::button1_Click);
// ...
}
以下是用修订版语法重写的同样的初始化过程,注意引用类型是一个 gcnew表达式的目标时不需要“帽子”。
void InitializeComponent()
{
components = gcnew System::ComponentModel::Container;
button1 = gcnew System::Windows::Forms::Button;
myDataGrid = gcnew DataGrid;
button1->Click +=
gcnew System::EventHandler( this, &Form1::button1_Click );
// ...
}
2.2.2 无对象的跟踪引用
在新的语言设计中,0不再表示一个空地址,而仅被处理为一个整型,与 1、10、100一样,这样我们需要引入一个特殊的标记来代表一个空值的跟踪引用。例如,在原版语言设计中,我们如下初始化一个引用类型来处理一个无对象:
//正确:我们设置 obj 不引用任何对象
Object * obj = 0;
//错误:没有隐式装箱
Object * obj2 = 1;
在修订版语言中,任何从值类型到一个 Object的初始化或者赋值都导致一个值类型的隐式装箱。在修订版语言中,obj和 obj2都被初始化为装箱过的 Int32对象,分别具有值 0和 1。例如:
//导致 0 和 1 的隐式装箱
Object ^ obj = 0;
Object ^ obj2 = 1;
因此,为了允许显式的初始化、赋值,以及将跟踪句柄与空进行比较,我们引入了一个新的关键字 nullptr。这样 V1示例的正确版本如下所示:
//OK:我们设置 obj 不引用任何对象
Object ^ obj = nullptr;
//OK:我们初始化 obj 为一个 Int32^
Object ^ obj2 = 1;
这使得从现存 V1代码到修订版语言设计的移植更加复杂。例如,考虑如下值类声明:
__value struct Holder { //原版 V1 语法
Holder( Continuation* c, Sexpr* v )
{
cont = c;
value = v;
args = 0;
env = 0;
}
private:
Continuation* cont;
Sexpr * value;
Environment* env;
Sexpr * args __gc [];
};
这里 args和 env都是 CLI引用类型。在构造函数中将这两个成员初始化为 0 的语句在转移到新语法的过程中必须修改为 nullptr:
//修订版 V2 语法
value struct Holder
{
Holder( Continuation^ c, Sexpr^ v )
{
cont = c;
value = v;
args = nullptr;
env = nullptr;
}
private:
Continuation^ cont;
Sexpr^ value;
Environment^ env;
array<Sexpr^>^ args;
};
类似的,将这些成员与 0进行比较的测试也必须改为和 nullptr比较。以下是原版的语法:
// 原版 V1 语法
Sexpr * Loop (Sexpr* input)
{
value = 0;
Holder holder = Interpret(this, input, env);
while (holder.cont != 0)
{
if (holder.env != 0)
{
holder=Interpret(holder.cont,holder.value,holder.env);
}
else if (holder.args != 0)
{
holder =
holder.value->closure()->
apply(holder.cont,holder.args);
}
}
return value;
}
而以下是修订版语法。将每个 0实例转换为 nullptr 。(转换工具有助于这个转换,进行许多自动处理 — 如果不是全部出现,包括使用 NULL 宏。)
//修订版 V2 语法
Sexpr ^ Loop (Sexpr^ input)
{
value = nullptr;
Holder holder = Interpret(this, input, env);
while ( holder.cont != nullptr )
{
if ( holder.env != nullptr )
{
holder=Interpret(holder.cont,holder.value,holder.env);
}
else if (holder.args != nullptr )
{
holder =
holder.value->closure()->
apply(holder.cont,holder.args);
}
}
return value;
}
nullptr可以转化成任何跟踪句柄类型或者指针,但是不能提升为一个整数类型。例如,在如下初始化集合中,nullptr只在开头两个初始值中有效。
//正确:我们设置 obj 和 pstr 不引用任何对象
Object^ obj = nullptr;
char* pstr = nullptr; //在这里用0也可以
//错误:没有从 nullptr 到 0 的转换 ...
int ival = nullptr;
类似的,给定一个重载过的方法集,如下所示:
void f( Object^ ); // (1)
void f( char* ); // (2)
void f( int ); // (3)
一段使用 nullptr的调用如下所示:
// 错误:歧义:匹配 (1) 和 (2)
f( nullptr );
是有歧义的,因为 nullptr既匹配一个跟踪句柄也匹配一个指针,而且在两者中没有一个优先选择(这需要一个显式的类型强制转换来消除歧义)。
一个使用 0的调用正好匹配实例 (3):
//正确:匹配 (3)
f( 0 );
由于 0是整型。当没有 f(int)的时候,调用会通过一个标准转换无歧义地匹配f(char*)。匹配规则优先于标准转换的精确匹配。在没有精确匹配时,标准转换优先于对于值类型的隐式装箱。这就是没有歧义的原因。
2.3 CLI 数组的声明
原版语言设计中的 CLI数组对象的声明是标准数组声明的有点不直观的扩展,其中,一个 __gc关键字放在数组对象名和可能的逗号填充的维数之间,如下一对示例所示:
// V1 语法
void PrintValues( Object* myArr __gc[]);
void PrintValues( int myArr __gc[,,]);
这在修订版语言设计中被简化了,其中,我们使用一个类似于模板的声明,它说明了STL 向量声明。第一个参数指定元素类型。第二个参数指定数组维数(默认值是 1,所以只有多维数组才需要第二个参数)。数组对象本身是一个跟踪句柄,所以必须给它一个帽子。如果元素类型也是一个引用类型,那么,它们也必须被标记。例如,上面的示例,在修订版语言中表达时如下所示:
// V2 语法
void PrintValues( array<Object^>^ myArr );
void PrintValues( array<int,3>^ myArr );
因为引用类型是一个跟踪句柄而不是一个对象,所以可能将一个 CLI数组类型用于函数的返回值类型(本机数组不能用作函数返回值)。在原版语言设计中,其语法也有点不直观。例如:
// V1 语法
Int32 f() [];
int GetArray() __gc[];
在 V2中,这个声明阅读和分析起来简单多了。例如:
// V2 语法
array<Int32>^ f();
array<int>^ GetArray();
本地托管数组的快捷初始化在两种版本的语言中都支持。例如
// V1 语法
int GetArray() __gc[]
{
int a1 __gc[] = { 1, 2, 3, 4, 5 };
Object* myObjArray __gc[] = {
__box(26), __box(27), __box(28), __box(29), __box(30)
};
// ...
}
在 V2中被大大简化了(注意因为修订版语言设计中的装箱是隐式的,__box操作符被去掉了— 关于其讨论参见第 3 节。
// V2 语法
array<int>^ GetArray()
{
array<int>^ a1 = {1,2,3,4,5};
array<Object^>^ myObjArray = {26,27,28,29,30};
// ...
}
因为数组是一个 CLI引用类型,每个数组对象的声明都是一个跟踪句柄。因此,它必须在CLI堆上被分配(快捷符号隐藏了在托管堆上进行分配的细节)。以下是原版语言设计中一个数组对象的显式初始化形式:
// V1 语法
Object* myArray[] = new Object*[2];
String* myMat[,] = new String*[4,4];
回忆一下,在新的语言设计中,new表达式被 gcnew替代了。数组的维大小作为参数传递给 gcnew表达式,如下所示:
// V2 语法
array<Object^>^ myArray = gcnew array<Object^>(2);
array<String^,2>^ myMat = gcnew array<String^,2>(4,4);
在修订版语言中,gcnew表达式后面可以跟一个显式的初始化列表,这在 V1语言中不被支持,例如:
// V2 语法
// explicit initialization list follow gcnew
// is not supported in V1
array<Object^>^ myArray =
gcnew array<Object^>(4){ 1, 1, 2, 3 }
2.4 析构函数语义的变化
在原版语言定义中,类的析构函数允许存在于引用类中,但是不允许存在于值类中。这在修订的 V2语言设计中没有变化。但是,类析构函数的语义有可观的变化。怎样和为什么变化(以及这会对现存 V1代码的转换造成怎样的影响)是本节的主题。这可能是本文中最复杂的一节,所以我们慢慢来讲。这也可能是两个语言版本之间最重要的编程级别的修改,所以需要以循序渐进的方式来进行学习。
2.4.1 不确定的终止
在对象关联的内存被垃圾回收器回收之前,如果对象有一个相关的 Finalize()方法存在,那么它将被调用。您可以将该方法想象为一种超级析构函数,因为它与对象编程生命周期无关。我们称此为终止。何时甚至是否调用 Finalize()方法的计时是不确定的。这就是我们提到垃圾回收代表不确定的终止(non-deterministic finalization)时表达的意思。
不确定的终止和动态内存管理合作的很好。当可用内存缺少到一定程度的时候,垃圾回收器介入,并且很好地工作。在垃圾回收环境中,用析构函数来释放内存是不必要的。您第一次实现应用程序时不为潜在的内存泄漏发愁才怪,但是很容易就会适应了。
然而,不确定的终止机制在对象维护一个关键的资源(例如一个数据库连接或者某种类型的锁)时运转并不好。这种情况下我们需要尽快释放资源。在本机代码的环境下,这是用构造函数/析构函数对的组合解决的。不管是通过执行完毕声明对象的本机代码块还是通过由于引发异常造成的拆栈,对象的生命周期一终止,析构函数就介入并且自动释放资源。这个机制运转得很好,而且在原版语言设计中没有它的存在是一个很大的失误。
CLI提供的解决方案是实现 IDisposable接口的 Dispose()方法的类。问题是Dispose()方法需要用户显式地调用。这是个错误的倾向,因此是个倒退。C# 语言提供一个适度的自动化方式,使用一个特别的 using语句。我们的原版语言设计(我已经提到过)根本没有提供特别的支持。
2.4.2 在 V1 中,析构函数转到 Finalize()
在原版语言中,一个引用类的析构函数通过如下两步实现:
用户编写的析构函数被内部重命名为 Finalize()。如果类有一个基类(记住,在 CLI对象模型中只支持单继承),编译器在用户的代码之后插入一个对其终结器的调用。例如,给定下列 V1语言规范中的普通层次
__gc class A {
public:
~A() { Console::WriteLine(S"in ~A"); }
};
__gc class B : public A {
public:
~B() { Console::WriteLine(S"in ~B"); }
};
两个析构函数都被重命名为 Finalize()。B 的 Finalize()在调用 WriteLine()之后加入一个 A的 Finalize()方法的调用。这些就是垃圾回收器在终止过程中默认调用的代码。它的内部转换结果如下所示:
//V1 下析构函数的内部转换
__gc class A {
public:
void Finalize() { Console::WriteLine(S"in ~A"); }
};
__gc class B : public A {
public:
void Finalize() {
Console::WriteLine(S"in ~B");
A::Finalize();
}
};
第二步中,编译器产生一个虚析构函数。这个析构函数就是我们的 V1用户程序直接调用或者通过 delete表达式的应用程序调用的。它永远不会被垃圾回收器调用。
这个产生的析构函数里面有什么内容呢?是两个语句。一个是调用GC::SuppressFinalize()以确保没有对 Finalize()方法的进一步调用。另一个是实际上的 Finalize()调用。回忆一下,这表达了用户提供的这个类的析构函数。如下所示:
__gc class A {
public:
virtual ~A()
{
System::GC::SuppressFinalize(this);
A::Finalize();
}
};
__gc class B : public A {
public:
virtual ~B()
{
System::GC:SuppressFinalize(this);
B::Finalize();
}
};
这个实现允许用户立刻显式调用类的 Finalize()方法,而不是随时调用,它并不真的依赖于使用 Dispose()方法的方案。这在修订版语言设计中进行了更改。
2.4.3 V2 中,析构函数转到 Dispose()
在修订版语言设计中,析构函数被内部重命名为 Dispose()方法,并且引用类自动扩展以实现 IDisposable接口。换句话说,在 V2中,这对类按如下所示进行转换:
// V2 下析构函数的内部转换
__gc class A : IDisposable {
public:
void Dispose() {
System::GC::SuppressFinalize(this);
Console::WriteLine( "in ~A"); }
}
};
__gc class B : public A {
public:
void Dispose() {
System::GC::SuppressFinalize(this);
Console::WriteLine( "in ~B");
A::Dispose();
}
};
在 V2 中,当析构函数被显式调用时,或者对跟踪句柄应用 delete时,底层的 Dispose()方法都会自动被调用。如果这是一个派生类,一个对基类的 Dispose()方法的调用会被插入到生成方法的末尾。
但是这样也没有给我们确定性终止的方法。为了解决这个问题,我们需要局部引用对象的额外支持(在原版语言设计中没有类似的支持,所以没有转换的问题)。
2.4.4 声明一个引用对象
修订版语言支持在本地栈上声明引用类的对象,或者声明为类的成员,就像它可以直接被访问一样(注意这在 Microsoft Visual Studio 2005 的Beta1 发布版中不可用)。析构函数和在 2.4.3 节中描述的 Dispose() 方法结合时,结果就是引用类型的终止语义的自动调用。使 CLI 社区苦恼的非确定性终止这条暴龙终于被驯服了,至少对于 C++/CLI的用户来说是这样。让我们看一下这到底意味着什么。
首先,我们这样定义一个引用类,使得对象创建函数在类构造函数中获取一个资源。其次,在类的析构函数中,释放对象创建时获得的资源。
public ref class R {
public:
R() { /* 获得外部资源 */ }
~R(){ /* 释放外部资源 */ }
// ... 杂七杂八 ...
};
对象声明为局部的,使用没有附加"帽子"的类型名。所有对对象的使用(如调用成员函数)是通过成员选择点 (.) 而不是箭头 (->) 完成的。在块的末尾,转换成 Dispose()的相关的析构函数被自动调用。
void f()
{
R r;
r.methodCall();
// ...
// r被自动析构 -
// 也就是说, r.Dispose() 被调用...
}
相对于 C#中的 using语句来说,这只是语法上的点缀而已,而不是对基本 CLI约定(所有引用类型必须在 CLI堆上分配)的违背。基础语法仍未变化。用户可能已经编写了下面同样功能的语句(这很像编译器执行的内部转换):
// 等价的实现...
// 除了它应该位于一个 try/finally 语句中之外
void f()
{
R^ r = gcnew R;
r->methodCall();
// ...
delete r;
}
事实上,在修订版语言设计中,析构函数再次与构造函数配对成为和一个局部对象生命周期关联的自动获得/释放资源的机制。这个显著的成就非常令人震惊,并且语言设计者应该因此被大力赞扬。
2.4.5 声明一个显式的 Finalize()-(!R)
在修订版语言设计中,如我们所见,构造函数被合成为 Dispose()方法。这意味着在析构函数没有被显式调用的情况下,垃圾回收器在终止过程中,不会像以前那样为对象查找相关的 Finalize()方法。为了同时支持析构函数和终止,修订版语言引入了一个特殊的语法来提供一个终止器。举例来说:
public ref class R {
public:
!R() { Console::WriteLine( "I am the R::finalizer()!" ); }
};
! 前缀表示引入类析构函数的类似符号 (~),也就是说,两种后生命周期的方法名都是在类名前加一个符号前缀。如果派生类中有一个合成的 Finalize()方法,那么在其末尾会插入一个基类的Finalize()方法的调用。如果析构函数被显式地调用,那么终止器会被抑制。这个转换如下所示:
// V2 中的内部转换
public ref class R {
public:
void Finalize()
{ Console::WriteLine( "I am the R::finalizer()!" ); }
};
2.4.6 这在 V1 到 V2 的转换中意味着什么
这意味着,只要一个引用类包含一个特别的析构函数,一个 V1程序在 V2 编译器下的运行时行为被静默地修改了。需要的转换算法如下所示:
在将代码从 V1移植到 V2的过程中,可能漏掉执行这个转换。如果应用程序某种程度上依赖于相关终止方法的执行,那么应用程序的行为将被静默地修改。
3.类或接口中的成员声明
属性和操作符的声明在修订版语言设计中已经被大范围重写了,隐藏了原版设计中暴露的底层实现细节。另外,事件声明也被修改了。
在 V1中不受支持的一项更改是,静态构造函数现在可以在类外部定义了(在 V1中它们必须被定义为内联的),并且引入了委托构造函数的概念。
3.1 属性声明
在原版语言设计中,每一个 set或者 get属性存取方法都被规定为一个独立的成员函数。每个方法的声明都由 __property关键字作为前缀。方法名以 set_或者 get_开头,后面接属性的实际名称(如用户所见)。这样,一个获得向量的 x坐标的属性存取方法将命名为 get_x,用户将以名称 x来调用它。这个名称约定和单独的方法规定实际上反映了属性的基本运行时实现。例如,以下是我们的向量,有一些坐标属性:
public __gc __sealed class Vector
{
public:
// ...
__property double get_x(){ return _x; }
__property double get_y(){ return _y; }
__property double get_z(){ return _z; }
__property void set_x( double newx ){ _x = newx; }
__property void set_y( double newy ){ _y = newy; }
__property void set_z( double newz ){ _z = newz; }
};
这使人感到迷惑,因为属性相关的函数被展开了,并且需要用户从语法上统一相关的 set 和 get。而且它在语法上过于冗长,并且感觉上不甚优雅。在修订版语言设计中,这个声明更类似于 C# — property 关键字后接属性的类型以及属性的原名。set 存取和get 存取方法放在属性名之后的一段中。注意,与 C# 不同,存取方法的符号被指出。例如,以下是上面的代码转换为新语言设计后的结果:
public ref class Vector sealed
{
public:
property double x
{
double get()
{
return _x;
}
void set( double newx )
{
_x = newx;
}
} // Note: no semi-colon ...
};
如果属性的存取方法表现为不同的访问级别 — 例如一个公有的 get和一个私有的或者保护的 set,那么可以指定一个显式的访问标志。默认情况下,属性的访问级别反映了它的封闭访问级别。例如,在上面的 Vector定义中,get和 set方法都是公有的。为了让 set方法成为保护或者私有的,必须如下修改定义:
public ref class Vector sealed
{
public:
property double x
{
double get()
{
return _x;
}
private:
void set( double newx )
{
_x = newx;
}
} // 注意:private 的作用域到此结束 ...
//注意:dot 是一个 Vector 的公有方法...
double dot( const Vector^ wv );
// etc.
};
属性中访问关键字的作用域延伸到属性的结束括号或者另一个访问关键字的说明。它不会延伸到属性的定义之外,直到进行属性定义的封闭访问级别。例如,在上面的声明中,Vector::dot()是一个公有成员函数。
为三个 Vector坐标编写 set/get属性有点乏味,因为实现的本质是定死的:(a) 用适当类型声明一个私有状态成员,(b) 在用户希望取得其值的时候返回,以及 (c) 将其设置为用户希望赋予的任何新值。在修订版语言设计中,一个简洁的属性语法可以用于自动化这个使用方式:
public ref class Vector sealed
{
public:
//等价的简洁属性语法
property double x;
property double y;
property double z;
};
简洁属性语法所产生的一个有趣的现象是,在编译器自动生成后台状态成员时,除非通过 set/get访问函数,否则这个成员在类的内部不可访问。这就是所谓的严格限制的数据隐藏!
3.2 属性索引声明
原版语言对索引属性的支持的两大缺点是不能提供类级别的下标,也就是说,所有索引属性必须有一个名字,举例来说,这样就没有办法提供可以直接应用到一个 Vector或者Matrix类对象的托管下标操作符。其次,一个次要的缺点是很难在视觉上区分属性和索引属性 — 参数的数目是唯一的判断方法。最后,索引属性具有与非索引属性同样的问题 — 存取函数没有作为一个基本单位,而是分为单独的方法。举例来说:
public __gc class Vector;
public __gc class Matrix
{
float mat[,];
public:
__property void set_Item( int r, int c, float value);
__property int get_Item( int r, int c );
__property void set_Row( int r, Vector* value );
__property int get_Row( int r );
};
如您所见,只能用额外的参数来指定一个二维或者一维的索引,从而区分索引器。在修订版语法中,索引器由名字后面的方括号 ([,]) 区分,并且表示每个索引的数目和类型:
public ref class Vector;
public ref class Matrix
{
private:
array<float, 2>^ mat;
public:
property int Item [int,int]
{
int get( int r, int c );
void set( int r, int c, float value );
}
property int Row [int]
{
int get( int r );
void set( int r, Vector^ value );
}
};
在修订版语法中,为了指定一个可以直接应用于类对象的类级别索引器,重用 default关键字以替换一个显式的名称。例如:
public ref class Matrix
{
private:
array<float, 2>^ mat;
public:
//OK,现在有类级别的索引器了
//
// Matrix mat ...
// mat[ 0, 0 ] = 1;
//
// 调用默认索引器的 set 存取函数...
property int default [int,int]
{
int get( int r, int c );
void set( int r, int c, float value );
}
property int Row [int]
{
int get( int r );
void set( int r, Vector^ value );
}
};
在修订版语法中,当指定了 default索引属性时,下面两个名字被保留:get_Item和set_Item。这是因为它们是 default索引属性产生的底层名称。
注意,简单索引语法与简单属性语法截然不同。
3.3 委托和事件
声明一个委托和普通事件仅有的变化是移除了双下划线,如下面的示例所述。在去掉了之后,这个更改被认为是完全没有争议的。换句话说,没有人支持保持双下划线,所有人现在看来都同意双下划线使得原版语言感觉很难看。
// 原版语言 (V1)
__delegate void ClickEventHandler(int, double);
__delegate void DblClickEventHandler(String*);
__gc class EventSource {
__event ClickEventHandler* OnClick;
__event DblClickEventHandler* OnDblClick;
// ...
};
// 修订版语言 (V2)
delegate void ClickEventHandler( int, double );
delegate void DblClickEventHandler( String^ );
ref class EventSource
{
event ClickEventHandler^ OnClick;
event DblClickEventHandler^ OnDblClick;
// ...
};
事件(以及委托)是引用类型,这在 V2中更为明显,因为有帽子 (^) 的存在。除了普通形式之外,事件支持一个显式的声明语法,用户显式指定事件关联的 add()、raise()、和 remove()方法。(只有 add()和 remove()方法是必须的;raise()方法是可选的)。
在 V1设计中,如果用户选择提供这些方法,尽管她必须决定尚未存在的事件的名称,她也不必提供一个显式的事件声明。每个单独的方法以 add_EventName、raise_EventName、和remove_EventName的格式指定,如以下引用自 V1语言规范的示例所述:
// 原版 V1 语言下
// 显式地实现 add、remove 和 raise ...
public __delegate void f(int);
public __gc struct E {
f* _E;
public:
E() { _E = 0; }
__event void add_E1(f* d) { _E += d; }
static void Go() {
E* pE = new E;
pE->E1 += new f(pE, &E::handler);
pE->E1(17);
pE->E1 -= new f(pE, &E::handler);
pE->E1(17);
}
private:
__event void raise_E1(int i) {
if (_E)
_E(i);
}
protected:
__event void remove_E1(f* d) {
_E -= d;
}
};
该设计的问题主要是感官上的,而不是功能上的。虽然设计支持添加这些方法,但是上面的示例看起来并不是一目了然。因为 V1属性和索引属性的存在,类声明中的方法看起来千疮百孔。更令人沮丧的是缺少一个实际的 E1事件声明。(再强调一遍,底层实现细节暴露了功能的用户级别语法,这显然增加了语法的复杂性。)这只是劳而无功。V2设计大大简化了这个声明,如下面的转换所示。事件在事件声明及其相关委托类型之后的一对花括号中指定两个或者三个方法如下所示:
// 修订版 V2 语言设计
delegate void f( int );
public ref struct E {
private:
f^ _E; //是的,委托也是引用类型
public:
E()
{ // 注意 0 换成了 nullptr!
_E = nullptr;
}
// V2 中显式事件声明的语法聚合
event f^ E1
{
public:
void add( f^ d )
{
_E += d;
}
protected:
void remove( f^ d )
{
_E -= d;
}
private:
void raise( int i )
{
if ( _E )
_E( i );
}
}
static void Go()
{
E^ pE = gcnew E;
pE->E1 += gcnew f( pE, &E::handler );
pE->E1( 17 );
pE->E1 -= gcnew f( pE, &E::handler );
pE->E1( 17 );
}
};
虽然在语言设计方面,人们因为语法的简单枯燥而倾向于忽视它,但是如果对语言的用户体验有很大的潜移默化的影响,那么它实际上很有意义。一个令人迷惑的、不优雅的语法可能增加开发过程的风险,很大程度上就像一个脏的或者不清晰的挡风玻璃增加开车的风险一样。在修订版语言设计中,我们努力使语法像一块高度磨光的新安装的挡风玻璃一样透明。
3.4 密封一个虚函数
__sealed关键字在 V1版中用于修饰一个引用类型,禁止从此继续派生 — 如 2.1.2 节所述 — 或者修饰一个虚函数,禁止从派生类中继续重写方法。举例来说:
class base { public: virtual void f(); };
class derived : public base {
public:
__sealed void f();
};
在此示例中,derived::f()根据函数原型的完全匹配来重写 base::f()实例。__sealed关键字指明一个继承自 derived类的后续类不能重写 derived::f()。
在新的语言设计中,sealed放在符号之后,而不是像在 V1 中那样,允许放在实际函数原型之前任何位置。另外,sealed的使用也需要显式使用 virtual关键字。换句话说,上面的 derived的正确转换如下所述:
class derived: public base
{
public:
virtual void f() sealed;
};
缺少 virtual关键字会产生一个错误。在 V2中,上下文关键字 abstract可以在 =0 处用来指明一个纯虚函数。这在 V1中不被支持。举例来说:
class base { public: virtual void f()=0; };
可以改写为
class base { public: virtual void f() abstract; };
3.5 操作符重载
原版语言设计最惊人之处可能是它对于操作符重载的支持 — 或者更恰当地说,是有效的缺乏支持。举例来说,在一个引用类型的声明中,不是使用内建的 operator+语法,而是必须显式编写出操作符的底层内部名称 — 在本例中是 op_Addition。但更加麻烦的是,操作符的调用必须通过该名称来显式触发,这样就妨碍了操作符重载的两个主要好处:(a) 直观的语法,和 (b) 混合现有类型和新类型的能力。举例来说:
public __gc __sealed class Vector {
public:
Vector( double x, double y, double z );
static bool op_Equality( const Vector*, const Vector* );
static Vector* op_Division( const Vector*, double );
static Vector* op_Addition( const Vector*, const Vector* );
static Vector* op_Subtraction( const Vector*, const Vector* );
};
int main()
{
Vector *pa = new Vector( 0.231, 2.4745, 0.023 );
Vector *pb = new Vector( 1.475, 4.8916, -1.23 );
Vector *pc1 = Vector::op_Addition( pa, pb );
Vector *pc2 = Vector::op_Subtraction( pa, pc1 );
Vector *pc3 = Vector::op_Division( pc1, pc2->x() );
if ( Vector::op_Equality( pc1, p2 ))
// ...
}
在语言的修订版中,满足了传统 C++程序员的普通期望,声明和使用静态操作符。以下是转换为 V2语法的 Vector类:
public ref class Vector sealed {
public:
Vector( double x, double y, double z );
static bool operator ==( const Vector^, const Vector^ );
static Vector^ operator /( const Vector^, double );
static Vector^ operator +( const Vector^, const Vector^ );
static Vector^ operator -( const Vector^, const Vector^ );
};
int main()
{
Vector^ pa = gcnew Vector( 0.231, 2.4745, 0.023 ),
Vector^ pb = gcnew Vector( 1.475,4.8916,-1.23 );
Vector^ pc1 = pa + pb;
Vector^ pc2 = pa-pc1;
Vector^ pc3 = pc1 / pc2->x();
if ( pc1 == p2 )
// ...
}
3.6 转换操作符
谈到令人不愉快的感觉,在 V1语言设计中必须编写 op_Implicit来指定一个转换感觉上就不像 C++。例如,以下是引自 V1语言规范的 MyDouble定义:
__gc struct MyDouble
{
static MyDouble* op_Implicit( int i );
static int op_Explicit( MyDouble* val );
static String* op_Explicit( MyDouble* val );
};
这就是说,给定一个整数,将这个整数转换为 MyDouble的算法是通过op_Implicit操作符实现的。进一步说,这个转换将被编译器隐式执行。类似的,给定一个 MyDouble对象,两个op_Explicit操作符分别提供了以下两种算法:将对象转换为整型或者托管字符串实体。但是,编译器不会执行这个转换,除非用户显式要求。
在 C#中,如下所示:
class MyDouble
{
public static implicit operator MyDouble( int i );
public static explicit operator int( MyDouble val );
public static explicit operator string( MyDouble val );
};
除了每个成员都有的显式公有 访问标志看起来很古怪,C#代码看起来比 C++的托管扩展更加像 C++。所以我们不得不修复这个问题。但是我们怎么才能做到?
一方面,C++程序员将构建为转换操作符单参数构造函数省略掉。但是,另一方面,该设计被证明是如此难于处理,以致于 ISO-C++委员会引入了一个关键字 explicit,只是为了处理它的意外后果— 例如,有一个整型参数作为维数的 Array类隐式地将任何整型变量转换为 Array对象,甚至在用户最不需要时也这样。Andy Koenig 是第一个引起我注意的人,他解释了一个设计习惯,构造函数中的第二虚参数只是用来阻止这种不好的事情的发生。所以我不会对 C++/CLI中缺乏单构造函数隐式转换而感到遗憾。
另一方面,在 C++中设计一个类类型时提供一个转换对从来不是一个好主意。这方面最好的示例是标准 string类。隐式转换是有一个 C风格字符串的单参数构造函数。但是,它没有提供一个对应的隐式转换操作符来将 string 对象转换为 C风格的字符串 — 而是需要用户显式调用一个命名函数 — 在这个示例中是 c_str()。
这样,将转换操作符的隐式/显式行为进行关联(以及将一组转换封装到一组声明)看起来是原始 C++ 对转换操作符支持的改进,这个支持自从 1988 年 Robert Murray 发布了关于 UsenixC++的标题为 Building Well-Behaved Type Relationships in C++的讲话之后,已经成为一个公开的警世篇,讲话最终产生了 explicit 关键字。修订版 V2语言对转换操作符的支持如下所示,比 C# 的支持稍微简略一点,因为操作符的默认行为支持隐式转换算法的应用:
ref struct MyDouble
{
public:
static operator MyDouble^ ( int i );
static explicit operator int ( MyDouble^ val );
static explicit operator String^ ( MyDouble^ val );
};
V1到 V2的另一个变化是,V2中的单参数构造函数以声明为 explicit 的方式处理。这意味着为了触发它的调用,需要一个显式的转换。但是要注意,如果一个显式的转换操作符已经定义,那么是它而不是单参数构造函数会被调用。
3.7 接口成员的显式重写
经常有必要在实现接口的类中提供两个接口成员的实例 — 一个用于通过接口句柄操作类对象,另一个用于通过类界面使用对象。例如:
public __gc class R : public ICloneable
{
// 通过ICloneable使用...
Object* ICloneable::Clone();
// 通过一个R对象使用 ...
R* Clone();
};
在 V1中,我们通过一个用接口名限定的方法名来提供接口方法的显式声明,从而解决这个问题。特定于类的实例是未被限定的。在这个示例中,当通过 R的一个实例显式调用 Clone()时,这样可以免除对其返回值的类型向下强制转换。
在 V2中,一个通用重写机制被引入,用来替换前面的语法。我们的示例会被重写,如下所示:
public ref class R : public ICloneable
{
// 通过 ICloneable 使用 ...
Object^ InterfaceClone() = ICloneable::Clone;
// 通过一个 R 对象使用 ...
virtual R^ Clone() new;
};
这个修订要求为显式重写的接口成员赋予一个在类中唯一的名称。这里我提供了一个有些笨拙的名称 InterfaceClone()。修订版的行为仍旧是相同的 — 通过 ICloneable接口的调用触发重命名的InterfaceClone(),而通过 R 类型对象的调用调用第二个 Clone()实例。
3.8 私有虚函数
在 V1中,虚函数的访问级别并不影响它在派生类中是否可以被重写。这在 V2中被修改了。在 V2中,虚函数不能重写不可访问的基类虚函数。例如:
__gc class My{ //在派生类中无法访问...virtual void g();};__gc class File : public My {public: // 正确:在 V 1中,g() 重写了 My::g() // 错误:在 V2 中,不能重写: My::g() 无法访问...void g();};
对于这种设计而言,实际上没有在 V2中的对应。要重写这个函数,必须使基类的成员可访问 — 也就是说,非私有的。继承的方法不必沿用同样的访问级别。在这个示例中,最小的改变是将 My成员声明为保护的。这样,一般的程序通过 My来访问这个方法仍旧是被禁止的。
ref class My {
protected:
virtual void g();
};
ref class File : My {
public:
void g();
};
注意在 V2 下,如果基类缺少显式的 virtual关键字,那么会产生一个警告消息。
3.9 静态常量整型的连接方式 (linkage) 不再是 literal 的
虽然 static const整型成员仍旧被支持,但是它们的 linkage 属性被修改了。以前的 linkage 属性现在通过一个 literal整型成员来完成。例如,考虑如下 V1类:
public __gc class Constants {
public:
static const int LOG_DEBUG = 4;
// ...
};
它为域产生如下的底层 CIL属性(注意黑体的 literal 属性):
.field public static literal int32
modopt([Microsoft.VisualC]Microsoft.VisualC.IsConstModifier) STANDARD_CLIENT_PRX = int32(0x00000004)
它虽然在 V2 语法下仍旧可以编译,
public ref class Constants {
public:
static const int LOG_DEBUG = 4;
// ...
};
但是不再产生 literal属性,所以不被 CLI运行库视为一个常量。
.field public static int32 modopt([Microsoft.VisualC]Microsoft.VisualC.IsConstModifier)
STANDARD_CLIENT_PRX = int32(0x00000004)
为了具有同样的中间语言的 literal属性,声明应该改为使用新支持的 literal数据成员,如下所示:
public ref class Constants {
public:
literal int LOG_DEBUG = 4;
// ...
};
4 值类型及其行为
本节中我们着眼于 CLI枚举类型和值类类型,同时研究装箱和对 CLI堆上装箱实例的访问,以及考虑内部和钉住指针。这个领域的语言变化范围很广。
4.1 CLI 枚举类型
原版语言的 CLI枚举声明前有一个 __value 关键字。 这里的意图是区分本机枚举和派生自 System::ValueType 的 CLI枚举,同时暗示它们具有同样的功能。例如,
__value enum e1 { fail, pass };
public __value enum e2 : unsigned short {
not_ok = 1024,
maybe, ok = 2048
};
修订版语言用强调后者的类本质而不是其值类型本源的方法来解决这个区分本机枚举和 CLI枚举的问题。同样,__value关键字被废弃了,替换成了一对由空格分隔的关键字 enum class。这实现了引用类、值类和接口类声明中关键字对的对称。
enum class ec;
value class vc;
ref class rc;
interface class ic;
修订版语言设计中的枚举对 e1 和 e2的转换如下所示:
enum class e1 { fail, pass };
public enum class e2 : unsigned short {
not_ok = 1024,
maybe, ok = 2048
};
除了这种句法上的小小修改之外,托管枚举类型的行为在很多方面有所改变:
CLI枚举的前置声明在 V2中不再支持。V2 中没有这样的对应。这只会导致编译时错误。
__value enum status; // V1: 正确
enum class status; // V2: 错误
在内建算术类型和对象类层次结构之间进行重载解析的次序在 V2和 V1中被颠倒了!一个副作用是,托管枚举在 V2 中不能再像在 V1中一样隐式转换成算术类型。
与在 V1 中不同,在 V2中,托管枚举具有它自己的范围。在 V1中,枚举数在包含枚举的范围内可见。在 V2中,枚举数被限定在枚举的范围内。
4.1.1 CLI 枚举是一种类型
举例来说,考虑如下代码片断:
__value enum status { fail, pass };
void f( Object* ){ cout << "f(Object)\n"; }
void f( int ){ cout << "f(int)\n"; }
int main()
{
status rslt;
// ...
f( rslt ); // which f is invoked?
}
对于本机 C++程序员来说,该问题自然的答案是,被调用的重载 f()的实例是 f(int)。枚举是一个整型符号常量,并且在此示例中作为标准整型被转换。实际上,在原版语言设计中,这事实上就是调用解析的实例。这产生了一些意想不到的结果 — 不是在我们以本机 C++框架思想使用它的时候 — 而是在我们需要它们与现存的 BCL(基类库)框架交互的时候,这里枚举是一个间接派生自 Object的类。在修订版语言设计中,被调用的 f()实例是 f(Object^)。
V2选择强制不支持 CLI枚举和算术类型之间的隐式转换。这意味着任何从托管枚举类型对象到算术类型的赋值都需要一个显式的强制转换。举例来说,假定
void f( int );
是一个非重载方法,在 V1中,调用
f( rslt ); // ok: V1; error: V2
是可行的,rslt 中的值被隐式转换为一个整型值。在 V2中,这个调用的编译会失败。要正确转换它,我们必须插入一个转换操作符:
f( safe_cast<int>( rslt )); // ok: V2
4.1.2 CLI 枚举类型的范围
C和 C++语言之间的不同之一就是 C++在 struct 中添加了范围。在 C中,struct 只是一个数据的聚合,既不支持接口也不支持关联的范围。这在当时是一个十分激进的改变,并且对于很多从 C 语言转移过来的新 C++ 用户来说是一个有争议的问题。本机和 CLI 的枚举的关系也类似。
在原始语言设计中,曾经尝试过为托管枚举的枚举数定义弱插入名称,用于模拟本机枚举内范围的缺失。这个尝试被证明是失败的,问题在于这造成了枚举数溢出到全局命名空间,造成了管理名称冲突的困难。在修订版语言中,我们按照其他 CLI语言来支持托管枚举的范围。
这意味着 CLI 枚举的枚举数的任何未限定使用将不能被修订版语言识别。让我们来看一个实际的例子。
// 原版语言设计支持弱插入
__gc class XDCMake {
public:
__value enum _recognizerEnum {
UNDEFINED,
OPTION_USAGE,
XDC0001_ERR_PATH_DOES_NOT_EXIST = 1,
XDC0002_ERR_CANNOT_WRITE_TO = 2,
XDC0003_ERR_INCLUDE_TAGS_NOT_SUPPORTED = 3,
XDC0004_WRN_XML_LOAD_FAILURE = 4,
XDC0006_WRN_NONEXISTENT_FILES = 6,
};
ListDictionary* optionList;
ListDictionary* itagList;
XDCMake()
{
optionList = new ListDictionary;
// here are the problems ...
optionList->Add(S"?", __box(OPTION_USAGE)); // (1)
optionList->Add(S"help", __box(OPTION_USAGE)); // (2)
itagList = new ListDictionary;
itagList->Add(S"returns",
__box(XDC0004_WRN_XML_LOAD_FAILURE)); // (3)
}
};
三个枚举数名称的未限定使用 ((1)、(2)和(3)) 都需要在转换为修订版语言语法时被限定,从而让源代码通过编译。以下是原始源代码的正确转换:
ref class XDCMake
{
public:
enum class _recognizerEnum
{
UNDEFINED, OPTION_USAGE,
XDC0001_ERR_PATH_DOES_NOT_EXIST = 1,
XDC0002_ERR_CANNOT_WRITE_TO = 2,
XDC0003_ERR_INCLUDE_TAGS_NOT_SUPPORTED = 3,
XDC0004_WRN_XML_LOAD_FAILURE = 4,
XDC0006_WRN_NONEXISTENT_FILES = 6
};
ListDictionary^ optionList;
ListDictionary^ itagList;
XDCMake()
{
optionList = gcnew ListDictionary;
optionList->Add("?",_recognizerEnum::OPTION_USAGE); // (1)
optionList->Add("help",_recognizerEnum::OPTION_USAGE); //(2)
itagList = gcnew ListDictionary;
itagList->Add( "returns",
recognizerEnum::XDC0004_WRN_XML_LOAD_FAILURE); //(3)
}
};
这改变了本机和 CLI 枚举之间的设计策略。因为 CLI 枚举在 V2中保持一个关联的范围,在一个类中封装枚举的声明不再是有必要和有效的了。这个用法随着贝尔实验室的 cfront 2.0而不断发展,也用来解决全局名称污染的问题。
在贝尔实验室的 Jerry Schwarz 所创建的 beta 原版新 iostream库中,Jerry 没有封装库中定义的全部相关枚举,而且通用枚举数 — 例如 read、write、append等 — 使得用户几乎不可能编译他们的现存代码。一个解决方案是破坏这些名称,例如 io_read 、 io_write等等。另一个解决方案是修改语言来添加枚举的范围,但是在当时是不可能实现的。(一个折衷的方案是将枚举封装在类或类层次结构中,这时枚举的标记名称及其枚举数填充封闭类范围。)换句话说,将枚举放在类中的动机 — 至少是原始动机 — 不是理论上的,而是全局命名空间污染问题的一个实际解决方案。
对于 V2CLI 枚举,将枚举封装在类中不再有任何明显的好处。实际上,如果您看看 System命名空间,您就会看到枚举、类和接口都在同一个声明空间中存在。
4.2 隐式装箱
OK,我们食言了。在政治领域中,这会使我们输掉一场选举。在语言设计中,这意味着我们在实际经验中强加了一个理论的位置,而且实际上它是一个错误。一个类似的情形是,在原始多继承语言设计中,Stroustrup 认为在派生类的构造函数中无法初始化一个虚基类子对象,这样,C++ 语言要求任何作为虚基类的类都必须定义一个默认构造函数。这样只有默认的构造函数才会被后续的虚派生调用。
虚基类层次结构的问题是将初始化共享虚子对象的职责转推到每个后续的派生类中。举例来说,我定义了一个基类,它的初始化需要分配一个缓冲区,用户指定的缓冲区大小作为构造函数的一个参数传递。如果我提供了两个后续的虚继承,名为 inputb和 outputb,每个都需要提供基类构造函数的一个特定值。现在我从 inputb和 outputb派生一个 in_out类,那么两个共享虚基类子对象的值都没有明显地被求值。
因此,在原版语言设计中,Stroustrup 在派生类构造函数的成员初始化列表中,禁用了虚基类的显式初始化。虽然这解决了问题,但是实际上无法控制虚基类的初始化证明是不可行的。国家健康协会的 Keith Gorlen(他实现了一个名为 nihcl的免费版本 SmallTalk集合库)劝告 Bjarne,让他必须考虑一个更加灵活的语言设计。
一个面向对象的层次设计原则是一个派生类只应该涉及其本身和直接基类的非私有成员。为了支持一个灵活的虚继承初始化设计,Bjarne 不得不破坏了这个原则。层次中最底层的类负责初始化所有虚子对象,不管他们在层次结构中有多深。例如,inputb和 outputb都有责任显式初始化他们的直虚基类。在从 inputb和 outputb派生 in_out类时,in_out开始负责初始化一度被移除的虚基类,并且 inputb和 outputb中的显式初始化被抑制了。
这提供了语言开发人员所需要的灵活性,但是却以复杂的语义为代价。如果我们将虚基类限定为无状态,并且只允许指定一个接口,那么就消除了这种复杂性。这在 C++中是一个推荐的设计方案。在 C++/CLI中,这是 Interface类型的方针。
以下是一个代码实例,完成一些简单的功能 — 在本例中,显式装箱很大程度上是无用的语法负担。
// 原版语言设计需要显式 __box 操作
int my1DIntArray __gc[] = { 1, 2, 3, 4, 5 };
Object* myObjArray __gc[] = {
__box(26), __box(27), __box(28), __box(29), __box(30)
};
Console::WriteLine( "{0}\t{1}\t{2}", __box(0),
__box(my1DIntArray->GetLowerBound(0)),
__box(my1DIntArray->GetUpperBound(0)) );
您可以了解,后面会有许多装箱操作。在 V2中,值类型的装箱是隐式的:
// 修订版语言进行隐式装箱
array<int>^ my1DIntArray = {1,2,3,4,5};
array<Object^>^ myObjArray = {26,27,28,29,30};
Console::WriteLine( "{0}\t{1}\t{2}", 0,
my1DIntArray->GetLowerBound( 0 ),
my1DIntArray->GetUpperBound( 0 ) );
4.3 装箱值的跟踪句柄
装箱是 CLI 统一类型系统的一个特性。值类型直接包含其状态,而引用类型有双重含义:命名实体是一个句柄,这个句柄指向托管堆上分配的一个非命名对象。举例来说,任何从值类型到对象的初始化或者赋值,都需要值类型放在 CLI 堆中(图像装箱发生的位置)首先分配相关的内存,然后复制值类型的状态,最后返回这个匿名值 / 引用的组合。因此,用 C# 编写如下代码时,
object o = 1024; // C# 隐式装箱
代码的简洁使得装箱十分接近透明。C# 的设计不仅隐藏了后台所发生的操作的复杂性,而且也隐藏了装箱本身的抽象性。另一方面,V1考虑到它可能导致效率降低,所以直接要求用户显式编写指令:
Object *o = __box( 1024 ); // V1 显式装箱
就像在本例中还有其他选择一样。依我之见,在这种情况下强迫用户进行显式请求就像一个人的老妈在他出门时不断唠叨一样。现在我们会照顾自己了,难道你不会?一方面,基于某些原因,一个人应该学会内敛,这被称为成熟。另一方面,基于某些原因,一个人必须信任子女的成熟。把老妈换成语言的设计者,程序员换成子女,这就是 V2中装箱成为隐式的原因。
Object ^o = 1024; // V2 隐式装箱
__box关键字在原版语言设计中是第二重要的服务,这种设计在C#和 Microsoft Visual Basic .NET 语言中是没有的:它提供词汇表和跟踪句柄来直接操作一个托管堆上的装箱实例。例如,考虑如下小程序:
int main()
{
double result = 3.14159;
__box double * by = __box( result );
result = 2.7;
*br = 2.17;
Object * o = br;
Console::WriteLine( S"result :: {0}", result.ToString() ) ;
Console::WriteLine( S"result :: {0}", __box(result) ) ;
Console::WriteLine( S"result :: {0}", br );
}
WriteLine的三个调用生成的底层代码显示了访问装箱值类型值的不同代价(感谢Yves Dolce指出这些差异),这里黑体的行显示了与每个调用相关的开销。
// Console::WriteLine( S"result :: {0}", result.ToString() ) ;
ldstr "result :: {0}"
ldloca.s result
call instance string [mscorlib]System.Double::ToString()
call void [mscorlib]System.Console::WriteLine(string, object)
// Console::WriteLine( S"result :: {0}", __box(result) ) ;
ldstr " result :: {0}"
ldloc.0
box [mscorlib]System.Double
call void [mscorlib]System.Console::WriteLine(string, object)
// Console::WriteLine( S"result :: {0}", br );
ldstr "result :: {0}"
ldloc.0
call void [mscorlib]System.Console::WriteLine(string, object)
直接将装箱值类型传递到 Console::WriteLin避免了装箱和调用 ToString()的需要(当然,这是用前面提到的对 result的装箱来初始化 br),所以除非真正使用 br,否则我们不会真正有所收获。
在修订版语言语法中,在保持装箱值类型的优点的同时,对它的支持也变得更加优雅,并且集成到类型系统中。例如,以下是上面的小程序的转换:
int main()
{
double result = 3.14159;
double^ br = result;
result = 2.7;
*br = 2.17;
Object^ o = br;
Console::WriteLine( S"result :: {0}", result.ToString() );
Console::WriteLine( S"result :: {0}", result );
Console::WriteLine( S"result :: {0}", br );
}
4.4 值类型语义
以下是 V1语言规范中使用的一个规范的普通值类型:
__value struct V { int i; };
__gc struct R { V vr; };
在 V1中,我们可以有 4 种值类型的语法变种(这里 2和 3的语义是一样的):
V v = { 0 };
V *pv = 0;
V __gc *pvgc = 0; // 格式 (2) 是(3)的隐式格式
__box V* pvbx = 0; // 必须是局部的
4.4.1 调用继承的虚方法
格式(1)是一个规范的值对象,并且它是相当容易理解的,除非有人试图调用一个继承虚方法,例如 ToString()。例如,
v.ToString(); // 错误!
为了调用这个方法,因为在 V中它不可重写,所以编译器必须可以访问基类的相关虚表。因为值类型是状态内存储,没有其虚表 (vptr) 的相关指针,所以这需要 v被装箱。在原版语言设计中,隐式装箱是不被支持的,程序员必须显式声明如下:
__box( v )->ToString(); // V1: 注意箭头
该设计背后的主要动机是具有教育意义的 — 它希望使底层机制对于程序员可见,使得他能理解不在值类型中提供实例的“代价”。如果 V包含一个 ToString实例,那么装箱是不必要的。
显式装箱对象的繁文缛节,而不是装箱本身的基本代价,在修订版语言设计中被移除了。
v.ToString(); // V2
但是代价是可能误导类设计者在 V中不提供显式 ToString方法的实例。首选隐式装箱的原因是通常只有一个类设计者而有无数的类使用者,他们不能自由地修改 V来避免可能很麻烦的显式装箱。
决定是否在值类中提供 ToString的一个重写实例取决于它的使用频率和位置。如果它很少被调用,那么这么定义很显然没什么好处。类似地,如果它在应用程序的非性能区域被调用,那么添加它将不会对应用程序的常规性能带来可观的提升。或者,可以保留一个装箱值的跟踪句柄,通过该句柄的调用不会需要装箱。
4.4.2 值类不再有默认构造函数
值类型的原版和修订版语言设计之间的另外一个差异是取消了对默认构造函数的支持。这是由于在执行中,CLI可能创建一个值类型的实例而不调用相关的默认构造函数。换句话说,在 V1中,实际上并不能够保证对值类型中默认构造函数的支持。由于缺乏保证,所以感觉完全去掉这个支持比在其应用程序中保持不确定性更好。
这并不像第一眼看上去那么坏。这是因为每个值类型对象会被自动清零(每个类型会被初始化为其默认的值)。也就是说,局部实例的成员不会是未定义的。在这个意义上,缺少定义一个普通默认构造函数的能力实际上根本不是一个损失 — 并且事实上在 CLI执行时更加高效。
问题发生在原版 V1语言的用户定义了一个非普通默认构造函数时。它没有在修订版V2语言设计中的对应。构造函数中的代码将需要移植到一个命名的初始化方法,并且这个方法需要被用户显式调用。
修订版 V2 语言设计中的值类型对象的声明没有变化。它的缺点是值类型不能包装本机类型,原因如下:
值类型不支持析构函数。换句话说,无法在对象生命周期结束时自动触发一组行为。
本机类只能作为指针包含在托管类型中,然后在本机堆上进行分配。
我们可能喜欢用值类型(而不是引用类型)来包装一个小的本机类来避免两次堆分配:本机堆存放本机类型,CLI堆存放托管包装。在值类型中包装一个本机类可以避免在托管堆的分配,但是无法自动回收本机堆上分配的内存。引用类型是唯一可行的用于包装非普通本机类的托管类型。
4.4.3 内部指针
格式(2)和 (3)几乎可以解决任何问题(即托管和本机)。因此,举例来说,在原版语言设计中以下内容都是被允许的:
// 来自于 4.4 节
__value struct V { int i; };
__gc struct R { V vr; };
V v = { 0 };
V *pv = 0;
V __gc *pvgc = 0; // 格式 (2) 是 (3) 的隐式格式
__box V* pvbx = 0; // 必须是局部的
R* r;
pv = &v; //指向栈上的一个值类型
pv = __nogc new V; //指向本机堆上的一个值类型
pv = pvgc; // 我们不确定这指向什么位置
pv = pvbx; // 指向托管堆上的装箱值类型
pv = &r->vr; //指向托管堆上一个引用类型中的值类型的内部指针
这样,一个 V*可以指向局部块中的地址(因此可以成为虚引用);对于全局范围来说,在本机堆内(例如,如果它指向的对象已经被删除);在 CLI 堆内(因此如果在垃圾回收期间会重新定位,则将进行跟踪),以及在 CLI堆上的引用对象的内部(顾名思义,内部指针也透明地被跟踪)。
在原版语言设计中,无法分离 V*的本机方面。也就是说,它的处理具有包含性,处理指向一个托管堆上对象或者子对象的可能性。
在修订版语言设计中,值类型指针有两种类型:V*,位置局限于非 CLI 堆,和内部指针 interior_ptr<V>,允许但是不强制一个地址位于传统堆中。
// 不能指向托管堆的地址
V *pv = 0;
// 可以但是不必须指向传统堆之外的地址
interior_ptr<V> pvgc = nullptr;
原版语言中的格式 (2)和 (3)对应 interior_ptr<V>。格式 (4)是一个跟踪句柄。它指向托管堆中装箱的整个对象。这在修订版语言中转换成 V^:
V^ pvbx = nullptr; // __box V* pvbx = 0;
原版语言设计中的下列声明在修订版语言设计中都对应到内部指针。(它们是 System命名空间内的值类型。)
Int32 *pi; => interior_ptr<Int32> pi;
Boolean *pb; => interior_ptr<Boolean> pb;
E *pe; => interior_ptr<E> pe; // 枚举
内建类型不被认为是托管类型,虽然它们确实作为 System命名空间内的类型的别名。因此,原版和修订版语言的以下对应是正确的:
int * pi; => int* pi;
int __gc * pi => interior_ptr<int> pi;
当转换现存程序中的 V*时,最保守的策略是总是将其转换成为interior_ptr<V>。这就是它在原版语言中的处理方法。在修订版语言中,程序员可以选择通过指定 V*而不是使用内部指针来限制一个值类型位于非托管堆。如果您在转换程序时,可以传递闭包它的所有使用,并且确认没有被赋值为托管堆中的地址,那么保留 V*就可以了。
4.4.4 钉住指针
垃圾回收器可能会在 CLI堆内将堆上的对象移动到不同的位置,这通常发生在压缩阶段。(这个移动对跟踪句柄、跟踪引用和内部指针来说不是问题,因为这些实体被透明的更新。但是,如果用户在运行库环境之外传递 CLI堆上对象的地址,这种移动就是个问题了。在这种情况下,这种不稳定的对象移动很容易造成运行库失败。为了避免这种对象被移动,我们必须局部地将其钉住以备外部使用。
在原版语言设计中,一个钉住指针是用 __pin关键字限定一个指针声明来声明的。以下是在原版语言规范的基础上作了少量修改的一个示例:
__gc struct H { int j; };
int main()
{
H * h = new H;
int __pin * k = & h -> j;
// ...
};
在新的语言设计中,一个钉住指针是以和内部指针类似的语法声明的。
ref struct H
{
public:
int j;
};
int main()
{
H^ h = gcnew H;
pin_ptr<int> k = &h->j;
// ...
}
修订版语言下的钉住指针是一个内部指针的特例。V1对钉住指针的限制仍旧存在。例如,它不能作为方法的参数或者返回类型使用,而且,它只能被声明为一个局部对象。但是,一些额外的限制被添加到了修订版语言设计中:
钉住指针的默认值是 nullptr,而不是 0。pin_ptr<>不能被初始化或者赋值为 0。现存代码中赋值为 0的都需要改为 nullptr。
V1下的钉住指针允许指向整个对象,如下面引用自原版语言规范的示例所述:
__gc struct H { int j; };
void f( G * g )
{
H __pin * pH = new H;
g->incr(& pH -> j);
};
在修订版语言中,钉住 new 表达式返回的整个对象是不被支持的。确切地说,是需要钉住内部成员的地址。举例来说:
void f( G^ g )
{
H ^ph = gcnew H;
pin_ptr<int> pj = &ph->j;
g->incr( pj );
}
5. 语言变化概要
本节中描述的更改某种意义上是语言杂记。本节包含处理字符串的修改,省略号和参数属性的重载解决方案的修改,从 typeof到 typeid的修改,以及一个新的强制转换标记 safe_cast的介绍。
5.1 字符串
在原版语言设计中,托管字符串是通过为字符串添加前缀 S的方式指明的。例如:
String *ps1 = "hello";
String *ps2 = S"goodbye";
两个初始化之间的性能开销差别并不小,如下面通过 ildasm看到的的 CIL表示所示:
// String *ps1 = "hello";
ldsflda valuetype $ArrayType$0xd61117dd
modopt([Microsoft.VisualC]Microsoft.VisualC.IsConstModifier)
'?A0xbdde7aca.unnamed-global-0'
newobj instance void [mscorlib]System.String::.ctor(int8*)
stloc.0
// String *ps2 = S"goodbye";
ldstr "goodbye"
stloc.0
记得(或者学习)在字符串前加上前缀 S就会有可观的性能节省。在修订版的 V2语言中,字符串的处理被透明化,由使用的上下文决定。S不再需要被指定。
在我们需要显式告诉编译器使用哪种解释时情况又是怎样呢?在这样的情况下,我们使用显式的转换。例如:
f( safe_cast<String^>("ABC") );
此外,字符串现在将一个 String与一个普通转换相匹配而不是匹配一个标准转换,虽然这看起来影响不大,但是却改变了包含 String和 constchar*作为区别形式参数的重载函数集的解析方式。一度被解析为 const char*实例的解析现在被标志为有歧义的。例如:
void f(const char*);
void f(String^);
// v1: f( const char* );
// v2: 错误:有歧义...
f("ABC");
这里发生了什么?为什么有区别?因为程序中存在不止一个以 f 为名称的实例,所以需要将函数重载解析算法应用于调用。正式的函数重载解析包含以下三个步骤:
搜集候选函数。候选函数是作用域内字面上匹配所调用函数的名称的方法。例如,因为 My()是通过 R的一个实例调用的,所有不是 R(或其基类层次结构)成员的命名函数 My都不是候选函数。在我们的示例中,有两个候选函数。这就是名为 My的R函数的两个成员。
候选函数中的可行函数集。一个可行函数是可以用调用中指定的参数调用的函数(如果给定参数的数量和类型)。如果可行函数集为空,那么调用失败。
选择表示最匹配调用的函数。这是通过对应用于从参数到可行函数参数类型的转换进行定级来实现的。对只有一个参数的函数来说,这相对简单一些;但是多参数函数的情况下有点复杂。如果没有最佳匹配,那么调用在该阶段会失败。换句话说,如果从实参类型到形参类型所需的转换都同样好,那么调用被标记为有歧义的。
在原版语言设计中,作为最佳匹配,该调用的解析调用 constchar*实例。在 V2中,“abc”到 constchar*和 String^匹配所需的转换现在是等价的 — 换句话说,同样好 — 因此调用被标记为坏的 — 也就是说,有歧义的。
这导致我们思考以下两个问题:
实际参数“abc”的类型是什么?
判断一个类型转换优于另一个类型转换的算法是什么?
字符串“abc”的类型是 constchar[4]— 记住,每个字符串的末尾有一个隐式的 null 终止符。
判断一个类型转换优于另一个的算法涉及到将可能的类型转换放在层次结构中。以下是我对这个层次结构的理解 — 当然,所有这些转换都是隐式的。使用显式转换标记会重新定义层次结构,就像圆括号重新定义表达式的运算次序一样。
一个精确匹配是最好的。令人惊异的是,对于精确匹配的参数来说,不必精确匹配参数类型,只需要足够接近。这是理解本例的原理和语言如何进行修改的关键。
提升优于标准转换。例如,short int到 int的提升优于 int到 double的转换。
标准转换优于装箱转换。例如,int到 double的转换优于 int到 Object的装箱。
装箱转换优于隐式用户自定义转换。例如,int到 Object的装箱优于应用 SmallInt值类的转换操作符。
隐式用户定义转换优于根本没有转换。隐式用户定义转换是错误之前的最后一个出口(警告:形式参数中的位置可能包含一个参数数组或者省略号)。
这样,为什么精确匹配不一定会确定一个匹配?举例来说,const char[4]并不精确匹配 const char*或者 String^,但是在我们的示例中,两个不一致的精确匹配之间仍然存在歧义!
精确匹配发生时,包含一系列小转换。在 ISO-C++中有 4 个普通转换可以使用,并且仍旧满足精确匹配,其中三个被称为左值转换。第四个转换被称为限定转换。三个左值转换比需要限定转换的精确匹配更优越。
左值转换的一种形式是本机-数组-指针的转换。这就是将 const char[4]匹配到 const char*所发生的事情。因此,从 My("abc")到 My(const char*)的匹配是一个精确匹配。在 C++/CLI语言的早期版本中,这实际上是最佳转换。
因为编译器要将调用标记为有歧义的,所以这要求一个从 const char[4]到 String^的转换也通过普通转换成为一个精确匹配。这就是 V2 中新加入的更改。并且这也是调用被标记为有歧义的原因。
5.2 参数数组和省略号
在原版语言设计和 VisualStudio2005中即将发布的 V2语言中都没有对 C#和 Visual Basic .NET 支持的参数数组的显式支持。作为替代,用一个属性标记普通数组如下:
void Trace1( String* format, [ParamArray]Object* args[] );
void Trace2( String* format, Object* args[] );
虽然这看起来都一样,但是 ParamArray 属性在 C#或者其他 CLI语言中将其标记为每个调用获取可变数量元素的数组。在重载函数集合的解析中,修订版语言针对原版进行了程序行为的更改,其中一个实例声明了省略号,另一个声明了 ParamArray 属性,如以下 Artur Laksberg 提供的示例所示:
int My(...); // 1
int My( [ParamArray] Int32[] ); // 2
在原版语言设计中,省略号的解析优先于属性,这是有道理的,因为属性不是语言的正式部分。然而在 V2中,现在语言直接支持参数数组,所以它优先于省略号,因为它是更强类型的。因此,在原版语言中,调用
My( 1, 2 );
解析至 My(...),而在修订版语言中,它解析至 ParamArray 实例。如果应用程序的行为依赖于省略号实例的调用优先于 ParamArray 的调用,那么您需要修改符号或者调用。
5.3 typeof 改为 T::typeid
在原版语言设计中,__typeof()操作符在传递一个托管类型的名称时返回相关的 Type*对象,例如:
//创建并初始化一个新的 Array 实例。
Array* myIntArray =
Array::CreateInstance( __typeof(Int32), 5 );
在修订版语言设计中,__typeof被另一种 typeid形式替代,它在指定一个托管类型时返回一个 Type^。
//创建并初始化一个新的 Array 实例。
Array^ myIntArray =
Array::CreateInstance( Int32::typeid, 5 );
5.4 强制转换符号和 safe_cast<> 简介
注意,这是较为冗长的一节,所以一些耐不住性子的人可以快速跳到末尾来阅读实际更改的说明。
修改一个已经存在的结构是完全不同的 — 在某种意义上,比编写原始的结构更加困艰难;自由度更少,以及解决方案趋于理想重构和实际上对现存的结构依赖性之间的妥协。举例来说,如果您曾经进行过排版,您就会知道,由于需要将重新格式化限定在当前页中,因此对现存页的更正就有所限制;您不能允许文本溢出到后面的页面中,这样您就不能添加或者删节太多(或太少)内容,而且经常会让人感觉到更正是为了适合版面,从而其意义有所妥协。
语言扩展是另外一个例子。回到 20 世纪 90 年代初,面向对象编程成为一个重要的范型,对 C++ 中类型安全的向下转换的需求逐渐增大。向下转换是用户对基类指针、对指针的引用,或派生类的引用的显式转换。向下转换需要一个显式的转换,这是因为如果基类指针不是派生类对象,程序很可能做出一些很不好的事情。问题在于基类指针的实际类型是运行库的一个方面,因此编译器无法检查它。或者换句话说,向下类型转换就像一个虚函数调用,需要某种形式的动态解析。这产生了两个问题:
为什么在面向对象范型中需要向下类型转换?难道虚函数机制不适合所有情况?也就是说,为什么不能声明任何对向下转换(或者任何类型的转换)的需要是程序员的设计失败?
为什么支持向下转换成为 C++的一个问题?毕竟,这在任何诸如 Smalltalk(或者随后的 Java和 C#)的面向对象语言中都不是问题?为什么在 C++中支持向下转换有困难?
虚函数代表类型家族中常见的一个依赖于类型的算法(我没有考虑接口,这在 ISO-C++中不被支持,但是在 C++/CLI中可用,并且代表一个有趣的替代设计方案)。该类型家族设计的典型代表是一个类层次结构,其中具有一个声明了通用接口(虚函数)的抽象基类的,以及一组具体派生类,代表应用程序域中实际类型家族。
举例来说,一个电脑成像 (CGI) 应用程序域中一个轻量级的的层次结构,会具有一些诸如 color、intensity、position、on、off等等的共同属性。可以在某个图像中撒下几束光,并且通过通用接口控制它们,而不用担心光到底是聚光、平行光、全向光(如太阳光),还是通过挡光板的光。在这种情况下,向下转换为一个特定的光类型来实现其虚接口是不必要的,因为所有的方式都一样,所以是不明智的。但是,在生产环境中,情况不总是一样的;很多情况下,考虑的是速度;程序员可能会选择向下转换,然后显式调用每个方法,如果这样,调用的内联直接执行会替代通过虚函数机制执行。
因此,在 C++中使用向下转换的一个原因是抑制虚函数机制而获得可观的运行库性能(注意,将手动优化进行自动化是研究的活跃领域。但是这比替换 register或者 inline关键字的显式使用更加困难)。
使用向下转换的另一个原因是多态性的双重属性。关于多态的一个观点是将它区分成被动和动态两种形式。
一个虚调用(和向下转换功能)代表多态性的动态使用:在程序执行过程中实现一个操作,该操作基于特殊实例的基类指针的实际类型。
但是,将一个派生类对象赋值给其基类指针是多态性的被动形式;这里将多态性作为一个传输机制。这是 Object类的主要用途,例如在普及的 CLI 中就是这样。作为被动形式使用时,用于传输和存储的基类指针通常提供一个过于抽象的接口。举例来说,Object通过其接口提供了大约 5 个方法;任何更明确的行为需要一个显式的向下转换。例如,如果我们希望调整聚光灯的角度或者照射角度,我们会需要显式的向下转换。子类型家族中的虚接口不能是其许多子成员的所有可能方法的超集,所以面向对象语言中向下转换功能总是必要的。
如果一个安全的向下转换功能在面向对象的语言中是必要的,那么为什么 C++花了这么久的时间来添加该功能?问题在于如何使指针的运行库类型信息可用。对于虚函数,就像大多数人目前了解的一样,运行库信息是编译器分两部分建立的:(a) 类对象包含一个额外的虚表指针成员(在类对象的开头或者末尾;这是它本身的一个有趣的历史),它指向适当的虚表 — 所以,举例来说,一个聚光对象指向一个聚光虚表,对平行光是平行光虚表,等等;以及 (b) 每个虚函数在表中有一个相关的固定位置,并且实际调用的实例由表中存储的地址来表示。因此,举例来说,虚析构函数 ~Light可能与位置 0相关联,Color与位置 1相关联,等等。这是一个如不灵活即有效的策略,因为它是在编译时设置的,而且代表最小的开销。
现在的问题是如何使类型信息可用于指针而不改变 C++指针的大小,方法是再添加一个地址,或者直接添加一些类型编码。这不可能被那些选择不进行面向对象范型编程的程序员(和程序) — 他们仍旧是具有很大影响的用户团体 — 接受。另外一个可能性是为多态类类型引入一个特定的指针,但这将造成可怕的混乱,并且使得混合两者变得非常困难 — 特别是在指针算法问题方面。维护将每个指针关联到当前相关类型的运行库表,以及动态对其进行更新也是不可接受的。
现在的问题是,两个用户社区有不同但是合理的编程期望。解决方案需要在两个社区之间进行妥协,不但允许每个社区的期望而且也允许互操作能力得以实现。这意味着两个社区提供的方案看起来都不可取,而最终实现的解决方案很可能并不完美。实际的解决方案围绕多态类的定义:多态类是一个包含虚函数的类。一个多态类支持动态类型安全的向下转换。这解决了“以地址的形式维护指针”的问题,因为所有多态类包含额外的指针成员,指向其相关虚表。因此,相关类型信息可以保存在一个扩展的虚表结构中。类型安全向下转换的开销是(几乎)限制了功能的使用者的范围。
关于类型安全的向下转换的下一个问题是它的语法。因为它是一个强制转换,ISO-C++协会的原意是使用未装饰的强制转换语法,因此编写如下示例代码:
spot = ( SpotLight* ) plight;
但是这被委员会否决了,因为这不允许用户控制强制转换的代价。如果动态类型安全的向下转换具有前面的不安全但是是静态的标记,那么它将成为一个替代方案,而且用户无法在不必要或者代价太大时降低运行库的开销。
通常,C++中总有机制抑制编译器支持的功能。例如,我们可以通过使用类作用域操作符 Box::rotate(angle)或者通过类对象(而不是通过这个类的指针或者引用)调用虚函数来关闭虚函数机制 — 后面一个抑制是语言不需要的,但是是一些实现问题所必需的……它类似于以如下形式在声明时构造一个临时对象:
//编译器可以自由优化掉这个临时对象...
X x = X::X( 10 );
因此提议被打回重新考虑,很多替代的符号被考虑过,而最后提交给委员会的是 (?type)形式,表示它的不确定 — 也就是动态 — 本质。这为用户提供了在两种形式 — 静态或者动态 — 之间切换的能力,但是没有人满意。所以它又回到制图板。第三个,也是成功的一个标记是现在的标准 dynamic_cast<type>,它被通用化为四个新风格的强制转换标记集合。
在 ISO-C++中,dynamic_cast在应用到一个不合适的指针类型时返回 0,并且在应用到一个引用类型时引发一个 std::bad_cast异常。在原版语言设计中,将 dynamic_cast应用到一个托管引用类型(因为它的指针表达方法)总是返回 0。__try_cast<type>作为一个引发 dynamic_cast变体的异常的类似被引入,但是它在强制转换失败时引发System::InvalidCastException异常。
public __gc class ItemVerb;
public __gc class ItemVerbCollection
{
public:
ItemVerb *EnsureVerbArray() []
{
return __try_cast<ItemVerb *[]>
(verbList->ToArray(__typeof(ItemVerb *)));
}
};
在修订版语言中,__try_cast被重新转换为 safe_cast。以下是修订版语言中同样的代码片断:
using namespace stdcli::language;
public ref class ItemVerb;
public ref class ItemVerbCollection
{
public:
array<ItemVerb^>^ EnsureVerbArray()
{
return safe_cast<array<ItemVerb^>^>
( verbList->ToArray( ItemVerb::typeid ));
}
};
在托管领域,限制程序员以代码不可验证的方式在类型间进行转换的能力,从而允许可验证代码是很重要的。这是 C++/CLI 代表的动态编程范型的一个关键部分。由于这个原因,旧风格类型转换的实例作为运行库转换被内部重新转换,所以,举例来说:
//内部转换为上面的等价的 safe_cast 表达式
( array<ItemVerb^>^ ) verbList->ToArray( ItemVerb::typeid );
另一方面,因为多态提供了动态和被动两种模式,有时有必要执行一个向下类型转换,只是为了获得对子类型的非虚 API的访问能力。举例来说,当指向层次中任何类型的类的成员(使用被动多态性作为传输机制),但是在一个特定程序上下文中的实际实例已知的时候,可能发生这种情况。在这种情况下,系统程序员强烈的感觉到,进行类型转换的运行库检查具有无法接受的性能开销。如果 C++/CLI 作为托管系统编程语言,它必须提供一些方法来允许编译时(即静态)向下转换。这就是为什么在修订版语言中 static_cast标记的使用仍允许保持为一个编译时向下转换的原因。
// OK:在编译时执行的强制转换
// 没有运行时的类型正确性检查
static_cast< array<ItemVerb^>^>(
verbList->ToArray( ItemVerb::typeid ));
当然,问题是无法保证程序员执行的 static_cast是正确和善意的。换句话说,无法保证托管代码的可验证性。这是在动态编程范型下比本机环境更迫切的一个考虑,但是不足以在系统编程语言中禁用用户切换静态和运行时类型转换的能力。
有一个 C++/CLI的性能陷阱和缺陷需要注意,在本机编程中,旧风格的强制转换标记和新风格的 static_cast标记在性能上没有区别。但是在新语言设计中,旧风格强制转换标记的性能开销比新风格static_cast标记的性能开销更加昂贵,因为编译器需要将旧风格标记的使用内部转换为引发异常的运行时检查。而且,它还更改了代码的执行配置文件,因为它导致在程序中引入一个未捕捉的异常 — 可能是智能的,但是如果使用 static_cast标记,那么同样的错误将不会导致该异常。可能有人有异议,好的,这将有助于刺激用户使用新风格的标记。但是只在它失败的时候才会这样;否则,它只会导致使用旧风格标记的程序运行更加缓慢,而没有可以理解清楚的原因,如以下 C 程序员所犯的错误:
// 缺陷 # 1:
// 初始化可以避免一个临时类对象的创建,而赋值不行
Matrix m;
m = another_matrix;
// 缺陷# 2: 类对象的声明远离其使用
Matrix m( 2000, 2000 ), n( 2000, 2000 );
if ( ! mumble ) return;
附录:推动修订版语言设计
原版和修订版语言设计之间最显著和引人注目的更改可能是托管引用类型声明的更改:
// 原版语言
Object * obj = 0;
// 修订版语言
Object ^ obj = nullptr;
看到这段代码时主要会提出两个问题:为什么帽子(^符号)在微软的走廊里家喻户晓,但是,更根本的是,为什么要新的语法?为什么原版语言设计不能被清理以减少侵略性,而推荐公认咄咄逼人的、陌生的修订版 C++/CLI语言设计?
C++是基于面向机器的系统视图建立的。虽然它支持一个高级的类型系统,但是总有回避它的机制,这些机制总是导致对机器的依赖性。当事态严重,而且用户努力去做一些不可思议的事的时候,他们会绕过应用程序的抽象过程,重新将类型分离为地址和偏移。
CLI是操作系统和应用程序之间运行的一个软件抽象层。当事态严重时,用户会毫无根据地逐字反思执行环境、查询、代码和对象创建问题,跳过而不是遵循类型系统,但是这个经验对于习惯脚踏实地的人来说会是一团糟。
例如,下面的内容是什么意思?
T t;
好的,在 ISO-C++中,不管 T的本质是什么,我们都可以确认下列特性:(1) 有一个与 t相关的字节的编译时内存委托等于 sizeof(T);(2) 在程序中 t的作用域内,这个与 t关联的内存独立于其他所有对象;(3) 内存直接保持与 t相关的状态/值;以及 (4) 内存和状态在 t 的作用域内存在。
下列特性的结果是什么?
第 (1) 项告诉我们 t不能是多态的。也就是说,它不能代表一个继承层次中的一系列类型。换句话说,一个多态类型不能有一个编译时的内存分配,除非派生实例没有额外的内存需求。无论 T是一个基本类型还是一个复杂层次的基类,这都成立。
C++中的多态类型只可能在类型限定为指针 (T*)或者引用 (T&) 才可用 — 也就是说,如果声明只是间接引用一个 T的对象。如果:
Base b = *new Derived;
那么 b并不指向一个位于本机堆上的 Derived对象。值 b没有和 new 表达式分配的Derived对象关联,而 Derived对象的 Base部分被截断,并且按位复制到独立的基于栈的实例 b。这在 CLI对象模型中实际上没有对应的描述。
为了将资源提交延迟到运行时进行,C++ 显式支持两种间接形式:
指针: T *pt = 0;
引用: T &rt = *pt;
指针和 C++对象模型一致。在
T *pt = 0;
中,pt直接保存一个 size_t类型的值,该值具有固定的大小和作用域。语法词汇习惯于在指针的直接使用和指向对象的间接使用之间切换。众所周知,*pt++在何种模式应用于什么/何时应用/如何应用这个问题上具有歧义。
引用为看起来有些复杂的指针词汇提供了一种简单的语法,同时保持其效率。
Matrix operator+( const Matrix&, const Matrix& );
Matrix m3 = m1 + m2;
引用并不在直接和间接模式之间切换;而是在两者之间进行阶段转移:(a) 初始时,它们被直接操作;但是 (b) 在所有后续的使用中,它们是透明的。
某种意义上说,引用代表了 C++ 对象模型物理学的一个奇异量子:(a) 它们占用空间,但是除了临时对象之外,它们并没有实体;(b) 它们在赋值时使用深拷贝 (deep copy),而在初始化时使用浅拷贝 (shallow copy);以及 (c) 与 const对象不同,参数实际上没有实体。虽然在 ISO-C++中它们除了用于函数参数之外没有太多的用途,但是在语言修订版方面,它们十分具有灵感。
C++.NET 设计难题
字面上,对于C++扩展支持 CLI的每一方面,问题总是归结到“我们如何将公共语言基础结构 (Common Language Infrastructure,CLI) 的这个(或者那个)方面集成到 C++ 中,使它 (a) 让 C++程序员感觉自然,以及 (b) 感觉像 CLI自身的一个一流的功能”。基于这些考虑,这个权衡在原版语言设计中没有实现。
读者的语言设计难题
因此,为了让你看到一些步骤,这里指出我们所面临的难题:我们如何声明和使用一个 CLI引用类型?它和 C++对象模型有显著区别:不同的内存模型(垃圾回收),不同的复制语义(浅拷贝),不同的继承模型(一体化,基于 Object,只有对接口有额外的支持时才支持单继承)。
C++设计的原版托管扩展
在 C++中支持 CLI引用类型的基础设计选择就是决定是保留现存的语言,还是扩展语言,因而打破现有标准。
您会作何选择?每个选择都会被指责。标准归结为一个人是否相信额外的语言支持代表域抽象(考虑并行和线程)或者范型转移(考虑面向对象的类型—子类型关系和泛型)。
如果您相信额外的语言支持只代表另一个域抽象,您将会选择保留现存语言。如果您了解到额外的语言支持代表编程范型的转移,您会扩展语言。
简而言之,原版语言设计认为额外的语言支持只是一个域抽象 — 这被笨拙的称为托管扩展— 因此逻辑上后续的设计选择是保持现存语言。
一旦我们致力于保持现存语言,只有三个替代的方法实际上可行 — 记住,我将讨论限制在简单的“如何表示一个 CLI引用类型”上:
让语言支持透明化。编译器会根据上下文决定语义。有歧义时会产生一个错误,并且用户会通过一些特殊语法决定上下文的含义(作为类比,可以考虑根据层次结构的先后顺序重载函数)。
以库的方式添加域抽象支持(考虑将标准模板库作为可能的模型)。
重用某些现存的语言元素,基于附带规范中的描述,根据上下文限制其允许的用途和行为。(考虑虚基类的初始化和类型强制转换的语义,或者函数中、文件范围内和类声明中 static 关键字的多种用途)。
每个人的首选都是第 1 项。“它只是和语言中其他内容一样,只是少许不同。让编译器判断就好了。”这里很大的成功在于对于现存代码来说,所有内容对用户都是透明的。将您现有的应用程序拿出来,添加一两个对象,编译,然后,ta-dah,它就完成了。使用方便,操作简单。在类型和源代码方面完全可以互用。没有人会质疑该方案不是理想方案,很大程度上就像没有人争论永动机的理想性一样。在物理学上,这个问题的障碍是热力学第二定律,以及熵的存在。在一个多范型编程语言中,规则有显著不同,但是系统的瓦解是一样明确的。
在一个多范型编程语言中,事情在各自的范型内运作相当良好,但是在范型被不正确地混合时趋于崩溃,导致程序崩溃或者更坏,运行但是产生错误的结果。这在支持独立的基于对象和多态的面向对象的类编程中最常见。切片使得每个 C++新手的编程变得混乱:
DerivedClass dc; // 一个对象
BaseClass &bc = dc; // OK:bc 真的是一个 dc
BaseClass bc2 = dc; // OK:但是 dc 可能被切片以适应 bc2
因此,打比方来说,语言设计的第二定律是让行为不同的事物看起来具有足够的差异以提醒用户,在他或者她编程时尽量避免,嗯,一团糟。我们习惯于用两个小时介绍中的半个小时来开始 C 程序员对指针和引用之间差异理解的第一步,而且大量的 C++ 程序员仍不能清楚地描述何时使用引用声明,何时使用指针,以及原因。
这些迷惑无可否认地使编程更困难,并且在简单地去除它们和其支持所提供的现实功能之间总有一个重要的权衡。并且它们的差异在于设计的透明度,以及在于它们是否实用。而且通常设计是通过类推实现的。当指向类成员的指针被引入到语言中时,成员选择操作符被扩展了(例如从 -> 到 ->*),并且指向函数语法的指针被类似的扩展了(从 int (*pf)()到int(X::*pf)())。同样地,类的静态数据成员的初始化也被扩展了,等等。
引用对操作符重载的支持是必须的。您可以得到直观的语法
Matrix c = a + b; // Matrix operator+( Matrix lhs, Matrix rhs );
c = a + b + c;
但是这很难说是一个有效的实现。C 语言指针的替代方案 — 这提供了效率 —被其非直观语法所分隔:
// Matrix operator+( const Matrix* lhs, const Matrix* rhs );
Matrix c = &a + &b;
c = &( &a + &b ) + &c;
引入引用提供了指针的效率,但是保留了直接访问值类型的简单语义。它的声明类似于指针,并且易于理解。
// Matrix operator+( const Matrix& lhs, const Matrix& rhs );
Matrix c = a + b;
但是对习惯于使用指针的程序员来说,它的语义行为还是令人迷惑。
这样,问题就是,对于习惯 C++对象的静态行为的 C++程序员来说,理解和正确使用托管引用类型会有多么容易?而且理所当然地,什么可以帮助程序员在这方面进行最好的设计?
我们觉得两个类型的差别足以保证特别处理,因此我们排除了选项 #1。甚至在修订版语言中,我们仍支持这个选择。那些争论这个选择的人(一度包括我们中的大部分)只是没有坐下来深入理解这个问题。这不是指责,只是事实。因此,如果您考虑前面的设计难题并且提出一个透明的设计,那么我会断定,根据我们的经验,那不是一个可行的解决方案,我坚持这一点。
第二和第三个选项,或者采取一个库设计,或者重用现有的语言元素,都是可行的,并且各有所长,因为 Stroustrup 的 cfront源代码很容易获得,所以在贝尔实验室中库解决方案连篇累牍。它在某种程度上曾经是大众化的(HCE)。甲修改 cfront来添加并行性,乙修改 cfront来添加他们喜欢的域扩展,每个人都炫耀其新的 C++ 语言修改版,而 Stroustrup 的正确回答是这最好在一个库中进行处理。
这样,为什么我们没有选择一个库解决方案?嗯,部分原因只是一个感觉上的问题。就像我们感觉两种类型的差异足以保证特别处理一样,我们感觉两种类型的类似之处足以保证类似地处理。一个库类型在很多方面表现得像语言中的内建类型一样,但是它实际上不是。它不是一个一级的语言。我们感觉,我们必须尽力使引用类型成为语言的一级元素,因此,我们选择不部署库解决方案。这个选择仍存在争议。
这样,为了引用类型和现存类型对象模型太过不同的感觉而抛弃了透明的解决方案,并且为了引用类型和现存类型对象模型需要在语言中有同等地位的感觉而抛弃了库解决方案,我们剩下的问题是如何将引用类型集成到现存语言中。
如果我们从零开始,我们当然可以实现任何所希望的,从而提供一个统一的类型系统,并且 — 至少在我们修改了这个类型系统之前 — 我们做的任何事情都会焕然一新。这通常是我们在生产和技术中所做的。但是,我们被限制了,这是福也是祸。我们不能抛弃现存的 C++对象模型,所以我们做的任何事情必须与它兼容。在原版语言设计中,我们进一步限制了自己,不引入任何新的标记;因此;我们必须使用已有的标记。这并未给我们提供多少灵活度。
因此,为了切入重点,在原版语言设计中,假设给定刚才列举过的限制(希望没有太多的混淆),语言设计者觉得唯一可行的托管引用类型的表示方法是重用现存指针语法 — 引用并不是足够灵活的,因为他们不能被重新赋值,并且他们不能不引用任何对象:
// 所有在托管堆上分配对象的母亲...
Object * pobj = new Object;
// 本机堆上分配的标准 string 类...
string * pstr = new string;
当然,这些指针有显著的不同。例如,在 pobj指向的对象实体在托管堆的压缩过程中移动时,pobj会被透明地更新。不存在 pobj及其指向实体之间的关系这样一个对象跟踪的概念。整个 C++ 的指针概念并不是机器地址和间接对象引用的铰接。一个引用类型的句柄封装了对象的实际虚拟地址以实现运行时垃圾回收;除了在垃圾回收环境中破坏这个封装的后果更加严重这一点之外,这很大程度上就像私有数据成员封装了类的实现以实现可扩展性和局部化一样。
因此,虽然 pobj看起来像一个指针,但是很多指针的常见特性被禁用了,例如指针算术和类型系统之外的强制类型转换。如果我们使用完全限定语法来生明和分配一个引用托管类型,我们可以使这个区别更加显著:
// 好的,现在这些看起来不同了……
Object __gc * pobj = __gc new Object;
string * pstr = new string;
乍一看,指针解决方案很有道理。毕竟,看起来像一个 new 表达式的自然目标,而且两者都支持浅拷贝。一个问题是指针不是一个类型抽象,而是一个机器表示(以及一个说明如何解释第一个字节地址之后内存范围和内部组织的标签类型),而且这不符合软件运行库对内存的抽象,以及因此推断出的自动和安全性。这是一个表述不同范型的对象模型之间的历史问题。
第二个问题是(比喻警告:即将尝试一个牵强的比喻 — 所有肠胃不好的人建议暂停阅读或者跳到下一段)封闭语言设计的不可避免的弊端就是被限制重新使用既过分简单又显著不同的构造,导致在沙漠海市蜃楼中程序员的精神的挥散(比喻警告结束。)
重用指针语法造成了程序员的认知混乱:您不得不区分太多的本机和托管指针,而这会干扰代码流,最好用一个高级的抽象来管理它。换句话说,作为系统程序员,我们有时需要降尊趋贵来压榨出一点性能,但是我们不会在这个级别久居。
原版语言设计的成功在于对现存 C++程序不加修改即可重编译,并且提供了只需少量工作就可以在新的托管环境中发布现存的界面的包装模式。之后也可以在托管环境中添加额外的功能,并且,依时间和经验而异,您可以直接将现存应用程序的一部分移植到托管环境。这对一个有现存代码基和专门技术基础的 C++程序员来说是一个伟大的成就。我们不需要为此惭愧。
但是,在原版语言设计的实际语法和视野中有显著的弱点。这不是设计者的不足,而是其基础设计选择的保守本质 — 继续保持在现存语言中。这来自对托管支持的误解,就是它不代表一个域抽象,而是一个革命性的编程范型,需要一个类似于 Stroustrup 引入以支持面向对象和普通编程的语言扩展。这就是修订版语言设计所代表的,以及它必要且合理的原因,即使它给忠于原版语言设计者带来一些难题+。这即是本指南和转换工具背后的动机。
修订版 C++/CLI 语言设计
一旦明确了支持 C++ 中的公共语言基础结构代表一个独立的编程范型,随之而来的就是语言必须被扩展,从而为用户提供一流编的程体验,以及与 ISO-C++标准的优雅的设计集成,以注重较大 C++ 社区的感受,并且赢得其委托和辅助。随之而来的还有,原版语言设计的昵称、C++ 的托管扩展,也必须被替换。
CLI 的最突出特性是引用类型,并且它在现存 C++语言中的集成代表一个概念的证明。一般的标准是什么?我们需要一种方法来表示托管引用类型,既将其分离,又仍感觉它和现存类型系统类似。这使人们意识到,这个普通形式类别很熟悉,但是也注意到它的唯一功能。类似性是 Stroustrup 在 C++ 的原始发明中引入的引用类型。因此这个普通形式变为:
Type TypeModToken Id [ = init ];
这里 TypeModToken是语言在新的上下文环境里重用的符号之一(也类似于引用的引入)。
这在最初争议十分强烈,并且仍旧使一些用户感到很困难。我记得一开始两个最常见的回应是 (a) 我可以用一个 typedef 来处理(不住地眨眼),以及 (b) 这真的不怎么坏(后者提醒了我,我的回复是使用左移和右移操作符来在 iostream 库中进行输入和输出)。
必要的行为特性是它在操作符应用到对象的时候展示了对象的语义,这是原版语法无法支持的一点。我喜欢称它为灵活的引用,思考它和现存 C++ 引用的差异(是的,这里两个引用的使用 — 一个是托管引用类型,另一个是“这不是一个指针(不住地眨眼)”这里的本机 C++类型 — 是令人遗憾的,很像在我喜欢的一本四人帮(Gangof FourPatterns)的设计模式书中对模板这个词的重用。):
它必须会不引用任何一个对象。当然,本机引用不能直接做到这一点,虽然人们总是告诉我如何将一个引用初始化为 0 的重新解释的类型强制转换。(常规的做法是使引用不指向任何对象,从而提供一个显式的由约定 null对象代表的单体,它也经常作为一个函数参数的默认参数。)
它可能不需要一个初始值,但是可以在生命期的开始不引用任何一个对象。
它可以被重新赋值为指向另一个对象。
默认情况下,一个实例对另一个实例进行的赋值或者初始化是浅拷贝。
就像一些人使我想到的,我是从反方向考虑这个问题的。也就是说,我通过区分它和本机引用的性质来引用它,而不是用它作为一个托管引用类型的句柄这个性质来识别它。
我们想将这个类型称为句柄而不是指针或者引用,因为这两个术语有本机方面的累赘。句柄是更适合的名字,因为它是一个封装的模式 — 一个叫做 John Carolan 的人首先向我介绍了该设计,以一个可爱的名称:露齿嬉笑的猫 (CheshireCat),因为被操作对象的实体可以在您不知情的情况下消失。
在这种情况下,这个令人失望的举动源自于在垃圾回收器的一次清扫中潜在的引用类型的重新定位。实际上发生的是,这个重新定位被运行库透明地跟踪,而且句柄被更新为正确地指向新位置。这就是它被称为跟踪句柄的原因。
因此,关于新的跟踪引用语法,我最后想提及的一点是成员选择操作符。对我来说,毫无疑问会使用对象语法 (.)。其他人觉得指针语法 (->) 也是同样显然的,并且我们从跟踪引用用途的多个方面进行了讨论:
// 喜好使用指针语法的人
T^ p = gcnew T;
// 喜好使用对象语法的人
T^ c = a + b;
这样,就像物理学里面的光一样,一个跟踪引用的行为在一些程序上下文中像一个对象,在另一些程序上下文中像一个指针。最后,投入使用的成员选择操作符是箭头,就像在原版语言设计中一样。
关于关键字的总结性补充
最后,一个有趣的问题是,为什么Stroustrup在C++语言设计中添加了类?实际上没有必要引入它,因为在 C++中 C语言的结构被扩展了,以支持类可以做到的任何事情。我没有问过 Bjarne 这个问题,所以我在这一点上没有特别的见识,但是这是一个有趣的问题,而且给定 C++/CLI中添加关键字的数量,这在某种程度上是相关的。
一个可能的回答 — 我称其为步兵的来福枪(footsoldiershuffle)— 是个争论:不,类的引入绝对必要。毕竟,两个关键字之间不仅有默认成员访问级别的差异,而且派生关系的访问级别也不一样,所以为什么我们不能两个都要?
但是慢一点,引入一个新关键字不仅和现存语言不兼容,而且导入了语言树的一个不同分支(Simula-68),会有冒犯 C语言社区的风险。其动机真的是默认访问级别规则的差异?我不能肯定。
一方面,语言在类设计者使用 class关键字将整个实现公开时既没有阻止也没有警告。语言本身并无公共和私有访问的策略,所以很难看到未明确的默认访问级别许可被重视 — 换句话说,比引入不兼容性的代价还重要。
类似的,将未标记的基类默认作为私有继承,看起来在设计实践上有些问题。这更加复杂,并且对于继承的形式更难于理解,因为它没有展示类型/子类的行为,并且因此破坏了可替代性规则。它代表了实现(而不是接口)的重用,并且我相信,把私有继承作为默认是个错误。
当然,我不能公开宣布这一点,因为在语言市场中,从来不应该承认产品中的一点点问题,因为这会为迅速抓住任何竞争优势抢占市场分额的敌人提供弹药。嘲笑在知识分子的小圈子里特别盛行。或者,更恰当地说,一个人直到新的改进产品准备铺开的时候再承认缺陷。
引入 class不兼容性还可能有什么其他原因?C语言的结构概念是一个抽象的数据类型。C++语言的类概念(当然,这不是源自于 C++)是数据抽象,以及随之而来的封装和接口约定的思想。抽象数据类型是与地址相关的邻近的数据块 — 指向它、数据转换、分隔、快速移动。数据抽象是有生命期和行为的实体。这是为了教育学上的重要性,因为用词会使语言大不一样 — 至少在一个语言中。这是修订版设计铭记在心的另一个教训。
为什么 C++没有完全移除结构?保留一个并引入另一个并不优雅,而且这样字面上最小化了他们之间的差异。但是有其它选择吗?Struct 关键字不得不被保留,因为 C++必须尽可能保留和 C的向下兼容;否则,不仅它会在现存程序员中更不受欢迎,而且可能不会被允许发布(但是这是另一个时间、另一个地点的另一个故事了)
为什么结构的访问级别默认是公有的?因为如果不这样,现存的 C程序不会编译通过。这在实践上会是一场灾难,虽然程序员很可能从来没在语言设计高级守则(Advanced Principles of Language Design)中听说过它。语言中可能有一个强制接受的过程,强制接受一个策略,从而使用结构保证了一个公有实现,反之,使用类保证了一个私有实现和公有接口,但是这个策略并不用于实践用途,所以会有点过于珍贵。
实际上,在贝尔实验室的 cfront1.0语言编译器的发布测试中,有一个语言律师之间的小争论:前置声明和后续定义(或者任何这样的组合)是否必须继续使用这个或者其他关键字,或者可以被互相替换来使用。当然,如果结构有任何实际的重要性,这个争论不会发生。
致谢
我想在这里感谢 Visual C++ 团队的很多成员,他们不断帮助和指引我理解从原版 C++托管扩展到修订的 C++/CLI语言设计移植相关的问题。特别感谢 Arjun Bijanki 和 Artur Laksberg,他们两个容忍了我的很大困惑。也感谢 Brandon Bray、Jonathan Caves、Siva Challa、Tanveer Gani、Mark Hall、Mahesh Hariharan、Jeff Peil、Andy Rich、Alvin Chardon 和 Herb Sutter。他们都提供了大量的帮助和反馈。本文颂扬了他们的专业精神。
相关书籍
STL Tutorial and Reference Guide ,David Musser、Gillmer Derge 和 Atul Saini 著,Addison-Wesley,2001 年
C++ Standard Library ,Nicolai Josuttis 著,Addison-Wesley,1999 年
C++ Primer ,Stanley Lippman 和 Josee Lajoie 著,Addison-Wesley,1998 年
关于作者
Stanley Lippman 是微软 Visual C++ 团队的一个架构师,曾在 1984 年于贝尔实验室和 C++ 发明者 Bjarne Stroustrup 一起工作。其间曾工作于华特·迪士尼和梦工场的特色动画公司,也是影片《狂想曲两千》(Fantasia 2000)的软件技术指导。