第十四章 重載運算與類型轉換
14.1 基本概念
重載運算符:他們的名字由operator加上運算符號組成。跟其他函數一樣,他們也包括返回類型,參數類型和函數體
重載運算符的參數數量與該運算符作用的運算對象數量一樣多。一元運算符有一個參數,二元運算符有兩個
注意:除了重載函數調用符以外,其他的重載運算符不能含有默認實參。
如果一個運算符函數是成員函數,則它的第一個運算對象綁定到隱式的this指針上,因此,成員運算符函數的參數數量比運算符的運算對象總數少一個
注意:對於一個運算符函數來說,它或者是類的成員,或者至少含有一個類類型的參數。如果都是內置類型,則是錯誤的。
//錯誤:不能爲int重定義內置的運算符
int operator+(int,int);
這就意味着當運算符作用於內置類型的運算對象時,我們無法改變該運算符的含義。
下表列出了可以重載的運算符
對於重載的運算符來說,其優先級和結合律與對應的內置運算符保持一致。
某些運算符不應該被重載
某些運算符指定了運算對象求值的順序。
因爲使用重載的運算符本質上是一次函數調用,所以這些關於運算對象求值順序的規則無法應用到重載的運算符上。
比如,邏輯與運算符和邏輯或運算符以及逗號運算符的運算對象求值順序規則無法保留下來。同時,邏輯與和邏輯或運算符的重載版本也無法保留內置運算符的短路求值屬性。
因此,對於以上這些運算符,不建議重載他們。
還有一個原因使得我們一般不重載逗號運算符和取地址運算符:c++ 語言已經定義了這兩種運算符用於類類型對象時的特殊含義,如果我們再重載他們,則將導致非常詭異的行爲。
注意:通常情況下,不應該重載逗號,取地址,邏輯與和邏輯或運算符
- 如果一個類定義了operator==,則該類也應該定義一個operator!=
- 如果類包含一個內在的單序比較操作,則定義operator<,如果類有了operator<,則應該還有其他的關係操作
- 如果含有算數運算符或者位運算符,則最好也提供對應的複合賦值運算符。
- 賦值,下標,調用和成員訪問運算符必須是成員函數
- 複合賦值運算符一般來說應該是成員,但並非必須,這一點與賦值運算符略有不同。
- 改變對象狀態的運算符或者與給定類型密切相關的運算符,如遞增,遞減和解引用運算符,通常應該是成員
- 具有對稱性的運算符可能轉換任意一段的運算對象,例如算數,相等,關係和位運算符等,因此他們通常應該是普通的非成員函數
14.2 輸入和輸出運算符
14.2.1 重載輸出運算符<<
通常情況下,輸出運算符的第一形參是非常量的ostream引用,非常量,是因爲需要修改ostream對象的內容,引用是,ostream不能複製。
第二個形參一般爲一個常量的引用,他是我們想要打印的類類型。爲引用是爲了避免複製,爲常量是因爲打印不改變對象大小。
爲了保持一致,operatror<<一般返回它的ostream形參。
注意:輸出運算符應儘量減少格式化操作,格式化操作由調用者來決定,這樣才能夠更加靈活
輸入輸出運算符必須是非成員函數
如果不是非成員函數,那麼輸出運算符的第一個對象必須是這個類的對象。如下的形式
Sales_data data;
data << cout;//operator<< 是Sales_data的成員函數
而我們正常使用,通常爲如下形式:
cout << data;
這就表示,重載的operator還必須是cout的成員函數,然而我們不能向cout添加類成員。
因此,只能定義爲非成員函數的形式。當然IO運算符通常需要讀寫類的非公有數據成員,所以IO運算符一般被聲明爲友元。
14.2.2 重載輸入運算符>>
輸入運算符的第一個形參爲非常量輸入流的引用,因爲需要修改流中的狀態,所以爲非常量,因爲不能複製,所以爲引用
輸入運算符的第二個形參爲非常亮的要讀入的對象的引用,因爲需要讀入,所以爲非常量,且爲引用
輸入時錯誤
在執行輸入的時候,可能發生如下的錯誤:
- 當流中含有錯誤類型的數據時,讀取操作可能失敗。
- 當讀取操作到達文件末尾或者遇到輸入流的其他錯誤時也會失敗。
如果在錯誤發生前,對象已經讀入了部分內容,那麼在錯誤發生後,應該將對象重置爲一個合法的狀態。這樣可以保護使用者受輸入錯誤的影響。
14.3 算術和關係運算符
通常情況下,我們把算術運算符和關係運算符定義爲非成員函數以允許對左側或右側的運算對象進行轉換。
因爲這些運算符一般不需要改變運算對象的狀態,所以形參都是常量的引用
通常情況下,類定義了一個算術運算符,也會定義一個相應的複合運算符,然後使用這個複合運算符來實現對應的算術運算符
14.3.1 相等運算符
通常情況下,c++中類通過定義相等運算符來檢驗兩個對象是否相等。也就是說,他們會比較對象的每一個數據成員,只有當所有對應的成員都相等時才認爲兩個對象相等。
-
通常情況下,相等運算符應該具有傳遞性,如果ab bc,那麼a == c
-
如果類定義了operator==,這個類也應該定義operato!=
注意:如果某個類在邏輯上有相等性,則該類應該定義operator==,這樣做可以使得用戶更容易使用標準庫算符來處理這個類
14.3.2 關係運算符
定義了相等運算符的類,也常常包含關係運算符。特別是,因爲關聯容器和一些算法要用到小於運算符,所以定義operator<會比較有用。
通常情況下關係運算符應該:
- 定義順序關係,令其與關聯容器中對關鍵字的要求一致;並且
- 如果類同時含有運算符的話,則定義一種關係令其與保持一致。特別是,如果兩個對象是!=的,那麼一個對象應該<另外一個對象
注意:如果存在唯一一種邏輯可靠的<定義,則應該考慮爲這個類定義<運算符。如果類同時還包含==,則當且僅當<的定義和==產生的結果一致時才定義<運算符
14.4 賦值運算符
注意:我們可以重載賦值運算符。無論形參的類型是什麼,賦值運算符都必須定義爲成員函數,返回左側運算符對象的引用。
複合賦值運算符不非得是類的成員函數,不過我們還是傾向於把複合賦值在內的所有賦值運算符都定義在類的內部。
14.5 下標運算符
operator[]
注意:下標運算符必須是成員函數
爲了與下標的原始定義兼容,下標運算符通常以所訪問元素的引用作爲返回值,這樣做的好處是下標可以出現在賦值運算符的任意一端。進一步,我們最好同時定義下標運算符的常量版本和非常量版本,當作用於一個常量對象時,下標運算符返回常量引用以確保我們不會給返回的對象賦值。
14.6 遞增和遞減運算符
在迭代器類中通常會實現遞增和遞減運算符,這兩種運算符使得類可以在元素的序列中前後移動。c++ 語言並不要求遞增和遞減運算符必須是類的成員,但是因爲他們改變的正好是所操作的對象的狀態,所以建議將其設定爲成員函數。
注意:爲了和內置版本保持一致,前置運算符應該返回遞增或遞減後對象的引用
區分前置和後置運算符
因爲普通的重載無法區分,前置和後置的區別。所以,規定後置版本接受一個額外的(不被使用)int類型的形參。
當我們使用後置運算符時,編譯器爲這個形參提供一個值爲0的實參。
這個額外的形參的唯一作用就是區分前置版本和後置版本的函數,而不是真的要參與運算。
注意:爲了保持內置版本的一致,後置運算符應該返回對象的原始值,返回的形式是一個值而非引用
14.7 成員訪問運算符
注意:箭頭運算符必須是類的成員。解引用運算符通常也是類的成員,儘管並非必須如此。
對箭頭運算符返回值的限定
對於形如point->mem的表達式來說,point必須指向類對象的指針或者是一個重載了operator->的類的對象。根據point類型的不同,point->mem 分別等價於
(*point).mem;//point是一個內置的指針類型
point.operator()->mem;//point是類的一個對象
除此之外,代碼都將發生錯誤。point->mem的執行過程如下:
-
如果point是指針,則我們應用內置的箭頭運算符,表達式等價於(*point).mem 首先解引用該指針,然後從所得的對象中獲取指定的成員。如果point所指的類型沒有名爲mem的成員,程序會發生錯誤
-
如果point是定義了operator->的類的一個對象,則我們使用point.operator->()的結果來獲取mem。其中,如果該結果是一個指針,則執行第一步;如果結果本身含有重載的operator->() ,則重複調用當前步驟。最終,當這一個過程結束時程序或者返回了所需的內容,或者返回一些表示程序錯誤的信息。
注意:重載的箭頭運算符必須返回類的指針或者定義了箭頭運算符的某個類的對象。
14.8 函數調用運算符
注意:函數調用運算符必須是成員函數。一個類可以定義多個不同版本的調用運算符,相互之間應該在參數數量或類型上有所區別。
如果類定義了調用運算符,則該類的對象稱爲函數對象。
14.8.1 lambda是函數對象
當我們編寫了一個lambda之後,編譯器將表達式翻譯成一個未命名類的未命名對象。
而這個未命名的類,就重載了函數調用運算符。
默認情況下,由lambda產生的類當中的函數調用運算符是一個const成員。如果lambda被聲明爲可變的,則調用運算符就不是const的。
表示lambda及相應捕獲行爲的類
如我們所知,當一個lambda表達式通過引用捕獲變量時,將由程序負責確保lambda執行時,引用所引的對象確實存在。因此,編譯器可以直接使用該引用而無須再lambda產生的類中將其存儲爲數據成員。
相反,通過值捕獲的變量被拷貝到lambda中。因此,這種lambda產生的類必須爲每個值捕獲的變量建立對應的數據成員,同時創建構造函數,令其使用捕獲的值來初始化數據成員。
注意:lambda表達式產生的類不含有默認構造函數、賦值運算符及默認析構函數;它是否含有默認的拷貝、移動構造函數則通常要視捕獲的數據成員類型而定,見13.1.6小節
14.8.2 標準庫定義的函數對象
標準庫定義了一組表示算術運算符、關係運算符、邏輯運算符的類,每個類分別定義了一個執行命名操作的調用運算符。見下表:
注意:標準庫規定其函數對象對於指針同樣適用。我們之前曾介紹過比較兩個無關指針將產生未定義的行爲,然而我們可能會希望通過比較兩個指針的內存地址來sort指針的vector。直接這麼做將產生未定義的行爲,因此,我們可以使用一個標準庫函數對象來實現該目的:
vector<string *> nameTable;
//錯誤:nameTable中的指針彼此之間沒有關係,所以<將產生未定義的行爲
sort(nameTable.begin(),nameTable.end(),[](string *a,string *b){return a < b;})
//正確:標準庫規定指針的less是定義良好的
sort(nameTable.begin(),nameTable.end(),less<string*>());
14.8.3 可調用對象和function
c++中的可調用對象:函數,函數指針,lambda,bind創建的對象,以及重載了函數運算符的類
和其他的對象一樣,可調用的對象也有類型.例如,每個lambda有他唯一的類類型;函數及函數指針的類型則由其返回類型和實參類型決定,等等.
然而,兩個不同類型的可調用對象卻可能共享同一種調用形式.調用形式指明瞭調用返回類型以及傳遞給調用的實參類型.一種調用形式對應一個函數類型.例如:
int(int,int)
因爲不同的調用對象,可能有不同的類型,但是可以有相同的調用形式,對調用形式的抽象,標準庫提供了function模板.如下表
functin模板在創建的時候,需要傳遞一個調用形式.如下:
function<int(int,int) f = add;//add是一個函數指針
重載的函數與function
考慮下面的代碼
int add(int i,int j){return i+j;}
Sales_data add(const Sales_data &,const Sales_data&);
map<string,function<int(int,int)>> binops;
binops.insert({"+",add});//錯誤,add到底指向哪一個函數????
解決上面問題的一種方法是存儲函數指針,而非函數的名字:
int (*fp)(int,int) = add;//指針指向的add是接收兩個int的版本
binops.insert({"+",fp});
另外一種解決方法是:
binops.insert({"+",[](int a,int b){return add(a+b);}});
14.9 重載,類型轉換與運算符
轉換構造函數和類型轉換運算符共同定義了類類型轉換.
14.9.1 類型轉換運算符
類型轉換運算符格式:
operator type() const;
其中type表示某種類型.type可以是任意類型(除void類型),只有這種類型可以作爲函數的返回類型.所以不允許轉換成數組或者函數類型,但允許轉換成指針或者引用類型
類型轉換運算符,沒有形參,沒有返回類型,必須爲類的成員函數,類型轉換運算符通常不應該轉換對象的內容,因此,類型轉換運算符一般被定義爲const成員.
class SmallInt{
public:
SmallInt(int i = 0):val(i){
if(i < 0 || i > 255)
throw std::out_of_range("Bad SamllInt value");
}
operator int() const {return val;}
private:
std::size_t val;
};
SmallInt定義了向類類型轉換的規則,也定義了從類類型轉換成其他類型的規則.
類型轉換運算符可能產生意外結果
在實踐中,類很少提供類型轉換運算符.在大多數情況下,如果類型轉換自動發生,用戶可能會感覺比較意外,而不是感覺受到了幫助.然而這條經驗法則則存在一種例外情況,對於類來說,定義向bool類型轉換還是比較普遍的現象.
但是會引入一種問題,思考下面的代碼
int i = 42;
cin << i;//如果向bool的類型轉換不是顯示的,則該代碼在編譯器看來是合法的.
這段程序視圖將輸出運算符作用於輸入流.因爲istream本身並沒有定義<<,所以本來代碼應該產生錯誤.然而,該代碼能使用istream的bool類型轉換運算符將cin轉換成bool,而這個bool值會被提升爲int並作用內置的左移運算符到左側的運算對象上.
這樣提升後的bool值,最終會被左移42個位置.這一結果與我們的預期完全不同.
顯式的類型轉換運算符
爲了防止這種情況發生,c++11新標準引入了顯式的類型轉換運算符
class SmallInt{
public:
explicit operator int() const{
return val;
}
//...
};
和顯式構造函數一樣,編譯器也不會將一個顯式的類型轉換運算符用於隱式類型轉換:
SmallInt si = 3;
si + 3;//錯誤:此處需要隱式的類型轉換,單類的運算符是顯式的
static_cast<int>(si)+3;
該規則存在一個例外,即如果表達式被用作條件,則編譯器會將顯式的類型轉換自動應用於它.換句話說,當表達式出現在下列位置時,顯式的類型轉換將被隱式的執行:
- if,while,do語句的條件部分
- for語句頭的條件表達式
- 邏輯非運算符,邏輯或運算符,邏輯與運算符的運算對象
- 條件運算符的條件表達式
注意:向bool類型的轉換通常用在條件表達式中,因此operator bool 一般定義爲explicit的
14.9.2 避免二義性的類型轉換
通常情況下,不要爲類定義相同的類型轉換,也不要在類中定義兩個即兩個以上轉換源或轉換目標是算數類型的轉換
思考下面的例子:
struct B;
struct A{
A() = default;
A(const B&);//把B轉換成A
};
struct B{
operator A() const;//也是吧一個B轉化成A
//...
};
A f(const A&);
B b;
A a = f(b);//二義性錯誤:含義是f(B::operator A())
// f(A::A(const B&))?
因爲上面存在兩種轉換方式:1.調用B爲參數的A的構造函數;2.也可以使用B中把B轉換成A的類型轉換運算符.
如果確實需要某個調用,需要顯式的使用,如下:
A a1 = f(b.operator A());
A a2 = f(A(b));
二義性與轉換目標爲內置類型的多重類型轉換
另外如果類定義了一組類型轉換,他們的轉換源類型本身可以通過其他類型轉換聯繫在一起,則同樣會產生二義性的問題.最簡單也是最困擾我們的例子就是類中定義了多個參數都是算術類型的構造函數,或者轉換目標都是算術類型的類型轉換運算符.如下:
struct A{
A(int = 0);//最好不要創建兩個轉換源都是算術類型的類型轉換
A(double );
operator int() const;//最好不要創建兩個轉換對象都是算術類型的類型轉換
operator double() const;
};
void f2(long double);
A a;
f2(a);//二義性錯誤:含義是f(A::operator int())
//還是 f(A::operator double())
long lg;
A a2(lg);//二義性錯誤:含義是A::A(int)還是A::A(double)
調用f2及初始化a2 的過程之所以產生二義性,根本原因是他們所需的標準類型轉換級別一致.當我們使用用戶定義的類型轉換時,如果轉換過程包含標準類型的轉換,則標準類型的轉換級別將決定編譯器選擇最佳的匹配
short s = 42;
//把short提升爲int,優於把short轉換成double的操作.
A a3(s);//使用 A::A(int)
注意:當我們使用兩個用戶定義的類型轉換時,如果轉換函數之前或之後存在標準類型轉換,則標準類型轉換將決定最佳匹配到底是哪個.
提示:類型轉換與運算符
要想正確地設計類的重載運算符,轉換構造函數以及類型轉換函數,必須加倍小心.尤其是當類同時定義了類型轉換運算符及重載運算符時特別容易產生二義性.以下的經驗規則可能對我們有幫助:
- 不要令兩個類執行相同的類型轉換:如果Foo類有一個接收Bar類對象的構造函數,則不要在Bar類中再定義目標是Foo類的類型轉換運算符
- 避免轉換目標是內置算術類型的類型轉換.特別是當你已經定義了一個轉換成算術類型的類型轉換時,接下來
a. 不要再定義接受算術類型的重載運算符.如果用戶需要使用這樣的運算符,則類型轉換操作將轉換你的類型的對象,然後使用內置的運算符.
b. 不要定義轉換到多種算術類型的類型轉換.讓標準類型轉換完成向其它算術類型轉換的工作.總得來說就是:除了顯式地向bool類型的轉換之外,我們應該儘量避免定義類型轉換函數並儘可能地限制那些"顯然正確"的非顯式構造函數.
重載函數與轉換構造函數
考慮下面的例子:
struct C{
C(int);
//...
};
struct D{
D(int);
};
void manip(const C&);
void manip(const D&);
manip(10);//二義性,含義是manip(C(10))還是manip(D(10))
注意:當我們調用重載的函數時,如果兩個或多個類型轉換都提供了同一種可行的匹配,則這些類型轉換一樣好.
重載函數與用戶定義的類型轉換
考慮下面的代碼
struct E{
E(double);
//其他成員
};
void manip2(const C&);
void manip2(const E&);
manip2(10);//含義是manip2(C(10)),還是manip(E(double(10)))
注意:當調用重載函數時,如果兩個用戶定義的類型轉換都提供了可行匹配,則我們認爲這些類型轉換一樣好.在這個過程中,我們不會考慮任何可能出現的標準類型轉換的級別.只有當重載函數能通過同一個類型轉換函數得到匹配時,我們纔會考慮其中出現的標準類型轉換.
函數匹配於重載運算符
考慮下面的代碼
class SmallInt{
friend
SmallInt operator+(const SamllInt&,const SmallInt &);
public:
SmallInt(int = 0);
operator int() const {return val;}
private:
std::size_t val;
};
SmallInt s1,s2;
SmallInt s3 = s1 + s2; //使用重載的operator+
int i = s3 + 0;//二義性錯誤
第二條加法語句具有二義性:因爲我們可以把0轉換成SmallInt,然後使用SmallInt的+;或者把s3轉換成int,然後對兩個int執行內置的加法運算.
注意:如果我們對同一個類提供了轉換目標是算術類型的類型轉換,也提供了重載的運算符,則將會遇到重載運算符於內置運算符的二義性問題.
本章完