向iOS開發者介紹C++(二)

發表於2014-05-05

歡迎回到向iOS開發者介紹C++系列的第二部分(向iOS開發者介紹C++(一)) !在第一部分,我們瞭解了類和記憶體管理。在第二部分部分我們將深入瞭解類以及其他有意思的特徵。你將會了解到什麼是“模板”以及標準模板庫。

多型性

簡單地說,多型性是一個過載子類中函式的概念。在Objective-C中,你可能已經做過很多次,例如,子類化UIViewController和過載viewDidLoad。
parfwefrot_lion-480x305

C++的多型性比Objective-C的多型性更進一層。因此,當我解釋這個強大的功能時要緊跟我的思路。

首先,以下為在類中過載成員函式的例子:

但是,如果你這樣做會發生什麼呢:

哇,這可不是你所期望的輸出!我猜你認為輸出值應該是10,對麼?這就是C++和Objective-C最大的不同。

在Objective-C中,將子類指標轉換成基類指標是無關緊要的。如果你向物件發訊息(如呼叫函式),是執行時找到物件的類並呼叫最先派生的方法。因此,Objective-C中這種情況下,子類Bar中的方法被呼叫。這裡凸顯出了我在第一部分提到的編譯時和執行時的不同。

在上面的例子中,編譯器呼叫value()時,編譯器的職責是計算出哪個函式需要被呼叫。由於f的型別是指向Foo類的指標,
它執行跳至Foo:value()的程式碼。編譯器不知道f實際上是Bar類的指標。

在這個簡單的例子中,你可以認為編譯器能推斷出f是Bar類的指標。但是想一想如果f確實是一個函式的輸入值的話將會發生什麼呢?這種情況下編譯器將不會知道它是一個繼承了Foo類的指標。

靜態繫結和動態繫結
上面的例子很好的證明了C++和Objective-C最主要的區別–靜態繫結和動態繫結。上面的例子是靜態繫結的例子。編譯器負責解決呼叫哪個函式,並且在編譯完成後這個過程將被儲存為二進位制。在執行時不能改變這個過程。

這與Objective-C中方法呼叫形成了對比,這就是動態繫結的一個例子。執行時本身負責決定呼叫哪個函式。

動態繫結會使Objective-C很強大。你可能已經意識到了在執行時可以為類方法或者交換方法實現。這在靜態繫結語言中是不能實現的,靜態繫結是在編譯時呼叫方法的。

但是,在C++中還不止這樣!C++通常是靜態繫結,但是也可以使用動態繫結機制,即“虛擬函式”。

虛擬函式和虛表

虛擬函式提供動態繫結機制。通過使用table lookup(每個類定義一個表),虛擬函式推遲到runtime時選擇呼叫哪個函式。然而,跟靜態繫結相比,這確實引起了執行時輕微的間接成本。除了呼叫函式外,table lookup是必須的。靜態繫結時僅需要執行呼叫的函式。

使用虛擬函式很簡單,只需要將關鍵詞“virtual”新增到談及的函式。例如上面的例子用虛擬函式方式寫的話,如下:

現在想一想執行同樣的程式碼會發生什麼:

這正是前面所預期的輸出值,對吧?因此在C++中可以用動態繫結,但是你需要根據遇到的情況決定是用靜態繫結還是動態繫結。

在C++中這種型別的靈活性是司空見慣的,這使C++成為一種多範型的語言。Objective-C很大程度上迫使你進入嚴格的模式,尤其是用Cocoa框架時。而C++中,很多都是由開發者決定的。

現在開始瞭解虛擬函式是如何發揮作用的吧!

picrgregregerg3

虛擬函式的內部功能

在你明白虛擬函式是怎樣工作之前,你需要知道非虛擬函式是如何工作的。

想一想下面的程式碼:

如果foo()是個非虛擬函式,那麼編譯器將會把它轉換成程式碼,直接跳到MyClass類的foo()函式。

