C++/CLI代表 ISO-C++標准語言的一個動態編程泛型擴展 (dynamic programming paradigm extension)。在原版語言設計 (V1) 中有許多顯著的弱點,我們覺得在修訂版語言設計 (V2) 中已經修正了這些弱點。本文列舉了 V1 版本語言的功能和它們在 V2 版本中的對應功能(如果存在);並指出了其對應功能不存在的構造。對於有興趣的讀者,可以查看附錄中提供新語言設計的擴展原理。另外,一個源代碼級別的轉換工具 (mscfront) 正在開發中,而且可能在 C++/CLI的發布版中提供給希望將 V1 代碼自動移植到新語言設計的人。
本文分為五個章節加一個附錄。第一節討論語言關鍵字的主要問題,特別是雙下劃線的移除以及與上下文相關和由空格分隔的關鍵字。第二節著眼於托管類型的變化 — 特別是托管引用類型和數組。還可以在這裡找到有關確定性終結語義 (deterministic finalization) 的詳細討論。關於類成員的變化,例如屬性、索引屬性和操作符,是第三節的重點。第四節著眼於 CLI 枚舉、內部和釘住指針的語法變化。它也討論了許多可觀的語義變化,例如隱式裝箱的引入、CLI枚舉的變化,和對值類中默認構造函數的支持的移除。第五節有點像大雜燴 — 亂七八糟的雜項。討論了類型轉換符號、字符串字符的行為和參數數組。
原版到修訂版語言設計的一個重要轉換是在所有關鍵字中去掉雙下劃線。舉例來說,一個屬性現在被聲明為 property 而不是 __property。在原版語言設計中使用雙下劃線前綴的兩個主要原因是
這是提供符合 ISO-C++標准的本地擴展的一致性方法。原版語言設計的一個主要目標就是不引入與標准語言的不兼容性,例如新的關鍵字和標記。這個原因很大程度上也推動了對聲明托管引用類型的對象的指針語法的選擇。
雙下劃線的使用,除了兼容性方面的原因之外,也是一個不會對有舊代碼基礎的用戶造成影響的合理保證。這是原版語言設計的第二主要目標。
這樣的話,為什麼我們移除雙下劃線(並且引入了一些新的標記)?不是的,這並不代表我們不再考慮和標准保持一致!
我們繼續致力於和標准一致。盡管如此,我們意識到對 CLI動態對象模型的支持表現出了一種全新的強大的編程范型。我們在原版語言設計上的經驗以及設計與發展 C++ 語言本身的經驗使我們確信,對這個新范型的支持需要它自己的高級關鍵字和標記。我們想提供一個該新范型的一流表達方式,整合它並且支持標准語言。我們希望您會感受到修訂版語言設計提供了對這兩種截然不同的對象模型的一流的編程體驗。
類似的,我們很關心最小化這些新的關鍵字的對現有代碼可能造成的沖擊。這是用與上下文相關和由空格分隔的關鍵字來解決的。在我們著眼於實際語言語法的修訂之前,讓我們試試搞清楚這兩個特別關鍵字的特點。
一個與上下文相關的關鍵字在特定的程序上下文中有特殊的含義。例如,在通常的程序中,sealed 是一個普通標識符。但是,在一個托管引用類類型的聲明部分,它就是類聲明上下文中的一個關鍵字。這使得在語言中引入一個新的關鍵字的潛在影響降到最低程度,我們認為,這對已經擁有代碼基的用戶非常重要。同時,它允許新功能的使用者獲得一流的新增語言功能的體驗 — 我們認為在原版語言設計中缺少這些因素。我們將在 2.1.2節中看到 sealed 用法的示例。
一個由空格分隔的關鍵字是與上下文相關關鍵字的特例。它在字面上將一個與上下文相關的修飾符和一個現存的關鍵字配對,用空格分隔。這個配對作為一個單獨的單位,例如 value class(示例參見 1.1 節),而不是兩個單獨的關鍵字。基於現實的因素,這意味著一個重新定義 value 的宏,如下所示:
#ifndef __cplusplus_cli #define value
不會在一個類聲明中去掉 value。如果確實要這麼做的話,必須重新定義單元對,編寫如下代碼:
#ifndef __cplusplus_cli #define value class class
考慮到現實的因素,這是十分必要的。否則,現存的 #define 可能轉換由空格分隔的關鍵字的與上下文相關的關鍵字部分。
聲明托管類型和創建以及使用這些類型的對象的語法已經大加修改,以提高 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(對於垃圾收集類型)是為了便於更好地暗示這個類型的本質。
2.1.1 指定一個類為抽象類型
在原版語言定義中,關鍵字 __abstract放在類型關鍵字之前(__gc之前或者之後)以指明該類尚未完成,而且此類的對象不能在程序中創建:
public __gc __abstract class Shape {}; public __gc __abstract class Shape2D: public Shape {};
在修訂版語言設計中,abstract 與上下文相關的關鍵字被限定在類名之後,類體、基類派生列表或者分號之前。
public ref class Shape abstract {}; public ref class Shape2D abstract : public Shape{};
當然,語義沒有變化。
2.1.2 指定一個類為密封類型
在原版語言定義中,關鍵字 __sealed放在 class 關鍵字之前(__gc之前或者之後)以指明類的對象不能從以下類繼承:
public __gc __sealed class String {};
在 V2語言設計中,與上下文相關的抽象關鍵字限定在類名之後,類體、基類派生列表或者分號之前(您可以聲明一個繼承類並密封它。舉例來說,String類隱式派生自 Object)。密封一個類的好處是允許靜態(即在編譯時)解析這個密封引用類對象的所有的虛函數調用。這是因為密封指示符保證了 String 跟蹤句柄不能指向一個可能重載被調用的虛方法實例的派生類。
public ref class String sealed {};
也可以將一個類既聲明為抽象類也聲明為密封類。這是一種被稱為靜態類的特殊情況。這在CLI文檔中描述如下:
同時為抽象和密封的類型只能有靜態成員,並且以一些語言中調用命名空間一樣的方式服務。
例如,以下是一個使用 V1語法的抽象密封類的聲明
public __gc __sealed __abstract class State { public: static State(); static bool inParamList(); private: static bool ms_inParam; }; 而以下是在修訂版語言設計中的聲明: public ref class State abstract sealed { public: static State(); static bool inParamList(); private: static bool ms_inParam; };
2.1.3 CLI 繼承 : 指定基類
在 CLI對象模型中,只支持公有方式的單繼承。但是,在原始語言定義中仍然保留了ISO-C++對基類的默認解釋,而無需訪問關鍵字指定私有派生。這意味著每一個 CLI繼承聲明必須用一個 public關鍵字來代替默認的解釋。很多用戶認為編譯器似乎過於嚴謹。
// V1:錯誤:默認為私有派生 __gc class My : File{};
在修訂版語言定義中,CLI繼承定義缺少訪問關鍵字時,默認是以公有的方式派生。這樣,公有訪問關鍵字就不再必要,而是可選的。雖然這個改變不需要對 V1的代碼做任何的修改,出於完整性考慮我仍將這個變化列出。
// V2:正確:默認是公有性派生 ref class My : File{};
在原版語言定義中,一個引用類類型對象是使用 ISO-C++指針語法聲明的,在星號左邊使用可選的 __gc關鍵字。例如,以下是 V1語法下多種引用類類型對象的聲明:
public __gc class Form1 : public System::Windows::Forms::Form { private: System::ComponentModel::Container __gc *components; Button __gc *button1; DataGrid __gc *myDataGrid; DataSet __gc *myDataSet; void PrintValues( Array* myArr ) { System::Collections::IEnumerator* myEnumerator = myArr->GetEnumerator(); Array *localArray = myArr->Copy(); // ... } };
在修訂版語言設計中,引用類類型的對象用一個新的聲明性符號(^)聲明,正式的表述為跟蹤句柄,不正式的表述為帽子。(跟蹤這個形容詞強調了引用類型對象位於 CLI堆中,因此可以透明地在垃圾回收堆的壓縮過程中移動它的位置。一個跟蹤句柄在運行時被透明地更新。兩個類似的概念:(a)跟蹤引用(%) 和 (b)內部指針(interior_ptr<>),在第4.4.3節討論。
聲明語法不再重用 ISO-C++指針語法有兩個主要原因:
指針語法的使用不允許重載的操作符直接應用於引用對象;而必須通過其內部名稱調用操作符,例如 rV1->op_Addition (rV2) 而不是更加直觀的 rV2+Rv2。
有許多指針操作,例如類型強制轉換和指針算術對於位於垃圾回收堆上的對象無效。我們認為一個跟蹤句柄的概念最好符合一個 CLI 引用類型的本性。
對一個跟蹤句柄使用 __gc修飾符是不必要的,而且是不被支持的。對象本身的用法並未變化,它仍舊通過指針成員選擇操作符 (->) 訪問成員。例如,以下是上面的 V1文字轉換到新語言語法的結果:
public ref class Form1: public System::Windows::Forms::Form{ private: System::ComponentModel::Container^ components; Button^ button1; DataGrid^ myDataGrid; DataSet^ myDataSet; void PrintValues( Array^ myArr ) { System::Collections::IEnumerator^ myEnumerator = myArr->GetEnumerator(); Array ^localArray = myArr->Copy(); // ... } };
2.2.1 在 CLI 堆上動態分配對象
在原版語言設計中,現有的在本機堆和托管堆上分配的兩種 new表達式很大程度上是透明的。在幾乎所有的情況下,編譯器能夠從上下文正確地確定所需的是本機堆還是托管堆。例如:
Button *button1 = new Button; // OK: 托管堆 int *pi1 = new int; // OK: 本機堆 Int32 *pi2 = new Int32; // OK: 托管堆
在上下文堆分配並非所期望的實例時,可以用 __gc或者 __nogc關鍵字指引編譯器。在修訂版語言中,使用新引入的 gcnew關鍵字來顯示兩個 new 表達式的不同本質。例如,上面三個聲明在修訂版語言中如下所示:
Button^ button1 = gcnew Button; // OK: 托管堆 int * pi1 = new int; // OK: 本機堆 interior_ptr<Int32> pi2 = gcnew Int32; // OK: 托管堆
(在第 3 節中討論 interior_ptr的更多細節。通常,它表示一個對象的地址,這個對象可能(但不必)位於托管堆上。如果指向的對象確實位於托管堆上,那麼它在對象被重新定位時被透明地更新。)
以下是前面一節中聲明的 Form1成員 V1版本的初始化:
void InitializeComponent() { components = new System::ComponentModel::Container(); button1 = new System::Windows::Forms::Button(); myDataGrid = new DataGrid(); button1->Click += new System::EventHandler(this, &Form1::button1_Click); // ... }
以下是用修訂版語法重寫的同樣的初始化過程,注意引用類型是一個 gcnew表達式的目標時不需要“帽子”。
void InitializeComponent() { components = gcnew System::ComponentModel::Container; button1 = gcnew System::Windows::Forms::Button; myDataGrid = gcnew DataGrid; button1->Click += gcnew System::EventHandler( this, &Form1::button1_Click ); // ... }
2.2.2 無對象的跟蹤引用
在新的語言設計中,0不再表示一個空地址,而僅被處理為一個整型,與 1、10、100一樣,這樣我們需要引入一個特殊的標記來代表一個空值的跟蹤引用。例如,在原版語言設計中,我們如下初始化一個引用類型來處理一個無對象:
//正確:我們設置 obj 不引用任何對象 Object * obj = 0; //錯誤:沒有隱式裝箱 Object * obj2 = 1;
在修訂版語言中,任何從值類型到一個 Object的初始化或者賦值都導致一個值類型的隱式裝箱。在修訂版語言中,obj和 obj2都被初始化為裝箱過的 Int32對象,分別具有值 0和 1。例如:
//導致 0 和 1 的隱式裝箱 Object ^ obj = 0; Object ^ obj2 = 1;
因此,為了允許顯式的初始化、賦值,以及將跟蹤句柄與空進行比較,我們引入了一個新的關鍵字 nullptr。這樣 V1示例的正確版本如下所示:
//OK:我們設置 obj 不引用任何對象 Object ^ obj = nullptr; //OK:我們初始化 obj 為一個 Int32^ Object ^ obj2 = 1;
這使得從現存 V1代碼到修訂版語言設計的移植更加復雜。例如,考慮如下值類聲明:
__value struct Holder { //原版 V1 語法 Holder( Continuation* c, Sexpr* v ) { cont = c; value = v; args = 0; env = 0; } private: Continuation* cont; Sexpr * value; Environment* env; Sexpr * args __gc []; };
這裡 args和 env都是 CLI引用類型。在構造函數中將這兩個成員初始化為 0 的語句在轉移到新語法的過程中必須修改為 nullptr:
//修訂版 V2 語法 value struct Holder { Holder( Continuation^ c, Sexpr^ v ) { cont = c; value = v; args = nullptr; env = nullptr; } private: Continuation^ cont; Sexpr^ value; Environment^ env; array<Sexpr^>^ args; };
類似的,將這些成員與 0進行比較的測試也必須改為和 nullptr比較。以下是原版的語法:
// 原版 V1 語法 Sexpr * Loop (Sexpr* input) { value = 0; Holder holder = Interpret(this, input, env); while (holder.cont != 0) { if (holder.env != 0) { holder=Interpret(holder.cont,holder.value,holder.env); } else if (holder.args != 0) { holder = holder.value->closure()-> apply(holder.cont,holder.args); } } return value; }
而以下是修訂版語法。將每個 0實例轉換為 nullptr 。(轉換工具有助於這個轉換,進行許多自動處理 — 如果不是全部出現,包括使用 NULL 宏。)
//修訂版 V2 語法 Sexpr ^ Loop (Sexpr^ input) { value = nullptr; Holder holder = Interpret(this, input, env); while ( holder.cont != nullptr ) { if ( holder.env != nullptr ) { holder=Interpret(holder.cont,holder.value,holder.env); } else if (holder.args != nullptr ) { holder = holder.value->closure()-> apply(holder.cont,holder.args); } } return value; }
nullptr可以轉化成任何跟蹤句柄類型或者指針,但是不能提升為一個整數類型。例如,在如下初始化集合中,nullptr只在開頭兩個初始值中有效。
//正確:我們設置 obj 和 pstr 不引用任何對象 Object^ obj = nullptr; char* pstr = nullptr; //在這裡用0也可以 //錯誤:沒有從 nullptr 到 0 的轉換 ... int ival = nullptr; 類似的,給定一個重載過的方法集,如下所示: void f( Object^ ); // (1) void f( char* ); // (2) void f( int ); // (3)
一段使用 nullptr的調用如下所示:
// 錯誤:歧義:匹配 (1) 和 (2) f( nullptr );
是有歧義的,因為 nullptr既匹配一個跟蹤句柄也匹配一個指針,而且在兩者中沒有一個優先選擇(這需要一個顯式的類型強制轉換來消除歧義)。
一個使用 0的調用正好匹配實例 (3):
//正確:匹配 (3) f( 0 );
由於 0是整型。當沒有 f(int)的時候,調用會通過一個標准轉換無歧義地匹配f(char*)。匹配規則優先於標准轉換的精確匹配。在沒有精確匹配時,標准轉換優先於對於值類型的隱式裝箱。這就是沒有歧義的原因。
原版語言設計中的 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代碼的轉換造成怎樣的影響)是本節的主題。這可能是本文中最復雜的一節,所以我們慢慢來講。這也可能是兩個語言版本之間最重要的編程級別的修改,所以需要以循序漸進的方式來進行學習。
2.4.1 不確定的終止
在對象關聯的內存被垃圾回收器回收之前,如果對象有一個相關的 Finalize()方法存在,那麼它將被調用。您可以將該方法想象為一種超級析構函數,因為它與對象編程生命周期無關。我們稱此為終止。何時甚至是否調用 Finalize()方法的計時是不確定的。這就是我們提到垃圾回收代表不確定的終止(non-deterministic finalization)時表達的意思。
不確定的終止和動態內存管理合作的很好。當可用內存缺少到一定程度的時候,垃圾回收器介入,並且很好地工作。在垃圾回收環境中,用析構函數來釋放內存是不必要的。您第一次實現應用程序時不為潛在的內存洩漏發愁才怪,但是很容易就會適應了。
然而,不確定的終止機制在對象維護一個關鍵的資源(例如一個數據庫連接或者某種類型的鎖)時運轉並不好。這種情況下我們需要盡快釋放資源。在本機代碼的環境下,這是用構造函數/析構函數對的組合解決的。不管是通過執行完畢聲明對象的本機代碼塊還是通過由於引發異常造成的拆棧,對象的生命周期一終止,析構函數就介入並且自動釋放資源。這個機制運轉得很好,而且在原版語言設計中沒有它的存在是一個很大的失誤。
CLI提供的解決方案是實現 IDisposable接口的 Dispose()方法的類。問題是Dispose()方法需要用戶顯式地調用。這是個錯誤的傾向,因此是個倒退。C# 語言提供一個適度的自動化方式,使用一個特別的 using語句。我們的原版語言設計(我已經提到過)根本沒有提供特別的支持。
2.4.2 在 V1 中,析構函數轉到 Finalize()
在原版語言中,一個引用類的析構函數通過如下兩步實現:
用戶編寫的析構函數被內部重命名為 Finalize()。如果類有一個基類(記住,在 CLI對象模型中只支持單繼承),編譯器在用戶的代碼之後插入一個對其終結器的調用。例如,給定下列 V1語言規范中的普通層次
__gc class A { public: ~A() { Console::WriteLine(S"in ~A"); } }; __gc class B : public A { public: ~B() { Console::WriteLine(S"in ~B"); } };
兩個析構函數都被重命名為 Finalize()。B 的 Finalize()在調用 WriteLine()之後加入一個 A的 Finalize()方法的調用。這些就是垃圾回收器在終止過程中默認調用的代碼。它的內部轉換結果如下所示:
//V1 下析構函數的內部轉換 __gc class A { public: void Finalize() { Console::WriteLine(S"in ~A"); } }; __gc class B : public A { public: void Finalize() { Console::WriteLine(S"in ~B"); A::Finalize(); } };
第二步中,編譯器產生一個虛析構函數。這個析構函數就是我們的 V1用戶程序直接調用或者通過 delete表達式的應用程序調用的。它永遠不會被垃圾回收器調用。
這個產生的析構函數裡面有什麼內容呢?是兩個語句。一個是調用GC::SuppressFinalize()以確保沒有對 Finalize()方法的進一步調用。另一個是實際上的 Finalize()調用。回憶一下,這表達了用戶提供的這個類的析構函數。如下所示:
__gc class A { public: virtual ~A() { System::GC::SuppressFinalize(this); A::Finalize(); } }; __gc class B : public A { public: virtual ~B() { System::GC:SuppressFinalize(this); B::Finalize(); } };
這個實現允許用戶立刻顯式調用類的 Finalize()方法,而不是隨時調用,它並不真的依賴於使用 Dispose()方法的方案。這在修訂版語言設計中進行了更改。
2.4.3 V2 中,析構函數轉到 Dispose()
在修訂版語言設計中,析構函數被內部重命名為 Dispose()方法,並且引用類自動擴展以實現 IDisposable接口。換句話說,在 V2中,這對類按如下所示進行轉換:
// V2 下析構函數的內部轉換 __gc class A : IDisposable { public: void Dispose() { System::GC::SuppressFinalize(this); Console::WriteLine( "in ~A"); } } }; __gc class B : public A { public: void Dispose() { System::GC::SuppressFinalize(this); Console::WriteLine( "in ~B"); A::Dispose(); } };
在 V2 中,當析構函數被顯式調用時,或者對跟蹤句柄應用 delete時,底層的 Dispose()方法都會自動被調用。如果這是一個派生類,一個對基類的 Dispose()方法的調用會被插入到生成方法的末尾。
但是這樣也沒有給我們確定性終止的方法。為了解決這個問題,我們需要局部引用對象的額外支持(在原版語言設計中沒有類似的支持,所以沒有轉換的問題)。
2.4.4 聲明一個引用對象
修訂版語言支持在本地棧上聲明引用類的對象,或者聲明為類的成員,就像它可以直接被訪問一樣(注意這在 Microsoft Visual Studio 2005 的Beta1 發布版中不可用)。析構函數和在 2.4.3 節中描述的 Dispose() 方法結合時,結果就是引用類型的終止語義的自動調用。使 CLI 社區苦惱的非確定性終止這條暴龍終於被馴服了,至少對於 C++/CLI的用戶來說是這樣。讓我們看一下這到底意味著什麼。
首先,我們這樣定義一個引用類,使得對象創建函數在類構造函數中獲取一個資源。其次,在類的析構函數中,釋放對象創建時獲得的資源。
public ref class R { public: R() { /* 獲得外部資源 */ } ~R(){ /* 釋放外部資源 */ } // ... 雜七雜八 ... };
對象聲明為局部的,使用沒有附加"帽子"的類型名。所有對對象的使用(如調用成員函數)是通過成員選擇點 (.) 而不是箭頭 (->) 完成的。在塊的末尾,轉換成 Dispose()的相關的析構函數被自動調用。
void f() { R r; r.methodCall(); // ... // r被自動析構 - // 也就是說, r.Dispose() 被調用... }
相對於 C#中的 using語句來說,這只是語法上的點綴而已,而不是對基本 CLI約定(所有引用類型必須在 CLI堆上分配)的違背。基礎語法仍未變化。用戶可能已經編寫了下面同樣功能的語句(這很像編譯器執行的內部轉換):
// 等價的實現... // 除了它應該位於一個 try/finally 語句中之外 void f() { R^ r = gcnew R; r->methodCall(); // ... delete r; }
事實上,在修訂版語言設計中,析構函數再次與構造函數配對成為和一個局部對象生命周期關聯的自動獲得/釋放資源的機制。這個顯著的成就非常令人震驚,並且語言設計者應該因此被大力贊揚。
2.4.5 聲明一個顯式的 Finalize()-(!R)
在修訂版語言設計中,如我們所見,構造函數被合成為 Dispose()方法。這意味著在析構函數沒有被顯式調用的情況下,垃圾回收器在終止過程中,不會像以前那樣為對象查找相關的 Finalize()方法。為了同時支持析構函數和終止,修訂版語言引入了一個特殊的語法來提供一個終止器。舉例來說:
public ref class R { public: !R() { Console::WriteLine( "I am the R::finalizer()!" ); } };
! 前綴表示引入類析構函數的類似符號 (~),也就是說,兩種後生命周期的方法名都是在類名前加一個符號前綴。如果派生類中有一個合成的 Finalize()方法,那麼在其末尾會插入一個基類的 Finalize()方法的調用。如果析構函數被顯式地調用,那麼終止器會被抑制。這個轉換如下所示:
// V2 中的內部轉換 public ref class R { public: void Finalize() { Console::WriteLine( "I am the R::finalizer()!" ); } };
2.4.6 這在 V1 到 V2 的轉換中意味著什麼
這意味著,只要一個引用類包含一個特別的析構函數,一個 V1程序在 V2 編譯器下的運行時行為被靜默地修改了。需要的轉換算法如下所示:
如果析構函數存在,重寫它為類終止器方法。
如果 Dispose()方法存在,重寫到類析構函數中。
如果析構函數存在,但是 Dispose()方法不存在,保留析構函數並且執行第 (1) 項。
在將代碼從 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: 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 ); } };
雖然在語言設計方面,人們因為語法的簡單枯燥而傾向於忽視它,但是如果對語言的用戶體驗有很大的潛移默化的影響,那麼它實際上很有意義。一個令人迷惑的、不優雅的語法可能增加開發過程的風險,很大程度上就像一個髒的或者不清晰的擋風玻璃增加開車的風險一樣。在修訂版語言設計中,我們努力使語法像一塊高度磨光的新安裝的擋風玻璃一樣透明。
__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; };
原版語言設計最驚人之處可能是它對於操作符重載的支持 — 或者更恰當地說,是有效的缺乏支持。舉例來說,在一個引用類型的聲明中,不是使用內建的 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 };
除了這種句法上的小小修改之外,托管枚舉類型的行為在很多方面有所改變:
CLI枚舉的前置聲明在 V2中不再支持。V2 中沒有這樣的對應。這只會導致編譯時錯誤。
__value enum status; // V1: 正確 enum class status; // V2: 錯誤
在內建算術類型和對象類層次結構之間進行重載解析的次序在 V2和 V1中被顛倒了!一個副作用是,托管枚舉在 V2 中不能再像在 V1中一樣隱式轉換成算術類型。
與在 V1 中不同,在 V2中,托管枚舉具有它自己的范圍。在 V1中,枚舉數在包含枚舉的范圍內可見。在 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
4.1.2 CLI 枚舉類型的范圍
C和 C++語言之間的不同之一就是 C++在 struct 中添加了范圍。在 C中,struct 只是一個數據的聚合,既不支持接口也不支持關聯的范圍。這在當時是一個十分激進的改變,並且對於很多從 C 語言轉移過來的新 C++ 用戶來說是一個有爭議的問題。本機和 CLI 的枚舉的關系也類似。
在原始語言設計中,曾經嘗試過為托管枚舉的枚舉數定義弱插入名稱,用於模擬本機枚舉內范圍的缺失。這個嘗試被證明是失敗的,問題在於這造成了枚舉數溢出到全局命名空間,造成了管理名稱沖突的困難。在修訂版語言中,我們按照其他 CLI語言來支持托管枚舉的范圍。
這意味著 CLI 枚舉的枚舉數的任何未限定使用將不能被修訂版語言識別。讓我們來看一個實際的例子。
// 原版語言設計支持弱插入 __gc class XDCMake { public: __value enum _recognizerEnum { UNDEFINED, OPTION_USAGE, XDC0001_ERR_PATH_DOES_NOT_EXIST = 1, XDC0002_ERR_CANNOT_WRITE_TO = 2, XDC0003_ERR_INCLUDE_TAGS_NOT_SUPPORTED = 3, XDC0004_WRN_XML_LOAD_FAILURE = 4, XDC0006_WRN_NONEXISTENT_FILES = 6, }; ListDictionary* optionList; ListDictionary* itagList; XDCMake() { optionList = new ListDictionary; // here are the problems ... optionList->Add(S"?", __box(OPTION_USAGE)); // (1) optionList->Add(S"help", __box(OPTION_USAGE)); // (2) itagList = new ListDictionary; itagList->Add(S"returns", __box(XDC0004_WRN_XML_LOAD_FAILURE)); // (3) } };
三個枚舉數名稱的未限定使用 ((1)、(2)和(3)) 都需要在轉換為修訂版語言語法時被限定,從而讓源代碼通過編譯。以下是原始源代碼的正確轉換:
ref class XDCMake { public: enum class _recognizerEnum { UNDEFINED, OPTION_USAGE, XDC0001_ERR_PATH_DOES_NOT_EXIST = 1, XDC0002_ERR_CANNOT_WRITE_TO = 2, XDC0003_ERR_INCLUDE_TAGS_NOT_SUPPORTED = 3, XDC0004_WRN_XML_LOAD_FAILURE = 4, XDC0006_WRN_NONEXISTENT_FILES = 6 }; ListDictionary^ optionList; ListDictionary^ itagList; XDCMake() { optionList = gcnew ListDictionary; optionList->Add("?",_recognizerEnum::OPTION_USAGE); // (1) optionList->Add("help",_recognizerEnum::OPTION_USAGE); //(2) itagList = gcnew ListDictionary; itagList->Add( "returns", recognizerEnum::XDC0004_WRN_XML_LOAD_FAILURE); //(3) } };
這改變了本機和 CLI 枚舉之間的設計策略。因為 CLI 枚舉在 V2中保持一個關聯的范圍,在一個類中封裝枚舉的聲明不再是有必要和有效的了。這個用法隨著貝爾實驗室的 cfront 2.0而不斷發展,也用來解決全局名稱污染的問題。
在貝爾實驗室的 Jerry Schwarz 所創建的 beta 原版新 iostream庫中,Jerry 沒有封裝庫中定義的全部相關枚舉,而且通用枚舉數 — 例如 read、write、append等 — 使得用戶幾乎不可能編譯他們的現存代碼。一個解決方案是破壞這些名稱,例如 io_read 、 io_write等等。另一個解決方案是修改語言來添加枚舉的范圍,但是在當時是不可能實現的。(一個折衷的方案是將枚舉封裝在類或類層次結構中,這時枚舉的標記名稱及其枚舉數填充封閉類范圍。)換句話說,將枚舉放在類中的動機 — 至少是原始動機 — 不是理論上的,而是全局命名空間污染問題的一個實際解決方案。
對於 V2CLI 枚舉,將枚舉封裝在類中不再有任何明顯的好處。實際上,如果您看看 System命名空間,您就會看到枚舉、類和接口都在同一個聲明空間中存在。
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; // 必須是局部的
4.4.1 調用繼承的虛方法
格式(1)是一個規范的值對象,並且它是相當容易理解的,除非有人試圖調用一個繼承虛方法,例如 ToString()。例如,
v.ToString(); // 錯誤!
為了調用這個方法,因為在 V中它不可重寫,所以編譯器必須可以訪問基類的相關虛表。因為值類型是狀態內存儲,沒有其虛表 (vptr) 的相關指針,所以這需要 v被裝箱。在原版語言設計中,隱式裝箱是不被支持的,程序員必須顯式聲明如下:
__box( v )->ToString(); // V1: 注意箭頭
該設計背後的主要動機是具有教育意義的 — 它希望使底層機制對於程序員可見,使得他能理解不在值類型中提供實例的“代價”。如果 V包含一個 ToString實例,那麼裝箱是不必要的。
顯式裝箱對象的繁文缛節,而不是裝箱本身的基本代價,在修訂版語言設計中被移除了。
v.ToString(); // V2
但是代價是可能誤導類設計者在 V中不提供顯式 ToString方法的實例。首選隱式裝箱的原因是通常只有一個類設計者而有無數的類使用者,他們不能自由地修改 V來避免可能很麻煩的顯式裝箱。
決定是否在值類中提供 ToString的一個重寫實例取決於它的使用頻率和位置。如果它很少被調用,那麼這麼定義很顯然沒什麼好處。類似地,如果它在應用程序的非性能區域被調用,那麼添加它將不會對應用程序的常規性能帶來可觀的提升。或者,可以保留一個裝箱值的跟蹤句柄,通過該句柄的調用不會需要裝箱。
4.4.2 值類不再有默認構造函數
值類型的原版和修訂版語言設計之間的另外一個差異是取消了對默認構造函數的支持。這是由於在執行中,CLI可能創建一個值類型的實例而不調用相關的默認構造函數。換句話說,在 V1中,實際上並不能夠保證對值類型中默認構造函數的支持。由於缺乏保證,所以感覺完全去掉這個支持比在其應用程序中保持不確定性更好。
這並不像第一眼看上去那麼壞。這是因為每個值類型對象會被自動清零(每個類型會被初始化為其默認的值)。也就是說,局部實例的成員不會是未定義的。在這個意義上,缺少定義一個普通默認構造函數的能力實際上根本不是一個損失 — 並且事實上在 CLI執行時更加高效。
問題發生在原版 V1語言的用戶定義了一個非普通默認構造函數時。它沒有在修訂版V2語言設計中的對應。構造函數中的代碼將需要移植到一個命名的初始化方法,並且這個方法需要被用戶顯式調用。
修訂版 V2 語言設計中的值類型對象的聲明沒有變化。它的缺點是值類型不能包裝本機類型,原因如下:
值類型不支持析構函數。換句話說,無法在對象生命周期結束時自動觸發一組行為。
本機類只能作為指針包含在托管類型中,然後在本機堆上進行分配。
我們可能喜歡用值類型(而不是引用類型)來包裝一個小的本機類來避免兩次堆分配:本機堆存放本機類型,CLI堆存放托管包裝。在值類型中包裝一個本機類可以避免在托管堆的分配,但是無法自動回收本機堆上分配的內存。引用類型是唯一可行的用於包裝非普通本機類的托管類型。
4.4.3 內部指針
格式(2)和 (3)幾乎可以解決任何問題(即托管和本機)。因此,舉例來說,在原版語言設計中以下內容都是被允許的:
// 來自於 4.4 節 __value struct V { int i; }; __gc struct R { V vr; }; V v = { 0 }; V *pv = 0; V __gc *pvgc = 0; // 格式 (2) 是 (3) 的隱式格式 __box V* pvbx = 0; // 必須是局部的 R* r; pv = &v; //指向棧上的一個值類型 pv = __nogc new V; //指向本機堆上的一個值類型 pv = pvgc; // 我們不確定這指向什麼位置 pv = pvbx; // 指向托管堆上的裝箱值類型 pv = &r->vr; //指向托管堆上一個引用類型中的值類型的內部指針
這樣,一個 V*可以指向局部塊中的地址(因此可以成為虛引用);對於全局范圍來說,在本機堆內(例如,如果它指向的對象已經被刪除);在 CLI 堆內(因此如果在垃圾回收期間會重新定位,則將進行跟蹤),以及在 CLI堆上的引用對象的內部(顧名思義,內部指針也透明地被跟蹤)。
在原版語言設計中,無法分離 V*的本機方面。也就是說,它的處理具有包含性,處理指向一個托管堆上對象或者子對象的可能性。
在修訂版語言設計中,值類型指針有兩種類型:V*,位置局限於非 CLI 堆,和內部指針 interior_ptr<V>,允許但是不強制一個地址位於傳統堆中。
// 不能指向托管堆的地址 V *pv = 0; // 可以但是不必須指向傳統堆之外的地址 interior_ptr<V> pvgc = nullptr;
原版語言中的格式 (2)和 (3)對應 interior_ptr<V>。格式 (4)是一個跟蹤句柄。它指向托管堆中裝箱的整個對象。這在修訂版語言中轉換成 V^:
V^ pvbx = nullptr; // __box V* pvbx = 0;
原版語言設計中的下列聲明在修訂版語言設計中都對應到內部指針。(它們是 System命名空間內的值類型。)
Int32 *pi; => interior_ptr<Int32> pi; Boolean *pb; => interior_ptr<Boolean> pb; E *pe; => interior_ptr<E> pe; // 枚舉
內建類型不被認為是托管類型,雖然它們確實作為 System命名空間內的類型的別名。因此,原版和修訂版語言的以下對應是正確的:
int * pi; => int* pi; int __gc * pi => interior_ptr<int> pi;
當轉換現存程序中的 V*時,最保守的策略是總是將其轉換成為interior_ptr<V>。這就是它在原版語言中的處理方法。在修訂版語言中,程序員可以選擇通過指定 V*而不是使用內部指針來限制一個值類型位於非托管堆。如果您在轉換程序時,可以傳遞閉包它的所有使用,並且確認沒有被賦值為托管堆中的地址,那麼保留 V*就可以了。
4.4.4 釘住指針
垃圾回收器可能會在 CLI堆內將堆上的對象移動到不同的位置,這通常發生在壓縮階段。(這個移動對跟蹤句柄、跟蹤引用和內部指針來說不是問題,因為這些實體被透明的更新。但是,如果用戶在運行庫環境之外傳遞 CLI堆上對象的地址,這種移動就是個問題了。在這種情況下,這種不穩定的對象移動很容易造成運行庫失敗。為了避免這種對象被移動,我們必須局部地將其釘住以備外部使用。
在原版語言設計中,一個釘住指針是用 __pin關鍵字限定一個指針聲明來聲明的。以下是在原版語言規范的基礎上作了少量修改的一個示例:
__gc struct H { int j; }; int main() { H * h = new H; int __pin * k = & h -> j; // ... };
在新的語言設計中,一個釘住指針是以和內部指針類似的語法聲明的。
ref struct H { public: int j; }; int main() { H^ h = gcnew H; pin_ptr<int> k = &h->j; // ... }
修訂版語言下的釘住指針是一個內部指針的特例。V1對釘住指針的限制仍舊存在。例如,它不能作為方法的參數或者返回類型使用,而且,它只能被聲明為一個局部對象。但是,一些額外的限制被添加到了修訂版語言設計中:
釘住指針的默認值是 nullptr,而不是 0。pin_ptr<>不能被初始化或者賦值為 0。現存代碼中賦值為 0的都需要改為 nullptr。
V1下的釘住指針允許指向整個對象,如下面引用自原版語言規范的示例所述:
__gc struct H { int j; }; void f( G * g ) { H __pin * pH = new H; g->incr(& pH -> j); };
在修訂版語言中,釘住 new 表達式返回的整個對象是不被支持的。確切地說,是需要釘住內部成員的地址。舉例來說:
void f( G^ g ) { H ^ph = gcnew H; pin_ptr<int> pj = &ph->j; g->incr( pj ); }
本節中描述的更改某種意義上是語言雜記。本節包含處理字符串的修改,省略號和參數屬性的重載解決方案的修改,從 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 為名稱的實例,所以需要將函數重載解析算法應用於調用。正式的函數重載解析包含以下三個步驟:
搜集候選函數。候選函數是作用域內字面上匹配所調用函數的名稱的方法。例如,因為 My()是通過 R的一個實例調用的,所有不是 R(或其基類層次結構)成員的命名函數 My都不是候選函數。在我們的示例中,有兩個候選函數。這就是名為 My的R函數的兩個成員。
候選函數中的可行函數集。一個可行函數是可以用調用中指定的參數調用的函數(如果給定參數的數量和類型)。如果可行函數集為空,那麼調用失敗。
選擇表示最匹配調用的函數。這是通過對應用於從參數到可行函數參數類型的轉換進行定級來實現的。對只有一個參數的函數來說,這相對簡單一些;但是多參數函數的情況下有點復雜。如果沒有最佳匹配,那麼調用在該階段會失敗。換句話說,如果從實參類型到形參類型所需的轉換都同樣好,那麼調用被標記為有歧義的。
在原版語言設計中,作為最佳匹配,該調用的解析調用 constchar*實例。在 V2中,“abc”到 constchar*和 String^匹配所需的轉換現在是等價的 — 換句話說,同樣好 — 因此調用被標記為壞的 — 也就是說,有歧義的。
這導致我們思考以下兩個問題:
實際參數“abc”的類型是什麼?
判斷一個類型轉換優於另一個類型轉換的算法是什麼?
字符串“abc”的類型是 constchar[4]— 記住,每個字符串的末尾有一個隱式的 null 終止符。
判斷一個類型轉換優於另一個的算法涉及到將可能的類型轉換放在層次結構中。以下是我對這個層次結構的理解 — 當然,所有這些轉換都是隱式的。使用顯式轉換標記會重新定義層次結構,就像圓括號重新定義表達式的運算次序一樣。
一個精確匹配是最好的。令人驚異的是,對於精確匹配的參數來說,不必精確匹配參數類型,只需要足夠接近。這是理解本例的原理和語言如何進行修改的關鍵。
提升優於標准轉換。例如,short int到 int的提升優於 int到 double的轉換。
標准轉換優於裝箱轉換。例如,int到 double的轉換優於 int到 Object的裝箱。
裝箱轉換優於隱式用戶自定義轉換。例如,int到 Object的裝箱優於應用 SmallInt值類的轉換操作符。
隱式用戶定義轉換優於根本沒有轉換。隱式用戶定義轉換是錯誤之前的最後一個出口(警告:形式參數中的位置可能包含一個參數數組或者省略號)。
這樣,為什麼精確匹配不一定會確定一個匹配?舉例來說,const char[4]並不精確匹配 const char*或者 String^,但是在我們的示例中,兩個不一致的精確匹配之間仍然存在歧義!
精確匹配發生時,包含一系列小轉換。在 ISO-C++中有 4 個普通轉換可以使用,並且仍舊滿足精確匹配,其中三個被稱為左值轉換。第四個轉換被稱為限定轉換。三個左值轉換比需要限定轉換的精確匹配更優越。
左值轉換的一種形式是本機-數組-指針的轉換。這就是將 const char[4]匹配到 const char*所發生的事情。因此,從 My("abc")到 My(const char*)的匹配是一個精確匹配。在 C++/CLI語言的早期版本中,這實際上是最佳轉換。
因為編譯器要將調用標記為有歧義的,所以這要求一個從 const char[4]到 String^的轉換也通過普通轉換成為一個精確匹配。這就是 V2 中新加入的更改。並且這也是調用被標記為有歧義的原因。
在原版語言設計和 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++ 中類型安全的向下轉換的需求逐漸增大。向下轉換是用戶對基類指針、對指針的引用,或派生類的引用的顯式轉換。向下轉換需要一個顯式的轉換,這是因為如果基類指針不是派生類對象,程序很可能做出一些很不好的事情。問題在於基類指針的實際類型是運行庫的一個方面,因此編譯器無法檢查它。或者換句話說,向下類型轉換就像一個虛函數調用,需要某種形式的動態解析。這產生了兩個問題:
為什麼在面向對象范型中需要向下類型轉換?難道虛函數機制不適合所有情況?也就是說,為什麼不能聲明任何對向下轉換(或者任何類型的轉換)的需要是程序員的設計失敗?
為什麼支持向下轉換成為 C++的一個問題?畢竟,這在任何諸如 Smalltalk(或者隨後的 Java和 C#)的面向對象語言中都不是問題?為什麼在 C++中支持向下轉換有困難?
虛函數代表類型家族中常見的一個依賴於類型的算法(我沒有考慮接口,這在 ISO-C++中不被支持,但是在 C++/CLI中可用,並且代表一個有趣的替代設計方案)。該類型家族設計的典型代表是一個類層次結構,其中具有一個聲明了通用接口(虛函數)的抽象基類的,以及一組具體派生類,代表應用程序域中實際類型家族。
舉例來說,一個電腦成像 (CGI) 應用程序域中一個輕量級的的層次結構,會具有一些諸如 color、intensity、position、on、off等等的共同屬性。可以在某個圖像中撒下幾束光,並且通過通用接口控制它們,而不用擔心光到底是聚光、平行光、全向光(如太陽光),還是通過擋光板的光。在這種情況下,向下轉換為一個特定的光類型來實現其虛接口是不必要的,因為所有的方式都一樣,所以是不明智的。但是,在生產環境中,情況不總是一樣的;很多情況下,考慮的是速度;程序員可能會選擇向下轉換,然後顯式調用每個方法,如果這樣,調用的內聯直接執行會替代通過虛函數機制執行。
因此,在 C++中使用向下轉換的一個原因是抑制虛函數機制而獲得可觀的運行庫性能(注意,將手動優化進行自動化是研究的活躍領域。但是這比替換 register或者 inline關鍵字的顯式使用更加困難)。
使用向下轉換的另一個原因是多態性的雙重屬性。關於多態的一個觀點是將它區分成被動和動態兩種形式。
一個虛調用(和向下轉換功能)代表多態性的動態使用:在程序執行過程中實現一個操作,該操作基於特殊實例的基類指針的實際類型。
但是,將一個派生類對象賦值給其基類指針是多態性的被動形式;這裡將多態性作為一個傳輸機制。這是 Object類的主要用途,例如在普及的 CLI 中就是這樣。作為被動形式使用時,用於傳輸和存儲的基類指針通常提供一個過於抽象的接口。舉例來說,Object通過其接口提供了大約 5 個方法;任何更明確的行為需要一個顯式的向下轉換。例如,如果我們希望調整聚光燈的角度或者照射角度,我們會需要顯式的向下轉換。子類型家族中的虛接口不能是其許多子成員的所有可能方法的超集,所以面向對象語言中向下轉換功能總是必要的。
如果一個安全的向下轉換功能在面向對象的語言中是必要的,那麼為什麼 C++花了這麼久的時間來添加該功能?問題在於如何使指針的運行庫類型信息可用。對於虛函數,就像大多數人目前了解的一樣,運行庫信息是編譯器分兩部分建立的:(a) 類對象包含一個額外的虛表指針成員(在類對象的開頭或者末尾;這是它本身的一個有趣的歷史),它指向適當的虛表 — 所以,舉例來說,一個聚光對象指向一個聚光虛表,對平行光是平行光虛表,等等;以及 (b) 每個虛函數在表中有一個相關的固定位置,並且實際調用的實例由表中存儲的地址來表示。因此,舉例來說,虛析構函數 ~Light可能與位置 0相關聯,Color與位置 1相關聯,等等。這是一個如不靈活即有效的策略,因為它是在編譯時設置的,而且代表最小的開銷。
現在的問題是如何使類型信息可用於指針而不改變 C++指針的大小,方法是再添加一個地址,或者直接添加一些類型編碼。這不可能被那些選擇不進行面向對象范型編程的程序員(和程序) — 他們仍舊是具有很大影響的用戶團體 — 接受。另外一個可能性是為多態類類型引入一個特定的指針,但這將造成可怕的混亂,並且使得混合兩者變得非常困難 — 特別是在指針算法問題方面。維護將每個指針關聯到當前相關類型的運行庫表,以及動態對其進行更新也是不可接受的。
現在的問題是,兩個用戶社區有不同但是合理的編程期望。解決方案需要在兩個社區之間進行妥協,不但允許每個社區的期望而且也允許互操作能力得以實現。這意味著兩個社區提供的方案看起來都不可取,而最終實現的解決方案很可能並不完美。實際的解決方案圍繞多態類的定義:多態類是一個包含虛函數的類。一個多態類支持動態類型安全的向下轉換。這解決了“以地址的形式維護指針”的問題,因為所有多態類包含額外的指針成員,指向其相關虛表。因此,相關類型信息可以保存在一個擴展的虛表結構中。類型安全向下轉換的開銷是(幾乎)限制了功能的使用者的范圍。
關於類型安全的向下轉換的下一個問題是它的語法。因為它是一個強制轉換,ISO-C++協會的原意是使用未裝飾的強制轉換語法,因此編寫如下示例代碼:
spot = ( SpotLight* ) plight;
但是這被委員會否決了,因為這不允許用戶控制強制轉換的代價。如果動態類型安全的向下轉換具有前面的不安全但是是靜態的標記,那麼它將成為一個替代方案,而且用戶無法在不必要或者代價太大時降低運行庫的開銷。
通常,C++中總有機制抑制編譯器支持的功能。例如,我們可以通過使用類作用域操作符 Box::rotate(angle)或者通過類對象(而不是通過這個類的指針或者引用)調用虛函數來關閉虛函數機制 — 後面一個抑制是語言不需要的,但是是一些實現問題所必需的……它類似於以如下形式在聲明時構造一個臨時對象:
//編譯器可以自由優化掉這個臨時對象... X x = X::X( 10 );
因此提議被打回重新考慮,很多替代的符號被考慮過,而最後提交給委員會的是 (?type)形式,表示它的不確定 — 也就是動態 — 本質。這為用戶提供了在兩種形式 — 靜態或者動態 — 之間切換的能力,但是沒有人滿意。所以它又回到制圖板。第三個,也是成功的一個標記是現在的標准 dynamic_cast<type>,它被通用化為四個新風格的強制轉換標記集合。
在 ISO-C++中,dynamic_cast在應用到一個不合適的指針類型時返回 0,並且在應用到一個引用類型時引發一個 std::bad_cast異常。在原版語言設計中,將 dynamic_cast應用到一個托管引用類型(因為它的指針表達方法)總是返回 0。__try_cast<type>作為一個引發 dynamic_cast變體的異常的類似被引入,但是它在強制轉換失敗時引發 System::InvalidCastException異常。
public __gc class ItemVerb; public __gc class ItemVerbCollection { public: ItemVerb *EnsureVerbArray() [] { return __try_cast<ItemVerb *[]> (verbList->ToArray(__typeof(ItemVerb *))); } };
在修訂版語言中,__try_cast被重新轉換為 safe_cast。以下是修訂版語言中同樣的代碼片斷:
using namespace stdcli::language; public ref class ItemVerb; public ref class ItemVerbCollection { public: array<ItemVerb^>^ EnsureVerbArray() { return safe_cast<array<ItemVerb^>^> ( verbList->ToArray( ItemVerb::typeid )); } };
在托管領域,限制程序員以代碼不可驗證的方式在類型間進行轉換的能力,從而允許可驗證代碼是很重要的。這是 C++/CLI 代表的動態編程范型的一個關鍵部分。由於這個原因,舊風格類型轉換的實例作為運行庫轉換被內部重新轉換,所以,舉例來說:
//內部轉換為上面的等價的 safe_cast 表達式 ( array<ItemVerb^>^ ) verbList->ToArray( ItemVerb::typeid );
另一方面,因為多態提供了動態和被動兩種模式,有時有必要執行一個向下類型轉換,只是為了獲得對子類型的非虛 API的訪問能力。舉例來說,當指向層次中任何類型的類的成員(使用被動多態性作為傳輸機制),但是在一個特定程序上下文中的實際實例已知的時候,可能發生這種情況。在這種情況下,系統程序員強烈的感覺到,進行類型轉換的運行庫檢查具有無法接受的性能開銷。如果 C++/CLI 作為托管系統編程語言,它必須提供一些方法來允許編譯時(即靜態)向下轉換。這就是為什麼在修訂版語言中 static_cast標記的使用仍允許保持為一個編譯時向下轉換的原因。
// OK:在編譯時執行的強制轉換 // 沒有運行時的類型正確性檢查 static_cast< array<ItemVerb^>^>( verbList->ToArray( ItemVerb::typeid ));
當然,問題是無法保證程序員執行的 static_cast是正確和善意的。換句話說,無法保證托管代碼的可驗證性。這是在動態編程范型下比本機環境更迫切的一個考慮,但是不足以在系統編程語言中禁用用戶切換靜態和運行時類型轉換的能力。
有一個 C++/CLI的性能陷阱和缺陷需要注意,在本機編程中,舊風格的強制轉換標記和新風格的 static_cast標記在性能上沒有區別。但是在新語言設計中,舊風格強制轉換標記的性能開銷比新風格 static_cast標記的性能開銷更加昂貴,因為編譯器需要將舊風格標記的使用內部轉換為引發異常的運行時檢查。而且,它還更改了代碼的執行配置文件,因為它導致在程序中引入一個未捕捉的異常 — 可能是智能的,但是如果使用 static_cast標記,那麼同樣的錯誤將不會導致該異常。可能有人有異議,好的,這將有助於刺激用戶使用新風格的標記。但是只在它失敗的時候才會這樣;否則,它只會導致使用舊風格標記的程序運行更加緩慢,而沒有可以理解清楚的原因,如以下 C 程序員所犯的錯誤:
// 缺陷 # 1: // 初始化可以避免一個臨時類對象的創建,而賦值不行 Matrix m; m = another_matrix; // 缺陷# 2: 類對象的聲明遠離其使用 Matrix m( 2000, 2000 ), n( 2000, 2000 ); if ( ! mumble ) return;
原版和修訂版語言設計之間最顯著和引人注目的更改可能是托管引用類型聲明的更改:
// 原版語言 Object * obj = 0; // 修訂版語言 Object ^ obj = nullptr;
看到這段代碼時主要會提出兩個問題:為什麼帽子(^符號)在微軟的走廊裡家喻戶曉,但是,更根本的是,為什麼要新的語法?為什麼原版語言設計不能被清理以減少侵略性,而推薦公認咄咄逼人的、陌生的修訂版 C++/CLI語言設計?
C++是基於面向機器的系統視圖建立的。雖然它支持一個高級的類型系統,但是總有回避它的機制,這些機制總是導致對機器的依賴性。當事態嚴重,而且用戶努力去做一些不可思議的事的時候,他們會繞過應用程序的抽象過程,重新將類型分離為地址和偏移。
CLI是操作系統和應用程序之間運行的一個軟件抽象層。當事態嚴重時,用戶會毫無根據地逐字反思執行環境、查詢、代碼和對象創建問題,跳過而不是遵循類型系統,但是這個經驗對於習慣腳踏實地的人來說會是一團糟。
例如,下面的內容是什麼意思?
T t;
好的,在 ISO-C++中,不管 T的本質是什麼,我們都可以確認下列特性:(1) 有一個與 t相關的字節的編譯時內存委托等於 sizeof(T);(2) 在程序中 t的作用域內,這個與 t關聯的內存獨立於其他所有對象;(3) 內存直接保持與 t相關的狀態/值;以及 (4) 內存和狀態在 t 的作用域內存在。
下列特性的結果是什麼?
第 (1) 項告訴我們 t不能是多態的。也就是說,它不能代表一個繼承層次中的一系列類型。換句話說,一個多態類型不能有一個編譯時的內存分配,除非派生實例沒有額外的內存需求。無論 T是一個基本類型還是一個復雜層次的基類,這都成立。
C++中的多態類型只可能在類型限定為指針 (T*)或者引用 (T&) 才可用 — 也就是說,如果聲明只是間接引用一個 T的對象。如果:
Base b = *new Derived;
那麼 b並不指向一個位於本機堆上的 Derived對象。值 b沒有和 new 表達式分配的Derived對象關聯,而 Derived對象的 Base部分被截斷,並且按位復制到獨立的基於棧的實例 b。這在 CLI對象模型中實際上沒有對應的描述。
為了將資源提交延遲到運行時進行,C++ 顯式支持兩種間接形式:
指針: T *pt = 0; 引用: T &rt = *pt;
指針和 C++對象模型一致。在
T *pt = 0;
中,pt直接保存一個 size_t類型的值,該值具有固定的大小和作用域。語法詞匯習慣於在指針的直接使用和指向對象的間接使用之間切換。眾所周知,*pt++在何種模式應用於什麼/何時應用/如何應用這個問題上具有歧義。
引用為看起來有些復雜的指針詞匯提供了一種簡單的語法,同時保持其效率。
Matrix operator+( const Matrix&, const Matrix& ); Matrix m3 = m1 + m2;
引用並不在直接和間接模式之間切換;而是在兩者之間進行階段轉移:(a) 初始時,它們被直接操作;但是 (b) 在所有後續的使用中,它們是透明的。
某種意義上說,引用代表了 C++ 對象模型物理學的一個奇異量子:(a) 它們占用空間,但是除了臨時對象之外,它們並沒有實體;(b) 它們在賦值時使用深拷貝 (deep copy),而在初始化時使用淺拷貝 (shallow copy);以及 (c) 與 const對象不同,參數實際上沒有實體。雖然在 ISO-C++中它們除了用於函數參數之外沒有太多的用途,但是在語言修訂版方面,它們十分具有靈感。
C++.NET 設計難題
字面上,對於C++擴展支持 CLI的每一方面,問題總是歸結到“我們如何將公共語言基礎結構 (Common Language Infrastructure,CLI) 的這個(或者那個)方面集成到 C++ 中,使它 (a) 讓 C++程序員感覺自然,以及 (b) 感覺像 CLI自身的一個一流的功能”。基於這些考慮,這個權衡在原版語言設計中沒有實現。
讀者的語言設計難題
因此,為了讓你看到一些步驟,這裡指出我們所面臨的難題:我們如何聲明和使用一個 CLI引用類型?它和 C++對象模型有顯著區別:不同的內存模型(垃圾回收),不同的復制語義(淺拷貝),不同的繼承模型(一體化,基於 Object,只有對接口有額外的支持時才支持單繼承)。
C++設計的原版托管擴展
在 C++中支持 CLI引用類型的基礎設計選擇就是決定是保留現存的語言,還是擴展語言,因而打破現有標准。
您會作何選擇?每個選擇都會被指責。標准歸結為一個人是否相信額外的語言支持代表域抽象(考慮並行和線程)或者范型轉移(考慮面向對象的類型—子類型關系和泛型)。
如果您相信額外的語言支持只代表另一個域抽象,您將會選擇保留現存語言。如果您了解到額外的語言支持代表編程范型的轉移,您會擴展語言。
簡而言之,原版語言設計認為額外的語言支持只是一個域抽象 — 這被笨拙的稱為托管擴展— 因此邏輯上後續的設計選擇是保持現存語言。
一旦我們致力於保持現存語言,只有三個替代的方法實際上可行 — 記住,我將討論限制在簡單的“如何表示一個 CLI引用類型”上:
讓語言支持透明化。編譯器會根據上下文決定語義。有歧義時會產生一個錯誤,並且用戶會通過一些特殊語法決定上下文的含義(作為類比,可以考慮根據層次結構的先後順序重載函數)。
以庫的方式添加域抽象支持(考慮將標准模板庫作為可能的模型)。
重用某些現存的語言元素,基於附帶規范中的描述,根據上下文限制其允許的用途和行為。(考慮虛基類的初始化和類型強制轉換的語義,或者函數中、文件范圍內和類聲明中 static 關鍵字的多種用途)。
每個人的首選都是第 1 項。“它只是和語言中其他內容一樣,只是少許不同。讓編譯器判斷就好了。”這裡很大的成功在於對於現存代碼來說,所有內容對用戶都是透明的。將您現有的應用程序拿出來,添加一兩個對象,編譯,然後,ta-dah,它就完成了。使用方便,操作簡單。在類型和源代碼方面完全可以互用。沒有人會質疑該方案不是理想方案,很大程度上就像沒有人爭論永動機的理想性一樣。在物理學上,這個問題的障礙是熱力學第二定律,以及熵的存在。在一個多范型編程語言中,規則有顯著不同,但是系統的瓦解是一樣明確的。
在一個多范型編程語言中,事情在各自的范型內運作相當良好,但是在范型被不正確地混合時趨於崩潰,導致程序崩潰或者更壞,運行但是產生錯誤的結果。這在支持獨立的基於對象和多態的面向對象的類編程中最常見。切片使得每個 C++新手的編程變得混亂:
DerivedClass dc; // 一個對象 BaseClass &bc = dc; // OK:bc 真的是一個 dc BaseClass bc2 = dc; // OK:但是 dc 可能被切片以適應 bc2
因此,打比方來說,語言設計的第二定律是讓行為不同的事物看起來具有足夠的差異以提醒用戶,在他或者她編程時盡量避免,嗯,一團糟。我們習慣於用兩個小時介紹中的半個小時來開始 C 程序員對指針和引用之間差異理解的第一步,而且大量的 C++ 程序員仍不能清楚地描述何時使用引用聲明,何時使用指針,以及原因。
這些迷惑無可否認地使編程更困難,並且在簡單地去除它們和其支持所提供的現實功能之間總有一個重要的權衡。並且它們的差異在於設計的透明度,以及在於它們是否實用。而且通常設計是通過類推實現的。當指向類成員的指針被引入到語言中時,成員選擇操作符被擴展了(例如從 -> 到 ->*),並且指向函數語法的指針被類似的擴展了(從 int (*pf)()到int(X::*pf)())。同樣地,類的靜態數據成員的初始化也被擴展了,等等。
引用對操作符重載的支持是必須的。您可以得到直觀的語法
Matrix c = a + b; // Matrix operator+( Matrix lhs, Matrix rhs ); c = a + b + c;
但是這很難說是一個有效的實現。C 語言指針的替代方案 — 這提供了效率 —被其非直觀語法所分隔:
// Matrix operator+( const Matrix* lhs, const Matrix* rhs ); Matrix c = &a + &b; c = &( &a + &b ) + &c;
引入引用提供了指針的效率,但是保留了直接訪問值類型的簡單語義。它的聲明類似於指針,並且易於理解。
// Matrix operator+( const Matrix& lhs, const Matrix& rhs ); Matrix c = a + b;
但是對習慣於使用指針的程序員來說,它的語義行為還是令人迷惑。
這樣,問題就是,對於習慣 C++對象的靜態行為的 C++程序員來說,理解和正確使用托管引用類型會有多麼容易?而且理所當然地,什麼可以幫助程序員在這方面進行最好的設計?
我們覺得兩個類型的差別足以保證特別處理,因此我們排除了選項 #1。甚至在修訂版語言中,我們仍支持這個選擇。那些爭論這個選擇的人(一度包括我們中的大部分)只是沒有坐下來深入理解這個問題。這不是指責,只是事實。因此,如果您考慮前面的設計難題並且提出一個透明的設計,那麼我會斷定,根據我們的經驗,那不是一個可行的解決方案,我堅持這一點。
第二和第三個選項,或者采取一個庫設計,或者重用現有的語言元素,都是可行的,並且各有所長,因為 Stroustrup 的 cfront源代碼很容易獲得,所以在貝爾實驗室中庫解決方案連篇累牍。它在某種程度上曾經是大眾化的(HCE)。甲修改 cfront來添加並行性,乙修改 cfront來添加他們喜歡的域擴展,每個人都炫耀其新的 C++ 語言修改版,而 Stroustrup 的正確回答是這最好在一個庫中進行處理。
這樣,為什麼我們沒有選擇一個庫解決方案?嗯,部分原因只是一個感覺上的問題。就像我們感覺兩種類型的差異足以保證特別處理一樣,我們感覺兩種類型的類似之處足以保證類似地處理。一個庫類型在很多方面表現得像語言中的內建類型一樣,但是它實際上不是。它不是一個一級的語言。我們感覺,我們必須盡力使引用類型成為語言的一級元素,因此,我們選擇不部署庫解決方案。這個選擇仍存在爭議。
這樣,為了引用類型和現存類型對象模型太過不同的感覺而拋棄了透明的解決方案,並且為了引用類型和現存類型對象模型需要在語言中有同等地位的感覺而拋棄了庫解決方案,我們剩下的問題是如何將引用類型集成到現存語言中。
如果我們從零開始,我們當然可以實現任何所希望的,從而提供一個統一的類型系統,並且 — 至少在我們修改了這個類型系統之前 — 我們做的任何事情都會煥然一新。這通常是我們在生產和技術中所做的。但是,我們被限制了,這是福也是禍。我們不能拋棄現存的 C++對象模型,所以我們做的任何事情必須與它兼容。在原版語言設計中,我們進一步限制了自己,不引入任何新的標記;因此;我們必須使用已有的標記。這並未給我們提供多少靈活度。
因此,為了切入重點,在原版語言設計中,假設給定剛才列舉過的限制(希望沒有太多的混淆),語言設計者覺得唯一可行的托管引用類型的表示方法是重用現存指針語法 — 引用並不是足夠靈活的,因為他們不能被重新賦值,並且他們不能不引用任何對象:
// 所有在托管堆上分配對象的母親... Object * pobj = new Object; // 本機堆上分配的標准 string 類... string * pstr = new string;
當然,這些指針有顯著的不同。例如,在 pobj指向的對象實體在托管堆的壓縮過程中移動時,pobj會被透明地更新。不存在 pobj及其指向實體之間的關系這樣一個對象跟蹤的概念。整個 C++ 的指針概念並不是機器地址和間接對象引用的鉸接。一個引用類型的句柄封裝了對象的實際虛擬地址以實現運行時垃圾回收;除了在垃圾回收環境中破壞這個封裝的後果更加嚴重這一點之外,這很大程度上就像私有數據成員封裝了類的實現以實現可擴展性和局部化一樣。
因此,雖然 pobj看起來像一個指針,但是很多指針的常見特性被禁用了,例如指針算術和類型系統之外的強制類型轉換。如果我們使用完全限定語法來生明和分配一個引用托管類型,我們可以使這個區別更加顯著:
// 好的,現在這些看起來不同了…… Object __gc * pobj = __gc new Object; string * pstr = new string;
乍一看,指針解決方案很有道理。畢竟,看起來像一個 new 表達式的自然目標,而且兩者都支持淺拷貝。一個問題是指針不是一個類型抽象,而是一個機器表示(以及一個說明如何解釋第一個字節地址之後內存范圍和內部組織的標簽類型),而且這不符合軟件運行庫對內存的抽象,以及因此推斷出的自動和安全性。這是一個表述不同范型的對象模型之間的歷史問題。
第二個問題是(比喻警告:即將嘗試一個牽強的比喻 — 所有腸胃不好的人建議暫停閱讀或者跳到下一段)封閉語言設計的不可避免的弊端就是被限制重新使用既過分簡單又顯著不同的構造,導致在沙漠海市蜃樓中程序員的精神的揮散(比喻警告結束。)
重用指針語法造成了程序員的認知混亂:您不得不區分太多的本機和托管指針,而這會干擾代碼流,最好用一個高級的抽象來管理它。換句話說,作為系統程序員,我們有時需要降尊趨貴來壓搾出一點性能,但是我們不會在這個級別久居。
原版語言設計的成功在於對現存 C++程序不加修改即可重編譯,並且提供了只需少量工作就可以在新的托管環境中發布現存的界面的包裝模式。之後也可以在托管環境中添加額外的功能,並且,依時間和經驗而異,您可以直接將現存應用程序的一部分移植到托管環境。這對一個有現存代碼基和專門技術基礎的 C++程序員來說是一個偉大的成就。我們不需要為此慚愧。
但是,在原版語言設計的實際語法和視野中有顯著的弱點。這不是設計者的不足,而是其基礎設計選擇的保守本質 — 繼續保持在現存語言中。這來自對托管支持的誤解,就是它不代表一個域抽象,而是一個革命性的編程范型,需要一個類似於 Stroustrup 引入以支持面向對象和普通編程的語言擴展。這就是修訂版語言設計所代表的,以及它必要且合理的原因,即使它給忠於原版語言設計者帶來一些難題+。這即是本指南和轉換工具背後的動機。
修訂版 C++/CLI 語言設計
一旦明確了支持 C++ 中的公共語言基礎結構代表一個獨立的編程范型,隨之而來的就是語言必須被擴展,從而為用戶提供一流編的程體驗,以及與 ISO-C++標准的優雅的設計集成,以注重較大 C++ 社區的感受,並且贏得其委托和輔助。隨之而來的還有,原版語言設計的昵稱、C++ 的托管擴展,也必須被替換。
CLI 的最突出特性是引用類型,並且它在現存 C++語言中的集成代表一個概念的證明。一般的標准是什麼?我們需要一種方法來表示托管引用類型,既將其分離,又仍感覺它和現存類型系統類似。這使人們意識到,這個普通形式類別很熟悉,但是也注意到它的唯一功能。類似性是 Stroustrup 在 C++ 的原始發明中引入的引用類型。因此這個普通形式變為:
Type TypeModToken Id [ = init ];
這裡 TypeModToken是語言在新的上下文環境裡重用的符號之一(也類似於引用的引入)。
這在最初爭議十分強烈,並且仍舊使一些用戶感到很困難。我記得一開始兩個最常見的回應是 (a) 我可以用一個 typedef 來處理(不住地眨眼),以及 (b) 這真的不怎麼壞(後者提醒了我,我的回復是使用左移和右移操作符來在 iostream 庫中進行輸入和輸出)。
必要的行為特性是它在操作符應用到對象的時候展示了對象的語義,這是原版語法無法支持的一點。我喜歡稱它為靈活的引用,思考它和現存 C++ 引用的差異(是的,這裡兩個引用的使用 — 一個是托管引用類型,另一個是“這不是一個指針(不住地眨眼)”這裡的本機 C++類型 — 是令人遺憾的,很像在我喜歡的一本四人幫(Gangof FourPatterns)的設計模式書中對模板這個詞的重用。):
它必須會不引用任何一個對象。當然,本機引用不能直接做到這一點,雖然人們總是告訴我如何將一個引用初始化為 0 的重新解釋的類型強制轉換。(常規的做法是使引用不指向任何對象,從而提供一個顯式的由約定 null對象代表的單體,它也經常作為一個函數參數的默認參數。)
它可能不需要一個初始值,但是可以在生命期的開始不引用任何一個對象。
它可以被重新賦值為指向另一個對象。
默認情況下,一個實例對另一個實例進行的賦值或者初始化是淺拷貝。
就像一些人使我想到的,我是從反方向考慮這個問題的。也就是說,我通過區分它和本機引用的性質來引用它,而不是用它作為一個托管引用類型的句柄這個性質來識別它。
我們想將這個類型稱為句柄而不是指針或者引用,因為這兩個術語有本機方面的累贅。句柄是更適合的名字,因為它是一個封裝的模式 — 一個叫做 John Carolan 的人首先向我介紹了該設計,以一個可愛的名稱:露齒嬉笑的貓 (CheshireCat),因為被操作對象的實體可以在您不知情的情況下消失。
在這種情況下,這個令人失望的舉動源自於在垃圾回收器的一次清掃中潛在的引用類型的重新定位。實際上發生的是,這個重新定位被運行庫透明地跟蹤,而且句柄被更新為正確地指向新位置。這就是它被稱為跟蹤句柄的原因。
因此,關於新的跟蹤引用語法,我最後想提及的一點是成員選擇操作符。對我來說,毫無疑問會使用對象語法 (.)。其他人覺得指針語法 (->) 也是同樣顯然的,並且我們從跟蹤引用用途的多個方面進行了討論:
// 喜好使用指針語法的人 T^ p = gcnew T; // 喜好使用對象語法的人 T^ c = a + b;
這樣,就像物理學裡面的光一樣,一個跟蹤引用的行為在一些程序上下文中像一個對象,在另一些程序上下文中像一個指針。最後,投入使用的成員選擇操作符是箭頭,就像在原版語言設計中一樣。
關於關鍵字的總結性補充
最後,一個有趣的問題是,為什麼Stroustrup在C++語言設計中添加了類?實際上沒有必要引入它,因為在 C++中 C語言的結構被擴展了,以支持類可以做到的任何事情。我沒有問過 Bjarne 這個問題,所以我在這一點上沒有特別的見識,但是這是一個有趣的問題,而且給定 C++/CLI中添加關鍵字的數量,這在某種程度上是相關的。
一個可能的回答 — 我稱其為步兵的來福槍(footsoldiershuffle)— 是個爭論:不,類的引入絕對必要。畢竟,兩個關鍵字之間不僅有默認成員訪問級別的差異,而且派生關系的訪問級別也不一樣,所以為什麼我們不能兩個都要?
但是慢一點,引入一個新關鍵字不僅和現存語言不兼容,而且導入了語言樹的一個不同分支(Simula-68),會有冒犯 C語言社區的風險。其動機真的是默認訪問級別規則的差異?我不能肯定。
一方面,語言在類設計者使用 class關鍵字將整個實現公開時既沒有阻止也沒有警告。語言本身並無公共和私有訪問的策略,所以很難看到未明確的默認訪問級別許可被重視 — 換句話說,比引入不兼容性的代價還重要。
類似的,將未標記的基類默認作為私有繼承,看起來在設計實踐上有些問題。這更加復雜,並且對於繼承的形式更難於理解,因為它沒有展示類型/子類的行為,並且因此破壞了可替代性規則。它代表了實現(而不是接口)的重用,並且我相信,把私有繼承作為默認是個錯誤。
當然,我不能公開宣布這一點,因為在語言市場中,從來不應該承認產品中的一點點問題,因為這會為迅速抓住任何競爭優勢搶占市場分額的敵人提供彈藥。嘲笑在知識分子的小圈子裡特別盛行。或者,更恰當地說,一個人直到新的改進產品准備鋪開的時候再承認缺陷。
引入 class不兼容性還可能有什麼其他原因?C語言的結構概念是一個抽象的數據類型。C++語言的類概念(當然,這不是源自於 C++)是數據抽象,以及隨之而來的封裝和接口約定的思想。抽象數據類型是與地址相關的鄰近的數據塊 — 指向它、數據轉換、分隔、快速移動。數據抽象是有生命期和行為的實體。這是為了教育學上的重要性,因為用詞會使語言大不一樣 — 至少在一個語言中。這是修訂版設計銘記在心的另一個教訓。
為什麼 C++沒有完全移除結構?保留一個並引入另一個並不優雅,而且這樣字面上最小化了他們之間的差異。但是有其它選擇嗎?Struct 關鍵字不得不被保留,因為 C++必須盡可能保留和 C的向下兼容;否則,不僅它會在現存程序員中更不受歡迎,而且可能不會被允許發布(但是這是另一個時間、另一個地點的另一個故事了)
為什麼結構的訪問級別默認是公有的?因為如果不這樣,現存的 C程序不會編譯通過。這在實踐上會是一場災難,雖然程序員很可能從來沒在語言設計高級守則(Advanced Principles of Language Design)中聽說過它。語言中可能有一個強制接受的過程,強制接受一個策略,從而使用結構保證了一個公有實現,反之,使用類保證了一個私有實現和公有接口,但是這個策略並不用於實踐用途,所以會有點過於珍貴。
實際上,在貝爾實驗室的 cfront1.0語言編譯器的發布測試中,有一個語言律師之間的小爭論:前置聲明和後續定義(或者任何這樣的組合)是否必須繼續使用這個或者其他關鍵字,或者可以被互相替換來使用。當然,如果結構有任何實際的重要性,這個爭論不會發生。