S14操作重載與類型轉換
一、基本概念
1、重載運算符:名字由關鍵字operator
和其後要定義的運算符號共同組成,包含返回類型、參數列表和函數體
(1)參數列表:數量和運算符的運算對象一樣多,即一元運算符有一個參數,二元運算符由左側對象傳遞第一個參數右側對象傳遞第二個參數
(2)運算符函數作爲成員函數,則左側運算對象綁定到隱式的this
指針上,使得顯式的參數數量比運算符作用對象少一個
(3)運算符函數或是類的成員,或至少含一個類類型的參數,不能重載內置類型的運算
注意:重載的運算符不影響本身的優先級和結合律,且無權發明新的運算符
注意:不能重載的運算符:::
.*
.
?:
2、直接調用與間接調用
data1 + data2;
operator+(data1, data2);
data1 += data2;
data1.operator+=(data2);
3、重載運算符本質上是函數調用,因此部分運算符重載會有一些變化,不建議重載:例如邏輯與||
運算會先對左側求值,若爲真則表達式直接爲真,若爲假則再求右側,而重載後的||
則總是會對左右側都求值,另外也一般不重載逗號,
和取地址運算符&
,重載運算符要求運算對象至少有一個類類型或枚舉類型
注意:一般不應該重載逗號,、取地址&、邏輯與&&和邏輯或||運算符
4、使用與內置類型一致的含義
(1)執行I/O操作,移位運算符應與I/O保持一致
(2)檢查相等性定義!=
則也應該有==
(3)包含比較操作<
則也應該有<=/>=/>
等,類似的有+
也最好有+=/-=
等
(4)重載運算符的返回類型應與內置版本兼容,例如邏輯運算和關係運算應返回bool
等
5、選擇重載運算符是成員或非成員
(1)一般法則:
- 賦值
=
、下標[]
、調用()
、成員訪問->
運算符必須是成員 - 複合賦值一般是成員但並非必須
- 改變對象狀態或是與給定類型密切相關的運算符通常是成員,如遞增、遞減、解引用
- .具有對稱性的運算符(即左右可以互換)通常是非成員,如算術、相等性、關係、位運算等
(2)對於對稱性的運算符
//若是成員
string t = s + "1"; //正確,+的左側是string s
string t = "1" + s; //錯誤,+的左側是const char*沒有+方法
//若非成員
"1"+s等價於s+"1"等價於operator+("1", s),只要兩個參數只要有一個是類類型就正確
operator+("1","2"); //錯誤,兩個都是const char*
二、輸入和輸出運算符
1、重載輸出運算符<<
通常,輸出運算符第一個形參是非常量的ostream
對象的引用,第二個形參是常量的對象的引用,同時一般要返回它的ostream
形參
(1)輸出運算符儘量減少格式化操作
通常輸出運算符應該主要負責打印對象的內容而非控制格式,同時不應該打印換行符,控制格式推薦使用printf
(2)輸入輸出運算符必須是非成員函數,否則運算符左側對象將是類的一個對象
2、重載輸入運算符>>
通常,輸入運算符第一個形參是非常量的流對象的引用,第二個形參是將要讀入的非常量的對象的引用,同時一般要返回流對象的引用
3、輸入時的錯誤:輸入運算符必須處理輸入可能失敗的情況,輸出運算符無需處理,發生讀取操作錯誤時,輸入運算符應該負責從錯誤中恢復,同時標示錯誤
三、算數和關係運算符
算術和關係運算符一般定義成非成員函數以允許左右側運算對象互換,並且由於一般不會改變運算對象,因此形參都是常量的引用,返回一個新值的副本
1、相等運算符
2、關係運算符
注意:由於存在類似a==b
邏輯上應有a<=b && a>=b
的關係,因此在定義相等運算符和關係運算符的時候需要注意這一點,不能導致出現令人難以理解的關係,例如由於==
和<
定義不同,出現了部分特例既a==b
又a<b
注意:如果存在唯一一種邏輯可靠的<
定義,則才考慮定義<
,並且當類同時有==
時,<
和==
的結果復合邏輯時才能定義<
注意:在實現上推薦實現==和<,所有其他運算調用==或<來實現,簡化實現過程
四、賦值運算符
注意:賦值運算符必須定義爲成員函數,並返回左側運算對象的引用,而複合賦值運算符則也推薦如此實現
五、下標運算符
注意:下標運算符必須是成員函數,且通常以訪問元素的引用作爲返回,並且會定義兩個版本:1.返回普通引用,2.類的常量成員並返回常量引用
六、遞增和遞減運算符
注意:遞增和遞減運算符應同時定義前置版本和後置版本,並且應該被定義爲類的成員
1、定義前置遞增/遞減運算符
與內置版本一致,前置應返回遞增/遞減後對象的引用
StrBlobPtr &StrBlobPtr::operator--()
{
--curr;
check(curr, "decrement past begin of StrBlobPtr"); //檢查是否下標越界
return *this;
}
2、定義後置遞增/遞減運算符
與內置版本一致,後置應返回對象遞增/遞減前的原值且非引用
注意:後置版本通過接受一個額外不被使用的int類型的形參來與前置版本區別,編譯器爲之提供值爲0的實參,該int參數隨不會被使用,但是若要顯式調用後置運算符時需要傳入一個0,同樣用於區分
StrBlobPtr StrBlobPtr::operator--(int)
{
StrBlobPtr ret = *this; //不需要檢查
--*this; //調用前置版本來完成遞增/遞減任務,並帶有檢查
return ret; //返回原值的拷貝而非引用
}
p.operator++(0); //調用後置版本
p.operator--(); //調用前置版本
七、成員訪問運算符
注意:箭頭運算符永遠不能丟掉訪問成員這個基本含義,當重載箭頭運算符時,可以改變的是箭頭從哪個對象當中獲取成員,而”獲取成員”本身不能改變
注意:->
的重載函數必須返回類的指針,或是定義了->
的類的某個類對象
1、對於形如point->mem
的表達式來說,point
必須是指向類的指針或者是一個重載了->
的對象
(1)point
是指針,則point->mem就是(*point).mem,若point指向的類沒有mem成員,則報錯
(2)point
是重載了->
的類,則point->mem
就是point.operator->()->mem
,即通過重載->
,使得在表達point->
轉換爲調用point
的重載函數point.operator->()
,結合->
返回類型,則point.operator->()
的結果要麼是一個指針,此時回到(1)中的步驟,要麼是一個有->
的對象,繼續重載執行(2)中的步驟,即成員訪問運算·->·的重載本質上是改變了箭頭獲取成員的源和路徑,最終完成的操作一定是獲取成員,類似進行了“迭代”的操作
2、實例
struct A { int foo, bar; };
struct B
{
A a;
A *operator->() { return &a; }
};
struct C
{
B b;
B operator->() { return b; }
};
struct D
{
C c;
C operator->() { return c; }
};
D d;
d->foo; //等價於d.operator->().operator->().operator->()->foo
//此時d是類而不是指針,d->調用重載->變成了d.operator->(),發現返回的是C類型的c而不是指針
//則進一步調用C類型中的重載->變成了d.operator->().operator->(),發現返回的是B類型的b而不是指針,
//則進一步調用B類型中的重載->變成d.operator->().operator->().operator->(),發現返回的是指向A類型的指針,
//則->等價於(*),即獲取A中的成員foo
八、函數調用運算符
如果類重載了函數調用運算符()
,則我們可以像使用函數一樣使用該類的對象,並稱該類的對象爲函數對象
1、lambda是函數對象
lambda表達式產生的類不含默認構造函數、賦值運算符及默認析構函數,是否含有默認的拷貝/移動構造函數取決於捕獲的數據成員類型
auto wc = find_if(words.begin(), words.end(), [sz](const string &a){ return a.size() >= sz; });
//該lambda表達式效果類似如下
class SizeComp
{
public:
SizeComp(size_t n) : sz(n) { }
//返回類型、參數、函數體都與lambda一致
bool operator()(const string &s) const { return s.size() >= sz; }
private:
size_t sz; //sz對應捕獲的變量
};
auto wc = find_if(words.begin(), words.end(), SizeComp(sz)); //SizeComp(sz)通過sz生成了匿名的函數對象
2、標準庫函數定義的函數對象,定義在functional頭文件中
算術 關係 邏輯
plut<T> equal_to<T> logical_and<T>
minus<T> not_equal_to<T> logical_or<T>
multiplies<T> greater<T> logical_not<T>
divides<T> greater_equal<T>
modulus<T> less<T>
negate<T> less_equal<T>
在算法中使用標準庫函數對象
sort(svec.begin(), svec.end(), greater<string>()); //使得比較基於greater函數對象,起到了>的效果
//直接比較兩個不相關指針的大小是未定義行爲,然而可以使用一個標準庫函數對象來實現這種比較
vector<string *> nameTables;
sort(.., .., [](string *a, string *b){ return a < b; }); //錯誤,a/b兩個指針無法直接比較大小
sort(.., .., less<string *>()); //正確,標準庫函數對象可以比較指針大小
3、可調用對象與function
(1)C++中可調用對象有:函數、函數指針、lambda表達式、bind
創建的對象、重載了函數調用運算符()
的類
- 函數類型:可調用的對象也有類型
- 調用形式:一種調用形式對應一個函數類型,如
int(int, int)
是一種接受兩個int
返回一個int
的函數類型
(2)標準庫function
類型定義在functional頭文件中
function<T> f; //f是用來儲存可調用對象的空function,這些可調用對象的調用形式與函數類型T相同
function<T> f(nullptr); //顯式構造一個空function
function<T> f(obj); //在f中存儲可調用對象obj的副本
f //將f作爲條件,當f含有一個可調用對象時爲真,否則爲假
f(args) //調用f中的對象,參數是args
result_type //成員,該function類型的可調用對象返回的類型
map<string, function<int(int, int)>> binops = { //使用function才能將各種可調用對象都加入map
{"+", add}, //函數指針
{"-", std::minus<int>()}, //標準庫函數對象
{"/", divide()}, //用戶定義的函數對象
{"*", [](int i, int j) { return i * j; }}, //未命名的lambda
{"%", mod} //命名了的lambda
} //可調用對象類型不同但都能放入function<int(int,int)>
binops["+"](10, 5); //調用add(10, 5),以此類推其他調用
(3)重載函數與function
不能直接將重載函數的名字存入function
類型的對象中,可以使用存儲函數指針或是使用lambda來消除二義性
class A{};
int add(int a, int b);
A add(A a, A b);
int main()
{
map<string, function<int(int, int)>> f;
int(*a)(int, int) = add;
f.insert({ "+", add }); //錯誤,add無法確定與哪個重載函數匹配
f.insert({ "+", a }); //正確,利用指針消除二義性
f.insert({ "+",[](int a, int b) {return a + b; } }); //正確,直接添加未命名的lambda函數對象
return 0;
}
九、重載、類型轉換與運算符
1、類型轉換運算符
類型轉換運算符是類的一種特殊成員函數,負責將一個類類型的值轉換成其他類型,不允許轉換成數組或者函數類型但允許轉換成指針(包括數組指針和函數指針)或者引用類型,並且類型轉換運算符沒有顯示的返回類型也沒有形參,必須是成員函數,一般不應該改變待轉換的對象因此是const
成員
(1)定義含有類型轉換運算符的類
class SmallInt;
operator int(SmallInt&); //錯誤,非成員函數
class SmallInt
{
public:
int operator int() const; //錯誤,指定了返回類型
operator int(int = 0) const; //錯誤,指定了參數
operator int*() const { return 42; } //錯誤,42與int*不匹配
operator int() const { return val; } //正確
private:
size_t val;
}
注意:慎用類型轉換運算符,當想要轉換的類型之間不存在邏輯明確的一對一映射關係時不定義類型轉換運算符
(2)類型轉換運算符可能產生意外的結果
一般情況下,實踐中類很少提供類型轉換運算符,但是定義向bool
的類型轉換是比較普遍的,然而定義隱式的類型轉換可能產生一些意外的結果
(3)顯式的類型轉換運算符
爲了避免出現意外情況,在類型轉換運算符前加上explicit
來強制顯式調用(參考顯式構造函數),此時只能顯式調用類型轉換才能執行類型轉換
...
explicit operator int() const { return val; }
...
SmallInt si = 3; //正確,構造函數不是顯式的,此處會隱式將3轉換成SmallInt
si + 3; //錯誤,此處需要隱式轉換,然而轉換運算符explicit是顯式的
static_cast<int>(si) + 3; //正確,顯式調用類型轉換
對於顯式的轉換符在以下情況會被隱式調用:
if/while/do
語句的條件部分for
語句頭的條件表達式- 邏輯運算符
!/||/&&
的運算對象 - 條件運算符
?:
的條件表達式
(4)轉換爲bool
向bool
的類型轉換通常用在條件部分,因此operator bool
一般定義爲explicit
的
2、避免有二義性的類型轉換
多重轉換路徑:
- A定義了接受B的轉換構造函數,B定義了目標是A的類型轉換運算符,兩者提供相同的類型轉換
- 定義了多個轉換規則
注意:通常情況下不要爲類定義相同的類型轉換,也不要定義兩個及以上的轉換源/轉換目標是算術類型的轉換
(1)實參匹配和相同的類型轉換
struct B;
struct A
{
A(const B&);
}
struct B
{
operator A() const;
}
A f(const A&);
B b;
A a = f(b); //二義性錯誤,f(B::operator A()) or f(A::A(const B&)) ?
A a1 = f(b.operator A()); //顯式調用,正確
A a2 = f(A(b)); //顯式調用,正確
注意:在這裏強制類型轉換也無法解決二義性,只能顯示調用
(2)二義性與轉換目標爲內置類型的多重類型轉換
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()) or f(A::operator double())?
long lg;
A a2(lg); //二義性錯誤,A::A(int) or A::A(double) ?
short s = 42;
A a3(s); //正確,short提升至int優於short提升至double,使用A::A(int)
注意:當使用用戶定義的類型轉換中包括標準類型轉換時,則標準類型轉換的級別決定最佳匹配過程
注意:除了顯式向bool
類型轉換外,儘可能避免定義類型轉換函數,並儘可能限制非顯式構造函數
3、函數匹配與重載運算符
當調用一個命名函數時,同名的成員函數和非成員函數不會彼此重載;當通過類類型的對象/指針/引用調用函數時,只考慮成員函數;而在表達式中使用重載的運算符時,成員函數與非成員函數都會在考慮範圍內
注意:如果一個類同時提供了轉換目標是算數類型的類型轉換和重載的運算符,則會遇到二義性問題