但是記住,這就是非虛擬函式的問題所在。回想之前的例子,如果這個類是多型的,那麼編譯器由於不知道變數的全部型別,也就不知道應該跳到哪個函式。這就需要一種方法在執行時查詢到正確的函式。

要完成這種查詢,虛擬函式要使用“virtual table”(也稱“v-table”,虛表)。虛表是一個查詢表來將函式對映到其實現上,並且每個類都訪問一個表。當一個虛擬函式被呼叫時,編譯器發出程式碼來檢索物件的虛表從而查詢到正確的函式。

回顧上面的例子來看看這是如何工作的:

當你建立一個類指標b和一個Bar類的例項,那麼它的虛表將是Bar類的虛表。當b指標轉換為Foo類的一個指標時,它並沒有改變物件的內容,虛表仍然是Bar類的虛表而不是Foo類的。因此當查詢v-table以呼叫value()時,結果是將呼叫Bar::value()。

建構函式和解構函式

每個物件在其生命週期中都要經歷兩個重要階段:建構函式和解構函式。C++允許你同時控制這兩個階段。在Objective-C中與這兩階段相同的是初始化方法(例如,init或者以init開頭的其他方法)和dealloc(釋放記憶體)。

C++中定義建構函式時與類同名。正如在Objective-C中有多個初始化方法,你也可以定義多個建構函式。

例如,下面這個類中有兩個不同的建構函式:

這就是兩個建構函式,一個是預設建構函式Foo(),另一個建構函式含有一個引數來設定成員變數。

如上例中,如果在建構函式中給成員變數初始化,有用少量程式碼實現的方法。不需要自己去設定成員變數的值,你可以用下面的語法:

通常來講,如果僅僅是給成員變數賦值的話可以用上面這種方式。但是如果你需要用到邏輯或者呼叫其他函式的話,那麼你就要實現函式主體。你也可以結合以上兩種方式。

當用繼承時,你需要呼叫父類的建構函式。在Objective-C中,你通常採用先呼叫父類指定的初始化程式的方法。

在C++中,你可以這樣做:

函式簽名後,列表中的第一個元素表示對父類建構函式的呼叫。你可以呼叫任何一個你想要的超類建構函式。

C++沒有一個指定的初始化程式。目前,沒有辦法呼叫同一個類的建構函式。在Objective-C中,有一個指定的初始化程式可以被其他初始化程式呼叫,並且只有這個指定的初始化程式去呼叫超類的指定初始化程式,例如:

在C++中,雖然你可以呼叫父類的建構函式,但是目前呼叫自己的建構函式仍是不合法的。因此,下面的解決方案很常見:

然而,這十分麻煩。為什麼你不能用Bar(int y)呼叫Bar(),然後在Bar()中這樣寫“Bar::commonInit()”呢?畢竟,Objective-C中恰恰是這樣寫的。

2011年釋出了最新版的C++標準:C++11。在這個更新的標準中確實可以這樣做。目前仍有許多C++程式碼還沒有按C++11標準來更新,所以知道這兩種方法很重要。任何2011年前標準的C++程式碼都按以下這種方式:

這種方法唯一一個不足的地方是,你不能在同一個類中呼叫建構函式的同時設定一個成員變數。上面的例子中,成員變數y在建構函式主體中設定。

注意:在2011年C++11標準成為一個完整的標準,起初稱為C++ 0x。意思是在2000年至2009年之間這項標準成熟的話,x可以替換為這一年的最後一個數字。然而比預期的時間要晚,因此以11為結尾!所有的現代編譯器,包括clang,現在都支援C++11標準。

以上為建構函式,那麼解構函式呢?當一個堆物件被刪除或者一個棧函式溢位時會呼叫解構函式。在解構函式中你需要做的事情就是清理物件。

解構函式中不能有任何引數。同樣,在Objective-C中dealloc也不需要任何引數。因此每個類中只有一個解構函式。

