C++/CLI可以說是標准C++語言一種新的"方言",它是Microsoft為充分利用CLI(Common Language Infrastructure)平台而開發出來的。那麼,它在語言方面有何新穎獨到之處呢,下面,就讓我們一起開始奇妙的C++/CLI語言之旅(文中所有示例代碼,均以Visual Studio.NET 2005 Professional編譯通過,所有的講解內容,也均以Visual Studio.NET 2005環境為基礎)。
程序集與元數據
傳統的C++編譯模式包括把單獨的源文件編譯為目標文件(obj),再把目標文件與庫函數鏈接在一起,以生成可執行程序。而CLI模式卻大不相同,它涉及到程序集的創建與使用。
簡單來說,在不計輸入源文件數目的基礎上,程序集即為單次編譯的輸出。如果輸出帶有一個進入點函數(例如main函數),它即為一個.exe文件;如果沒有,它則為一個.dll文件。任何引用外部程序集而生成的編譯,必須要訪問所依賴的程序集,此時也沒有類似傳統鏈接時用到的頭文件機制,而是通過編譯器在所依賴的程序集內部查找,來訪問所需的外部信息。
程序集包含了元數據,其描述了包含在那裡的類型與函數,還有CIL(Common Intermediate Language)指令--Microsoft稱其為"MSIL"。元數據與指令能通過獨立的VES(Virtual Execution System)來執行。
CLI類型
例1是一個模擬二維點的類。此處不得不提到命名空間,所有的CLI標准庫類型都屬於System命名空間,或嵌套在其內部的某個命名空間之下,例如System::Object和System::String,還有System::IO、 System::Text、System::Runtime::CompilerOptions等等。標記1可避免在程序中一直使用namespace限定詞。
例1:
/*1*/
using namespace System;
/*2*/
public ref class Point
{
int x;
int y;
public:
//定義用於讀寫X與Y實例屬性
/*3a*/ property int X
{
/*3b*/ int get() { return x; }
/*3c*/ void set(int val) { x = val; }
}
/*4a*/ property int Y
{
/*4b*/ int get() { return y; }
/*4c*/ void set(int val) { y = val; }
}
//定義實例構造函數
/*5a*/ Point()
{
/*5b*/ X = 0;
/*5c*/ Y = 0;
}
/*6a*/ Point(int xor, int yor)
{
/*6b*/ X = xor;
/*6c*/ Y = yor;
}
//定義實例方法
/*7a*/ void Move(int xor, int yor)
{
/*7b*/ X = xor;
/*7c*/ Y = yor;
}
/*8a*/ virtual bool Equals(Object^ obj) override
{
/*8b*/ if (obj == nullptr)
{
return false;
}
/*8c*/ if (this == obj) //我們在測試自己嗎?
{
return true;
}
/*8d*/ if (GetType() == obj->GetType())
{
/*8e*/ Point^ p = static_cast<Point^>(obj);
/*8f*/ return (X == p->X) && (Y == p->Y);
}
return false;
}
/*9*/ virtual int GetHashCode() override
{
return X ^ (Y << 1);
}
/*10a*/ virtual String^ ToString() override
{
/*10b*/ return String::Concat("(", X, ",", Y, ")");
}
};
在標記2中,我們定義了一個稱為Point的引用類(ref class),一個引用類是一個CLI引用類型,當兩者一起使用時,ref與class(中間有空格)表示了一個新的關鍵詞。
public前綴表明了類型在它的父類程序集之外可見--即可訪問(只有兩種類型的可見性,public和private,類型默認為private),另外,只有類型才能有可見性屬性,非成員函數、全局變量及文件范圍內的typedef都不能在它們的父類程序集之外訪問。
與C++程序員預想的一樣,除了默認的成員可訪問性,一個引用結構(ref struct)與引用類基本上一模一樣,在這,我們把兩者都稱為引用類。
每個引用類都有一個基類,如果沒有顯式指定,那麼默認的基類即為System::Object,一個引用類有且只能有一個基類。
我們先不管Point在內部是怎麼表示的,考慮到它有X與Y屬性,我們在此使用了笛卡爾坐標,實現起來非常簡單;如果它使用極坐標,那麼就復雜多了。
作為成員的標量屬性,也對實例提供了類似字段的訪問性,在標記3(a)中,用int類型定義了一個X屬性,property符號是一個上下文關鍵字,而不是一個全局保留的關鍵字,它的用法只限於在這個上下文中。
對於get與set存取程序,在一個屬性中即可有任意一個,也可兩者兼有。在標記3(b)中,get返回既定屬性的值;而在標記3(c)中,set使用編程者提供的值來設置即定的屬性值。這兩個存取程序分別以名字get與set定義為單獨的函數,必須接受或返回相應的聲明類型值,在本例中,為int(注意,這兩個名字不是關鍵字)。存取程序也能具有不同的可訪問性,但可能會妨礙到語言間的互操作性(interop),因為其他CLI語言可能不支持。
在標記5(b)與5(c)代表的默認構造函數中,是使用set的簡單例子--X與Y均被設置為零,注意,不能使用X=Y=0來代替,因為set為一個void返回類型,所以子表達式Y=0不能出現在另一個表達式中。
對一個引用類來說,相等性是通過函數Equals來實現的,而不是重載==操作符,如標記8(a)所示。因為Point重載了System::Object::Equals,所以Point::Equals必須被聲明為virtual,再次提醒的是,override符號也是一個上下文關鍵字,而不是一個保留關鍵字。而這個函數重載了Object中的一個函數,所以需要接受一個Object作為參數,而不是一個Point。
實際上,參數帶有類型Object^,其表示"Object的句柄",並指向托管堆(垃圾回收)中的一個對象。句柄在此是一個C++/CLI術語,CLI實際上把它稱為"引用",但C++已經有引用了,這是兩回事。
有經驗的C++類設計人員可能會留意到,在這個類的定義中,缺乏了兩個重要的東西:函數未const限定;且參數不是作為一個const句柄傳遞的。為什麼會這樣呢?因為引用類的成員函數不會用const來限定,CLI也沒有概念上的const函數;把參數聲明為一個const句柄將會使它成為另一種類型,這樣它就不再能被System::Object::Equals重載了(const類型的句柄是允許的,但它們只能被用在一個C++/CLI上下文之內,而不能與任何CLI標准庫函數一起使用的,因為目前CLI中還未有const這個概念,未來版本的C++/CLI有可能會全面支持const,但其他語言仍不會支持const)。
在標記8(b)中,我們把obj與nullptr作一比較。nullptr關鍵字表示常量空值,當使用在一個句柄上下文中時,它表示空句柄--沒有指向任何對象的句柄;當使用在一個指針上下文中時,它表示空指針--沒有包含任何地址的指針。
為防止自身比較,在標記8(c)中,把obj與this作一對比。在一個非引用類(指本地類)中,this是一個實例函數調用時指向對象的指針,可帶有const限定符;在一個引用類中,則是實例函數調用時指向對象的句柄--此處要再次提醒大家,不允許帶有const限定符。也可以通過類似以指針訪問成員時的指向操作符 ->,來訪問類中成員,只不過此處使用的是句柄。
Equals是為了確保其比較的兩個對象有著相同的類型,所以在標記8(d)中調用了System::Object::GetType,其返回一個代表當前實例運行時類型的System::Type句柄,如果兩個System::Type對象引用指向同一對象,則它們代表了同一類型。此處,我們比較的是兩個句柄,而不是兩個類型對象。
一旦你獲知兩個對象為同一類型,就可以安全地把Object句柄向上轉換為一個Point句柄,進而執行數據比較,而不用擔心發生錯誤的類型匹配這樣的異常,在此,使用了static_cast。
為使哈希表(散列表)數據結構工作正常,在對象中必須有一個名為GetHashCode的函數。基本上,如果一個類型定義了Equals,它也應該同時定義GetHashCode,其是重載System::Object的版本,如標記9。
與相等性比較類似,值的格式化是通過一個重載System::Object的函數實現的,如標記10(a),而不是重載<<操作符。這個函數稱為ToString,它的功能是創建並返回一個當前實例的字符串,它調用了System::String::Concat連接三個字符串及兩個int,實現了所需功能。
毫無疑問,不可能對任一參數及類型的搭配,Concat都能有一個適當的重載版本,那麼,Concat是怎樣處理這些參數的呢?本例中使用的重載版本如下: static String^ Concat(... array<Object^>^ list);
圓括號中的參數聲明(其必須有一托管的數組類型),表明可接受任意數量給定元素類型的參數,即,它是一個類型安全的varargs--參數數組,參數列表為一指向對象句柄托管數組的句柄。
那麼這兩個int--X與Y,是怎樣轉換為Object^的呢?其實,在基本數據類型對Object^的表達式中,都存在著一個隱式轉換,這個過程稱為"裝箱",也就是包含基本數據類型值的對象,在托管堆上的分配。逆過程稱為"解箱",這需要顯式轉換。
最後提一下命名約定。CLI指定了類、函數、屬性必須以PascalCase模式來編寫,也就是說,每個單詞的首字母必須大寫,而CLI標准庫也遵循這條原則。
一個簡單的示例程序
例2是一個使用了Point類的簡單程序,下面以此為例簡單講解各方面的含義:
例2:
using namespace System;
int main()
{
/*1*/ Point^ p1 = gcnew Point;
/*2*/ Console::WriteLine("p1 = {0}, p1's HashCode = {1}", p1, p1->GetHashCode());
/*3*/ p1->Move(5, 7);
/*4*/ Console::WriteLine("p1 = {0}, p1's HashCode = {1}", p1, p1->GetHashCode());
/*5*/ Console::WriteLine("p1 Equals Point(9, 1) = {0}",
p1->Equals(gcnew Point(9, 1)));
}
分配托管內存:在標記1中,定義了一個指向Point類型的句柄,並用gcnew操作符返回的位置初始化它,gcnew操作符是一個關鍵字,它為一個新的Point對象在托管堆中,分配了相應的空間,與大家想的一樣,此處還會調用默認的構造函數。在目前的C++/CLI版本中,引用類的對象只能駐留於堆棧或托管堆中,與其他CLI語言不同,C++/CLI可以讓你編寫能被傳遞,並通過復制構造函數或 = 操作符賦值的引用類,還可以重載Clone函數,實現虛擬(深度)賦值。 格式化輸出:CLI提供了一系列的I/O類型--使用功能性注解的函數。最簡單的例子就是System::Console Write和WriteLine(見標記2)的重載版本,其向標准輸出設備輸出文本,WriteLine會跟上一個新行,而Write則不會。
這類函數有許多重載的版本,然而,最常見的形式是接受一個包含文本的格式化字符串,並帶有可選的格式指定符--由花括號進行分隔,其後緊接需要格式化其值的參數。格式指定符 {0} 對應於緊接著格式化字符串傳遞進來的第一個參數;而 {1} 則對應於第二個參數,以此類推。與Concat類似,也有一些接受幾個固定參數的重載版本,或可接受幾個固定參數並同時接受一個可變數目的參數,在本例中,使用了如下的版本:
static void WriteLine(String^ format, Object^ arg0, Object^ arg1);
字符串在此被隱式轉換為String^。因為p1是一個Point^,且Point是從Object繼承而來,所以p1是is關系。GetHashCode返回一個int,因此在被傳遞之前,會被裝箱為Object^。一旦執行到WriteLine,它會調用第二個和第三個參數的ToString函數,並輸出結果字符串。以下是程序的輸出:
p1 = (0,0), p1's HashCode = 0
p1 = (5,7), p1's HashCode = 11
p1 Equals Point(9, 1) = False
垃圾回收:由句柄p1引用的內存駐留於托管堆中,而托管堆則處於垃圾回收器"監視"之下,當一個句柄超出作用域時,其引用的內存就少了一個與此相聯的句柄,繼而當句柄計數為零時,內存就被自動回收了。如果一個句柄在某段時間內並沒有超出作用域,但你已不需要其引用的內存了,就可以設置句柄為nullptr來減少其的引用計數,在此,沒有辦法來顯式釋放一塊托管內存。另外,也可以對句柄調用delete,它會馬上運行析構函數(Dispose函數),但這塊內存仍不會被回收,直到垃圾回收器決定回收它。
編譯程序
如果要把Point與main程序放在兩個不同的程序集中,必須創建兩個項目--為Point類創建Point項目,為應用程序創建Main項目。
要創建Point項目,可在Visual Studio.NET 2005中選擇"文件|新建|項目|空項目"(不要選擇"類庫")。在"解決方案資源管理器"中找到"源文件",鼠標右鍵單擊選擇"添加|新建項",在對話框左邊的類別欄中選擇"代碼",接著在右邊選擇"C++文件",輸入Point名稱,並在打開的文件中粘貼例1中代碼,保存文件。
在"解決方案資源管理器"中,右鍵單擊項目名Point,首先,選擇"屬性|配置屬性|常規",把"配置類型"改為"動態庫(.dll)",選擇"公共語言運行庫支持"為"公共語言運行庫支持(/clr)";其次,在"C/C++|代碼生成"中,把"運行時庫"改為多線程 DLL (/MD);最後,在"鏈接器|常規"欄中,把"輸出文件"後綴名從.exe改為.dll。
雖然在選擇"類庫"時,這些都是由Visual Studio.NET 2005自動完成的,但它會生成一大堆你不需要的支持文件。此時,選擇"生成",就會在Point\debug目錄中找到Point.dll了。
創建Main項目與創建Point項目非常類似,除了這個項目叫做"Main",且源文件為Main.cpp外。(在此有一個小技巧,你可以運行Visual Stuio.NET的兩個實例,這樣,你就可以同時編輯兩個項目了。)默認情況下,選擇"空項目"會生成一個.exe文件,這正是我們想要的。因為Main.cpp引用了Point類型,所以需要告訴編譯器在哪可以找這個類型的父類程序集:首先,在"解決方案資源管理器"中,右鍵單擊項目名Main,依次選擇選擇"屬性|配置屬性|常規",選擇"公共語言運行庫支持"為"公共語言運行庫支持(/clr)",點擊對話框的"應用"按鈕;其次,在"通用屬性|引用|添加新引用"對話框中,選擇"浏覽"選項頁,定位至Point目錄的Point.dll文件,點擊"確定"退出;最後,在"C/C++|代碼生成"中,把"運行時庫"改為多線程 DLL (/MD)。此時,選擇"生成",就會在Main\debug目錄中生成Main.exe了,執行此文件,就可以看到相應的輸出。