C++運算符重載-上篇
本章內容:
1. 運算符重載的概述
2. 重載算術運算符
3. 重載按位運算符和二元邏輯運算符
4. 重載插入運算符和提取運算符
5. 重載下標運算符
6. 重載函數調用運算符
7. 重載解除引用運算符
8. 編寫轉換運算符
9. 重載內存分配和釋放運算符
1. 運算符重載的概述
- C++中的運算符是一些類似于+、<、*和<<的符號。這些符號可以應用于內建類型,例如int和double,從而實現算術操作、邏輯操作和其他操作。還有->和*運算符可以對指針進行解除引用操作。C++中運算符包括[] (數組索引)、()函數調用、類型轉換以及內存分配和釋放例程。可通過運算符重載來改變語言運算符對自定義類的行為。
1.1 重載運算符原因
- 運算符重載的基本指導原則是為了讓自定義類的行為和內建類型一樣。自定義類的行為越接近內建類型,就越便于這些類的客戶使用。例如,如果要編寫一個表示分數的類,最好定義+、-、*和/運算符應用于這個類的對象時的意義。
- 重載運算符的第二個原因是為了獲得對程序行為更大的控制權。例如,可對自定義類重載內存分配和內存釋放例程,來精確控制每個對象的內存分配和內存回收。
- 需要強調的是,運算符重載未必能給類開發者帶來方便;主要用途是給類的客戶帶來方便。
1.2 運算符重載的限制
下面列出重載運算符時不能做的事情:
- 不能添加新的運算符符號。只能重定義語言中已經存在的運算符的意義。
- 有少數運算符不能重載,例如.(對象成員訪問)、::(作用域解析運算符)、sizeof、?:(三元運算符)以及其他幾個運算符。不能重載的運算符通常是不需要重載的,因此這些限制應該不會令人感到受限。
- arity描述了運算符關聯的參數或操作數的數量。只能修改函數調用、new和delete運算符的arity。其他運算符的arity不能修改。一元運算符,例如++,只能用于一個操作數。二元運算符,例如+,只能用于2個操作數。
- 不能修改運算符的優先級和結合性。這些規則確定了運算符在語句中的求值順序。同樣,這條約束對于大多數程序來說不是問題,因為改變了值順序并不會帶來什么好處。
- 不能對內建類型重定義運算符。運算符必須是類中的一個方法,或者全局重載運算符函數至少有一個參數必須是一個用戶定義的類型(例如一個類)。這意味著不允許做一些荒唐的事情,例如將int的+重定義為減法(盡管自定義類可以這么做)。這條規則有一個例外,那就是內存分配和釋放例程;可以替換程序中所有的內存分配使用的全局例程。
有一些運算符已經有兩種不同的含義。例如,-運算符可以作為二元運算符,如x = y-z;
,還可以作為一元運算符,如x = -y;
。*運算符可以作乘法操作,也可以用于解除指針的引用。根據上下文的不同,<<可以是插入運算符,也可以是左移運算符。可以重載具有雙意義的運算符的兩個意義。
1.3 運算符重載的選擇
-
重載運算符時,需要編寫名為operator X的函數或者方法,X是表示這個運算符的符號,可以在operator和X之間添加空白字符。例如,operator+,如下:
friend const SpreadsheetCell Operator+(const SpreadsheetCell &lhs, const SpreadsheetCell &rhs);
下面幾節描述了編寫每個重載運算符函數或者方法時需要做出的選擇。
(1) 方法還是全局函數
- 要決定運算符應該實現為類的方法還是全局函數(通常是類的友元)。首先,需要理解這兩個選擇之間的區別。當運算符是類的方法時,運算符表達式的左側必須是這個類的對象。當編寫全局函數時,運算符的左側可以是不同類型的對象。
- 有三種不同類型的運算符:
- (i) 必須為方法的運算符:C++語言要求一些運算符必須是類中的方法,因為這些運算符在類外部沒有意義。例如,operator=和類綁定的非常緊密,不能出現在其它地方。
- (ii) 必須為全局函數的運算符:如果允許運算符左側的變量除了自己定義之外的任何類型,那么必須將這個運算符定義為全局函數。確切的說,這條規則應用于operator<<和operator>>,這兩個運算符的左側是iostream對象,而不是自定義類的對象。此外,可交換的運算符(例如二元的+和-)允許運算符左側的變量不是自定義類的對象。
- (iii) 既可以為方法又可以為全局函數的運算符:有關編寫方法重載運算符更好還是編寫全局函數重載運算符更好的問題存在一些爭議。不過建議的規則如下:把所有運算符都定義為方法,除非根據以上的描述必須定義為全局函數。這條規則的一個主要優點是方法可以是virtual的,但是friend函數不能。因此,如果準備在繼承樹種編寫重載的運算符,那么應該盡可能將這些運算符定義為方法。
將重載的運算符定義為方法時,如果這個運算符不能修改對象,應該將整個方法標記為const。這樣,就可以對const對象調用這個方法。
(2) 選擇參數類型
- 參數類型的選擇有一些限制,因為如前面描述,大多數運算符不能修改參數的數量。例如,operator/在作為全局函數的情況下必須總是接受兩個參數;在作為類方法的情況下必須總接受一個參數。如果不符合這個規則,編譯器會產生錯誤。從這個角度看,運算符函數和普通函數有區別,普通函數可以使用任意數量的參數重載。此外,盡管可以編寫接受任何類型參數的運算符,但是可選范圍通常受到了這個運算符所在的類的限制。例如,如果要為類T實現一個加法操作,就不能編寫接受兩個string的operator+。真正需要選擇的地方在于判斷是按值還是引用接受參數,以及是否需要把參數標記為const。
- 按值傳遞還是按引用傳遞的決策如下:應該按引用接受每一個非基本類型的參數。如果能按引用傳遞,就永遠不要用按值傳遞對象。
- const的決策如下:除非要真正修改參數,否則每一個參數都設置為const。
(3) 選擇返回類型
- C++不是根據返回類型來解析重載。因此,在編寫重載運算符時,可以指定任意返回類型。然而,可以做某件事情并不意味著應該做這件事情。這種靈活性可能會導致令人迷惑的代碼,例如比較運算符返回指針,算術運算符返回bool類型。不應該編寫這樣的代碼。其實在編寫重載運算符時,應該讓運算符返回的類型和運算符對內建類型操作時返回的類型一樣。如果編寫比較運算符,那么應該返回bool。如果編寫的是算術運算符,那么應該返回表示運算結果的對象。
- 引用和const標記的決策也適用于返回類型。不過對于返回值來說,這些決策要更困難一些。值還是引用的一般原則:如果可以,就返回一個引用,否則返回一個值。如何判斷何時能返回引用?這個決策只能應用于返回對象的運算符:對于返回bool值的比較運算符、沒有返回類型的轉換運算符和函數調用運算符(可能返回所需的任何類型)來說,這個決策沒有意義。如果運算符構造了一個新的對象,那么必須按值返回新的對象。如果不構造新對象,那么可以返回調用這個運算符的對象的引用,或者返回其中一個參數的引用。
- 可以作為左值(賦值表達式左側的部分)修改的返回值必須是非const。否則,這個值應該是const。大部分很容易想到的運算符都要求返回左值,包括所有的賦值運算符(operator=、operator+=和operator-=等)。
(4) 選擇行為
- 在重載的運算符中,可以提供任意需要的實現。例如,可以編寫一個啟動Scrabble拼字游戲的operator+。通常情況下,應該將實現約束為客戶期待的行為。編寫operator+時,使這個運算符能夠執行加法,或其他類似加法的操作,例如字符串串聯。
1.4 不要重載的運算符
- 有一些運算符即使允許重載,也不應該重載。具體來說,取地址運算符(operator&)的重載一般沒有特別的用途,如果重載時會導致混亂,因為這樣做會以可能異常的方式修改基礎語言的行為(獲得變量的地址)。整個STL大量使用了運算符重載,但從沒有重載取地址運算符。
- 此外,還要避免重載二元布爾運算符operator&&和operator||,因為這樣會使C++的短路求值規范失效。
- 最后,不要重載逗號運算符(operator,)。C++中確實有一個逗號運算符,它也稱之為序列運算符,用于分隔一條語句中的兩個表達式,確保從左至右的求值順序。幾乎沒有什么正當的理由需要重載這個運算符。
1.5 可重載運算符小結
- 下表1-1中總結了什么時候應該(或不應該)重載,并提供了示例原型,展示了正確的返回值。
- 下表1-1中,T表示要編寫的重載運算符的類名,E是一個不同的類型(不是這個類的名稱)。
表1-1
1.6 右值引用
表1-1中列出的普通賦值運算符的原型如下所示:
T& operator=(const T&);
-
移動賦值運算符的原型幾乎一致,但使用了右值引用。這個運算符會修改參數,因此不能傳遞const參數,如下所示:
T& operator=(T&&);
-
表1-1中沒有包含右值引用語義的示例原型。然而,對于大部分運算符來說,編寫一個使用普通左值引用的版本和一個使用右值引用的版本都是有意義的,但是否真正有意義取決于類的實現細節。比如通過operator+避免不必要的內存分配。例如STL中的std::string類利用右值引用實現了operator+,如下所示(簡化版本):
string operator+(string&& lhs, string&& rhs);
-
這個運算符的實現會重用其中一個參數的內存,因為這些參數是以右值引用傳遞的,也就是說這兩個參數表示的都是operator+完成之后銷毀的臨時對象。上述operator+的實現具有以下效果(具體取決于兩個操作數的大小和容量):
return std::move(lhs.append(rhs));
-
或
return std::move(rhs.insert(0, lhs));
-
事實上,std::string定義了幾個重載的具有不同左值引用和右值引用組合的operator+運算符。下面列出std::string中所有接受兩個string參數的operator+運算符(簡化版本):
string operator+(const string& lhs, const string& rhs); string operator+(string&& lhs, const string& rhs); string operator+(const string& lhs, string&& rhs); string operator+(string&& lhs, string&& rhs);
重用其中一個右值引用參數的內存的實現方式和移動賦值運算符一致。
1.7 關系運算符
-
C++標準庫中一個方便的<utility>頭文件,它包含幾個輔助函數和類,還在std::rel_ops命名空間中給關系運算符包含如下函數模板:
template<class T> bool operator!=(const T& a, const T& b); //需要operator== template<class T> bool operator>(const T& a, const T& b); //需要operator< template<class T> bool operator<=(const T& a, const T& b); //需要operator< template<class T> bool operator>=(const T& a, const T& b); //需要operator<
-
這些函數模板根據==和<運算符給任意類定義了運算符!=、>、<=和>=。如果在類中實現operator==和operator<,就會通過這些模板自動獲得其他關系運算符。只要添加
#include <utility>
和下面的using聲明,就可以將這些運算符用于自己的類:using std::rel_ops::operator!=; using std::rel_ops::operator>; using std::rel_ops::operator>=; using std::rel_ops::operator<=;
2. 重載算術運算符
- 本節主要講如何重載其他算術運算符的相關方法。
2.1 重載一元負號和一元正號
-
C++有幾個一元算術運算符,其中包括一元負號和一元正號。下面列出一些使用int的運算符例子:
int i, j = 4; i = -j; //一元負號 j = +i; //一元正號 j = +(-i); //對i做一元負號產生的結果再做一元正號運算 j = -(-i); //對i做一元負號產生的結果再做一元負號運算
一元負號運算符對其操作數取反,而一元正號運算符直接返回操作數。注意,可以對一元正號或一元負號產生的結果應用一元正號或一元負號。這些運算符不改變調用它們的對象,所以應該把它們標記為const。
-
下面的例子把一元operator-運算符重載為SpreadsheetCell類的成員函數。一元正號通常是恒等運算,因此這個類沒有重載這個運算符:
SpreadsheetCell SpreadsheetCell::operator-() const { SpreadsheetCell newCell(*this); newCell.set(-mValue); //調用set方法去更新mValue和mString return newCell; }
operator-沒有修改操作數,因此這個方法必須構造一個帶有相反值的新SpreadsheetCell,并返回這個對象的副本。因此,這個運算符不能返回引用。
2.2 重載遞增和遞減運算符
-
可以采用4種方法給一個變量增加1:
i = i + 1; i += 1; ++i; i++;
后兩種稱為遞增運算符。第一種形式是前綴遞增,這個操作將變量的值增加1,然后返回增加后的新值,供表達式的其他部分使用。第二種形式是后綴遞增,返回舊的(沒有增加的)值,供表達式其他部分使用。遞減運算符的功能類似。
operator++和operator--的雙重意義(前綴和后綴)給重載帶來了問題。例如,編寫重載的operator++時,怎么表示重載的是前綴版本還是后綴版本?C++引入了一個方法來區分:前綴的operator++和operator--不接受參數,而后綴的版本需要接受一個不用的int類型參數。
-
如果要為SpreadsheetCell類重載這些運算符,原型如下所示:
SpreadsheetCell& operator++(); //前綴(prefix) SpreadsheetCell operator++(int); //后綴(postfix) SpreadsheetCell& operator--(); //前綴(prefix) SpreadsheetCell operator--(int); //后綴(postfix)
前綴形式的結果值和操作數的最終值一致,因此前綴遞增和前綴遞減返回被調用對象的引用。然而后綴版本的遞增操作和遞減操作返回的結果值和操作數的最終值不同,因此不能返回引用。
-
下面是operator++運算符的實現:
SpreadsheetCell& SpreadsheetCell::operator++() { set(mValue + 1); return *this; } SpreadsheetCell SpreadsheetCell::operator++(int) { SpreadsheetCell oldCell(*this); //在遞增之前保存當前值的值 set(mValue + 1); //把值加1(遞增) return oldCell; //返回之前保存過的原來的值 }
-
operator--的實現幾乎和遞增的相同,我們通過遞增和遞減來操作SpreadsheetCell對象:
SpreadsheetCell c1(4); SpreadsheetCell c2(4); c1++; ++c2;
遞增和遞減還能應用于指針。當編寫的類是智能指針或迭代器時,可以重載operator++和operator--,以提供指針的遞增和遞減操作。
3. 重載按位運算符和二元邏輯運算符
按位運算符和算術運算符類似,簡寫的按位運算符也和簡寫的算術運算符類似。在表1-1中已經展示了示例原型。
邏輯運算符要困難一些。建議不重載&&和||。這些運算符并不應用于單個類型:而是整合布爾表達式的結果。此外,重載這些運算符會失去短路求值,原因是在將運算符左側和右側的值綁定至重載的&&和||運算符之前,必須對運算符的左側和右側進行求值。因此,一般對特定的類型重載這些運算符都沒有意義。
-
下面來講解下短路求值的概念:在C++中短路求值有邏輯與(&&)和邏輯或(||)。
(1). 邏輯與的短路
-
首先看如下代碼:
#include <iostream> using namespace std; int main() { int a = 1; cout << "a = " << a << endl; false && (a=3); cout << "a = " << a << endl; }
-
運行結果如下:
a = 1 a = 1
-
邏輯或的表現形式如下:
expression1 && exexpression2
-
這里用到了邏輯與,由于邏輯與的短路,expression1為false,則后面的expression2(即:(a=3))不再求值,整個表達式的結果為false,所以a的值仍為1,沒有改變。
(2). 邏輯或的短路
-
首先看如下代碼:
#include <iostream> using namespace std; int main() { int a = 1; cout << "a = " << a << endl; true || (a=0); cout << "a = " << a << endl; }
-
運行結果如下:
a = 1 a = 1
-
邏輯或的表現形式如下:
expression1 || exexpression2
-
這里用到了邏輯或,由于邏輯或的短路,expression1為true,則后面的expression2(即:(a=0))不再求值,整個表達式的結果為true,所以a的值仍為1,沒有改變。
(3). 應用舉例
如何不用if語句,不用匯編使得兩個數之積總是小于等于255?
-
比如可以用簡單的條件表達式:
result = ((a*b) > 255) ? 255 : (a*b);
-
也可以用邏輯或的短路來解:
bool btmp = ((result = a*b) < 255) || (result = 255);
-
同時也可以用邏輯與的短路來解:
bool btmp = ((result = a*b) >= 255) && (result = 255);
4. 重載插入運算符和提取運算符
-
在C++中,不僅算術操作需要使用運算符,從流中讀寫數據都可以使用運算符。例如,向cout寫入int和string時使用插入運算符<<:
int number = 10; cout << "The number is " << number << endl;
-
從流中讀取數據時,使用提取運算符>>:
int number; string str; cin >> nubmer >> str;
-
還可以為自己定義的類編寫合適的插入和提取運算符,從而可按以下方式進行讀寫:
SpreadsheetCell myCell, anotherCell, aThirdCell; cin >> myCell >> anotherCell >> aThirdCell; cout << myCell << " " << anotherCell << " " << aThirdCell << endl;
在編寫插入和提取運算符之前,需要決定如何將自定義的類向流輸出,以及如何從流中提取自定義的類。在上面的例子中,
SpreadsheetCell
將讀取和寫入字符串。-
插入和提取運算符左側的對象是
istream
和ostream
(例如cin
和cout
),而不是SpreadsheetCell
對象。由于不能向istream
或ostream
類添加方法,因此應該將插入和提取運算符寫為SpreadsheetCell
類的全局friend
函數。這些函數在SpreadsheetCell
類中的聲明如下所示:class SpreadsheetCell { public: //省略…… friend std::ostream& operator<<(std::ostream& ostr, const SpreadsheetCell& cell); friend std::istream& operator>>(std::istream& istr, SpreadsheetCell& cell); //省略…… };
將插入運算符的第一個參數設置為ostream的引用,這個運算符就能夠應用于文件輸出流、字符串輸出流、cout、cerr和clog等。與此同時,將提取運算符的第一個參數設置為istream的引用,這個運算符就能應用于文件輸入流、字符串輸入流和cin。
operator<<和operator>>的第二個參數是對要寫入或讀取的SpreadsheetCell對象的引用。插入運算符不會修改寫入的SpreadsheetCell,因此這個引用可以是const。然而提取運算符會修改SpreadsheetCell對象,因此要求這個參數為非const引用。
-
兩個運算符返回的都是第一個參數傳入的流的引用,所以這個運算符可以嵌套。記住,這個運算符的語法實際上是顯示調用全局operator>>函數或operator<<函數的簡寫形式。例如下面這一行代碼:
cin >> myCell >> anotherCell >> aThirdCell;
-
實際上是這一行的簡寫形式:
operator>>(operator>>(operator>>(cin, myCell), anotherCell), aThirdCell);
從中可以看出,第一次調用operator>>的返回值作下一次調用的輸入值。因此必須返回流的引用,結果才能可以用于下一次嵌套的調用。否則嵌套調用無法編譯。
-
下面是SpreadsheetCell類的operator<<和operator>>的實現:
ostream& operator<<(ostream& ostr, const SpreadsheetCell& cell) { ostr << cell.mString; return ostr; } istream& operator>>(istream& istr, SpreadsheetCell& cell) { string strTemp; istr >> strTemp; cell.set(strTemp); return istr; }
這些函數中最棘手的部分是為了正確配置mValue的值,operator>>必須調用SpreadsheetCell的set()方法,而不是直接設置mString。