在類中定義解構函式時在函式名字前要加字首–波浪號(~)。如下:

看一下當你的類被繼承時,會發生什麼:

如果你不這樣寫的話,當通過Foo指標刪除Bar類的一個例項的時候將會發生異常,如下:

這樣是錯誤的。刪除的應該是Bar類的例項,但是為什麼是去呼叫Foo類的解構函式呢?

回想一下,之前發生的同樣的問題,你是使用虛擬函式解決的。這個正是同樣的問題。編譯器看到是一個Foo需要被刪除,因為Foo的解構函式並不是虛擬函式,所以編譯器認為要呼叫的是Foo的解構函式。

解決這個問題的辦法就是將解構函式定義為虛擬函式,如下:

這就接近了期望的結果,但最終結果不同於之前使用虛擬函式得到的結果。在這裡,兩個函式都被呼叫了。首先Bar的解構函式被呼叫,然後Foo的解構函式被呼叫。為什麼呢?

這是因為解構函式比較特殊。由於Foo的解構函式是父類的解構函式,所以Bar的解構函式自動呼叫Foo的解構函式。

這正是所需要的,正如Objective-c中的ARC方法中,你呼叫的是父類的dealloc。

pregregergrgwrgic4

你可能在想這個:你認為編譯器會為你做這個事情,但是並不是在所有類中都是最佳方法。

例如,如果你沒有從某個類繼承呢?如果解構函式是虛擬函式,那麼每次都要通過虛表來刪除一個例項,或許這種間接方法並不是你需要的。C++中你可以自己做決定,另一個方法很強大,但是開發者必須清楚發生了什麼。

 注意:除非你確定你不需要繼承一個類,否則一定要定義解構函式為虛擬函式。

運算子過載

在Objective-C中沒有運算子過載的概念,但是這並不複雜。

操作符是實體,如我們熟悉的+,-,*,/。例如,你可以用“+”運算子與標準常量(運算元)做如下運算:

運算子“+”在這裡的作用顯而易見,將x加上5然後返回一個值。或許這個還不夠明顯,如果以函式的形式就很清楚了:

在函式add()中,兩個引數相加並返回一個值。

在C++中,在類中使用操作符時是可以定義功能函式的。這一功能很強大。當然,這也不是總能行得通的。例如,將兩個Person類相加就無任何實際意義。

然而,這一特性很有用處。考慮下面的類:

這樣做可能更好一些:

我們想要將DoubleInt(4, 6)的值賦值給變數c,即將兩個DoubleInt的例項x和y相加,然後賦值給c。事實證明這很簡單。你需要做的就是給DoubleInt類新增一個方法,即:

函式operator+很特別。編譯器將使用這個函式,當它看到“+”運算子任一側的DoubleInt時。“+”運算子左邊的物件將呼叫這個函式,將“+”運算子右邊的物件作為引數進行傳遞。因此,經常命名引數為“rhs”,意思是“右邊”。

由於使用實參的副本不僅沒必要還可能會改變值,函式的引數將作為引用,可能會建立一個新的物件。此外,這個物件將是常量,這是因為在相加的過程中,對於“+”運算子的右邊來講這是非法的。

C++能做的不僅是這些。你可能不僅僅想把DoubleInt新增至DoubleInt。你可能想要給DoubleInt新增一個整數。這些都是可能實現的!

為實現此操作,你需要實現如下成員函式:

然後你可以這樣做:

很有用吧!

除了加法運算,其他運算也可以這樣做。你可以過載++, –, +=, -=, *, ->等等。這裡就不一一列舉了。如果想要對運算子過載做更多瞭解,你可以訪問learncpp.com,這裡有整個章節在介紹運算子過載。

模板

在C++中,模板很有意思。

你是否發現你經常會重複寫相同的函式或者類,但只是函式或者類的型別不同呢?例如,交換兩個值的函式:

 注:這裡是對引數做引用傳遞,以確保是對函式的實參作交換。如果兩個引數是用值傳遞,那麼所交換的值只是實參的副本。這個例子很好的說明了C++中引用好處。

