转换指南: 将程序从托管扩展C++迁移到C++/CLI
翻译:蒋晟
2004年8月
适用于:
C++/CLI第二版
ISO-C++
摘要: C++/CLI代表一个ISO-C++语言标准的动态范型扩展。本文列举了V1版本语言的特色 ,以及它们在V2版本中的对应(如果存在);并指出为不存在相应对应的V1特色构建的语言特性。(68打印页)
译者注:
- 原文地址在http://msdn.microsoft.com/visualc/default.aspx?pull=/library/en-us/dnvs05/html/transguide.asp(英文)。
- 本文已发表在MSDN中文网站上,网址是http://www.microsoft.com/china/MSDN/library/langtool/VCPP/TransGuide.mspx。MSDN版有所删改
- 可以在微软网站上下载C++/CLI 语言规范
- MSDN中关于Visual C++.Net的文章的聚合:http://msdn.microsoft.com/visualc/rss.xml
目录
简介
C++/CLI代表ISO-C++标准语言的一个动态编程范型扩展(dynamic programming paradigm extension). 在原版语言设计(V1)中有许多显著的弱点,我们觉得在修订版语言设计(V2)中已经修正了。 本文列举了V1版本语言的特色和它们在V2版本中的对应 (如果有这样的对应存在的话);并指出为对应不存在的V1特色构建的语言特性。对于有兴趣的读者,附录中提供新语言设计的扩展原理。 另外,一个源代码级别的转换工具(mscfront)正在开发中,而且可能在C++/CLI的发布版中提供给希望 自动化移植V1代码到新语言设计的人。
本文分为五个章节加一个附录。 第一节讨论主要的语言关键字,特别是双下划线的移除以及 上下文性和 分段关键字。 第二节着眼于托管数据类型的变化——特别是托管 引用类型和数组类型。还可以在这里找到有关确定性终结化语义 (deterministic finalization)的详细讨论。关于类成员的变化,例如属性、索引属性和操作符,是 第三节的焦点。 第四节着眼于托管枚举、内部和约束指针的语法变化。它也讨论了许多可观的语义变化,例如隐式装箱的引入、托管 CLI 枚举的变化,和对 值类默认构造函数的支持的移除。 第五节有几分像大杂烩——声名狼藉的 杂项。可以在里面找到对类型转换符号、字符串常数的行为和参数 数组的讨论。1. 语言关键字
原版到修订版的一个大体转换是在所有关键字中去掉双下划线。举例来说,一个属性现在被声明为property而不是__property。在原版设计中使用双下划线前缀的两个主要原因是:
- 这是提供符合ISO-C++标准的语言扩展的方法。原版语言设计的一个主要目的就是不引入和标准语言的不兼容性,例如新的关键字和标记。这个原因很大程度上也推动了对声明托管引用类型的对象的指针语法的选择。
- 双下划线的使用,除了兼容性方面的原因之外,也是一个不会对有旧代码基础的用户造成影响的合理保证。这是原版设计的第二主要目的。
这样的话,为什么我们移除双下划线(并且引入了一些新的标记)?不是的,这不代表我们不再考虑和标准保持一致!
我们继续致力于和标准一致。尽管如此,我们意识到对CLI动态对象模型的支持表现出了一种全新的强大的编程范型。我们在原版设计以及设计与发展C++语言的经验使我们确信,对这个新范型的支持需要它自己的高级关键字和标记。我们想提供一个新范型的一流表达方式,整合它并且支持标准语言。我们希望你会感到修订版语言设计提供了对这两种截然不同的对象模型的一流的编程体验。
类似的,我们很关心最小化这些新的关键字的对现有代码可能造成的冲击。这是用上下文性和分段关键字解决的。在我们着眼于实际语言语法的修订之前,让我们试试搞清楚这两个特别的关键字的风味。
一个上下文性关键字在特定的语言环境中有特殊的含义。例如,在通常的程序中,sealed被识别为一个普通标识符。但是,在一个托管引用类类型的声明部分,它被识别为类声明上下文中的一个关键字。这最小化了在语言中引入一个新的关键字的潜在影响,我们觉得这对我们的有旧代码基础的用户非常重要。同时,它允许新的功能的使用者获得一流的新增语言特色的体验——我们在觉得原版设计中缺少这些因素。我们将在2.1.2节中看到sealed的用法。
一个分段关键字是上下文性关键字的特例。字面上是一个上下文性修饰符和一个现存关键字配对,用空格分隔。这个配对被识别为一个语法单位,例如value class(示例参见2.1),而不是两个单独的关键字。基于现实的因素,这意味着一个重新定义value的宏,如下所示:
#ifndef __cplusplus_cli #define value
不会在一个类声明中去掉value。如果确实要这么做的话,不得不重新定义语法单位对,如下所述:
#ifndef __cplusplus_cli #define value class class
考虑到现实的因素,这是十分必要的。否则,现存的#define可能转换分段关键字的上下文性关键字部分。(译者注:例如2003年1月份的平台SDK头文件中的#define interface struct,参见拙作http://blog.joycode.com/jiangsheng/archive/2004/12/17/41283.aspx)。
2. 托管数据类型
声明托管数据类型和创建以及使用这些类型的对象的语法已经被大加修改,以提高对ISO-C++类型系统的兼容性。这些更改在后面的小节中详述。委托的讨论延后到3.3节以用类的事件成员表述它们——这是第3节的主题。(关于更加详细的跟踪引用语法介绍内幕和设计上的主要转变的讨论,参见附录 推动修订版语言设计。)
2.1 声明一个托管类类型
在原版语言定义中,一个引用类类型以__gc关键字开头。在修订版语言中,__gc关键字被两个分段关键字ref class或者ref struct之一替代。struct或者class的选择只是指明在类型体中开头未明确访问级别的部分的默认公开(对于struct)或者私有(对于class)默认访问级别。
类似地,在原版语言设计中,一个引用类类型以__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被放在类关键字之前(__gc之前或者之后)以指明类不能被继承:
public __gc __sealed class String {};
在V2语言设计中,sealed上下文性关键字限定在类名之后,类体、基类派生列表或者分号之前(你可以在声明一个继承类的同时封闭它。举例来说,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; // 好的: 托管堆 int *pi1 = new int; // 好的: 传统堆 Int32 *pi2 = new Int32; // 好的: 托管堆
在上下文性堆分配的结果并非所期望的行为时,可以用__gc或者__nogc关键字指引编译器。在修订版语言中,使用新引入的gcnew关键字来明显化两个new表达式的不同本质。例如,上面三个声明在修订版语言中看起来像这样:
Button^ button1 = gcnew Button; // 好的: 托管堆 int * pi1 = new int; // 好的: 传统堆 interior_ptr<Int32> pi2 = gcnew Int32; // 好的: 托管堆
(在第4节中讨论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的初始化或者赋值都导致一个值类型的隐式装箱(implicit boxing)。在修订版语言中,obj和obj2都被初始化为装箱过的Int32对象,分别具有值0和1。例如
//导致0和1的隐式装箱 Object ^ obj = 0; Object ^ obj2 = 1;
因此,为了允许显式的初始化、赋值一个跟踪句柄为空,以及和空指针比较,我们引入了一个新的关键字,nullptr。这样V1示例的正确的修订版看起来如下:
//好的:我们设置obj不引用任何对象 Object ^ obj = nullptr; //好的:我们初始化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操作符被去掉了——关于其讨论参见第4节。
// 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下的析构函数的内部转换 __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编译器下的运行期行为被暗地修改了。需要的翻译算法看起来如下:
- 如果析构函数存在,重写它为类终结化方法。
- 如果Dispose()方法存在,重写到类析构函数中。
- 如果构造函数存在但是Dispose()方法不存在,保留析构函数并且执行第(1)项
从V1移植你的代码到V2的过程中可能漏掉这个转换。如果引用程序某种程度上依赖于相关终结化方法的执行,那么应用程序的行为将被暗地修改。
3.类或接口中的成员声明
属性和操作符的声明在修订版语言设计中已经被大范围重写了,隐藏了原版设计中暴露的底层事件细节。另外,事件声明也被修改了。
V2中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:
//好的,现在有类级别的索引了
//
// 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对象,甚至在这是用户最不需要的情况的时候也这样。AndyKoenig是第一个引起我注意这个问题的人,那时他解释了一个设计习惯,构造函数中的挂名第二参数只是用来阻止这种不好的事情的发生。所以我不会对C++/CLI中的缺乏单参数构造函数的隐式转换感到遗憾。
(译者注:C++语法中在Visual Studio .NET 2003中才被支持的Koenig Lookup就是用Andy Koenig的名字命名的)。
另一方面,在C++中设计一个类的时候实现一个转换对从来不是一个好主意。关于这个的最好的示例是标准string类。隐式转换是有一个C风格的字符串的单参数构造函数。但是,它没有提供一个对应的隐式转换操作符来转换一个string对象到一个C风格的字符串——而是需要用户显式调用一个命名函数——在这个示例中,是c_str()。
这样,转换操作符的隐式/显式的行为的关联(以及封装这样的转换的集合到统一的声明形式)看起来是一个C++对转换操作符的原始支持的改进,这个支持自从1988年Robert Murray发布了关于Usenix C++的标题为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: // 好的:在V1中,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 静态常量的连接方式不再是literal的
虽然static const整合成员仍旧被支持,但是它们的连接方式属性被修改了。以前的连接方式属性现在通过一个literal整合成员来完成。例如,考虑如下V1类:
public __gc class Constants { public: static const int LOG_DEBUG = 4; // ... };
它为这个域产生如下的底层CIL属性(注意黑体的字面常数属性):
.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。这实现了引用类、值类和接口类的声明中的关键字对的押韵。(译者注:写程序居然要押韵?^_^bb)
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中被颠倒了!一个副作用是,托管枚举不能再和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++在结构中添加了限定域。在C中,结构只是一个数据的集合,既不支持接口也不支持关联的限定域。这个在当时是一个激进的变化,并且对于很多从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等等。第二个解决方案是修改语言来添加枚举的限定域,但是在当时是不可能实现的。(一个折中的方案是把枚举封装在类或嵌套类中,这时枚举及其枚举元的名称存在于围绕的类限定域中。)换句话说,在类中放枚举的动机——至少是原始动机——不是理论上的,而是一个全局命名空间污染问题的实际解决方案。
对于V2 CLI的枚举,把枚举强行封装在类中不再有任何好处。实际上,如果你看看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类型的方针。
这是一个代码实例,做一些很简单事情——在这里显式装箱很大程度上是无用的语法负担。
// 原版语言设计需要显式的装箱操作 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#的设计不仅隐藏了引擎盖下(译者注:关于under the hood的翻译的讨论可以在http://blog.joycode.com/zhanbos/archive/2004/06/10/24208.aspx找到,这里是直译)发生的操作的复杂性,而且也隐藏了装箱本身的复杂性。在另一方面,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命名空间内的类型的别名。因此上面的原版和修订版语言的对应是正确的:
当翻译你的现存程序中的V*时,最保守的策略是总是转换成为interior_ptr<V>。这就是它在原版语言中的处理方法。在修订版语言中,程序员可以选择通过指定V*而不是使用内部指针来限制一个值类型位于传统堆。如果你在翻译你的程序的时候,可以封存它的所有实例并且确认没有被赋值为非传统堆中的地址,那么保留V*就可以了。
4.4.4 约束指针
垃圾收集机可能会在CLI堆内到处移动堆上的对象,这通常发生在堆压缩过程中。(译者注:好怀念的感觉啊,Win32的程序员肯定有同感)这个移动对跟踪句柄、跟踪引用和内部指针来说不是问题,因为这些实体被透明的更新。但是,如果用户传递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和const char*作为区别参数的重载函数集的解析方式。一度被解析至const char*实例的解析现在被标志为有歧义的。举例来说:
void f(const char*); void f(String^); // v1: f( const char* ); // v2: 错误:有歧义... f("ABC");
这里发生了什么?为什么这里有区别?因为程序中存在一个以上名字为f的函数示例,所以需要应用函数重载解析机制来调用函数。正式的函数重载解析包含以下三个步骤:
- 搜集候选函数。候选函数是作用域内字面上匹配调用的函数的名称函数。例如,因为My()是通过R的一个实例调用的,所有名字为My但是不是任何基类及基类体系的成员的函数不是候选函数。在我们的示例中,有两个候选函数。R中有两个成员函数名字叫做My。如果候选函数集合为空,那么调用失败。
- 候选函数中的可行函数集。一个可行函数是可以用调用时指定的给定数目和类型的参数触发的函数。如果可行函数集为空,那么调用失败。
- 选择最匹配调用的函数。这是通过对从参数到可选函数参数的转换排定等级来实现的。对一个一元函数来说相对简单一些;但是多元函数的情况下有点复杂。如果没有最佳匹配,那么调用失败。换句话说,如果从实际参数到形式参数的必要的转换的等级都是一样的,那么调用被标记为有歧义的。
在原版语言设计中,作为最佳匹配,这个调用的解析触发const char*实例。在V2中,从"abc"到const char*和String^的匹配中必要的转换现在是等价的——换句话说,一样的级别——因此调用被标志为坏的——也就是说,有歧义的。
这导致我们思考两个问题:
- 实际参数"abc"的类型是什么?
- 判断一个转换比另外一个优先的算法是什么?
字符串常量"abc"的类型是const char[4]——记住,每个字符串常量的末尾有一个隐含的null终止符。
判断一个转换比另外一个优先的算法包含将可能的类型转换排序。这里是我对这个排行榜的理解——当然,所有这些转换都是隐式的。使用显式转换标记会重定义排行,就像圆括号重新定义表达式的运算次序一样。
- 一个精确匹配是最好的。令人惊异的是,一个实际参数不必完全匹配形式参数类型来进行精确匹配,只需要足够接近。这是理解这个示例的原理和语言如何修改的关键。
- 提升优先于标准转换。例如,short int到int的提升优先于int到double的转换。
- 标准转换优先于装箱转换。例如,int到double的转换优先于int到Object的装箱。
- 装箱转换优先于隐式用户自定义转换。例如,int到Object的装箱优先于到SmallInt值类的转换操作符。
- 隐式用户自定义转换优先于根本没有转换。隐式用户自定义转换是错误之前的最后一个出口(警告:形式参数中可能包含一个参数数组或者省略号的位置)。
这样的话,为什么精确匹配不一定会确定一个匹配?举例来说,const char[4]类型并不完全符合const char*或者String^,但是我们的示例的歧义仍然在两个精确匹配之间!
精确匹配发生时,包含一系列小转换。在ISO-C++中有4个平凡转换仍旧满足精确匹配,其中三个被称为左值转换(译者注:右值到左值,数组到指针和函数到函数指针)。第四个转换被称为限定转换(译者注:增加限定符,例如const或者volatile)。三个左值转换被识别为比限定转换更优先的精确匹配。
我们的左值转换形式是传统数组到指针的转换。这就是从const char[4]到const char*中间发生的事情。因此,从My("abc")到My(const char*)的匹配是一个精确匹配。在我们的C++/CLI语言的早期版本中,这实际上是最佳转换。
因为编译器要标记调用为有歧义的,所以这需要一个从const char[4]到String^的转换也成为一个精确匹配。这就是引入V2之后的修订,和调用被标记为有歧义的原因
5.2 参数数组和省略号
在原版语言设计和Visual Studio 2005中即将发布的V2语言中都没有对C#和Visual Basic .NET支持的参数数组的显式支持。作为替代,用一个属性标记普通数组如下:
void Trace1( String* format, [ParamArray]Object* args[] ); void Trace2( String* format, Object* args[] );
虽然这看起来都一样,但是参数数组属性标记它为在C#或者其他CLI语言中的一个调用时参数数目可变的数组。原版和修订版语言之间程序行为的更改是在重载函数集合的解析,像下面Artur Laksberg提供的实例一样,一个实例声明了省略号,另一个声明了参数数组属性:
int My(...); // 1 int My( [ParamArray] Int32[] ); // 2
在原版语言设计中,省略号的解析被定位于属性之前,这是有道理的,因为属性不是语言的正式部分。在V2中,参数数组现在被语言直接支持,所以它优先于省略号,因为它是更强类型的。因此,在原版语言中,调用
My( 1, 2 );
解析至My(...),而在修订版语言中,它解析至参数数组实例。如果你的应用程序的行为依赖于省略号优先于参数数组的调用,那么你需要修改函数的声明或者调用函数的代码。
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 );
另一方面,因为多态提供了动态和被动两种模式,有时有必要执行一个类型强制转换,只是为了获得子类型的非虚应用程序编程接口的存取能力。举例来说,这可以发生在对于层次中任何类型的一个类的成员(使用被动多态性作为透明机制),但是在一个特定程序上下文中的实际实例已知的时候。这种情况下系统程序员觉得一个运行时类型检查的性能代价是不可接受的。如果CLI作为托管系统编程语言,它必须提供一些方法来允许一些编译时(也就是静态)向下强制转换。这就是在修订版语言中static_cast标记的使用仍允许保持为一个编译时类型强制转换的原因。
// 好的:强制转换在编译时执行 // 没有运行时的类型正确性检查 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引用类型”上:
- 让语言支持透明化。编译器会根据上下文决定语义。有歧义时会产生一个错误,并且用户会通过一些特殊语法决定上下文的含义(可以考虑一下根据优先级的函数重载解析作为类比)。
- 以一个库的方式添加领域抽象支持(考虑把标准模板库STL作为可能的模型)
- 重用某些现存的语言元素,基于附带的规范中的描述,根据上下文限制其允许的用途和行为。考虑虚基类的初始化和类型强制转换的语义,或者多种static关键字的用途,例如函数中、文件范围内和类声明中)。
每个人的首选都是第1项。“它只是和语言中其他东西一样,只是少许不同。让编译器判断就好了。”。这里很大的成功在于什么都是以和现存的代码一样的方式对用户透明的。把你的现存应用程序拿出来,添加一两个对象,编译,然后,ta-dah,它就完成了。使用方便操作简单。在类型和源代码方面完全可以互用。没有人会争论说这个方案不是理想方案,很大程度上就像没有人争论永动机的理想性一样。在物理学上,这个问题的障碍是热力学第二定律,以及熵的存在。在一个多范型编程语言中,规则有显著不同,但是理想系统的瓦解是一样明确的。
在一个多范型编程语言中,事情在各自的范型内运作相当良好,但是在范型被不正确的混合的时候趋于崩溃,导致程序崩溃或者更坏,运行但是产生错误的结果。这在支持独立的基于对象和多态的对象导向的类编程中最常见。切片使得每个C++新手的编程混乱:
DerivedClass dc; // 一个对象 BaseClass &bc = dc; // 好的:bc真的是一个dc BaseClass bc2 = dc; // 好的:但是dc可能被截断以适应bc2
因此,语言设计的第二定律,打比方来说,是让行为不同的东西看起来有足够的差异以提醒用户,在他或者她编程时避免,嗯,一团糟。我们习惯于用一个一个半小时或者两个小时的介绍来开始程序员对指针和引用之间的差异的理解的第一步,而且大量的程序员仍不能清楚地描述何时使用引用声明,何时使用指针,以及原因。
这些迷惑无可否认地使编程更困难,并且在简单地去除它们和支持它们可以提供的实际世界的能力之间总有一个权衡。并且它们差异在于设计的透明度,以及在于它们是否实用。当指向成员的指针被引入到语言时,成员选择操作符被扩展了(例如从->到->*),并且指向函数的指针被类似的扩展了(从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会被透明地更新。传统C++中,不存在pobj及其指向的实体之间的关系这样一个对象跟踪的概念。传统C++的指针概念并不是机器地址和间接对象引用的铰接。一个引用类型的句柄封装了对象的实际虚拟地址以实现运行时垃圾收集;除了在垃圾收集环境中破坏这个封装的后果更加严重这一点之外,这很大程度上就像私有数据成员封装了类的实现以实现可扩展性和局部化。
因此,虽然pobj看起来仍然像一个指针,但是很多指针的常见特性被禁用了,例如指针算术和类型系统之外的强制类型转换。如果我们使用完全限定语法来生命和分配一个引用,我们可以使这个区别更加显著:
// 好的,现在这些看起来不同了…… Object __gc * pobj = __gc new Object; string * pstr = new string;
乍一看上去指针解决方案很有道理。毕竟,看起来像一个new表达式的自然目标,而且两者都支持浅拷贝。 一个问题是指针不是一个类型的抽象,而是一个机器中的表示(以及一个说明如何解释第一个字节之后的内存的范围和内部组织的类型标签),而且这不符合软件运行时对内存的抽象,以及因此缺乏安全性。这是一个表述不同范型的对象模型之间的历史问题。
第二个问题是(隐喻警告:一个矫揉造作的隐喻即将被尝试——所有肠胃不好的人建议暂停阅读或者跳到下一段)一个封闭的语言设计的不可避免的弊端就是被强迫重新使用既过分简单又显著不同的概念,导致在沙漠的海市蜃楼中对程序员的精神的挥霍(隐喻警告结束。)
重用指针语法造成了程序员的认知混乱:你不得不区分太多的传统和托管的指针,而这会干扰代码的编写,最好用一个高级的抽象来管理它。换句话说,作为系统程序员,我们有时需要降尊纡贵来压榨出一点性能,但是我们不会在这个级别久居。
原版语言设计的成功在于对现存C++程序不加修改即可编译,并且提供了只需少量工作就可以在新的托管环境中发布现存的界面的包装模式。之后也可以在托管环境中添加额外的功能,并且,依实践和经验而异,一个人可以直接移植现存应用程序的这部分或者那部分到托管环境。这对一个有现存代码和应验基础的C++程序员来说是一个宏伟的成就。我们不需要为此惭愧。
但是,在原版语言设计的语法和洞察力中有显著的弱点。这不是设计者的不足,而是基础设计选择的保守本质——继续呆在现存语言中。这来自对托管支持的误解,就是它是代表一个域抽象,而不是一个革命性的编程范型,需要一个类似于Stroustrup引入以同时支持面向对象和普通编程的语言扩展。这就是修订版语言设计代表的,以及它是必要的和合理的的原因,即使它造成忠于原版语言设计者的一些忧伤。这即是本指南和翻译工具背后的共同动机。
修订版C++/CLI语言设计
一旦明确了支持C++中的公共语言基础代表一个独立的编程范型,随之而来的就是语言必须被扩展以同时给用户提供一个一流编程体验,以及一个优雅的与ISO-C++的设计整合以注重很大的C++社区的感受,并且赢得他们的忠诚和辅助。随之而来的还有,原版语言设计的平凡的名字,C++的托管扩展,也必须被替换。
公共语言基础的特性中的旗舰是引用类型,并且它在现存C++语言中的整合代表一个概念的证明。一般的标准是什么?我们需要一种方法来表示托管类型,既分离它又仍感觉它和现存类型系统类似。这允许人们认识到这个普通形式类别很熟悉但是也注意到它的唯一的特色。类似性是Stroustrup在C++的原始发明中引入的引用类型。因此这个普通形式成为:
Type TypeModToken Id [ = init ];
这里TypeModToken是语言中在新的上下文环境里重用的符号之一(也类似于引用的引入)。
这在最初令人惊讶的有争议,并且仍旧是一些用户的痛处。我记得一开始两个最常见的回应是(a)我可以用一个typedef来处理(不住地眨眼),以及(b)这真的不怎么坏(后者提醒了我,我的回复是使用左移和右移操作符来在iostream库中进行输入和输出)。
必要的行为上的特性是它在操作符操作对象的时候展示了对象的语义,这是原版语法无法支持的一点。我喜欢称它为灵活的引用,思考它和现存C++引用的差异(是的,这里两个引用的使用——一个是托管引用类型,另一个是“这不是一个指针(不住地眨眼)”这里的传统C++类型——是令人遗憾的,很像在我喜欢的一本四人帮(Gang of Four Patterns)的设计模式书中对模板这个词的重用。):
- 它必须会不引用任何一个对象。当然,传统的引用不能直接做到这一点,虽然人们总是告诉我如何把一个引用初始化为一个0的类型强制转换。(常规的做法是提供一个显式的代表null对象的转换体的单体,它也经常作为一个函数参数的默认参数)
- 它可能不需要一个初值,但是可以在生命期的开始不引用任何一个对象。
- 它可以被赋值为另一个对象。
- 默认的用一个实例对另一个实例进行的赋值或者初始化是浅赋值。
就像一个人让我意识到的,我是从反方向考虑这只可爱的小狗的。也就是说,我通过区分它和传统的引用性质来定义它,而不是用它作为一个托管引用类型的句柄这个性质来识别它。
我们想称呼这个类型为句柄而不是指针或者引用,因为这两个术语有传统方面的累赘。句柄是更适合的名字,因为它是一个封装的模式——一个叫做John Carolan的人首先给我介绍了它,以一个可爱的名义:柴郡的猫(Cheshire Cat,此处典出Lewis Carroll的艾丽丝漫游奇境记,参考http://en.wikipedia.org/wiki/Cheshire_cat),因为被操作的对象的本体可以在你不知情的情况下消失。
(译者注:Cheshire Cat代表一种设计模式:在Gamma等人所著《设计模式》中被描述为“桥”模式的一个特例:一个私有的实现全部功能的类,以及一个公有的类,具有一个指向一个私有类实例的指针,以及转发所有成员函数的调用到私有类实例。)
在这种情况下,这个令人失望的事件是源自于在垃圾收集机的一次清扫中潜在的引用类型的重新定位。实际上发生的是,这个重新定位是被运行时刻库透明地跟踪的,而且句柄被更新为新的正确位置。这就是它被称为跟踪句柄的原因。
因此,关于新的跟踪引用语法我最后想提及的成员选择操作符。对于我来说,看起来想都不用想就会使用对象语法(.)。其他人觉得指针语法(->)也是同样显然的,并且我们在跟踪引用的用途的多个方面进行了讨论:
// 想都不用想就会用指针语法的人 T^ p = gcnew T; // 想都不用想就会用对象语法的人 T^ c = a + b;
这样,就像物理学里面的光一样,一个跟踪引用的行为在一些程序上下文中像一个对象,在另一些程序上下文中像一个指针。最后,投入使用的成员选择操作符是箭头,就像在原版语言设计中一样.
关于关键字的总结性离题段落
最后,一个有趣的问题是,为什么Stroustrup在C++语言设计中添加了class?实际上没有必要引入它,因为C++中的C语言结构被扩展了,支持class可以做到的任何事情。我没有问过Bjarne这个问题,所以我在这一点上没有特别的见识,但是这是一个有趣的问题,而且看起来某种程度上等价于在C++/CLI中添加一些关键字。
一个可能的回答——我称它为步兵的来福枪(foot soldier shuffle)——是个争论:不,class的引入绝对必要。毕竟,两个关键字之间不仅有默认成员访问级别的差异,而且派生访问级别也不一样,所以为什么我们不能两个都要?
但是慢一点,引入一个新关键字不仅和现存语言不兼容,而且导入了语言的一个不同分支(Simula-68),会有触怒C语言社区的风险。这个动机真的是默认访问级别的差异?我不能肯定。
一方面,语言在类设计者使用class关键字而且把所有实现公开时既没有阻止也没有警告。语言本身并无共有和私有访问的策略,所以很难看到未明确的默认访问级别许可被重视的原因——换句话说,比引入不兼容型的代价还重要。
类似的,把未明确的基类默认为私有继承的常识看起来在设计实践上比较有问题。这既更复杂又是一个更难理解的形式,因为它没有展示类/子类的行为,并且因此破坏了可替代性。它代表了一个对不是接口而是实现的重用,并且我相信,把私有继承作为默认是个错误。
当然,我不能公开宣布这一点,因为在语言市场中,一个人从来不应该承认产品的一点点问题,因为这给迅速抓住任何优势抢占市场分额的敌人提供了弹药。嘲笑在知识分子的小圈子里面是特别盛行的。或者,更加合适的,一个人直到新的改进的产品准备铺开的时候再承认。
引入class这个不兼容性还可能有什么其它原因?C语言的结构概念是一个抽象的数据类型。C++语言的类概念(当然,这不是源自于C++)是数据抽象,以及随之而来的封装和接口约定的思想。数据类型抽象是有生命期和行为的实体。这是为了教育学上的重要性,因为词会使语言会大不一样——至少在一一个语言中。这是修订版设计铭记在心的另一个教训。
为什么C++没有完全移除结构?保留一个并引入另一个并不优雅,而且这样字面上最小化了他们之间的差异。但是有其它选择吗?结构关键字不得不被保留,因为C++不得不尽可能保留和C的向下兼容;否则,不仅它会在现存程序员中更不受欢迎,而且可能不会被允许发布(但是这是另一个时间,另一个地点的另一个故事了)
为什么结构的访问级别默认是公有的?因为如果不这样,现存的C程序不会编译通过。这在实践上会是一场灾难,虽然程序员很可能从来没听说过语言设计高级守则(Advanced Principles of Language Design)中提到过它。语言中可能有一个强制接受的过程,强制接受一个策略:使用结构保证了一个公有实现,反之,使用类保证了一个私有实现和公有接口,但是这个策略并非源自于实践用途,所以会有点矫揉造作。
实际上,在贝尔实验室的cfront 1.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年于贝尔实验室和发明者Bjarne Stroustrup一起工作于C++领域。其间曾工作于华特·迪士尼和梦工场的特色动画公司,也是影片《狂想曲两千》(Fantasia 2000)的软件技术指导。