程序師世界是廣大編程愛好者互助、分享、學習的平台,程序師世界有你更精彩!
首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫知識
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
 程式師世界 >> 編程語言 >> C語言 >> 關於C語言 >> C++的函數重載基礎教程

C++的函數重載基礎教程

編輯:關於C語言
 

函數重載的重要性不言而明,但是你知道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++中,我們可以這樣做:

#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編譯,然後執行)

C 函數重載例子1

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)

image

發現函數void print(string str) 編譯之後為:(注意它的函數簽名變為——_Z5printSs)

image

我們可以發現編譯之後,重載函數的名字變了不再都是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、重載函數的調用匹配
現在已經解決了重載函數命名沖突的問題,在定義完重載函數之後,用函數名調用的時候是如何去解析的?為了估計哪個重載函數最適合,需要依次按照下列規則來判斷:

精確匹配:參數匹配而不做轉換,或者只是做微不足道的轉換,如數組名到指針、函數名到指向函數的指針、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中省略號參數
如果在最高層有多個匹配函數找到,調用將被拒絕(因為有歧義、模凌兩可)。看下面的例子:

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++文法、符號表、抽象語法樹交互處理,交互圖大致如下:

image

這個四個解析步驟所做的事情大致如下:

由匹配文法中的函數調用,獲取函數名;
獲得函數各參數表達式類型;
語法分析器查找重載函數,符號表內部經過重載解析返回最佳的函數
語法分析器創建抽象語法樹,將符號表中存儲的最佳函數綁定到抽象語法樹上
 

下面我們重點解釋一下重載解析,重載解析要滿足前面《3、重載函數的調用匹配》中介紹的匹配順序和規則。重載函數解析大致可以分為三步:

根據函數名確定候選函數集
從候選函數集中選擇可用函數集合
從可用函數集中確定最佳函數,或由於模凌兩可返回錯誤
4.1、根據函數名確定候選函數集
根據函數在同一作用域內所有同名的函數,並且要求是可見的(像private、protected、public、friend之類)。“同一作用域”也是在函數重載的定義中的一個限定,如果不在一個作用域,不能算是函數重載,如下面的代碼:

void f(int); void g() { void f(double); f(1); //這裡調用的是f(double),而不是f(int) }即內層作用域的函數會隱藏外層的同名函數!同樣的派生類的成員函數會隱藏基類的同名函數。這很好理解,變量的訪問也是如此,如一個函數體內要訪問全局的同名變量要用“::”限定。

為了查找候選函數集,一般采用深度優選搜索算法:

step1:從函數調用點開始查找,逐層作用域向外查找可見的候選函數
step2:如果上一步收集的不在用戶自定義命名空間中,則用到了using機制引入的命名空間中的候選函數,否則結束

在收集候選函數時,如果調用函數的實參類型為非結構體類型,候選函數僅包含調用點可見的函數;如果調用函數的實參類型包括類類型對象、類類型指針、類類型引用或指向類成員的指針,候選函數為下面集合的並:

(1)在調用點上可見的函數;
(2)在定義該類類型的名字空間或定義該類的基類的名字空間中聲明的函數;
(3)該類或其基類的友元函數;
下面我們來看一個例子更直觀:

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-》為哪個字符等等類似的差異)
image

圖附1、過程1-建立函數鏈表(說明,函數名的編碼格式為:<原函數名>_<作用域換名><函數參數表編碼>,這跟g++中的有點不一樣)

2、在函數調用語句翻譯過程中,訪問符號表,查找相應函數聲明原型鏈表,按照類型匹配原則,查找最優匹配函數節點,並輸出換名後的名字下面給出兩個子過程的算法建立函數聲明原型鏈表算法流程如圖附1,函數調用語句翻譯算法流程如圖附2。
image

圖附2、過程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.

 

 
  1. 上一頁:
  2. 下一頁:
Copyright © 程式師世界 All Rights Reserved