上面的例子只適用於整數型別。如果是浮點數型別,那麼你需要寫另一個函式:

如果你重複寫函式的主體,這樣很不明智。C++介紹一種語法可以有效的忽略變數的型別。你可以通過模板這個特性來實現這一功能。取代上面的兩種方法,在C++中,你可以這樣寫:

因此,你的函式可以交換任何型別的引數。你可以用以下任一種方式來呼叫函式:

但是,你在用模板的時候仍需仔細。只有在標頭檔案中實現模板函式,這種方法才能起作用。這是由模板的編譯方式決定的。使用模板函式時,如果函式型別不存在,編譯器會根據型別例項化一個函式模板。

考慮到編譯器需要知道模板函式的實現,你需要在標頭檔案中定義一個實現,並且在使用的時候必須要包含這個標頭檔案。

同理,如果要修改模板函式中的實現,所有用到這個函式的檔案都需要重編譯。相比之下,如果在實現檔案中修改函式或者實現類成員函式,那麼只有這個實現檔案需要重編譯。

因此,過度地使用模板會使應用程式很繁瑣。但是正如C++中很多方法,模板的作用很大。

模板類

不僅僅有模板函式,還可以在整個類中使用模板。

假設你的類中有三個值,這三個值用來儲存一些資料。首先,你想用整數型別,因此你要這樣寫:

但是,你繼續寫程式時發現你需要三個浮點型資料。這是你又要寫一個類,如下:

這裡,模板就會很有用。與模板函式相同,可以在類中使用模板。語法是一樣的。上面的兩個類可以寫成這樣:

但是,用模板類需要做一些細微的改動。使用模板函式不會改變程式碼,這是因為引數型別允許模板推斷需要做什麼。然而,使用模板類時,你要告訴編譯器你需要模板類使用什麼型別。

幸運的是,這個很簡單。用上面的模板類也很簡單:

很強大,對吧?

prgergrwegergic5

此外,模板函式和模板類並不侷限於單個未知型別。三重態的類可以被擴充套件以支援任何三種型別,而不是每個值必須是同樣的型別。

要做到這一點,只需要擴充套件提供更多型別的模板定義,如下:

以上模板中有三個不同型別,每個型別都在程式碼中的適當位置被使用。

使用這樣的模板也很簡單,如下所示:

以上為模板的間接。接下來看看大量使用其特性的一個庫–標準模板庫

標準模板庫(STL)

每個規範的程式語言都有一個標準庫,這個標準庫包含通用的資料結構、演算法以及函式。在Objective-C中你有Foundation。其中,包含NSArray、NSDictionary等熟悉或者不熟悉的成員函式。在C++中,標準模板庫(簡稱STL)包含這些標準程式碼。

之所以成為標準模板庫,是因為在這個庫中使用了大量的模板。

STL中有很多內容,要介紹所有需要很長時間,所以在這裡我只介紹一些重要的。

容器

陣列、字典和集合都是物件的容器。在Objective-C中,Foundation框架包含了大部分常用容器的實現。在C++中,STL包含了這些實現。實際上,STL所包含的的容器要比Foundation多一些。

在STL中有兩點與NSArray不同。分別是vector(列表)和list(列表)。兩個都可以儲存物件的序列,但是每個容器都有自己的優點和缺點。在C++中,從所給的容器中選擇你需要的很重要。

首先,看一看vector的用法:

 注意std::的用法,這是因為大部分STL位於名稱空間內。STL將其所有的類放在自己的名為”std”的名稱空間中以避免潛在的命名衝突。

上面的程式碼中,首先你建立一個vector來存放整型資料(int),然後五個整數被依次壓入vector的棧頂。操作完成後,vector中將是從1到5的有序序列。

這裡需要注意的是,正如Objective-C中,所有的容器都是可變的,沒有可變或者不可變的變數。

