—— 每個現象後面都隱藏一個本質,關鍵在於我們是否去挖掘
寫在前面:
函式過載的重要性不言而明,但是你知道C++中函式過載是如何實現的呢(雖然本文談的是C++中函式過載的實現,但我想其它語言也是類似的)?這個可以分解為下面兩個問題
- 1、宣告/定義過載函式時,是如何解決命名衝突的?(拋開函式過載不談,using就是一種解決命名衝突的方法,解決命名衝突還有很多其它的方法,這裡就不論述了)
- 2、當我們呼叫一個過載的函式時,又是如何去解析的?(即怎麼知道呼叫的是哪個函式呢)
這兩個問題是任何支援函式過載的語言都必須要解決的問題!帶著這兩個問題,我們開始本文的探討。本文的主要內容如下:
- 1、例子引入(現象)
- 什麼是函式過載(what)?
- 為什麼需要函式過載(why)?
- 2、編譯器如何解決命名衝突的?
- 函式過載為什麼不考慮返回值型別
- 3、過載函式的呼叫匹配
- 模凌兩可的情況
- 4、編譯器是如何解析過載函式呼叫的?
- 根據函式名確定候選函式集
- 確定可用函式
- 確定最佳匹配函式
- 5、總結
1、例子引入(現象)
1.1、什麼是函式過載(what)?
函式過載是指在同一作用域內,可以有一組具有相同函式名,不同引數列表的函式,這組函式被稱為過載函式。過載函式通常用來命名一組功能相似的函式,這樣做減少了函式名的數量,避免了名字空間的汙染,對於程式的可讀性有很大的好處。
When two or more different declarations are specified for a single name in the same scope, that name is said to overloaded. By extension, two declarations in the same scope that declare the same name but with different types are called overloaded declarations. Only function declarations can be overloaded; object and type declarations cannot be overloaded. ——摘自《ANSI C++ Standard. P290》
看下面的一個例子,來體會一下:實現一個列印函式,既可以列印int型、也可以列印字串型。在C++中,我們可以這樣做:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
#include<iostream> using namespace std; void print(int i) { cout<<"print a integer :"<<i<<endl; } void print(string str) { cout<<"print a string :"<<str<<endl; } int main() { print(12); print("hello world!"); return 0; } |
通過上面程式碼的實現,可以根據具體的print()的引數去呼叫print(int)還是print(string)。上面print(12)會去呼叫print(int),print(“hello world”)會去呼叫print(string)。
1.2、為什麼需要函式過載(why)?
試想如果沒有函式過載機制,如在C中,你必須要這樣去做:為這個print函式取不同的名字,如print_int、print_string。這裡還只是兩個的情況,如果是很多個的話,就需要為實現同一個功能的函式取很多個名字,如加入列印long型、char*、各種型別的陣列等等。這樣做很不友好!
類的建構函式跟類名相同,也就是說:建構函式都同名。如果沒有函式過載機制,要想例項化不同的物件,那是相當的麻煩!
操作符過載,本質上就是函式過載,它大大豐富了已有操作符的含義,方便使用,如+可用於連線字串等!
通過上面的介紹我們對函式過載,應該喚醒了我們對函式過載的大概記憶。下面我們就來分析,C++是如何實現函式過載機制的。
2、編譯器如何解決命名衝突的?
為了瞭解編譯器是如何處理這些過載函式的,我們反編譯下上面我們生成的執行檔案,看下彙編程式碼(全文都是在Linux下面做的實驗,Windows類似,你也可以參考《一道簡單的題目引發的思考》一文,那裡既用到Linux下面的反彙編和Windows下面的反彙編,並註明了Linux和Windows組合語言的區別)。我們執行命令objdump -d a.out >log.txt反彙編並將結果重定向到log.txt檔案中,然後分析log.txt檔案。
發現函式void print(int i) 編譯之後為:(注意它的函式簽名變為——_Z5printi)
發現函式void print(string str) 編譯之後為:(注意它的函式簽名變為——_Z5printSs)
我們可以發現編譯之後,過載函式的名字變了不再都是print!這樣不存在命名衝突的問題了,但又有新的問題了——變名機制是怎樣的,即如何將一個過載函式的簽名對映到一個新的標識?我的第一反應是:函式名+引數列表,因為函式過載取決於引數的型別、個數,而跟返回型別無關。但看下面的對映關係:
void print(int i) –> _Z5printi
void print(string str) –> _Z5printSs
進一步猜想,前面的Z5表示返回值型別,print函式名,i表示整型int,Ss表示字串string,即對映為返回型別+函式名+引數列表。最後在main函式中就是通過_Z5printi、_Z5printSs來呼叫對應的函式的:
80489bc: e8 73 ff ff ff call 8048934 <_Z5printi>
……………
80489f0: e8 7a ff ff ff call 804896f <_Z5printSs>
我們再寫幾個過載函式來驗證一下猜想,如:
void print(long l) –> _Z5printl
void print(char str) –> _Z5printc
可以發現大概是int->i,long->l,char->c,string->Ss….基本上都是用首字母代表,現在我們來現在一個函式的返回值型別是否真的對函式變名有影響,如:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
#include<iostream> using namespace std; int max(int a,int b) { return a>=b?a:b; } double max(double a,double b) { return a>=b?a:b; } int main() { cout<<"max int is: "<<max(1,3)<<endl; cout<<"max double is: "<<max(1.2,1.3)<<endl; return 0; } |
int max(int a,int b) 對映為_Z3maxii、double max(double a,double b) 對映為_Z3maxdd,這證實了我的猜想,Z後面的數字程式碼各種返回型別。更加詳細的對應關係,如那個數字對應那個返回型別,哪個字元代表哪重引數型別,就不去具體研究了,因為這個東西跟編譯器有關,上面的研究都是基於g++編譯器,如果用的是vs編譯器的話,對應關係跟這個肯定不一樣。但是規則是一樣的:“返回型別+函式名+引數列表”。
既然返回型別也考慮到對映機制中,這樣不同的返回型別對映之後的函式名肯定不一樣了,但為什麼不將函式返回型別考慮到函式過載中呢?——這是為了保持解析操作符或函式呼叫時,獨立於上下文(不依賴於上下文),看下面的例子
1 2 3 4 5 6 7 8 9 10 11 |
float sqrt(float); double sqrt(double); void f(double da, float fla) { float fl=sqrt(da);//呼叫sqrt(double) double d=sqrt(da);//呼叫sqrt(double) fl=sqrt(fla);//呼叫sqrt(float) d=sqrt(fla);//呼叫sqrt(float) } |
如果返回型別考慮到函式過載中,這樣將不可能再獨立於上下文決定呼叫哪個函式。
至此似乎已經完全分析清楚了,但我們還漏了函式過載的重要限定——作用域。上面我們介紹的函式過載都是全域性函式,下面我們來看一下一個類中的函式過載,用類的物件呼叫print函式,並根據實參呼叫不同的函式:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
#include<iostream> using namespace std; class test{ public: void print(int i) { cout<<"int"<<endl; } void print(char c) { cout<<"char"<<endl; } }; int main() { test t; t.print(1); t.print('a'); return 0; } |
我們現在再來看一下這時print函式對映之後的函式名:
void print(int i) –> _ZN4test5printEi
void print(char c) –> _ZN4test5printEc
注意前面的N4test,我們可以很容易猜到應該表示作用域,N4可能為名稱空間、test類名等等。這說明最準確的對映機制為:作用域+返回型別+函式名+引數列表。
3、過載函式的呼叫匹配
現在已經解決了過載函式命名衝突的問題,在定義完過載函式之後,用函式名呼叫的時候是如何去解析的?為了估計哪個過載函式最適合,需要依次按照下列規則來判斷:
- 精確匹配:引數匹配而不做轉換,或者只是做微不足道的轉換,如陣列名到指標、函式名到指向函式的指標、T到const T;
- 提升匹配:即整數提升(如bool 到 int、char到int、short 到int),float到double
- 使用標準轉換匹配:如int 到double、double到int、double到long double、Derived*到Base*、T*到void*、int到unsigned int;
- 使用使用者自定義匹配;
- 使用省略號匹配:類似printf中省略號引數
如果在最高層有多個匹配函式找到,呼叫將被拒絕(因為有歧義、模凌兩可)。看下面的例子:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
void print(int); void print(const char*); void print(double); void print(long); void print(char); void h(char c,int i,short s, float f) { print(c);//精確匹配,呼叫print(char) print(i);//精確匹配,呼叫print(int) print(s);//整數提升,呼叫print(int) print(f);//float到double的提升,呼叫print(double) print('a');//精確匹配,呼叫print(char) print(49);//精確匹配,呼叫print(int) print(0);//精確匹配,呼叫print(int) print("a");//精確匹配,呼叫print(const char*) } |
定義太少或太多的過載函式,都有可能導致模凌兩可,看下面的一個例子:
1 2 3 4 5 6 7 8 9 10 11 |
void f1(char); void f1(long); void f2(char*); void f2(int*); void k(int i) { f1(i);//呼叫f1(char)? f1(long)? f2(0);//呼叫f2(char*)?f2(int*)? } |
這時侯編譯器就會報錯,將錯誤拋給使用者自己來處理:通過顯示型別轉換來呼叫等等(如f2(static_cast<int *>(0),當然這樣做很醜,而且你想呼叫別的方法時有用做轉換)。上面的例子只是一個引數的情況,下面我們再來看一個兩個引數的情況:
1 2 3 4 5 6 7 |
int pow(int ,int); double pow(double,double); void g() { double d=pow(2.0,2)//呼叫pow(int(2.0),2)? pow(2.0,double(2))? } |
4、編譯器是如何解析過載函式呼叫的?
編譯器實現呼叫過載函式解析機制的時候,肯定是首先找出同名的一些候選函式,然後從候選函式中找出最符合的,如果找不到就報錯。下面介紹一種過載函式解析的方法:編譯器在對過載函式呼叫進行處理時,由語法分析、C++文法、符號表、抽象語法樹互動處理,互動圖大致如下:
這個四個解析步驟所做的事情大致如下:
- 由匹配文法中的函式呼叫,獲取函式名;
- 獲得函式各參數列達式型別;
- 語法分析器查詢過載函式,符號表內部經過過載解析返回最佳的函式
- 語法分析器建立抽象語法樹,將符號表中儲存的最佳函式繫結到抽象語法樹上
下面我們重點解釋一下過載解析,過載解析要滿足前面《3、過載函式的呼叫匹配》中介紹的匹配順序和規則。過載函式解析大致可以分為三步:
- 根據函式名確定候選函式集
- 從候選函式集中選擇可用函式集合
- 從可用函式集中確定最佳函式,或由於模凌兩可返回錯誤
4.1、根據函式名確定候選函式集
根據函式在同一作用域內所有同名的函式,並且要求是可見的(像private、protected、public、friend之類)。“同一作用域”也是在函式過載的定義中的一個限定,如果不在一個作用域,不能算是函式過載,如下面的程式碼:
1 2 3 4 5 6 7 |
void f(int); void g() { void f(double); f(1); //這裡呼叫的是f(double),而不是f(int) } |
即內層作用域的函式會隱藏外層的同名函式!同樣的派生類的成員函式會隱藏基類的同名函式。這很好理解,變數的訪問也是如此,如一個函式體內要訪問全域性的同名變數要用“::”限定。
為了查詢候選函式集,一般採用深度優選搜尋演算法:
step1:從函式呼叫點開始查詢,逐層作用域向外查詢可見的候選函式
step2:如果上一步收集的不在使用者自定義名稱空間中,則用到了using機制引入的名稱空間中的候選函式,否則結束
在收集候選函式時,如果呼叫函式的實參型別為非結構體型別,候選函式僅包含呼叫點可見的函式;如果呼叫函式的實參型別包括類型別物件、類型別指標、類型別引用或指向類成員的指標,候選函式為下面集合的並:
(1)在呼叫點上可見的函式;
(2)在定義該類型別的名字空間或定義該類的基類的名字空間中宣告的函式;
(3)該類或其基類的友元函式;
下面我們來看一個例子更直觀:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
void f(); void f(int); void f(double, double = 314); names pace N { void f(char3 ,char3); } classA{ public: operat or double() { } }; int main ( ) { using names pace N; //using指示符 A a; f(a); return 0; } |
根據上述方法,由於實參是類型別的物件,候選函式的收集分為3步:
(1)從函式呼叫所在的main函式作用域內開始查詢函式f的宣告, 結果未找到。到main函式
作用域的外層作用域查詢,此時在全域性作用域找到3個函式f的宣告,將它們放入候選集合;
(2)到using指示符所指向的名稱空間 N中收集f ( char3 , char3 ) ;
(3)考慮2類集合。其一為定義該類型別的名字空間或定義該類的基類的名字空間中宣告的函
數;其二為該類或其基類的友元函式。本例中這2類集合為空。
最終候選集合為上述所列的 4個函式f。
4.2、確定可用函式
可用的函式是指:函式引數個數匹配並且每一個引數都有隱式轉換序列。
- (1)如果實參有m個引數,所有候選引數中,有且只有 m個引數;
- (2)所有候選引數中,引數個數不足m個,當前僅當引數列表中有省略號;
- (3)所有候選引數中,引數個數超過 m個,當前僅當第m + 1個引數以後都有預設值。如果可用
- 集合為空,函式呼叫會失敗。
這些規則在前面的《3、過載函式的呼叫匹配》中就有所體現了。
4.3、確定最佳匹配函式
確定可用函式之後,對可用函式集中的每一個函式,如果呼叫函式的實參要呼叫它計算優先順序,最後選出優先順序最高的。如對《3、過載函式的呼叫匹配》中介紹的匹配規則中按順序分配權重,然後計算總的優先順序,最後選出最優的函式。
5、總結
本文介紹了什麼是函式過載、為什麼需要函式過載、編譯器如何解決函式重名問題、編譯器如何解析過載函式的呼叫。通過本文,我想大家對C++中的過載應該算是比較清楚了。說明:在介紹函式名對映機制是基於g++編譯器,不同的編譯器對映有些差別;編譯器解析過載函式的呼叫,也只是所有編譯器中的一種。如果你對某個編譯器感興趣,請自己深入去研究。
最後我拋給大家兩個問題:
- 1、在C++中加號+,即可用於兩個int型之間的相加、也可以用於浮點數數之間的相加、字串之間的連線,那+算不算是操作符過載呢?換個場景C語言中加號+,即可用於兩個int型之間的相加、也可以用於浮點數數之間的相加,那算不算操作符過載呢?
- 2、模板(template)的過載時怎麼樣的?模板函式和普通函式構成的過載,呼叫時又是如何匹配的呢?
附錄:一種C++函式過載機制
這個機制是由張素琴等人提出並實現的,他們寫了一個C++的編譯系統COC++(開發在國產機上,UNIX作業系統環境下具有中國自己版權的C、C++和FORTRAN語言編譯系統,這些編譯系統分別滿足了ISOC90、AT&T的C++85和ISOFORTRAN90標準)。COC++中的函式過載處理過程主要包括兩個子過程:
1、在函式宣告時的處理過程中,編譯系統建立函式宣告原型連結串列,按照換名規則進行換名並在函式宣告原型連結串列中記錄函式換名後的名字(換名規則跟本文上面描述的差不多,只是那個int-》為哪個字元、char-》為哪個字元等等類似的差異)
圖附1、過程1-建立函式連結串列(說明,函式名的編碼格式為:<原函式名>_<作用域換名><函式參數列編碼>,這跟g++中的有點不一樣)
2、在函式呼叫語句翻譯過程中,訪問符號表,查詢相應函式宣告原型連結串列,按照型別匹配原則,查詢最優匹配函式節點,並輸出換名後的名字下面給出兩個子過程的演算法建立函式宣告原型連結串列演算法流程如圖附1,函式呼叫語句翻譯演算法流程如圖附2。
附-模板函式和普通函式構成的過載,呼叫時又是如何匹配的呢?
下面是C++創始人Bjarne Stroustrup的回答:
1)Find the set of function template specializations that will take part in overload resolution.
2)if two template functions can be called and one is more specified than the other, consider only the most specialized template function in the following steps.
3)Do overload resolution for this set of functions, plus any ordinary functions as for ordinary functions.
4)If a function and a specialization are equally good matches, the function is perferred.
5)If no match is found, the call is an error.