路过的毛衣 · 福建省生态环境厅关于加快省环保科技计划项目实 ...· 3 周前 · |
热心肠的茶叶 · 给萌新的一些推荐阵容和发展路线 - ...· 6 月前 · |
不敢表白的豆浆 · 珍珠少年漫画吻戏_珍珠少年漫画头像_珍珠少年 ...· 1 年前 · |
腼腆的柠檬 · 十三邀 番外篇:许知远对话刘擎 - 知乎· 1 年前 · |
潇洒的猴子 · 揭秘!为什么越来越多的企业牵手拉曼大学_腾讯新闻· 1 年前 · |
Visual Studio 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 枚举 的变化,和对 值 类中默认构造函数的支持的移除。第五节有点像大杂烩 — 乱七八糟的杂项。讨论了类型转换符号、字符串字符的行为和参数 数组 。
原版到修订版语言设计的一个重要转换是在所有关键字中去掉双下划线。举例来说,一个属性现在被声明为 property 而不是 __property 。在原版语言设计中使用双下划线前缀的两个主要原因是:
这样的话,为什么我们移除双下划线(并且引入了一些新的标记)?不是的,这并不代表我们不再考虑和标准保持一致!
我们继续致力于和标准一致。尽管如此,我们意识到对 CLI动态对象模型的支持表现出了一种全新的强大的编程范型。我们在原版语言设计上的经验以及设计与发展 C++ 语言本身的经验使我们确信,对这个新范型的支持需要它自己的高级关键字和标记。我们想提供一个该新范型的一流表达方式,整合它并且支持标准语言。我们希望您会感受到修订版语言设计提供了对这两种截然不同的对象模型的一流的编程体验。
类似的,我们很关心最小化这些新的关键字的对现有代码可能造成的冲击。这是用与上下文相关和由空格分隔的关键字来解决的。在我们着眼于实际语言语法的修订之前,让我们试试搞清楚这两个特别关键字的特点。
一个与上下文相关的关键字在特定的程序上下文中有特殊的含义。例如,在通常的程序中,sealed 是一个普通标识符。但是,在一个托管
引用类
类型的声明部分,它就是类声明上下文中的一个关键字。这使得在语言中引入一个新的关键字的潜在影响降到最低程度,我们认为,这对已经拥有代码基的用户非常重要。同时,它允许新功能的使用者获得一流的新增语言功能的体验 — 我们认为在原版语言设计中缺少这些因素。我们将在
一个由空格分隔的关键字是与上下文相关关键字的特例。它在字面上将一个与上下文相关的修饰符和一个现存的关键字配对,用空格分隔。这个配对作为一个单独的单位,例如 value class (示例参见 1.1 节),而不是两个单独的关键字。基于现实的因素,这意味着一个重新定义 value 的宏,如下所示:
#ifndef __cplusplus_cli
#define value
不会在一个类声明中去掉 value 。如果确实要这么做的话,必须重新定义单元对,编写如下代码:
#ifndef __cplusplus_cli
#define value class class
考虑到现实的因素,这是十分必要的。否则,现存的 #define 可能转换由空格分隔的关键字的与上下文相关的关键字部分。
声明托管类型和创建以及使用这些类型的 对象 的语法已经大加修改,以提高 ISO-C++类型系统内的集成性。这些更改在后面的小节中详述。委托的讨论延后到 2.3节以用类中的事件成员表述它们 — 这是第 2 节的主题。(有关更加详细的跟踪 引用 语法介绍的内幕和设计上的主要转变的讨论,请参见附录A:推动修订版语言设计。)
在原版语言定义中,一个 引用 类类型以 __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 (对于垃圾收集类型)是为了便于更好地暗示这个类型的本质。
在原版语言定义中,关键字 __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{};
当然,语义没有变化。
在原版语言定义中,关键字 __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;
在 CLI对象模型中,只支持公有方式的单继承。但是,在原始语言定义中仍然保留了ISO-C++对基类的默认解释,而无需访问关键字指定私有派生。这意味着每一个 CLI继承声明必须用一个 public 关键字来代替默认的解释。很多用户认为编译器似乎过于严谨。
// V1:错误:默认为私有派生
__gc class My : File{};
在修订版语言定义中,CLI继承定义缺少访问关键字时,默认是以公有的方式派生。这样,公有访问关键字就不再必要,而是可选的。虽然这个改变不需要对 V1的代码做任何的修改,出于完整性考虑我仍将这个变化列出。
// V2:正确:默认是公有性派生
ref class My : File{};
在原版语言定义中,一个 引用类 类型对象是使用 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<>
),在第
声明语法不再重用 ISO-C++指针语法有两个主要原因:
对一个跟踪句柄使用 __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();
// ...
在原版语言设计中,现有的在本机堆和托管堆上分配的两种 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 );
// ...
在新的语言设计中,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*) 。匹配规则优先于标准转换的精确匹配。在没有精确匹配时,标准转换优先于对于 值 类型的隐式装箱。这就是没有歧义的原因。
原版语言设计中的 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 }
在原版语言定义中,类的析构函数允许存在于 引用 类中,但是不允许存在于 值 类中。这在修订的 V2语言设计中没有变化。但是,类析构函数的语义有可观的变化。怎样和为什么变化(以及这会对现存 V1代码的转换造成怎样的影响)是本节的主题。这可能是本文中最复杂的一节,所以我们慢慢来讲。这也可能是两个语言版本之间最重要的编程级别的修改,所以需要以循序渐进的方式来进行学习。
在 对象 关联的内存被垃圾回收器回收之前,如果对象有一个相关的 Finalize()方法存在,那么它将被调用。您可以将该方法想象为一种超级析构函数,因为它与对象编程生命周期无关。我们称此为终止。何时甚至是否调用 Finalize()方法的计时是不确定的。这就是我们提到垃圾回收代表不确定的终止(non-deterministic finalization)时表达的意思。
不确定的终止和动态内存管理合作的很好。当可用内存缺少到一定程度的时候,垃圾回收器介入,并且很好地工作。在垃圾回收环境中,用析构函数来释放内存是不必要的。您第一次实现应用程序时不为潜在的内存泄漏发愁才怪,但是很容易就会适应了。
然而,不确定的终止机制在 对象 维护一个关键的资源(例如一个数据库连接或者某种类型的锁)时运转并不好。这种情况下我们需要尽快释放资源。在本机代码的环境下,这是用构造函数/析构函数对的组合解决的。不管是通过执行完毕声明对象的本机代码块还是通过由于引发异常造成的拆栈, 对象 的生命周期一终止,析构函数就介入并且自动释放资源。这个机制运转得很好,而且在原版语言设计中没有它的存在是一个很大的失误。
CLI提供的解决方案是实现 IDisposable 接口的 Dispose() 方法的类。问题是 Dispose() 方法需要用户显式地调用。这是个错误的倾向,因此是个倒退。C# 语言提供一个适度的自动化方式,使用一个特别的 using语句。我们的原版语言设计(我已经提到过)根本没有提供特别的支持。
在原版语言中,一个 引用 类的析构函数通过如下两步实现:
2. __gc class A {
3. public:
4. ~A() { Console::WriteLine(S"in ~A"); }
5. };
6. __gc class B : public A {
7. public:
8. ~B() { Console::WriteLine(S"in ~B"); }
9. };
两个析构函数都被重命名为 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();
这个产生的析构函数里面有什么内容呢?是两个语句。一个是调用 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()方法的方案。这在修订版语言设计中进行了更改。
在修订版语言设计中,析构函数被内部重命名为 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() 方法的调用会被插入到生成方法的末尾。
但是这样也没有给我们确定性终止的方法。为了解决这个问题,我们需要局部 引用 对象的额外支持(在原版语言设计中没有类似的支持,所以没有转换的问题)。
修订版语言支持在本地栈上声明
引用
类的对象,或者声明为类的成员,就像它可以直接被访问一样(注意这在 Microsoft Visual Studio 2005 的Beta1 发布版中不可用)。析构函数和在
首先,我们这样定义一个 引用 类,使得 对象 创建函数在类构造函数中获取一个资源。其次,在类的析构函数中,释放 对象 创建时获得的资源。
public ref class R {
public:
R() { /* 获得外部资源 */ }
~R(){ /* 释放外部资源 */ }
// ... 杂七杂八 ...
对象 声明为局部的,使用没有附加"帽子"的类型名。所有对 对象 的使用(如调用成员函数)是通过成员选择点 ( . ) 而不是箭头 ( -> ) 完成的。在块的末尾,转换成 Dispose() 的相关的析构函数被自动调用。
void f()
r.methodCall();
// ...
// r被自动析构 -
// 也就是说, r.Dispose() 被调用...
相对于 C#中的 using 语句来说,这只是语法上的点缀而已,而不是对基本 CLI约定(所有 引用 类型必须在 CLI堆上分配)的违背。基础语法仍未变化。用户可能已经编写了下面同样功能的语句(这很像编译器执行的内部转换):
// 等价的实现...
// 除了它应该位于一个 try/finally 语句中之外
void f()
R^ r = gcnew R;
r->methodCall();
// ...
delete 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()!" ); }
这意味着,只要一个 引用 类包含一个特别的析构函数,一个 V1程序在 V2 编译器下的运行时行为被静默地修改了。需要的转换算法如下所示:
在将代码从 V1移植到 V2的过程中,可能漏掉执行这个转换。如果应用程序某种程度上依赖于相关终止方法的执行,那么应用程序的行为将被静默地修改。
属性和操作符的声明在修订版语言设计中已经被大范围重写了,隐藏了原版设计中暴露的底层实现细节。另外,事件声明也被修改了。
在 V1中不受支持的一项更改是,静态构造函数现在可以在类外部定义了(在 V1中它们必须被定义为内联的),并且引入了委托构造函数的概念。
在原版语言设计中,每一个 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 访问函数,否则这个成员在类的内部不可访问。这就是所谓的严格限制的数据隐藏!
原版语言对索引属性的支持的两大缺点是不能提供类级别的下标,也就是说,所有索引属性必须有一个名字,举例来说,这样就没有办法提供可以直接应用到一个 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 索引属性产生的底层名称。
注意,简单索引语法与简单属性语法截然不同。
声明一个委托和普通事件仅有的变化是移除了双下划线,如下面的示例所述。在去掉了之后,这个更改被认为是完全没有争议的。换句话说,没有人支持保持双下划线,所有人现在看来都同意双下划线使得原版语言感觉很难看。
// 原版语言 (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:
{ // 注意 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 );
虽然在语言设计方面,人们因为语法的简单枯燥而倾向于忽视它,但是如果对语言的用户体验有很大的潜移默化的影响,那么它实际上很有意义。一个令人迷惑的、不优雅的语法可能增加开发过程的风险,很大程度上就像一个脏的或者不清晰的挡风玻璃增加开车的风险一样。在修订版语言设计中,我们努力使语法像一块高度磨光的新安装的挡风玻璃一样透明。
__sealed
关键字在
V1版中用于修饰一个
引用
类型,禁止从此继续派生 — 如
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 关键字。换句话说,上面的 derive d的正确转换如下所述:
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; };
原版语言设计最惊人之处可能是它对于操作符重载的支持 — 或者更恰当地说,是有效的缺乏支持。举例来说,在一个 引用 类型的声明中,不是使用内建的 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 )
// ...
谈到令人不愉快的感觉,在 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 的方式处理。这意味着为了触发它的调用,需要一个显式的转换。但是要注意,如果一个显式的转换操作符已经定义,那么是它而不是单参数构造函数会被调用。
经常有必要在实现接口的类中提供两个接口成员的实例 — 一个用于通过接口句柄操作类 对象 ,另一个用于通过类界面使用 对象 。例如:
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() 实例。
在 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 关键字,那么会产生一个警告消息。
虽然 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;
// ...
本节中我们着眼于 CLI 枚举 类型和 值类类型 ,同时研究装箱和对 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
除了这种句法上的小小修改之外,托管 枚举 类型的行为在很多方面有所改变:
2. __value enum status; // V1: 正确
3. enum class status; // V2: 错误
举例来说,考虑如下代码片断:
__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
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 命名空间,您就会看到枚举、类和接口都在同一个声明空间中存在。
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 ) );
装箱是 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 );
以下是 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; // 必须是局部的
格式 (1) 是一个规范的值对象,并且它是相当容易理解的,除非有人试图调用一个继承虚方法,例如 ToString() 。例如,
v.ToString(); // 错误!
为了调用这个方法,因为在 V中它不可重写,所以编译器必须可以访问基类的相关虚表。因为值类型是状态内存储,没有其虚表 ( vptr ) 的相关指针,所以这需要 v被装箱。在原版语言设计中,隐式装箱是不被支持的,程序员必须显式声明如下:
__box( v )->ToString(); // V1: 注意箭头
该设计背后的主要动机是具有教育意义的 — 它希望使底层机制对于程序员可见,使得他能理解不在 值 类型中提供实例的“代价”。如果 V 包含一个 ToString 实例,那么装箱是不必要的。
显式装箱 对象 的繁文缛节,而不是装箱本身的基本代价,在修订版语言设计中被移除了。
v.ToString(); // V2
但是代价是可能误导类设计者在 V 中不提供显式 ToString 方法的实例。首选隐式装箱的原因是通常只有一个类设计者而有无数的类使用者,他们不能自由地修改 V 来避免可能很麻烦的显式装箱。
决定是否在值类中提供 ToString 的一个重写实例取决于它的使用频率和位置。如果它很少被调用,那么这么定义很显然没什么好处。类似地,如果它在应用程序的非性能区域被调用,那么添加它将不会对应用程序的常规性能带来可观的提升。或者,可以保留一个装箱值的 跟踪句柄 ,通过该句柄的调用不会需要装箱。
值 类型的原版和修订版语言设计之间的另外一个差异是取消了对默认构造函数的支持。这是由于在执行中,CLI可能创建一个 值 类型的实例而不调用相关的默认构造函数。换句话说,在 V1中,实际上并不能够保证对 值 类型中默认构造函数的支持。由于缺乏保证,所以感觉完全去掉这个支持比在其应用程序中保持不确定性更好。
这并不像第一眼看上去那么坏。这是因为每个 值 类型 对象 会被自动清零(每个类型会被初始化为其默认的值)。也就是说,局部实例的成员不会是未定义的。在这个意义上,缺少定义一个普通默认构造函数的能力实际上根本不是一个损失 — 并且事实上在 CLI执行时更加高效。
问题发生在原版 V1语言的用户定义了一个非普通默认构造函数时。它没有在修订版V2语言设计中的对应。构造函数中的代码将需要移植到一个命名的初始化方法,并且这个方法需要被用户显式调用。
修订版 V2 语言设计中的 值 类型对象的声明没有变化。它的缺点是 值 类型不能包装本机类型,原因如下:
我们可能喜欢用 值 类型(而不是 引用 类型)来包装一个小的本机类来避免两次堆分配:本机堆存放本机类型,CLI堆存放托管包装。在 值 类型中包装一个本机类可以避免在托管堆的分配,但是无法自动回收本机堆上分配的内存。 引用 类型是唯一可行的用于包装非普通本机类的托管类型。
格式(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* 就可以了。
垃圾回收器可能会在 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对钉住指针的限制仍旧存在。例如,它不能作为方法的参数或者返回类型使用,而且,它只能被声明为一个局部 对象 。但是,一些额外的限制被添加到了修订版语言设计中:
__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 );
本节中描述的更改某种意义上是语言杂记。本节包含处理字符串的修改,省略号和 参数 属性的重载解决方案的修改,从 typeof 到 typeid 的修改,以及一个新的强制转换标记 safe_cast 的介绍。
在原版语言设计中,托管字符串是通过为字符串添加前缀 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 为名称的实例,所以需要将函数重载解析算法应用于调用。正式的函数重载解析包含以下三个步骤:
在原版语言设计中,作为最佳匹配,该调用的解析调用 constchar* 实例。在 V2中,“abc”到 constchar* 和 String^ 匹配所需的转换现在是等价的 — 换句话说,同样好 — 因此调用被标记为坏的 — 也就是说,有歧义的。
这导致我们思考以下两个问题:
字符串“abc”的类型是 constchar[4]— 记住,每个字符串的末尾有一个隐式的 null 终止符。
判断一个类型转换优于另一个的算法涉及到将可能的类型转换放在层次结构中。以下是我对这个层次结构的理解 — 当然,所有这些转换都是隐式的。使用显式转换标记会重新定义层次结构,就像圆括号重新定义表达式的运算次序一样。
这样,为什么精确匹配不一定会确定一个匹配?举例来说, 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 中新加入的更改。并且这也是调用被标记为有歧义的原因。
在原版语言设计和 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 的调用,那么您需要修改符号或者调用。
在原版语言设计中, __typeof() 操作符在传递一个托管类型的名称时返回相关的 Type* 对象,例如:
//创建并初始化一个新的 Array 实例。
Array* myIntArray =
Array::CreateInstance( __typeof(Int32), 5 );
在修订版语言设计中, __typeof 被另一种 typeid 形式替代,它在指定一个托管类型时返回一个 Type^ 。
//创建并初始化一个新的 Array 实例。
Array^ myIntArray =
Array::CreateInstance( Int32::typeid, 5 );
注意,这是较为冗长的一节,所以一些耐不住性子的人可以快速跳到末尾来阅读实际更改的说明。
修改一个已经存在的结构是完全不同的 — 在某种意义上,比编写原始的结构更加困艰难;自由度更少,以及解决方案趋于理想重构和实际上对现存的结构依赖性之间的妥协。举例来说,如果您曾经进行过排版,您就会知道,由于需要将重新格式化限定在当前页中,因此对现存页的更正就有所限制;您不能允许文本溢出到后面的页面中,这样您就不能添加或者删节太多(或太少)内容,而且经常会让人感觉到更正是为了适合版面,从而其意义有所妥协。
语言扩展是另外一个例子。回到 20 世纪 90 年代初,面向对象编程成为一个重要的范型,对 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是操作系统和应用程序之间运行的一个软件抽象层。当事态严重时,用户会毫无根据地逐字反思执行环境、查询、代码和 对象 创建问题,跳过而不是遵循类型系统,但是这个经验对于习惯脚踏实地的人来说会是一团糟。
例如,下面的内容是什么意思?
好的,在 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 引用 类型”上:
每个人的首选都是第 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)的设计模式书中对模板这个词的重用。):
就像一些人使我想到的,我是从反方向考虑这个问题的。也就是说,我通过区分它和本机 引用 的性质来引用它,而不是用它作为一个托管 引用 类型的句柄这个性质来识别它。
我们想将这个类型称为 句柄 而不是 指针 或者 引用 ,因为这两个术语有本机方面的累赘。 句柄 是更适合的名字,因为它是一个封装的模式 — 一个叫做 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)的软件技术指导。