訪問一個vector的元素是這樣完成的:

這兩種方法都能有效地訪問vector中的元素。第一種使用方括號的方法,這便是索引C語言陣列的方法。Objective-C中的下標取值方法也是用這種方法索引NSArray。

上面例子中的第二行使用at()成員函式,和方括號功能相同,只是at()函式需要檢查是否在vector範圍內索引,超出範圍的話會丟擲異常。

vector被實現為一個單一的或連續的記憶體塊。vector的空間大小等於所儲存的物件的大小乘以vector中物件數(儲存4位元組或者8位元組的整數取決於你使用的體系結構是32位還是64位的)。

向vector中新增元素是很昂貴的,因為一個新的記憶體塊需要被分配給這個新的vector。然而,訪問一個確定的索引很快,因為這僅僅是訪問記憶體中的一個字

std::list與std::vector很相似。但是,list的實現方式稍稍有些不同。不是作為一個連續的記憶體塊被實現而是作為一個雙向連結串列被實現。這意味著,list中每個的元素都包含一個資料,一個指向前一個元素的指標和一個指向後一個元素的指標。

由於是雙向連結串列,插入和刪除操作很簡單。然而,如果要訪問list中的第n個元素,就需要從0到n去遍歷。

綜上,list和vector的用法很相似:

正如上面的vector例子,這將建立一個從1到5的有序序列。但是,在list中你不能使用方括號或者at()成員函式去訪問一個指定元素。你需要用一個迭代器(iterators)去遍歷list。

你可以這樣遍歷list中的每個元素:

大多數容器類有迭代器(iterator)的概念。迭代器是一個物件,可以遍歷並指向一個特定的元素。你可以通過增量或減量來控制迭代前移或者後移。

用迭代器在當前位置獲得元素的值與使用解引用運算子(*)一樣簡單。

 注:在上面的程式碼中,有兩個運算子過載的例項。i++是迭代器過載增量運算子(++),*i是過載解引用操作符(*)。STL中大量使用了這樣的運算子過載。

除了vector(向量)和list(列表),C++中還有很多容器。都有不同的特徵。例如Objective-C中的集合,C++中為std::set;Objective-C中的字典,C++中為std::map。C++中,另一個常用的容器是std::pair,其中只儲存兩個值。

Shared Pointer

回想一下記憶體管理,當在C++中使用堆物件是,你需要自己處理記憶體。沒有引用計數。在C++中確實是這樣。但是在C++ 11標準中,STL中新增了一個新類,這個類中新增了引用計數,稱之為shared_ptr,意思是“shared pointer”。

Shared Pointer是一個物件,這個物件定義一個指標以便在underlying pointer中實現引用計數。這與在Objective-C中在ARC下使用指標是相同的。例如,以下例子展示瞭如何用智慧指標來定義一個指標去指向一個整數:

執行這三行程式碼後,每個shared pointer的引用計數為3。當每個shared pointer被刪除或者被釋放後,引用指數減少。直到最後一個包含underlying pointer的shared pointer被刪除後,底層指標被刪除。

由於shared pointer本身就是棧物件,溢位時就會被刪除。因此,shared pointer與Objective-C中的自動引用計數(ARC)下的物件指標的約束規則相同。

下面的例子為shared pointer建立和刪除的全過程:

把p1分配給p2是將p1的副本分配給p2。記住當一個函式引數是按值傳遞的話,是將引數的副本傳給了函式。這一點是很有用處的,因為如果你將一個shared pointer傳給一個函式,傳遞給這個函式的是一個新的shared pointer。當然,在函式結束時就會發生越界,從而被刪除。所以在函式週期中,underlying pointer的使用數量將會增加。這與在Objective-C中的自動引用計數(ARC)下的引用計數功能相同。

當然,你需要能夠獲得或者使用underlying pointer,有兩種方式可以實現這一操作。過載解引用操作符(*)和箭頭操作符(->)以使shared pointer的工作方式本質上與一個正常的指標相同。如下:

