前注:這篇隨筆是我在學習C++過程中對於內聯函數的一些總結與思考。內聯函數是一個看似很簡單,卻總是在不經意間給人帶來困擾的東西。最初學習C語言的過程中,我經常被編譯器的自動內聯優化而搞得暈頭轉向,后來學習C++之時,大多書籍資料也未作詳細解釋。近日拜讀Scott Meyer的經典之作Effective C++,其中關於內聯的相關解釋,頗有醍醐灌頂之感。故作此文,作為自己復習相關知識和實踐技巧的機會,也希望能帶給別人一些收獲。本來打算一個晚上能將其寫完的,后來為了確保內容盡可能正確,我參考了數本書籍中關於內聯函數的部分,在VC++和GCC下做了一些實驗,並且在文字上也斟酌再三;如果文中有哪些地方存在錯誤、有爭議,或者語言文字表述不清的,敬請指出。
內聯函數的前世,#define
說到內聯函數,就不得不提到 #define max(a, b) (a) > (b) ? (a) : (b) 這種預處理器宏定義。在最早些的C語言中,類似上面 max 這種宏定義隨處可見。因為這種宏用起來跟函數很相似,所以這種宏定義還有一個綽號,叫“宏函數”。然而與真正的函數相比,使用define定義的宏只是在預處理階段做了簡單的文本替換,替換完了再交給編譯器去編譯,因此它並不具備類型檢查,而且在使用中也容易出現一些意想不到的錯誤。最容易犯的錯誤就是,定義宏時缺少了必要的小括號。下面便是一個使用define定義的經典錯誤:
1 #define square(x) x * x
上面的定義了一個求平方的宏,在使用時我們往往將其看作接受一個任意參數的函數。比如想計算 5 的平方,於是就可以調用 square(5) 來進行。倘若我們想計算2 + 7的平方呢?僅僅使用 square(2 + 7) 就可以了嗎?如果是這樣,意想不到的問題便就此產生了。由於宏是一個優先於編譯的預處理指令,在編譯之前,所有的 square(2 + 7) 都會被替換為 2 + 7 * 2 + 7,因此在編譯時,所產生的源代碼的語義就發生了改變。可能你會覺得2 + 7這種簡單到可以口算的表達式不會在你的代碼里出現,然而 a + b 這種含有變量的表達式則是頗為常見的。因此在使用define構造“函數”時,一定需要保證小括號能夠保證參數是完整的,也就是說不會因為替換后的優先級問題而改變了代碼的本意。
那么確保小括號不丟就可以高枕無憂了嗎?我原本也以為是這樣,但是似乎另有玄機。在 Exceptional C++ Style:40 New Engineering Puzzles, Programming Problems and Solutions 一書中(這書名也確實有點長了,不過內容也是值得一看的)是這樣指出的,在上述求解平方的宏中,作為參數的表達式x,實際上被求解了兩遍。至於同樣的表達式在正式編譯前被展開后,編譯器會不會進行合並優化我們不得而知。例如考慮square (a++)被展開后是(a++) * (a++),a++很可能被計算了兩遍,從而在后面如果使用了a的值,其運行結果可能就會受到影響,產生十分令人困惑的問題了。
縱然define有着這樣那樣的不足,誰也無法阻擋其橫掃江湖的腳步。在C標准庫放眼望去,到處都有define的身影。define如此受歡迎的最大原因,恐怕便是在於其直接展開的高效性。在IA32體系下,函數調用需要保存調用者的幀,並為被調用函數開辟新的幀,逐個壓入實參,隨后執行call指令跳轉到所調用的函數的入口。額外的棧幀操作需要巨大的開銷,而call跳轉指令則會讓處理器的指令預取失效。在函數體本身較為短小的情況下,這些額外的工作和跳轉會帶來巨大的性能損失。在這樣的情況下,宏替換的優點便展現的淋漓盡致:預處理器將函數體直接展開在調用者的地方,便不再需要層層遞進的函數調用,因而節約了大量的時間,提高了程序的執行效率。那么有沒有可以克服宏缺點的方法呢?
inline,升級了的解決方案
為了避免宏替換所帶來的缺點,同時保持宏替換所帶來的高效性,標准C++引入了內聯函數的概念。內聯函數確實是個寶,以至於后來發布C語言的C99的標准也將其納入C語言之中。內聯函數本質上還是一個函數,它包含了先計算參數、類型檢查、有作用域限制等普通函數所具有的特性,同時包含了宏直接展開而無需函數調用的高效性。沒有了實際的函數調用指令,額外開銷便會減少很多,在頻繁調用的函數身上便會產生非常好的效果。
對於需要泛型的內聯函數,在C99中即使使用inline也不大容易實現,而在C++中便顯得容易得多了。下面就是一個max的實現(摘自Effective C++),能夠接受任意類型的變量(指針、立即數除外,它們無法轉換為引用類型)。無論是宏還是內聯了的模板函數對於泛型的使用有很多陷阱,而泛型不在本文(內聯函數)討論之列,這里就不做深究了。
1 template<typename T> inline const T & std::max(const T & a, const T & b) 2 { 3 return a < b ? b : a; 4 }
編譯器,你怎么看
既然內聯函數如此之美好,是不是我想給某個函數內聯,只要在定義處加上inline關鍵字就萬事大吉了?不,首先你得問問編譯器同不同意。
對於內聯函數,使用inline關鍵字,只是建議編譯器去用內聯的方式展開該函數;但是實際是否能成功展開,還是取決於編譯器的實現。在有些情況,比如遞歸調用,或者函數體十分龐大,或者存在函數指針需要取得該函數的地址,又或者調用者與inline定義的函數不在同一個文件,那么內聯是不會有效的。有的編譯器可能也會出現“妥協”的實現,即在可能的地方,對使用inline的函數使用內聯式展開,而在不可能的地方(取函數地址)使用原有的辦法。在不適合內聯函數甚至不可能出現內聯函數的地方,即使使用類似__attribute__((always_inline))(GCC)或者__forceinline(MSVC)之類的強制內聯的編譯器指令,也無法保證100%地能夠實現內聯。
在C++中,還有這么一個傳統的說法:定義在類內部的成員函數,通常都是作為內聯函數的。一般說來,根據通常的編碼習慣,在類定義里面的函數往往都是比較短小精悍的,因而編譯器會對其使用內聯;然而在類定義里定義較為復雜的成員函數,情況可能就不是那樣了。下面是一個例子:
1 #include <iostream> 2 #include <cstring> 3 using namespace std; 4 class inline_class1 5 { 6 private: 7 int * ptr_array = 0; 8 int arr_size = 2; 9 public: 10 inline_class1() 11 { 12 ptr_array = new int[2]; 13 } 14 inline int call_me() 15 { 16 int i = 0; 17 for (int j = 0; j < 10000; j++) 18 { 19 if (j >= arr_size - 1) 20 { 21 int * tmp_ptr; 22 tmp_ptr = ptr_array; 23 ptr_array = new int[2 * arr_size]; 24 memcpy(ptr_array, tmp_ptr, sizeof(int)*arr_size); 25 delete[] tmp_ptr; 26 arr_size *= 2; 27 } 28 ptr_array[j] = i; 29 i += j; 30 } 31 return ptr_array[arr_size / 2]; 32 } 33 int call_me(int a) 34 { 35 return a; 36 } 37 }; 38 int main() 39 { 40 int result, result2; 41 inline_class1 cls1; 42 result = cls1.call_me(); 43 result2 = cls1.call_me(result); 44 cout << result; 45 return 0; 46 }
首先簡單說明一下上面的例子。上述的代碼定義了一個類來演示成員函數的內聯。首先需要說明的是,這是一個十分糟糕的類的設計,因為沒有析構函數進行垃圾回收,也沒有考慮其復制構造函數和賦值運算符,但是作為演示內聯與否的示例來說是足夠了。成員函數call_me()包含兩個重載版本,一個較長(包括了一個循環和其他函數調用),一個較短。我們分別在main()函數中調用他們。在Microsoft Visual Studio 2015下,我啟用內聯函數優化,/O2速度優化,在Release x86模式下,得到這樣的Intel格式(目的操作數在前,不同於AT&T格式的目的操作數在后)的匯編代碼:
1 int main() 2 { 3 00FE1090 push ebp 4 00FE1091 mov ebp,esp 5 00FE1093 sub esp,8 6 int result, result2; 7 inline_class1 cls1; 8 00FE1096 push 8 9 00FE1098 mov dword ptr [ebp-4],2 10 00FE109F call operator new[] (0FE10DBh) 11 00FE10A4 add esp,4 12 00FE10A7 mov dword ptr [cls1],eax 13 result = cls1.call_me(); 14 00FE10AA lea ecx,[cls1] 15 00FE10AD call inline_class1::call_me (0FE1000h) 16 result2 = cls1.call_me(result); 17 cout << result; 18 00FE10B2 mov ecx,dword ptr [_imp_?cout@std@@3V?$basic_ostream@DU?$char_traits@D@std@@@1@A (0FE2034h)] 19 00FE10B8 push eax 20 00FE10B9 call dword ptr [__imp_std::basic_ostream<char,std::char_traits<char> >::operator<< (0FE2038h)] 21 return 0; 22 00FE10BF xor eax,eax 23 } 24 00FE10C1 mov esp,ebp 25 00FE10C3 pop ebp 26 00FE10C4 ret
上述的匯編代碼顯示,默認構造函數被內聯進了main(),無參數的call_me()函數(體積較為龐大的)依然使用了函數調用,而帶有一個參數的call_me()函數(只有一行代碼的)則被展開進了main()函數。如果我們強制使用__forceinline編譯指令,則MSVC也會按照我們的想法將較長的call_me()展開進main()),但是這種情況,就需要仔細考慮:是不是確實需要內聯了。
同樣的代碼在GCC中(MinGW)編譯,則得到如下的代碼(與使用inline關鍵字前后無關):
1 0x47c120 lea 0x4(%esp),%ecx 2 0x47c124 and $0xfffffff0,%esp 3 0x47c127 pushl -0x4(%ecx) 4 0x47c12a push %ebp 5 0x47c12b mov %esp,%ebp 6 0x47c12d push %edi 7 0x47c12e push %esi 8 0x47c12f push %ebx 9 0x47c130 push %ecx 10 0x47c131 xor %edi,%edi 11 0x47c133 xor %ebx,%ebx 12 0x47c135 mov $0x2,%esi 13 0x47c13a sub $0x28,%esp 14 0x47c13d call 0x41c250 <__main> 15 0x47c142 movl $0x8,(%esp) 16 0x47c149 call 0x401fa0 <operator new[](unsigned int)> 17 0x47c14e mov %eax,%edx 18 0x47c150 mov %edi,(%edx,%ebx,4) 19 0x47c153 add %ebx,%edi 20 0x47c155 add $0x1,%ebx 21 0x47c158 cmp $0x2710,%ebx 22 0x47c15e je 0x47c1ce <main()+174> 23 0x47c160 lea -0x1(%esi),%eax 24 0x47c163 cmp %ebx,%eax 25 0x47c165 jg 0x47c150 <main()+48> 26 0x47c167 lea (%esi,%esi,1),%eax 27 0x47c16a mov %edx,-0x20(%ebp) 28 0x47c16d mov $0xffffffff,%edx 29 0x47c172 mov %eax,%ecx 30 0x47c174 lea 0x0(,%esi,8),%eax 31 0x47c17b cmp $0x1fc00000,%ecx 32 0x47c181 mov %ecx,-0x1c(%ebp) 33 0x47c184 cmovg %edx,%eax 34 0x47c187 shl $0x2,%esi 35 0x47c18a mov %eax,(%esp) 36 0x47c18d call 0x401fa0 <operator new[](unsigned int)> 37 0x47c192 mov -0x20(%ebp),%edx 38 0x47c195 mov %esi,0x8(%esp) 39 0x47c199 mov %eax,(%esp) 40 0x47c19c mov %eax,-0x20(%ebp) 41 0x47c19f mov %edx,0x4(%esp) 42 0x47c1a3 mov %edx,-0x24(%ebp) 43 0x47c1a6 call 0x425d20 <memcpy> 44 0x47c1ab mov -0x24(%ebp),%edx 45 0x47c1ae mov %edx,(%esp) 46 0x47c1b1 call 0x402030 <operator delete[](void*)> 47 0x47c1b6 mov -0x20(%ebp),%ecx 48 0x47c1b9 mov -0x1c(%ebp),%esi 49 0x47c1bc mov %ecx,%edx 50 0x47c1be mov %edi,(%edx,%ebx,4) 51 0x47c1c1 add %ebx,%edi 52 0x47c1c3 add $0x1,%ebx 53 0x47c1c6 cmp $0x2710,%ebx 54 0x47c1cc jne 0x47c160 <main()+64> 55 0x47c1ce mov (%edx,%esi,2),%eax 56 0x47c1d1 mov $0x489940,%ecx 57 0x47c1d6 mov %eax,(%esp) 58 0x47c1d9 call 0x4595a0 <std::ostream::operator<<(int)> 59 0x47c1de sub $0x4,%esp 60 0x47c1e1 lea -0x10(%ebp),%esp 61 0x47c1e4 xor %eax,%eax 62 0x47c1e6 pop %ecx 63 0x47c1e7 pop %ebx 64 0x47c1e8 pop %esi 65 0x47c1e9 pop %edi 66 0x47c1ea pop %ebp 67 0x47c1eb lea -0x4(%ecx),%esp 68 0x47c1ee ret
很明顯,我們可以看出類的構造函數被展開了,除此之外,兩個call_me()調用都被展開了:標志性的call <delete>和call <memcpy>。GCC在這里展現出了嚴格按照內聯語義,展開了類定義處的內聯函數的行為,即使函數體有較為龐大的循環語句(雖然這通常不是很好的做法,因為循環的執行時間是線性的,而函數調用的時間是常數的;較大的循環長度則會讓循環體本身的執行時間掩蓋微不足道的函數調用時間);而MSVC則認為較長的、帶有復雜跳轉的函數展開無益,甚至可能有害,因而即使打開了內聯優化選項,它也拒絕將標識為inline的、定義在類內部的函數進行內聯。
在有些時候,縱然沒有寫出inline關鍵字,編譯器已經在幫你默默地進行內聯優化了。看一下下面的這段簡短的示例:
1 #include <iostream> 2 using namespace std; 3 int inline_test1(int a,int b){ 4 return a * b + a + b; 5 } 6 int main() 7 { 8 int m; 9 int n; 10 cin >> m >> n; 11 int r = inline_test1(m, n); 12 cout << r << endl; 13 return 0; 14 }
在上面的代碼中,我並沒有為函數inline_test1顯式地使用inline關鍵字,但是在-O2的編譯選項下,觀察GCC為上述的C++代碼編譯並生成了的匯編代碼(如下所示),可以發現,21~23行中,lea、imul、add指令的組合恰好就是函數inline_test1的主體,而函數調用的call指令並未出現。也就是說,在-O2的優化條件下,GCC直接將簡短的函數內聯進了調用者。
1 0x47c120 lea 0x4(%esp),%ecx 2 0x47c124 and $0xfffffff0,%esp 3 0x47c127 pushl -0x4(%ecx) 4 0x47c12a push %ebp 5 0x47c12b mov %esp,%ebp 6 0x47c12d push %ecx 7 0x47c12e sub $0x24,%esp 8 0x47c131 call 0x41c250 <__main> 9 0x47c136 lea -0x10(%ebp),%eax 10 0x47c139 mov $0x489a00,%ecx 11 0x47c13e mov %eax,(%esp) 12 0x47c141 call 0x456950 <std::istream::operator>>(int&)> 13 0x47c146 lea -0xc(%ebp),%edx 14 0x47c149 sub $0x4,%esp 15 0x47c14c mov %eax,%ecx 16 0x47c14e mov %edx,(%esp) 17 0x47c151 call 0x456950 <std::istream::operator>>(int&)> 18 0x47c156 mov -0xc(%ebp),%edx 19 0x47c159 sub $0x4,%esp 20 0x47c15c mov $0x489940,%ecx 21 0x47c161 lea 0x1(%edx),%eax 22 0x47c164 imul -0x10(%ebp),%eax 23 0x47c168 add %edx,%eax 24 0x47c16a mov %eax,(%esp) 25 0x47c16d call 0x4595a0 <std::ostream::operator<<(int)> 26 0x47c172 sub $0x4,%esp 27 0x47c175 mov %eax,(%esp) 28 0x47c178 call 0x478ad0 <std::basic_ostream<char, std::char_traits<char> >& std::endl<char, std::char_traits<char> >(std::basic_ostream<char, std::char_traits<char> >&)> 29 0x47c17d mov -0x4(%ebp),%ecx 30 0x47c180 xor %eax,%eax 31 0x47c182 leave 32 0x47c183 lea -0x4(%ecx),%esp 33 0x47c186 ret
因此,從上面的例子來看,具體是否產生了內聯這一行為,inline這一關鍵字是並不能起到決定性作用的。在現在的編譯器下,內聯往往成為了一種編譯器行為,編譯器會根據具體的情況做出適當的取舍。人為地使用inline關鍵字,只是給了編譯器一條建議:最好能把這個函數內聯了。既然是建議,那就有好有壞,未必必須要遵從執行;編譯器既然有權采納你的建議,當然也有權拒絕了。
inline,就不會有坑嗎
如果編譯器同意使用inlining了,那么一切就會如你所願,就是平坦的陽光大道嗎?具體的答案並不明確,不過下面列出的,也許就是內聯函數默默給你挖下的坑。
目標代碼變得太大。內聯函數展開的原理,是在調用處將整個函數體展開(棧幀操作、返回等忽略了),所以如果某個函數被反復調用,而函數體本身較長,那么目標碼的體積就會急劇膨脹,減少函數調用開銷帶來的性能提升很可能被內存緊張而抵消掉(當程序占用內存過大時,容易引發Page Fault缺頁異常導致操作系統的換頁操作,這會嚴重降低性能。)。
出現莫名其妙的鏈接錯誤。C/C++編譯器在編譯時,是在預處理器展開#include指令包含的頭文件后逐個編譯源代碼文件。在內聯函數沒有被包含卻被跨文件調用時,某些編譯器很有可能會出現“無法解析的外部符號”這一鏈接錯誤(這種情況取決於編譯器本身,GCC在編譯內聯函數時有時候會生成獨立函數的代碼,這樣跨文件調用、遞歸等情況就可以使用普通的函數調用)。
不同的編譯器、語言標准對同樣的關鍵字語義差別很大。例如在C99(GNU99)和GNU89中,extern inline、static inline、inline的語義就有所區別;而在C++中,則只有inline這一種表述方式,並沒有static inline、extern inline之類的說法。編譯器指令也隨着編譯器的不同而不同。這種混亂的使用,往往也是造成問題的罪魁禍首。
大量調試器面對內聯函數束手無策。這雖然是Effective C++中的條款,而這本書出版也已經很久了,然而我使用的Visual Studio 2015的調試器,遇到內聯函數時,也會報告斷點無法命中。對於內聯函數,當它被展開嵌入進主調函數時,編譯器是無法跟蹤其運行的,因此往往會出現一種“設置了某個斷點,卻無法命中”的情況。在顯式聲明的內聯函數中設置斷點,顯然是多此一舉,想必誰也不會去干這種徒勞的事。而對於編譯器偷偷摸摸擅作主張的內聯,就要留個心眼了。最起碼,在碰到問題而斷點不命中時,在心里得有這個意識:是不是編譯器在后面做鬼內聯,讓我的斷點失效了?這時候就得試着關閉編譯器的內聯選項,再觀察斷點和進一步調試。雖然導致斷點失效的情況可能很多,但是內聯函數確實一個很重要的原因。
內聯函數無法隨着程序庫的升級而升級。這也是Effective C++從實際工程中給出的參考建議。理由也很簡單,內聯函數嵌入到了代碼的各個角落,直接更新函數庫並不能更新已經展開了的函數。使用普通函數可以在鏈接時對其進行更新,遠比重新編譯負擔低;而動態鏈接則是一種更好的做法。
inline,我真的需要“強調“嗎
作為本篇隨筆的結尾,自然順水推舟的給出了這個問題:在什么情況下適用inlining?是不是該我們自己inlining?
因為內聯函數的本意是縮小函數調用的開銷。那么函數調用的開銷在什么情況會占很大比重呢?答案是顯然的,只有在函數體本身足夠短小精悍時,函數調用才有可能成為性能的瓶頸。因此,援引Meyer Scott在Effective C++中的建議就是,將大多數內聯函數限制在小型的,被頻繁調用的函數身上。個人認為,更激進的做法便是,不必要手工inline,一切交給編譯器即可。如果真的發現函數調用成為性能瓶頸了,再進行內聯構造也不遲。記得知乎曾經有個笑話,說是怎么寫5*7最快。下面各種方法都有,然而最后道破天機的,便是直接寫5*7。
2017.2.25
