第一章 對象的簡介
彙編語言對底層機器進行了很小程度的抽象描述。在它之後出現的許多“指令式"(imperative)程式設計語言(例如Fortran,BASIC和C)則 是對彙編語言的抽象描述。與彙編語言相比,這些語言有巨大的進步;然而它們所提供的抽象依然要求程式員從計算機,而不是要解決的問題的角度來進行思考。
面向對象語言(OOP)允許程式員從問題而不是計算機的角度來思考。
通路控制(access control)
實施通路控制的第一個理由:確定客戶程式員無法看到它們不應該看到的東西。
實施通路控制的第二個理由:允許庫的設計者可以放心修改類的内部工作機制,而不用擔心會影響客戶程式員。
代碼重用是OOP所提供的巨大優勢之一。
在組合(composition)與繼承(inheritance)之間,應該首先考慮組合,因為它相比起來更簡單,也更靈活。
pure substitution & substitution principle
即派生類不在基類的基礎上增加任何新的接口,兩者的接口保持完全一緻。
後期綁定(late binding)
編譯器負責确認被調用的函數确實存在,而且完成必要的參數和傳回值類型檢查,然而編譯器并不在意要執行的确切代碼。
upcasting:将派生類對象視為基類對象
對于在堆上建立的實體,編譯器并不了解其生存期;而在棧上建立的實體,編譯器很清楚它将在很是被建立和銷毀。
對于大多數錯誤處理機制,存在的一個主要問題是,整個機制是基于程式員的嚴謹而不是由語言強制要求的。
需要注意的是,異常處理機制并不是OOP特性。在OOP出現之前,異常處理就已經存在了。
極限程式設計(Extreme Programming)
極限程式設計中最重要和最突出的兩個貢獻,分别是“write test first"和"pari programming“。
XP徹底的改變了測試的概念,它把測試視為與代碼同等甚至更為重要的部分。
第二章 建立和使用對象
使用者定義類型,或者說類,是C++與傳統的面向過程語言的差別所在。
靜态類型檢查(static type checking)
在編譯期間執行的類型檢查被稱為靜态類型檢查。
某些動态語言執行某些運作時類型檢查(動态類型檢查)。動态類型檢查與靜态類型檢查相結合,比單獨使用靜态類型檢查具備更強大的功能,然而也增加了程式執行時的開銷。
在構造大型項目時,separate compilation具有特别重要的意義。
第三章 C++中的C語言元素
C的函數定義中要求每個參數都有名字,而在C++中函數定義的參數清單中可以存在未命名的參數。
C和C++中關于函數參數的另一個差別在于參數清單為空時的意義,在C++中,這和顯式使用void具有相同的意思即該函數是無參函數;而在C中,其表示的意思是:該函數的參數未确定。
C++中必須明确指定函數的傳回類型,而在C中,若省略了傳回類型,編譯器會預設它的傳回類型為int。
範圍(scope)被定義為最近的兩個尖括号之間的部分。
局部變量隻存在于範圍之内,位于某個函數的局部。局部變量經常又被稱為自動變量,因為它們在程式進入某個範圍時自動被建立,而在離開某個範圍時自動消失。
reintepret_cast操作暗含着如下意味:使用它所得到的結果是如此的與初值不相符,以至于決不能再按最初類型來使用,除非再顯式的轉換回去。
利用宏的調試技巧
#define PR(x) cout << #x " = " << x << "/n";
table-driven-code:函數指針數組的一種應用
第四章 資料抽象
The only way to get massive increases in productivity is to leverage off other people’s code. That is, to use libraries
每個獨立的C語言實作檔案(字尾名.c)是一個轉換單元。也就是說,編譯器分别處理每個轉換單元,在處理時對其它單元是無任何了解的。
使用C語言的函數庫時的最大障礙之一,就是名字沖突的問題。
盡止步于将函數和資料打包這種程度的程式語言,被稱為基于對象(object-based),而不是面向對象(object-oriented)。
C++中允許建立不含任何資料成員的結構,而這在C語言中是非法的(注:在C99中是合法的)。
對象的基本特性之一,就是不同對象具有不同的位址,是以不含資料成員的結構的大小總是非零的。
與頭檔案相關的第一個問題是應該在頭檔案中放置什麼内容。基本原則是隻包含聲明,也就是說,隻向編譯器提供資訊而不包含任何會導緻空間配置設定的語句。
C++編譯器将結構和類的重複定義視為錯誤。
實際上,你在頭檔案中應該永遠不會看到using語句。一句話,絕不要在頭檔案中使用using語句。
第五章 隐藏實作
友元
可以将一個全局函數定義為類的友元,也可以将另一個類的成員函數定義為該類的友元,甚至可以将另一個類定義為該類的友元。
對于友元函數,在聲明其與目前類的友元關系的同時,也完成了對該函數原型 的聲明。
Nested friends
Making a structure nested doesn’t automatically give it access to private members. To accomplish this, you must follow a particular form: first, declare (without defining) the nested structure, then declare it as a friend, and finally define the structure. The structure definition must be separate from the friend declaration, otherwise it would be seen by the compiler as a non-member.
第六章 初始化與清除
編譯器在對象建立的時候自動調用構造函數,以保證客戶在能夠操作對象之前對象已經完成了初始化。
C++中,定義和初始化是一個統一的概念。
空間配置設定
盡管編譯器可能會選擇在進入一個範圍(scope)時,為這個範圍内的所有對象配置設定空間,但是對象的構造函數隻有在程式執行到這個對象被定義的那個序列點時才會被自動調用。
Aggregate Initialization
when there are fewer initializers than array size: atomically initialized to 0
第七章 函數重載 &預設參數
預設參數隻在函數聲明中出現(通常包括在頭檔案中)。編譯器必須在調用這樣的函數前已擷取預設參數的值。
當未命名參數和預設參數這兩者結合起來時,就出現了一個有意思的技術:占位符參數。通常庫程式員會使用這種技巧,以便于在不改變外部接口的情況下修改函數的功能。
第八章 const
C++中引入const的最初動機是要消除使用預處理指令#define以進行常值替換的必要性。
C++中的const預設具有内部連結屬性,也就是說,用const定義的對象隻在定義該對象的檔案内可見,在連結時對于其它轉換單元 (translation unit)是不可見的。對于使用const的定義,必須總是同時為其初始化,除非顯式的用extern關鍵字進行聲明。
通常,C++編譯器不會為const對象配置設定存儲空間,而是在符号表中記錄下其定義,以用于值替換。
當extern與const一同使用時,程式員強迫編譯器為其配置設定空間(在其它情況下也會發生,例如取const對象的位址)。
對于複雜結構,編譯器試圖避免為const對象配置設定空間的目的同樣會無法實作。
對于内置類型的const,編譯器總是執行常量替換(前提是該對象為compiler-time const)。
compiler-time const & run-time const
對于run-time const,編譯器會為其配置設定空間,并不在符号表中記錄相關資訊(這裡變得和C一樣),是以這種情況下編譯器不會進行值替換。
const與聚合類型
const可以用來修飾聚合類型(如數組)。然而,你應該理所當然的認為,沒有那個編譯器會複雜到為聚合類型在符号表中儲存相關資訊,是以對于const 聚合類型,編譯器和為其配置設定空間;在這種情況下,const應該被了解為readonly:"一塊無法被改寫的記憶體區域“。
然而,聚合類型的值不像之前所提到的内置類型const變量一樣具有compile-time const屬性,因為編譯器不知道也未被要求知道存儲區域在編譯時的值。
C++與C中const的差別
C99中的const是從C++中引入的,然而在C中const意味着“一個不可改寫的普通變量”。
在C中,編譯器總是為const配置設定存儲,而且變量的名字是全局的(外部連結)。C編譯器無法将const變量視為compile-time常量。
在C++中,在所有函數外部定義的const具備檔案範圍(即在檔案外是不可見的),也就是說,預設具備internal linkage屬性。這和C++中所有其它預設為external linkage的辨別符都大大不同。
為了給一個const對象加上external linkage,以便可以在其它檔案中引用它,需要在對象定義時顯式的加上extern關鍵字
例如 extern const int x=1;
注意,上述定義中,由于extern和初始化的存在,一方面是x具備了external linkage屬性,另一方面,也強迫編譯器為變量配置設定了存儲空間;當然,編譯器依然為變量x保留着值替換的特性。
函數參數與傳回值
傳遞const value:C++提供給函數建立者而不是調用者的工具
傳回const value:
對于内置類型,函數傳回值是否用const修飾是無所謂的。
而對于使用者定義類型(class),函數傳回值是否用const修飾則變得很重要,即在有const的情況下,函數傳回的對象不能調用non-const的成員函數
const傳回值對于内置類型無影響的原因在于編譯器總是禁止将傳回值作為左值(因為它總是個值,而不是變量)。
臨時對象:
臨時對象的一個特性是它們自動被編譯器賦予了const屬性
傳遞和傳回位址
事實上,在編寫函數時,若某個參數為對象位址,應該盡可能的将參數類型加上const屬性。
一個接受const指針的函數,較之不接受const指針的函數,更具有通用性。
C++中參數傳遞的首選方式是引用,确切的說的帶有const的引用。
類中的const
在類中,const的語義蛻化為C中const的語義。編譯器會為class中的const成員配置設定空間,并在其初始化後保證值不被修改。換言之,類中的const意味着:“這個成員的值在對象的聲明期保持不變”;然而,不同對象中對應的成員可能有不同的值。
是以,在類中定義一個普通的、非static的const成員時,不允許在定義時完成初始化;初始化工作必須由構造函數來完成,然而是在一個特殊的位置進行,即 initializer list。
類中的compile-time const
類中的static const的内置類型成員變量可以被視為compile-time const。
static const必須在定義時初始化,其它所有成員變量則必須在構造函數中完成初始化。
const object & const member function
要了解const成員函數的概念,必須首先了解const對象的概念。
當在類中定義了一個const成員函數時,實際上是告知編譯器,該函數可以被const對象調用。預設情況下(未顯式聲明const)的成員函數是不允許被const對象調用的。
注意,const關鍵字在成員函數的聲明和定義中都要求出現。
另外,構造和析構函數不允許聲明為const,因為從它們所完成的功能來看通常總是對對象執行修改操作的。
mutable關鍵字
bitwise const VS logical const
bitwise:意味着整個對象的每個bit都是不允許進行修改的。
logical wise:意味着對象在概念上是不允許修改的,但是可以存在以成員為機關的可寫性。
volatile關鍵字
volatile關鍵字用于告知編譯器不要對資料的持久性作任何假設,尤其是在代碼優化時。
可以建立const volatile對象,意思是這個對象不會被客戶程式修改,然而可能會被某些外部agency修改。
和const一樣,volatile可用于成員變量、成員函數和對象;volatile對象隻能調用volatile函數。
第九章 内聯函數
C++中宏的兩個大問題
1.宏看起來像是函數,然而并不具備函數的特性。
2.預處理器無權通路類成員變量,沒有辦法在宏定義中表示出類範圍的概念。
普通函數具備的任何特性,都可以從内聯函數中得到,隻有一點不同即内聯函數在被調用處,如同宏一般,函數體被展開并替換函數調用。
通常将内聯函數的定義放置在頭檔案中。
内聯函數需要在函數定義處顯式使用inline關鍵字,僅在函數聲明處使用inline是不夠的。
在類中定義的成員函數自動具備了内聯函數的特性。
以下劃線開頭的辨別符屬于被保留的,程式員最好不要定義這種形式的辨別符。
内聯函數與編譯器
當編譯器發現一個内聯函數時,會在符号表中添加這個函數的類型資訊(即函數原型,包括函數明、參數類型和個數、以及函數傳回類型)以及函數體代碼。之後當 編譯器發現對這個函數的調用時,會和對待普通函數調用一般的執行類型檢驗,在函數調用合法的情況下用函數體代碼替換函數調用。
内聯函數的限制
1.編譯器無法将複雜函數作為内聯函數處理。通常來說,任何類型的循環都被認為過于複雜而不能内聯展開。
2.當代碼中有顯式的取函數位址的操作時,編譯器無法執行内聯展開,而是為函數體代碼配置設定存儲空間。
重要的是要了解inline關鍵字隻是對編譯器的建議和請求,而不是強制性的指令。
第十章 名字控制
C中的static關鍵字在人們發明"重載"這個概念之前實際上已經被重載了,而C++則為static增添了新的含義。
在C和C++中,static都具備兩個基本語意,不幸的是,兩者經常混雜在一起。
1.在固定位址空間配置設定記憶體,而不是每次函數調用時在棧上配置設定空間。這裡static的意味是靜态存儲(static storage)。
2.隻對一個特定的轉換單元(translation unit)可見。這裡static的意味是内部連結(internal linkage)。
函數中的static變量
若程式員不為static局部變量提供初始化值,編譯器會保證該對象被初始化為0(對于内置類型)。
函數中的static對象
預設初始化為0隻對内置類型有意義,對于static類對象,必須由構造函數完成初始化。
靜态對象的析構
靜态對象(不僅限于局部靜态變量)在程式從main()函數推出時被自動調用析構函數。
如果調用标準庫函數abort()結束程式的話,靜态對象的析構函數是不會被自動調用的。
需要了解的是,隻有已經被建立的對象才被析構。具體的說,一個包含局部靜态對象的函數若從未被調用過的話,該類對象的析構函數自然不會在推出main()時被調用,因為該對象根本還不曾存在過!
連結控制
使用内部連結(internal linkage)的優點之一在于可以在将辨別符的定義放置在頭檔案中,而不用擔心連結時會出現名字沖突的問題。
C++中通常放置于頭檔案中的名字定義,如const,預設具有internal linkage屬性。
當用于局部變量時,static不再具有可見性的含義,而隻是改變變量的存儲方式。
名字空間
namespace關鍵字的唯一目的是建立名字空間
建立名字空間時需要注意的:
1。名字空間的定義隻能出現在全局範圍,或者是在另一個名字空間的内部
2。名字空間定義的結尾處不需要加分号。
3。一個名字空間的定義可在多個檔案中完成。
4。一個名字空間可以定義為另一個名字空間的alias。
匿名名字空間
匿名名字空間中的名字在其所在的轉換單元中自動可見,不用加限定符。
編譯器保證每個轉換單元(translation unit)中最多隻有一個匿名名字空間。
匿名名字空間中的名字雖然對于外部TU是不可見的,但其連結屬性仍是external linkage。
C++廢棄了使用static來設定internal linkage的方法,而推薦使用匿名名字空間。
友元與名字空間
若在類定義中聲明友元的話,該友元自動被插入類所屬的名字空間。 例如:
namespace Me {
class Us {
//...
friend void you();
};
}
現在函數you()是名字空間Me的一員了。
使用名字空間
可以通過三種方式來使用某個名字空間内的名字
1。顯式使用範圍限定符::。
2。使用using directive,将某個名字空間内的名字全部引入目前名字空間。
3。使用using declaration,一次引入一個名字。
using和namespace關鍵字一起使用時被稱為using directive。
using declaration被視為在目前範圍内的聲明,而using directive被視為對目前範圍全局性的聲明;是以,using declaration可能會覆寫using directive引入的名字。
using declaration隻是引入了名字,并沒有包含類型資訊;
using directive的有效性隻到那個檔案的編譯器為止。
實際上,using directive 通常出現在.cpp檔案而不是在頭檔案中,因為這樣做會污染全局名字空間。
C++中的靜态成員
類中的靜态成員必須在類定義之外被定義(但是在類定義中同樣要進行聲明),并且隻允許被定義一次,是以,這樣的靜态成員的定義一般放在類的實作部分(.cpp),而不是定義部分(.h)。
初始化:對于static const的整型變量可以在類中進行定義,對所有其它情況,必須在類外完成定義。
局部類不允許擁有靜态成員變量。
靜态成員函數:靜态成員函數隻能通路靜态成員變量,無法通路非靜态成員變量,也不發調用非靜态成員函數。這是因為靜态成員函數在調用時沒有this指針被傳遞。
第十一章 引用&拷貝構造函數
C++和C中的指針基本保持一緻,同時增強了類型檢查。在C中可以在void *指針與其它類型指針之間任意指派,而在C++中隻允許将其它類型指針指派給void *,反過來的指派需要顯式的類型轉換。
C++中的引用是從Algol中借鑒而來的。
引用
引用在概念上可以視為由編譯器自動解析的常量指針。
使用引用時需要遵循的規則:
1.引用在建立時必須初始化。
2.引用一旦初始化完畢,就不能更改指向的目标。
3.不允許空引用(類似于NULL 指針),必須保證某個引用指向一處合法的存儲區。
函數中的引用
如果确信函數會維持傳入參數的const特性,那麼将參數類型設為const引用,則會令函數具有最廣泛的可用性。
将參數類型設為const引用在函數有可能被傳入臨時對象時具有特殊的重要的作用,因為臨時對象總是具有const性,是以若函數參數不是const引用的話,是無法通過編譯的。
參數傳遞原則:盡可能使用const引用的參數傳遞方式,這不僅是考慮到效率問題,更重要的是與将對象按值傳遞時會導緻拷貝構造函數的調用有關。
拷貝構造函數是C++語言中最令人迷惑的特性之一。
函數傳回值
對于無法通過寄存器傳回的複雜結構和對象,試圖通過将要傳回的對象放置在棧上的政策是不可取的,因為C/C++是支援中斷的概念的,若函數将代傳回對象放 置在棧上(隻能位于傳回位址之下)後傳回,然後被其它函數中斷,則ISR在執行過程中可能會覆寫原本的傳回内容。
要解決該問題,則需要函數的調用者在調用函數前在棧上額外配置設定空間以存放函數的傳回值。然而,C/C++采取了一種更有效率的解決方案。
具體的說,就是在調用函數前,将最終要容納函數傳回值的對象位址作為隐藏參數傳遞給函數,函數在執行過程中直接将傳回值寫入目标位址。
臨時對象
考慮這樣的情形:函數要傳回某個對象,而調用者在調用時選擇了忽略傳回值,這時前面所提到的指針指向何處?
事實上編譯器在這種情況下會在棧上建立一個臨時對象來容納傳回值。臨時對象的生存期總是盡可能短,以避免過多的臨時對象存在而占用寶貴的資源。
一個簡單的技巧
可以在類定義中使用一種簡單的技術,來保證類對象無法以值傳遞的方式用于參數傳遞或函數傳回。
方法如下:在類定義中聲明(甚至不用定義)一個private的拷貝構造函數。
第十二章 運算符重載
運算符重載的唯一理由是可以令代碼的書寫和閱讀更容易。
字首和字尾增量運算符的重載
當編譯器發現字首操作如++a時,會調用operator ++(a)
而編譯器發現字尾操作如a++時,會調用operator(a,int)
這樣就可以在運算符重載時将字首和字尾操作區分開來。
與指派相關的運算符的重載
所有被重載的運算符函數,在其内都應該檢查是否要執行的操作屬于"自我指派",這是基本原則;如果忽略這一檢查的話,代碼中很可能會引入難以察覺的bug。
重載時的參數和傳回值
1。參數預設情況下以const引用的方式進行傳遞;當操作符是類成員時,函數需聲明為const成員函數。
2。傳回值類型取決于運算符所表示的意義。
3。所有指派運算符都修改左值,是以通常情況下傳回類型應為非const引用。
4。對于字首運算符,可以以引用方式簡單的傳回*this,而對于字尾操作,應該以值傳遞的方式傳回。
傳回值優化(return value optimization)
假設Interger是使用者定義的類,考慮在運算符重載中以下兩種傳回結果的方式會有何差别?
方式一: return Integer(left.i+right.i);
方式二: Integer temp(left.i+right.i);
return temp;
在方式二中,三個操作陸續發生:
首先,temp對象被建立,并自動調用構造函數
其次,拷貝構造函數被調用,将temp對象的内容複制到外部容納傳回值的位址。
最後,析構函數被調用,以銷毀temp對象。
而在方式一中,編譯器在看到這樣的代碼時會明白,程式員的要求隻是傳回一個對象;是以編譯器會利用這一點,直接在外部容納傳回值的存儲區域出建構這個對象——而這僅需要調用構造函數而已,既不需要調用拷貝構造函數,也不需要調用析構函數。
兩種方式相比,第二種方式更富有效率。以上的特性常被稱為傳回值優化(ROV)。
非常見運算符的重載
運算符[]:
必須是成員函數
運算符->
如果你希望一個對象看起來像是一個指針,那麼一般會需要重載運算符->。這樣的對象比起典型的指針具有更多智能特性,是以經常被稱為智能指針。
該操作符必須是成員函數。
不允許重載的運算符
某些運算符禁止重載的主要原因是考慮到安全。
成員選擇運算符.和.*不允許重載。
另外,重載不會改變運算符的優先級和結合性,也不能改變運算符的參數個數。
指派運算符的重載
一定要明白,編譯器什麼時候調用指派運算符,什麼時候調用拷貝構造函數。
例如 MType a;
MType b=a;
b=a;
代碼的第二行中,從一個已有的對象a中建立一個新的對象b,編譯器此時的唯一選擇就是調用拷貝構造函數。盡管從表達式上看來,這個操作涉及到了運算符=,但是編譯器是不會調用其對應函數的。
代碼的第三行,運算符=的兩側都是之前被建立的對象,顯然,此處編譯器會調用=運算符函數。
簡而言之,對象尚未建立時,需要初始化;否則則執行=操作。
從可讀性的角度來看,應該盡量避免使用=的形式進行初始化,而最好顯式使用構造函數的形式。
運算符=必須是成員函數。
構造轉換(constructor conversion)
如果為類定義了一個單參數(其它類型)的構造的函數的話,則允許編譯器完成從參數類型到該類類型的自動類型轉換。
由構造函數引起的自動類型轉換有時可能會引起問題,可以使用explicit關鍵字顯式的禁止自動轉換。
運算符轉換(operatro conversion)
可以為類建立一個成員函數,完成從目前類型到指定目标類型的轉換。
函數的一般形式: operater ret-type () { return ret-value;}
對比:對于構造轉換,是由目标類型完成的;而對于運算符轉換,是由源類型完成的。
實際上,建立一個單參數構造函數總是定義了一種類型自動轉換。
第十三章 動态建立對象
C++針對 動态建立對象 提供的 的 解決方案是将其納入語言的核心;而在C中,malloc()和free()是以庫函數的形式提供,不在編譯器的控制之中。
在棧上的空間配置設定有内置的處理器指令進行支援,是以非常高效。
new操作府
預設的new操作符在将記憶體位址傳回前檢查并确認記憶體配置設定操作是成功的,是以C++程式員不必顯式的判斷new操作是否成功。
delete操作符
如果傳遞給delete操作符的指針為0的話,不會執行任何操作。
值得注意的是,如果程式員的代碼中出現 delete pv(其中pv的類型為void *)這樣的代碼,幾乎可以肯定這将成為程式的一個bug,除非pv指向的對象非常簡單——不具備析構函數。
記憶體不足
當new操作符配置設定記憶體失敗時,會導緻一個稱為new-hander的特殊函數被調用;或者說,(編譯器)會檢查某個函數指針,如果該函數指針非0的話,則調用對應的函數。
new操作符在失敗時的預設行為是抛出一個bad_alloc異常。
程式員可以通過包含頭檔案<new>并調用set_new_handler()來設定new-handler,參數是使用者定義的函數位址。
注意,當new被重載時,原有的new-handler不再會被預設調用。
重載new&delete
當程式員使用new語句時,首先是編譯器調用operator new 配置設定空間,然後編譯器調用構造函數完成對象的初始化。
當程式員使用delete語句時,首先編譯器調用對象的析構函數,然後調用operator delete釋放記憶體空間。
以上操作中對構造函數和析構函數的調用是程式員永遠無法進行控制的(由編譯器負責),然而程式員可以定制operator new和operator delete。
一定要清楚,new和delete的重載,所改變的僅是空間配置設定的方式。
全局性的重載new和delete
重載的new需要一個類型為size_t的參數;該參數是由編譯器産生并傳遞給new的,其值為要建立的對象的大小。
再次重申,new所完成的工作隻是空間配置設定,而不是對象建立;對象在構造函數被調用前是不存在的——這是由編譯器控制和保證的,你,程式員,是無法插手其中的。
重載的delete需要一個類型為void *的指針;類型是void *是因為delete操作符在析構函數被調用後才得到該參數,而析構函數已經消除了存儲區域的對象特性。
還需要注意的是,在重載new時,不能在函數體中使用iostream,因為iostream對象(若全局性的cin,cout,cerr)在建立時,會調用new來配置設定空間。
在類中重載new和delete
當為類重載new和delete操作符時,無須顯式的使用static,實際上是為類建立了static成員函數。
重載面向數組的new 和delete
其形式與普通的new和delete類似,隻是文法形式改為 new [] 和delete[]
記住,從接口的角度看,重載new所要求完成的唯一任務就是傳回一個指向一塊足夠大存儲空間的指針。
構造函數的調用
考慮下面的代碼: MyType * p=new MyType;
在一切正常的情況下,編譯器會調用new來配置設定空間,然後調用類MyType的構造函數來對存儲空間完成初始化,進而建立對象。
那麼,在new操作失敗的情況下,會發生什麼?事實是在這種情況下,構造函數不會被調用。
C++标準要求new的實作在失敗的情況下抛出bad_alloc異常。
placement new &delete
存在兩種不常使用的重載new的用途:
1.某些情況下需要将某個對象放在确定的位址。
2.在調用new時希望能有多種記憶體配置設定方式進行選擇。
這兩種需要都可以通過在重載new時傳入不止一個參數來實作。
如前所述,new的第一個參數總是對象的大小,并且是由編譯器隐式計算和傳遞的;然而new還可以被傳入使用者希望的任何參數
一般形式 MyType * p= new (arg-list) MyType;
顯式調用析構函數
顯式的調用析構函數是可以通過編譯的,而顯式的調用構造函數則不能(很符合邏輯,對象都已建立了,再次構造顯然是個錯誤)。
需要注意的是,如果對在stack配置設定的對象顯式調用析構函數,則對象在生存期結束時析構函數會被編譯器再次調用——重複析構一個對象很可能是bug之源。另一方面,如果對在heap上配置設定的對象顯式調用析構函數,對象會被銷毀,但其占用的空間不會被釋放。
第十四章 繼承
C++中基于類機制,在不污染現有代碼的前提下實作代碼重用。基本方式有兩種:組合和繼承。
組合
常見做法是将嵌入的對象聲明為private成員,這樣就些嵌入的對象就變成了底層實作而徹底與接口無關了。
繼承
預設情況下繼承是以private方式進行的,而通常情形是采取public方式繼承的。
在與繼承相關的構造函數的一個基本概念就是,在進入新類的構造函數的函數體時,所有基類的構造函數都已經調用完畢。
繼承中的名字隐藏(Name hiding)
情況一:redefining(non virtual function)or overriding(virtual function)
即派生類提供了與基類具有signature和傳回類型的接口函數。
通常,如果派生類中重定義了基類中的某個被重載的函數,那麼基類中的所有同名函數在新類中都被隐藏了。
情況二:
派生類修改了基類中某個接口函數的signature和/或傳回類型,這種情形通常來說違背了繼承的本意,因為繼承的最終目标是實作多提阿,即在維持單一接口的前提下,完成不同操作。
非自動繼承的成員函數
并不是所有的基類成員函數都自動繼承給派生類的。
構造析構、析構函數、拷貝構造函數、指派運算符這四個類成員函數不會被派生類自動繼承。
若派生類中未定義上述函數,則編譯器會為派生類合成之。所合成的構造函數按照memberwise進行初始化,所合成的運算符=按照memberwise進行指派操作。
值得一提的時,基類中若存在轉換函數,則被派生類繼承。
另外,編譯器隻為同類型對象之間的指派操作自動生成函數。
在組合&繼承中選擇
通常在需要某個已存在的類提供的功能而不是接口時,會采用組合的方式。也就是說,程式員将對象嵌入自定義的類中以實作新類的某些功能,然而這個新類的用 戶是通過新類的接口而不是原有類的接口來執行操作的。通常在組合方式中會将嵌入的對象聲明為新類的private成員。當然,有時需要允許使用者通路原有類 的接口,進而使代碼更清晰、更具可讀性,這是可以将嵌入的對象聲明為public成員。
概括的說,繼承表達的是"is -a “關系,而組合表達的是"has-a“關系
Subtyping
類對象的自動轉換隻發生在函數調用的參數中,而不是成員選擇的過程中。
私有繼承
對于私有繼承,派生類的對象無法像在public繼承時,被視為基類的一個執行個體。
私有繼承中的publicizing
在private inheritage中,可以在派生類中顯式的使用通路限定符,進而使基類的某些成員可見和可用。
protected通路控制
在理想世界中,private成員永遠都具備不可有任何折扣的private屬性;然而,在實際工程中,有些時候是希望能夠使得成員對于外部而言是不可見而對于派生類的成員是允許通路的。
protected關鍵字實際上表示:“該成員對于類的使用者而言,具有private性質,但是對于所有派生類的成員都是可通路的。“
最佳的解決方案是将所有的成員變量都設定為private,而将這些成員變量的通路接口定義為protected,這樣派生類就可以在底層實作中使用基類提供的這些接口。
protected inheritage
類似于private繼承,protected繼承很少被使用,C++中引入更多的是出于保持語言完成性的考慮。
繼承與運算符重載
除了指派運算符=之外,其它運算符函數被派生類自動繼承。
多重繼承
一言以蔽之,非大牛勿碰。
Upcasting
繼承所提供的最重要的特性并不是為派生類提供了基類的成員函數,而是通過繼承所表現出的基類與新類之間的關系。
簡單的概括起來就是,新類可以視為基類。
upcasting永遠都是安全的——由更特殊的類向更通用的類轉化時,對于類接口唯一可能發生的事情是丢失成員函數,而不是新增成員函數。這也是為何編譯器總是允許upcasting。
upcasting和拷貝構造函數
在編寫派生類的拷貝構造函數時,一定要顯式的調用基類的拷貝構造函數。
組合與繼承
要判斷究竟使用組合還是派生,最清晰的方法之一就是看是否需要進行upcast。是的話,則用繼承,不是,則用組合。
第十五章 多态和虛函數
多态從另一個緯度提供接口與實作的分離,即将能夠做什麼和怎麼做分離(decouple what from how)
隻有從設計的角度才能了解虛函數的意義所在。
函數綁定
将函數調用和函數體聯系起來的操作稱為綁定。
在程式運作之前完成的綁定(由編譯器和連結器完成),稱為早期綁定。
虛函數
在類中建立一個虛函數,隻需在函數聲明中使用virtual關鍵字即可,函數定義中不需要。
虛函數的特性可以描述為:“使用者隻需要向對象發送消息,完成什麼工作以及如何完成都由對象操心。“
C++裡後期綁定的實作
VTABLE:編譯器為每個包含虛函數的類建立一個稱為VTABLE表格,表格中存放類中每個虛函數的位址。
VPTR:編譯器在每個對象中都插入一個稱為VPTR的指針(前提是類中包含虛函數),指向對象所屬類的VTABLE。
建立VTABLE,為每個對象初始化VPTR,都是由編譯器在幕後完成的。
存儲類型資訊
要實作後期綁定,就必然要求在對象中存儲某些類型資訊。
一旦對象中的VPTR被正确初始化後,對象實際上已經"了解"自己的類型。
基類和派生類中的VPTR在對象中都處于相同的位置,通常是放置在對象的開始處。
VPTR的初始化
對于虛函數的實作,VPTR指針的正确初始化是非常重要的。在VPTR被初始化前,是無法調用虛函數的。
VPTR的初始化是在構造函數中完成的,編譯器會自動的在構造函數(無論是預設還是使用者定義的)中插入初始化VPTR的代碼。
純虛函數和抽象基類
當建立一個純虛函數時,編譯器會在VTABLE中為該函數保留一個slot,但是并不在slot中填寫适當的函數位址(通常情況下也不存在适當的位址)。這樣,即使類中隻有一個純虛函數,VTABLE也是不完整的。
在一個類的VTABLE是不完整的情況下,建立該類的一個對象是不安全的,是以編譯器不會為含有純虛函數的類建立對象。
純虛函數的存在,可以禁止抽象基類以值傳遞的方式用于函數參數;另外,也避免了object slicing這種現象的出現。
純虛函數的定義(函數體)
即使是純虛函數,程式員也可以選擇為其提供定義(函數體)。
在這種情況下,編譯器依然不會為類建立對象;純虛函數在類的VTABLE中的對應slot仍然為空,但是與普通純虛函數的差別在于,在派生類中會存在一個可以調用的函數。
另外的優點在于設計者可以在不破壞現有代碼的前提下輕松的将一個純虛函數改變為普通的虛函數。
繼承與VTABLE
當繼承以及虛函數重載發生時,編譯器會為新類建立一個新的VTABLE;基類中未被重載的函數的位址被插入新類的VTABLE中。
Object Slicing
純虛函數的最重要應用可能就是通過編譯錯誤來組織object slicing的發生。
重載VS覆寫(overloading & overriding)
在第十四章中已經看到,派生類中對基類中某個重載函數的重定義會隐藏基類中所有版本的函數。
然而,對于基類中的虛函數,情形稍有不同:編譯器不允許派生類在進行覆寫時修改虛函數的傳回類型,而這在函數為非虛函數時是允許的。即對于虛函數,在覆寫時不允許修改傳回類型。
虛函數與構造函數
構造函數不能是虛函數,這從邏輯上很容易了解:任何虛函數調用在對象的VPTR被正确初始化後才能正常工作,而負責初始化工作的正是構造函數,如果構造函數也是虛函數的話,會出現邏輯上的循環依賴關系。
當一個包含虛函數的類對象被建立時,必須保證其VPTR指針正确指向對應的VTABLE;這一工作必須在任何虛函數被調用前完成。構造函數不僅負責對象的建立,同時也負責VPTR的正确初始化。編譯器會在構造函數的開始處插入初始化VPTR的代碼。
以上的事實隐含說明了以下的事實
首先是效率問題;類的構造函數所完成的工作很可能遠比代碼中展現的要多,
第二個方面是構造函數被調用的順序:按照類層次結構被調用。
第三個方面是構造函數中虛函數被調用的方式。如果在構造函數中調用虛函數,則調用虛函數的目前版本,也就是說,虛函數機制在構造函數内部失效。
第三點特性是合理的,這可以 從兩方面解釋。
首先,在構造函數中,隻能确認基類對象已被初始化,然而并不知道那個類會從目前類派生。而虛函數調用機制,會發生繼承體系的越界(向下)的清況;假使在構 造函數中允許虛函數機制的話,可能會調用一個函數,而這個函數對尚未初始化的成員進行操作,而這顯然可能導緻災難性的後果。
其次,這可以從技術上解釋清楚。對象的VPTR總是初始化指向最近一個被調用的構造函數對應的VTABLE,即VPTR的狀态又最近被調用的構造函數決 定,這也是為什麼構造函數按照從基類到最後被派生的順序被調用。是以在目前構造函數在執行時,對象的VPTR被設定指向目前類的VTABLE,如果在構造 函數中調用虛函數的話,隻會根據目前類的VTABLE完成函數調用。是以,最終結果隻能是調用虛函數的目前版本。
虛函數與析構函數
構造函數不允許是虛函數,與之對應的是,析構函數可以可以而且經常被定義為虛函數。
在析構函數中可以安全的調用基類的成員函數。
每個析構函數都了解自己是基于哪個類而派生而來的,然而卻不知道哪個類會基于自己而派生出來。
應該在腦子中明白,構造函數和析構函數是僅有的編譯器要求必須按照結構關系的順序依次被調用的兩個特例。
忘記将析構函數設為virtual可能導緻一個隐藏很深的bug,因為這通常并不直接影響程式的執行,然而卻會悄無聲息的引入記憶體洩露。
純虛析構函數
盡管純虛析構函數在C++中是合法定義,然而在使用時卻有一個額外的限制:程式員必須為純虛虛構函數提供函數定義。
這看起來似乎違背直覺:一個虛函數在聲明為純虛的前提下卻必須提供函數體。
然而你需要再次記住,析構函數是特殊函數,在類結構中的每個類的析構函數都要被調用,如果純虛析構函數的函數體不存在的話,在析構函數中将面臨無函數可調用的窘境。是以,由編譯器和連結器保證純虛析構函數函數體的存在是絕對必要的。
令人感到疑惑的事實是,當繼承一個包含純虛析構函數的類時,與普通情況不同,派生類中不需要為純虛析構函數提供定義。
簡單的說,析構函數定義為虛函數是很必要的,而是否是純虛函數并不是十分重要。
析構函數中的虛函數
類似于構造函數中的情形,析構函數中調用虛函數時執行虛函數的目前版本,虛函數機制被忽略。
在構造函數中類型資訊是不可知的,而在析構函數中類型資訊可知,但确實不可靠的。
single-rooted hierarchy
事實上,除C++之外的所有其它OOP語言都采取了single-rooted hierarchy。
運算符重載
運算符類似其它成員函數,也可以設定為虛函數。
Downcasting
dynamic_cast是一個類型安全的downcast操作。
當使用dynamic_cast進行downcast時,隻有在要執行的轉換是合法的情況下操作才會傳回指向一個目标類型的指針。
dynamic_cast必須應用于一個多态層次結構,因為dyanmic_cast需要使用VTABLE中的資訊來确認對象的實際類型。
頻繁的執行dynamic_cast可能會導緻性能問題。
多态和後期綁定是不可分離的。
第十六章 模闆簡介
通過使用繼承群組合可以重用目标代碼,而通過使用模闆可以重用源代碼。
存在三種源代碼重用的方案:C方案、Smalltalk方案和C++方案(模闆)
C方式:在複制源代碼的基礎上手動進行修改。
Smalltalk方案:要重用代碼,就使用繼承;該方案的可行性是基于Smalltalk的single-rooted的語言特性。由于C++支援多個互相獨立的類層次結構,Smalltalk方案在C++中不會有預期中的效果。
頭檔案和模闆
對于模闆類,通常情況下其所有的聲明和實作都放入頭檔案中;這看起來違背了頭檔案使用的基本原則——不要在頭檔案放入任何會導緻編譯器配置設定空間的代碼,以此來避免在連結時出現多重定義的錯誤。
實際上,對于模闆,這種“違背原則"的做法并不會導緻連結時錯誤的出現。對于任何有template <...>前置的代碼塊,編譯器在第一次看到這個代碼塊時并不會為代碼塊配置設定存儲空間,而是等到它發現一個模闆執行個體時才會為代碼塊配置設定存儲空 間;此外,編譯器和連結器中存在着移除同一模闆的多重定義的機制。
從另一個角度來看,将模闆的實作部分放在頭檔案中使得其它人可能偷取并修改你的代碼。
模闆隐式的為其支援的類型定義好了接口。
換句話說,雖然C++屬于強類型語言,模闆為C++提供一種弱類型機制。
(注:smalltalk和Python中的所有方法都具有弱類型性,這些語言并不需要模闆機制)。
在實作容器時所面臨的基本困難是,容器的清理工作需要了解容器所容納對象的類型,而容器的建立工作卻要求不對其所容納對象的類型有特定要求。
通過值方式容納對象的容器,不需要擔心對象所有權的問題,因為該容器是對象毫無疑問的所有者。
而對于通過引用或指針方式容納對象的容器,在設計和實作時,必須仔細考慮對象所有權的問題。
疊代器是這樣的一個對象,它在一個容納其它對象的容器中移動,并每次選擇其中一個對象,并且不提供對容器實作的直接通路。
從很多角度來看,疊代器就是一個智能指針;實際上,你會發現疊代器通常模仿大多數指針操作。
疊代器設計的目标是使得客戶程式員的程式中所有用到的疊代器都擁有一緻的接口。
nested iterator
為了建立一個nested friend class(iterator),通常要遵循以下的步驟:
首先聲明類的名稱,其次将類聲明為友元,最後是類定義。
end sentinel
從一個普通類到模闆的轉換是很清晰的。首先建立并調試一個普通類,然後将其轉為模闆,這種開發方式比起從零開始建立模闆要更容易。
容器類的重用是通過模闆而不是繼承實作的。
實際上,通過模闆實作代碼重用,比起通過繼承群組合,要簡單很多。