在我的早期印象中,C++這門語言是軟件工程發展過程中,出於對面向對象語言級支持不可或缺的情況下,一群曾經信誓旦旦想要用C統治宇宙的極客們妥協出來的一個高性能怪咖。
它駁雜萬分,但引人入勝,出於多(mian)種(shi)原因,我把它拿出來進行一次重新的學習。
這篇筆記從G++編譯出的匯編代碼出發,對部分C++的常用面向對象特性進行原理性解釋和總結,其中包括 引用、類(成員函數,構造函數)、多態(編譯時,運行時)、模板與泛型
Here we go!
引用
這是一個老生常談的話題了,C++ primer中文譯本上說引用是對象的一個別名,別名是什么鬼?
上碼:
int invoke(int a) {
return ++a;
}
int main(int argc, char **argv) {
int a = 123; // movl $123,-20(%rbp)
int *pa = &a; // leaq -20(%rbp),%rax
// movq %rax,-16(%rbp)
int &ra = a; // leaq -20(%rbp),%rax
// movq %rax,-8(%rbp)
invoke(a); // movl -20(%rbp),%eax
// movl %eax,%edi
// call _Z6invokei
invoke(*pa); // movq -16(%rbp),%rax
// movl (%rax),%eax
// movl %eax,%edi
// call _Z6invokei
invoke(ra); // movq -8(%rbp),%rax
// movl (%rax),%eax
// movl %eax,%edi
// call _Z6invokei
}
簡單明了,pa是一個指向a的指針,ra是一個a的引用,可以看到編譯器對pa和ra的的定義以及參數傳遞做的工作幾乎是一模一樣,它們都在棧里有自己的空間且都存了一個a的地址,因此可以十分肯定的說引用是用指針實現的。
引用是對指針的一個語言級別的封裝,其出現的意義大概是為了提升程序的可讀性,通常都是用來進行參數傳遞。
關於引用的好處和使用技巧,有待進一步學習。//TODO
類(成員函數,構造函數)
貼代碼之前,有必要回顧一下標號這個概念,在匯編語言里,每條指令的前面都可以擁有一個標號,以代表和指示該指令地址的匯編地址,因為畢竟由我們自己來計算和跟蹤每條指令所在的匯編地址是極其困難的。
在匯編翻譯成機器碼的過程中,這些標號會被轉換成標號所在行的具體偏移地址,多數情況下用來標記指令塊入口地址,就是進行所謂函數的跳轉。忘記的同學可以先行度娘。
接下來的代碼,會在每個函數后的注釋中標出該函數編譯后的標號名。
int invoke(int a) { // _Z6invokei
return ++a;
}
class Animal {
public:
int age;
int weight;
Animal(): age(0), weight(0.0) {} // _ZN6AnimalC2Ev
void run() { } // _ZN6Animal3runEv
};
class Human {
public:
Human() {} // _ZN5HumanC2Ev
};
int main(int argc, char **argv) {
Animal cat; // leaq -16(%rbp), %rax
// movq %rax, %rdi
// call _ZN6AnimalC1Ev
cat.age = 5; // movl $5, -16(%rbp)
cat.weight = 2; // movl $2, -12(%rbp)
cat.run(); // leaq -16(%rbp), %rax
// movq %rax, %rdi
// call _ZN6Animal3runEv
}
相比上一個例子,這波代碼里,增加了一個Animal類和一個Human類。
我們從main函數開始
-
對象初始化
首先語句Animal cat;
初始化了一個Animal的對象cat,從右邊的匯編代碼可以看到,cat作為一個復合類型被存入新擴展的棧幀的第16個字節的偏移處-16(%rbp)
,然后將cat的地址存入rdi,顯而易見,這就是C++在調用類的成員函數時傳遞的隱式參數this指針,接着跳轉到標號名為_ZN6AnimalC1Ev
的地方繼續執行,在Animal類里可以看到,對應該標號名的函數就是Animal類的構造函數。 -
類成員賦值
這沒什么好談的,跟C里結構體成員的賦值一樣。 -
成員函數調用
對成員函數run()
的調用,編譯器的處理方式與對構造函數的調用一模一樣。
對比G++編譯過程中對不同的函數的標號命名:
Animal 類
普通函數: invoke() _Z6invokei
普通成員函數:run() _ZN6Animal3runEv
構造函數: Animal() _ZN6AnimalC2Ev
Human 類:
構造函數: Human() _ZN5HumanC2Ev
在語法層面上,C++規定了不同函數的定義和調用方式,編譯器會對不同函數使用不同的處理方式,比如調用成員函數會隱式傳遞this指針,比如直接調用成員函數會導致編譯出錯,在成功編譯后,所有函數都不外乎是以一個特定標號標志的指令序列。
從標號的命名上可以看出C++確保其唯一的方式。
因此,狹義上講,所謂類,其實就是一個復合類型,所謂成員函數,其實就是一個默認會傳遞調用對象本身指針的普通函數,所謂構造函數,其實就是一個在對象初始化的時候會自動調用的普通函數,這些額外的特性都是在編譯階段實現的。
多態(編譯時,運行時)
- 重載
從匯編的角度看,重載的多個函數也不過是對應多個不同的標號名而已:
class Animal {
public:
void run() {} // _ZN6Animal3runEv
void run(int a) {} // _ZN6Animal3runEi
void run(char b) {} // _ZN6Animal3runEc
void run(int a, Human p) {} // _ZN6Animal3runEi5Human
};
G++正是通過重載的多個函數的不同形參列表來對標號進行唯一的命名,也是所謂的編譯時多態。
-
繼承
簡單的繼承是很容易實現的,第一點,編譯器在分配空間的時候會分配子類自有成員變量和其父類成員變量的總大小,第二點,編譯時會在子類構造函數的中調用父類的構造函數。
這里就不給例子了,主要篇幅放在下面的運行時多態上。 -
運行時多態
class Animal {
public:
virtual void run() {} // _ZN6Animal3runEv
};
class Cat : public Animal {
public:
void run() {} // _ZN3Cat3runEv
};
int main(int argc, char **argv) {
Animal *tom = new Cat(); // _ZN3CatC2Ev:
// _ZN6AnimalC2Ev:
// movq $_ZTV6Animal+16, (%rax)
// movq $_ZTV3Cat+16, (%rax)
tom.run(); // movq %rbx, -24(%rbp)
// movq -24(%rbp), %rax
// movq (%rax), %rax
// movq (%rax), %rax
// movq -24(%rbp), %rdx
// movq %rdx, %rdi
// call *%rax
}
這里,我們把new Cat()
要調用的2個構造函數按照執行順序進行選擇性展開,可以看到兩條關鍵的匯編代碼,其中(%rax)表示tom對象在堆中的起始位置,於是,唯一有效的最后一條代碼movq $_ZTV3Cat+16, (%rax)
將Cat類的_虛函數表_指針存入了cat對象的起始位置。
再看tom.run()
的匯編,追蹤發現,最后一條代碼call *%rax
正好調用了Cat類的虛函數表的第一個函數。
這就是所謂的運行時多態的調用邏輯,為什么說是所謂的呢?因為這個邏輯在編譯的時候就可以實現了,有些聰明的編譯器會在你將tom指針指向Cat對象的時候就確定了tom到底對哪個run進行調用,它會將tom.run()
直接優化編譯成call _ZN3Cat3runEv
。
那么,什么樣的運行時多態是在編譯階段做不了的呢?看下面代碼:
int main(int argc, char **argv) {
Animal *tom;
if (argc == 0)
tom = new Animal();
else
tom = new Cat();
tom->run();
}
這時,編譯tom->run()
的時候是不可能知道該調哪個run的,所以,根據上一段代碼我們展開的構造函數可以知道,在運行時,哪一個構造函數被調用,tom所指向的對象里就存了哪個類的虛函數表指針,這才是真正意義上的運行時多態。
模板與泛型
class Cat {};
class Mouse {};
template <typename T>
class Cave {
public:
void capture(T& a) {};
};
int main(int argc, char **argv) {
Cat tom;
Mouse jerry;
Cave<Cat> catsCave;
catsCave.capture(tom); // call _ZN4CaveI3CatE7captureERS0_
Cave<Mouse> miceCave;
miceCave.capture(jerry); // call _ZN4CaveI5MouseE7captureERS0_
}
有了之前對函數和標號的認識,理解模板與泛型的實現就是信手拈來了。
編譯器會識別一個模板類有幾種指定了不同類型的聲明,然后會為每一種類型生成對應的唯一的函數標號和不同的函數實現。
就這個簡單的例子來說,編譯器會為抓貓的籠子和抓老鼠的籠子編譯出不同捕捉函數。
傳統的實現方式是為不同的籠子聲明不同的類和函數,這所產生的匯編代碼與使用模板與泛型產生的匯編代碼在功能上是一模一樣的,甚至在代碼細節上都是差不多的,不同的只是標號名罷了。
模板與泛型在語言級別上提供了這種簡便且擴展性極佳的編程方式,這種設計思維是C++所推薦的。
希望這寫篇筆記能夠為C++初學者提供些許指引,同時為我即將開始的求職之路提供一些幫助。
附上《C++程序設計語言》上的一句話:C++是一個可以伴隨你成長的語言。
歡迎批評和討論。