[C++ Primer Note13] 過載運算與型別轉換
當運算子作用於類型別的運算物件時,可以通過運算子過載重新定義該運算子的含義
- 過載的運算子是具有特殊名字 的函式:它們的名字由關鍵字operator 和其後要定義的運算子號共同組成。和其他函式一樣,過載的運算子也包含返回型別,引數列表以及函式體
- 過載運算子函式的引數數量與該運算子作用的運算物件數量一樣多。除了過載的函式呼叫運算子operator() 之外,其他過載運算子不能含有預設實參 。
- 如果一個運算子函式是成員函式,則它的第一個(左側)運算物件繫結到隱式的this 指標上,因此,成員運算子函式的顯式引數數量比運算子的運算物件總數少一個
- 對於一個運算子函式來說,它或者是類的成員,或者至少含有一個類型別的引數 ,這一約定意味著當運算子作用於內建型別的運算物件時,我們無法改變該運算子的含義。
- 我們只能過載已有的大多數運算子,而無權發明新的運算子號。
- 對於一個過載的運算子來說,其優先順序和結合律與對應的內建運算子保持一致。
- 我們既可以直接將運算子作用於型別正確的實參,從而間接“呼叫”過載的運算子函式,也可以像呼叫普通(成員)函式一樣呼叫運算子函式。
- 某些運算子指定了運算物件求值的順序。因為使用過載的運算子本質上是一次函式呼叫,所以這些求值順序的規則無法應用到過載的運算子上。特別是,邏輯與&&,邏輯或||和逗號運算子。同時也不應該過載取地址符。
- 過載運算子的返回型別通常情況下應該與其內建版本的返回型別相容。一般情況下,只有當操作的含義對於使用者來說清晰明瞭時才過載運算子。
- 當我們定義過載的運算子時,必須首先決定是將其宣告為類的成員函式 還是宣告為一個普通的非成員函式 ,下面是一些有助於抉擇的準則:
- 賦值(=) ,下標([]) ,呼叫(()) 和成員訪問箭頭運算子(->) 必須是成員
- 複合賦值運算子一般是成員,但並非必須
- 改變物件狀態的運算子或者與給定型別密切相關的運算子,通常應該是成員
- 具有對稱性 的運算子應該是普通的非成員函式
- 當我們把運算子定義成成員函式時,它的左側運算物件必須是運算子所屬類的一個物件 。
- 如我們所知,IO標準庫分別使用了<< 和>> 執行輸出和輸入操作,對於這兩個運算子來說,IO庫定義了用其讀寫內建型別的版本,而類則需要自定義適合其物件的新版本以支援IO操作。
- 舉個例子:
ostream& operator<<(ostream &os,const Sales_data &item){ os<<item.isbn()<<" "<<items.units_sold; return os; }
與iostream標準庫相容的輸入輸出運算子必須是普通的非成員函式 ,而不能是類的成員函式。否則,它們的左側運算物件將是我們的類的一個物件,這顯然不可能。當然,IO運算子通常需要讀寫類的非公有資料成員,所以IO運算子一般被宣告為友元 。
- 對於輸入運算子而言,也比較類似,不過第二個引數是非常量物件的引用,同時函式體內要對流進行檢測(比如通過if ),避免一些輸入錯誤的影響。
- 通常情況下,我們把算術和關係運算符定義成非成員函式以允許運算物件位置的轉換,因為這些運算子一般不會改變運算物件的狀態,所以形參都是常量的引用 。
- 如果類定義了operator== ,則這個類也應該定義operator!= ,同時其中之一的任務應該委託 給另外一方,而不用重複書寫一套非常相似的邏輯。
- 如果存在唯一一種邏輯可靠的< 定義,則應該考慮為這個類定義<運算子。如果類同時還包括== ,則當且僅當 <的定義和==產生的結果一致時才定義<運算子。
-
之前已經介紹過拷貝賦值和移動賦值運算子,此外,類還可以定義其他賦值運算子以使用別的型別作為右側運算物件。
比如,vector可以使用花括號的元素列表作為引數,實際上是利用了std::iniitializer_list<T> 這個型別作為引數,以此類推。 - 為了與下標的原始定義相容,下標運算子 通常以所訪問元素的引用 作為返回值。同時,我們最好同時定義下標運算子的常量版本和非常量版本,當作用於一個常量物件時,下標運算子返回常量引用確保我們不會賦值。
- 定義遞增和遞減運算子的類應該同時定義前置 和後置 版本,並且通常被定義為成員 。
- 要想同時定義前置和後置,需要解決一個問題,即普通的過載形式無法區分這兩種情況。為了解決這個問題,後置版本接受一個額外的(不被使用)的int型別的形參 ,當我們使用後置運算子時,編譯器為這個形參提供一個值為0的實參。
ClassName operator++(int); ClassName operator--(int);
很多時候,後置版本可以通過呼叫前置版本來完成實際的工作。
同時,如果想要顯式地呼叫後置版本,需要為那個不被使用的int引數傳入一個值 。
-
與大多數其他運算子一樣,我們能令operator* 完成任何我們指定的操作。但是箭頭運算子 則不是這樣,它永遠不能丟掉成員訪問這個最基本的含義 ,當我們過載箭頭時,可以改變的是箭頭從哪個物件獲取成員。
對於形如point->mem的表示式來說,point 必須是指向類物件的指標或者是一個過載operator->的類的物件。根據point型別的不同,point->mem分別等價於:
(*point).mem;//point是一個內建的指標型別 point.operator()->mem;//point是類的一個物件
所以很顯然,過載的箭頭運算子必須返回類的指標或者自定義了箭頭運算子的某個類的物件 。
這兩個運算子往往定義成const成員,因為他們一般不改變物件的狀態。
- 如果類定義了呼叫運算子,則該類的物件稱作函式物件 ,因為可以呼叫這種物件,我們說這些的物件”行為像函式一樣”。
- 當我們編寫了一個lambda 後,編譯器將該表示式翻譯成一個未命名類的未命名物件 ,在產生的類中有一個過載的函式呼叫運算子。
- 當一個lambda表示式通過引用捕獲 變數時,將由程式確保所引物件確實存在,因此編譯器可以直接使用。而如果通過值捕獲 變數,產生類必須為每個值捕獲的變數建立對應的資料成員 ,同時建立建構函式 用於初始化這些成員。
- 標準庫還定義了一組表示算術,關係,邏輯運算子的類,每個類分別定義了一個執行命名操作的呼叫運算子。由於屬於函數語言程式設計的範疇,此處不贅述。
- 前面的筆記提到過,如果建構函式只接受一個實參 ,則它實際上定義了轉換為此類型別的隱式轉換規則 ,這種建構函式也被稱為轉換建構函式 。
- 型別轉換運算子(conversion operator) 是類的一種特殊成員函式,它負責將一個類型別 的值轉換成其他型別。一般形式如下:
operator type () const;
其中type表示某種型別,型別轉換運算子可以面向任意型別(除了void )進行定義,只要該型別能作為函式的返回型別 。
比如:
class SmallInt{ public: SmallInt(int i=0):val(i){} operator int() const{return val;} private: size_t val; };
其中,建構函式將算術型別的值轉換成SmallInt物件,而型別轉換運算子將SmallInt轉換成int。
- 一個型別轉換函式必須是 類的成員函式;它不能宣告返回型別,形參列表也必須為空 ,且通常是const 的。
-
在實踐中,類很少提供型別轉換運算子,因為大多數情況下如果型別轉換自動發生使用者可能會感到意外而不是受到了幫助。不過定義向bool
的型別轉換還是比較普遍的現象。
在早期的版本中,因為bool是一個算術型別,所以類型別被轉換成bool後能被用於任何需要算術型別的上下中,比如:
int i=42; cin << i;
該程式碼能使用istream的bool轉換,接著提升至int並左移42位,這一結果不可謂不出人意料。
- 為了防止上述異常發生,C++11標準引入了顯式的型別轉換運算子
class SmallInt{ public: explicit operator int() const {return val;} ... }
和顯式的建構函式一樣,編譯器不會將一個顯式的型別轉換運算子用於隱式型別轉換。
SmallInt s1=3; //正確,建構函式非顯式 s1+3; //錯誤:此處需要隱式的型別轉換,但運算子是顯式的 static_cast<int>(s1)+3; //正確,顯式地請求型別轉換
該規定存在一個例外,如果表示式被用作條件 ,則編譯器會將顯式的型別轉換自動應用於它。由於向bool的型別轉換通常用在條件部分,所以operator bool 一般也定義成explicit的。
- 類型別轉換的定義非常容易出現二義性 ,除了顯式地向bool型別的轉換之外,我們應該儘量避免定義型別轉換函式 。具體的二義性規則此處不贅述,可以簡單地人為判斷出來。
- 過載的運算子也是過載的函式,但候選函式集要比我們使用呼叫運算子呼叫函式時更大,因為我們無法通過語法形式 來區分到底使用的是成員函式還是非成員函式,這同樣會有可能引發二義性 問題。一般來說,如果我們對同一個類既提供了轉換目標是算術型別的型別轉換,也提供了過載的運算子,則將會遇到過載運算子與內建運算子的二義性問題。