Shared Pointer很好地給C++引入了引用計數的技術。當然,shared pointer也新增了一些少量的開銷,但是這個開銷帶來了很明顯的好處,所以也是值得的。

Objective-C++

C++很好,但是跟Objective-C有什麼關係呢?

通過用Objective-C++可以將Objective-C和C++結合起來。它並不是一個全新的語言,而是Objective-C和C++兩者的結合。

通過兩者的結合,你可以使用兩者的語言特徵。可以將C++的物件作為Objective-C的例項,反之亦然。如果在應用程式中使用C++庫的話這將會很有用處。

要使編譯器理解一個Objective-C++檔案是很容易的。你需要做的只是將檔名從.m改為.mm。當你這樣做的時候,編譯器會考慮到這個檔案的不同,並將允許你使用Objective-C++。

以下為如何在兩者間使用物件的例子:

簡單吧!注意這個屬性被定義為assign,然而你不能用strong或者weak,因為這些對非OBjective-C物件型別沒有意義。編譯器不能“保留”或者“釋放”一個C++物件型別,因為它並不是一個Objective-C物件。

assign的記憶體管理仍然是正確的,因為你使用了shared pointer。你可以使用raw pointer,但是你需要自己寫一個setter來刪除原來的例項並根據情況設定一個新的值。

 注:Objective-C++是有侷限性的。C++的類不能繼承Objective-C的類,反之亦然。異常處理也是需要注意的地方。現代編譯器和執行時確實允許C++異常和Objective-C異常共存,但是仍需要注意。使用異常處理之前一定要閱讀相關文件。

Objective-C++很有用處,因為很多好的庫都是用C++寫的。能夠在iOS和Mac的應用程式上使用它是很有價值的。

需要注意的是,Objective-C++確實有它需要注意的地方。第一個需要注意的地方是記憶體管理。記住Objective-C的物件都是建立在堆上的,而C++的物件可以建立在棧上也可以是在堆上。如果Objective-C類的物件是建立在棧上的話會很奇怪。必須是在堆上,因為整個Objective-C物件都是建立在堆上的。

編譯器通過自動在程式碼中新增alloc和dealloc來構造和析構C++棧物件以確保這種情況。在此過程中,編譯器需要建立兩個函式“.cxx_construct”和“.cxx_destruct”,這兩個函式分別被alloc和delloc呼叫。在這寫方法中,執行所有相關的C++處理是必要的。

 注:ARC實際上依託於“.cxx_destruct”,現在它為所有的Objective-C類建立了一個函式來寫所有的自動消除程式碼。

這個處理所有基於棧的C++物件,但是你要記住任何基於堆的物件都需要在適當的情況下建立和銷燬。你可以在指定的初始化程式中建立物件然後再dealloc中刪除。

另一個在Objective-C++中需要注意的地方是減少對C++的依賴。這一點要儘量避免。要想明白這是為什麼,看看下面這個使用Objective-C++的類。

MyClass類的實現檔案必須是.mm檔案,因為它是使用C++編寫的。這沒有錯,但是想一想如果你想要使用MyCLass類的話會發生什麼呢。你需要import MyClass.h,但是這樣做你引入了一個使用C++編寫的檔案。所以即使其他的檔案不需要用C++編寫,也需要使用Objective-C++來進行編譯。

因此,最好是在公共標頭檔案中減少使用C++。你可以使用在實現檔案中宣告的私有屬性或者實體變數實現這一目的。

下一步

C++是一個偉大的語言。它與Objective-C有相似的根源,但是它選擇一種很不同的方式去編寫程式。總之,學習C++可以很好的理解物件導向程式。而且C++能幫助你在objective – c程式碼做出更好的設計決策。我鼓勵你去學習更多的C++知識並自己寫程式。你可以在learncpp.com中找到很多好的資源。如果你有任何評論或者疑問或者C++問題,請留言。

相關文章