——每個現象後面都隱藏一個本質,關鍵在於我們是否去挖掘
寫在前面:
函數重載的重要性不言而明,但是你知道C++中函數重載是如何實現的呢雖然本文談的是C++中函數重載的實現,但我想其它語言也是類似的)?這個可以分解為下面兩個問題
這兩個問題是任何支持函數重載的語言都必須要解決的問題!帶著這兩個問題,我們開始本文的探討。本文的主要內容如下:
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++中,我們可以這樣做:
- #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),如下面的結果:先用g++ test.c編譯,然後執行)
1.2、為什麼需要函數重載why)?
通過上面的介紹我們對函數重載,應該喚醒了我們對函數重載的大概記憶。下面我們就來分析,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….基本上都是用首字母代表,現在我們來現在一個函數的返回值類型是否真的對函數變名有影響,如:
- #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編譯器的話,對應關系跟這個肯定不一樣。但是規則是一樣的:“返回類型+函數名+參數列表”。
既然返回類型也考慮到映射機制中,這樣不同的返回類型映射之後的函數名肯定不一樣了,但為什麼不將函數返回類型考慮到函數重載中呢?——這是為了保持解析操作符或函數調用時,獨立於上下文不依賴於上下文),看下面的例子
- 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函數,並根據實參調用不同的函數:
- #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、重載函數的調用匹配
現在已經解決了重載函數命名沖突的問題,在定義完重載函數之後,用函數名調用的時候是如何去解析的?為了估計哪個重載函數最適合,需要依次按照下列規則來判斷:
如果在最高層有多個匹配函數找到,調用將被拒絕因為有歧義、模凌兩可)。看下面的例子:
- 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*)
- }
定義太少或太多的重載函數,都有可能導致模凌兩可,看下面的一個例子:
- 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),當然這樣做很丑,而且你想調用別的方法時有用做轉換)。上面的例子只是一個參數的情況,下面我們再來看一個兩個參數的情況:
- 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之類)。“同一作用域”也是在函數重載的定義中的一個限定,如果不在一個作用域,不能算是函數重載,如下面的代碼:
- void f(int);
- void g()
- {
- void f(double);
- f(1); //這裡調用的是f(double),而不是f(int)
- }
即內層作用域的函數會隱藏外層的同名函數!同樣的派生類的成員函數會隱藏基類的同名函數。這很好理解,變量的訪問也是如此,如一個函數體內要訪問全局的同名變量要用“::”限定。
為了查找候選函數集,一般采用深度優選搜索算法:
step1:從函數調用點開始查找,逐層作用域向外查找可見的候選函數
step2:如果上一步收集的不在用戶自定義命名空間中,則用到了using機制引入的命名空間中的候選函數,否則結束
在收集候選函數時,如果調用函數的實參類型為非結構體類型,候選函數僅包含調用點可見的函數;如果調用函數的實參類型包括類類型對象、類類型指針、類類型引用或指向類成員的指針,候選函數為下面集合的並:
下面我們來看一個例子更直觀:
- 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、確定可用函數
可用的函數是指:函數參數個數匹配並且每一個參數都有隱式轉換序列。
這些規則在前面的《3、重載函數的調用匹配》中就有所體現了。
4.3、確定最佳匹配函數
確定可用函數之後,對可用函數集中的每一個函數,如果調用函數的實參要調用它計算優先級,最後選出優先級最高的。如對《3、重載函數的調用匹配》中介紹的匹配規則中按順序分配權重,然後計算總的優先級,最後選出最優的函數。
5、總結
本文介紹了什麼是函數重載、為什麼需要函數重載、編譯器如何解決函數重名問題、編譯器如何解析重載函數的調用。通過本文,我想大家對C++中的重載應該算是比較清楚了。說明:在介紹函數名映射機制是基於g++編譯器,不同的編譯器映射有些差別;編譯器解析重載函數的調用,也只是所有編譯器中的一種。如果你對某個編譯器感興趣,請自己深入去研究。
最後我拋給大家兩個問題:
附錄:一種C++函數重載機制
這個機制是由張素琴等人提出並實現的,他們寫了一個C++的編譯系統COC++開發在國產機上,UNIX操作系統環境下具有中國自己版權的C、C++和FORTRAN語言編譯系統,這些編譯系統分別滿足了ISOC90、AT&T的C++85和ISOFORTRAN90標准)。COC++中的函數重載處理過程主要包括兩個子過程:
圖附1、過程1-建立函數鏈表說明,函數名的編碼格式為:<原函數名>_<作用域換名><函數參數表編碼>,這跟g++中的有點不一樣)
圖附2、過程2- 重載函數調用,查找鏈表