文章目錄
- 6個默認成員函數
- 構造函數
- 概念
- 默認構造函數的類型
- 默認實參
- 概念
- 默認實參的使用
- 默認實參聲明
- 全局變量作為默認實參
- 某些類不能依賴于編譯器合成的默認構造函數
- 第一個原因
- 第二個原因
- 第三個原因
- 構造函數初始化
- 構造函數里面的“=”是初始化嗎?
- 為什么要使用列表初始化?
- 列表初始化
- 成員初始化的順序
- 類內成員的默認初始化
- 賦值和初始化的效率差異
- 拷貝構造函數
- 概念
- 拷貝構造函數的參數必須是引用類型
- 編譯器合成的拷貝構造函數
- 構造函數體內賦值和列表初始化的效率差異
- 構造函數體內賦值
- 列表初始化
- 總結
- 拷貝構造函數的兩種調用方式及易錯問題
- 調用方式一
- 調用方式二
- 易錯問題
- 重載運算符
- 概念
- ==運算符的重載
- 賦值運算符的重載
- 分清拷貝構造函數和賦值運算符
- 注意
- 實例
- 取地址運算符重載、對const對象取地址運算符的重載
- 析構函數
- 概念
- 構造函數和析構函數的類比
- 怎樣調用析構函數
- 下面代碼中會調用幾次析構函數?
- 析構函數實例
- =default
- =delete
- 三/五法則
6個默認成員函數
class Date
{
};
可以看到,上面那個類沒有任何成員,是一個空類,但是它真的什么都沒有嗎?
其實一個類在我們不寫的情況下,也會生成6個默認的成員函數,分別是:構造函數,析構函數,拷貝構造函數,賦值運算符重載,取地址運算符重載,對const對象取地址運算符的重載
構造函數
概念
特征:
- 函數名與類名相同。
- 無返回值。
- 對象實例化時編譯器自動調用對應的構造函數。
- 構造函數可以重載。
- 不同于其他成員函數,構造函數不能被聲明成const的。
不能被聲明成const的原因:
構造函數的作用就是為了初始化對象的成員參數,如果被聲明為const則會認為自己無法修改調用對象的值,也就剝奪了構造函數的作用。
但構造函數仍可以用來初始化const對象:
當我們需要創建類的一個const對象時,直到構造函數完成初始化過程,對象才能真正取得其“常量”屬性(構造函數在進行初始化時對象的const屬性不生效)。因此,構造函數在const對象的構造過程中可以向其寫值,并且構造函數不必(實則不能)被聲明為const。
默認構造函數的類型
可分成兩類:
- 編譯器合成的默認構造函數
- 程序員定義的默認構造函數
合成的默認構造函數按照如下規則初始化類的數據成員:
- 如果存在類內的初始值,用它來初始化成員
- 否則,默認初始化該成員
如果我們要自己定義一個默認構造函數,那我們有兩種方法:
1.定義一個無參的構造函數。
2.定義所有參數都有缺省值(默認實參,在下面介紹)的構造函數【全缺省的構造函數】。
在實際編程中,只能使用上述兩種方法中的一種,全缺省的構造函數和無參構造函數不能同時出現,因為編譯器會無法識別此時到底該調用哪一個。
class Date
{
public://無參默認構造函數Date(){_year = 0;_month = 1;_day = 1;}//全缺省的默認構造函數Date(int year = 0, int month = 1, int day = 1){_year = year;_month = month;_day = day;}//有參構造函數,也就是一般構造函數Date(int year, int month, int day){_year = year;_month = month;_day = day;}private:int _year;int _month;int _day;
};int main()
{Date d1;//調用默認構造函數Date d3();//如果要調用默認構造函數后面不能加上括號//加上了則變成了函數聲明Date d2(2020, 4, 19);//調用有參數的return 0;
}
默認實參
概念
默認實參:某些函數有這樣一種形參,在函數的很多次調用中它們都被賦予一個相同的值,此時我們把這個反復出現的值成為函數的默認實參。
默認實參作為形參的初始值出現在形參列表中。
需要注意的是:
- 我們可以為一個或多個形參定義默認值,但一旦某個形參被賦予了默認值,它后面的所有形參都必須有默認值。
- 局部變量不能作為默認形參。
- 類型可以轉化成形參所需類型的表達式也能作為默認實參。(比如函數的返回類型)
第一點:
class Date{Date(int year, int month = 1, int day)// error:一旦某個形參被賦予了默認值(month)// 它后面的所有形參都必須有默認值(day){_year = year;_month = month;_day = day;}
};
第二點:
class Date{int i;Date(int year = i, int month = 1, int day = 1)// error:局部變量不能作為默認形參{_year = year;_month = month;_day = day;}
};
第三點:
int sd();class Date
{
public:Date(int year = 0, int month = sd(), int day = 1);
};
默認實參的使用
如果想使用默認實參,只要在調用函數時忽略該實參就行了。
int main()
{Date d2(2020); // 等價于d2(2020,1,1)return 0;
}
函數調用時實參按其位置解析,默認實參負責填補函數調用缺少的尾部實參(靠右側位置)。
int main()
{Date d2(,3,); // error:只能省略尾部實參return 0;
}
默認實參聲明
通常的習慣是一個函數只聲明一次,但是實際上多次聲明同一個函數也是合法的。
不過給定作用域中一個形參只能被賦予一次默認實參。換言之,函數的后續聲明只能為之前那些沒有默認值的形參添加默認實參。
而且同樣要遵循:該形參右側的所有形參必須都有默認值。
Date(int year, int month, int day = 1);Date(int year, int month, int day = 2)// error:重復聲明Date(int year = 2, int month = 2, int day)// 正確
全局變量作為默認實參
//偽代碼
int i = 100, y =3;
class Date
{
public:Date(int year = i, int month = y, int day = 1);// 用作默認實參的變量名在函數聲明所在的作用域內解析
};
void fun()
{i = 2020; // 改變默認實參的值int y = 4; //隱藏了外層定義的y,但沒有改變默認實參的值Date d = Date(); // 調用Date(2020,3,1)// 而變量名的求值過程發生在函數調用時
}
- 其實就是fun中的 i 仍為全局變量,改變函數中的 i 就是改變 全局變量 i 。
- 但是 函數中的 y 是生存期只在函數體內的局部變量,改變其值不影響全局變量 y 的值。
某些類不能依賴于編譯器合成的默認構造函數
第一個原因
如果我們沒有顯式創建構造函數,編譯器會自動構建一個默認構造函數,但如果我們已經顯式定義了構造函數,則編譯器不會再生成默認構造函數。那么除非我們再定義一個默認的構造函數,否則類將沒有默認的構造函數。
這條規則的依據是:如果一個類在某種情況下需要控制對象初始化(我們顯式定義構造函數),那么該類很可能在所有情況下都需要控制。
第二個原因
合成的默認構造函數可能執行錯誤的操作。如果定義在塊中的內置類型或復合類型(比如數組和指針)的對象被默認初始化,則他們的值將是未定義的。
類中有其他類類型成員也一樣:
如果真的非常想使用合成的默認構造函數又不想得到未定義的值,則可以將成員全部賦予類內的初始值,這個類才適合于使用編譯器合成的默認構造函數。
第三個原因
編譯器不能為有些類合成默認構造函數,例如,如果類中包含一個其他類類型的成員,且這個成員的類型沒有默認構造函數,那么編譯器將無法初始化該成員。
從例子中可以看出,A類的構造函數可以正常工作,但是當使用Date類的合成默認構造函數創建一個對象時,由于Date類中有其他類類型的成員(A類型的成員a),且其所在類(A類)沒有默認構造函數(只有一般構造函數),導致編譯器無法初始化該成員(a)。
構造函數初始化
構造函數里面的“=”是初始化嗎?
上面構造函數內的賦值語句是初始化嗎?
乍一看很可能會覺得構造函數內的賦值語句是初始化,但是如果這樣寫呢?
class Date
{
public:Date(int year, int month, int day){_year = year;_month = month;_day = day;_year = 2020;}int getyear(){return this -> _year;}private:int _year;int _month;int _day;
};
往后面加上了一個_year = 2020,那這樣還是初始化嗎?總不可能是先用year初始化_year,再用2020來初始化它,這明顯不成立,因為初始化只能一次,而函數體內的賦值可以多次, 所以我們可以將函數體內的賦值理解為賦初值,而非初始化。
為什么要使用列表初始化?
有時我們可以忽略數據成員初始化和賦值之間的差異,但并非總能這樣。
當類的成員是以下三種時,必須通過構造函數初始值列表為它們提供初值(列表初始化):
- 引用成員變量,引用必須在定義的時候初始化,并且不能重新賦值,所以也要寫在初始化列表中;
- const成員變量,因為常量只能在初始化,不能賦值,所以必須放在初始化列表中;
- 未提供默認構造函數的類類型,因為使用初始化列表可以不必調用默認構造函數來初始化,而是直接調用拷貝構造函數;
通過一個例子來理解1、2點的意思:
- 隨著構造函數體一開始執行,初始化就完成了
- 初始化完成了也就意味著只能進行賦值操作了,不能進行定義且賦值的操作了。
- 不能賦值的成員(const屬性、引用類型)如果沒有在此之前完成初始化過程也就成為了未定義的狀態。例如下面的:_month的const屬性已經成立,無法再在函數體內為_month賦值了;_day是引用類型,沒有進行初始化的引用類型是無法賦值的。
而我們上面說過,構造函數里面的“=”是賦值行為而非初始化,因此:
class Date
{
public:Date(int i) {_year = i; // 正確_month = i; // 錯誤:不能給const賦值_day = i; // 錯誤:i沒被初始化}private:int _year;const int _month;int &_day;
};
因此我們初始化const或者引用類型的數據成員的唯一機會就是 通過構造函數初始值列表為它們提供初值。
列表初始化
class Date
{
public:// 列表初始化Date(int& year, const int month, int day):_year(year),_day(day),_month(month){}int getye(){return this -> _year;}int getmoth(){return this -> _month;}int getday(){return this -> _day;}private:int &_year;const int _month;int _day;
};
int main(int argc, char const *argv[]) {int i = 2020;Date a(i,3,14);cout << a.getye() << endl;cout << a.getmoth() << endl;cout << a.getday() << endl;return 0;
}
成員初始化的順序
列表初始化中有一個容易出錯的地方——成員初始化的順序,可以看到,我這里初始化列表的順序是year,day,month。但是實際上初始化的順序和初始化列表中順序毫無關聯,初始化的順序是按照參數在類中聲明的順序的, 也就是下面的year,month,day(如圖)。
一般來說,初始值列表的初始化順序不會影響什么,就如上面的代碼,結果依然符合我們的預期:
不過如果一個成員是用另一個成員來初始化的,那么這兩個成員的初始化順序就很關鍵了,具體是什么意思呢?舉個例子,將初始值列表做出如下更改:
Date(const int& year, const int month):_year(year),_day(month),_month(_day){}
查看結果:
int main()
{int i = 2020;Date d2(i, 4);cout << d2.getmoth() << endl;cout << d2.getday() << endl;return 0;
}
從形式上初始值列表的順序來講:
- 先用形參month初始化成員_day
- 再用初始化成功的_day去初始化成員_month
但實際上真的是這樣嗎? 我們來看看運行結果:
可以看到初始化成功的只有成員_day,實際上,初始化的順序是按照參數在類中的聲明順序來的:
- 也就是先用形參year初始化成員_year。
- 再用成員_day初始化成員_month,但由于此時成員_day尚未被形參month初始化,因此成員_month值是未定義的。
- 接下來用形參month初始化成員_day。
從而生成了上圖的結果。
類內成員的默認初始化
如果沒有在構造函數的初始值列表中顯式地初始化成員,則該成員會在構造函數體之前執行默認初始化。
執行默認初始化分兩種情況:
第一種,被忽略的成員有類內初始值(本例中的_month,_day):
class Date
{
public:Date(int year):_year(year){ }int getye(){return this -> _year;}int getmoth(){return this -> _month;}int getday(){return this -> _day;}private:int _year;int _month = 3;int _day = 24;
};
int main(int argc, char const *argv[]) {Date a(2020);cout << a.getye() << endl;cout << a.getmoth() << endl;cout << a.getday() << endl;return 0;
}
從結果可知,沒有在初始值列表中顯式初始化的數據成員,如果其具有類內初始值,會隱式地使用類內初始值初始化。
第二種情況,被忽略的成員沒有類內初始值(本例中的_month,_day):
class Date
{
public:Date(int year):_year(year){ }int getye(){return this -> _year;}int getmoth(){return this -> _month;}int getday(){return this -> _day;}private:int _year;int _month;int _day;
};
int main(int argc, char const *argv[]) {Date a(2020);cout << a.getye() << endl;cout << a.getmoth() << endl;cout << a.getday() << endl;return 0;
}
從結果可知,沒有在初始值列表中顯式初始化的數據成員,如果其也沒有類內初始值,則其值是未定義的,試圖拷貝或以其他形式訪問此類值將引發錯誤。
綜述:
- 構造函數不應該輕易覆蓋掉類內初始值,除非新賦的值與原值不同。
- 構造函數使用類內初始值不失為一種好的選擇,因為這樣能確保為成員賦予了一個正確的值。
- 如果不能使用類內初始值(編譯器不支持或其他原因),則所有構造函數都應該顯式地初始化每一個內置類型的成員。
賦值和初始化的效率差異
在很多類中,賦值和初始化的區別事關底層效率問題(對于內置類型而言賦值和初始化在效率上的區別不是很大):
- 賦值首先會用默認構造函數來構造對象,再通過重載后的賦值運算符進行賦值。
- 列表初始化會直接調用拷貝構造函數,減少了一次調用默認構造函數的時間。
具體詳解在下面的拷貝構造函數中。
拷貝構造函數
概念
如果構造函數的
- 第一個參數是自身類類型的引用
- 其他參數(如果有的話)都有默認值。
則此構造函數是拷貝構造函數。
拷貝構造函數和賦值運算符重載是C++為我們準備的兩種能夠通過其他對象的值來初始化另一個對象的默認成員函數。
拷貝構造函數是構造函數的一個重載形式
Date(const Date& d){_year = d._year;_month = d._month;_day = d._day;}
調用方法,用別的對象來為本對象賦值,同時因為不需要修改引用的對象則為它加上const屬性。
int main()
{Date d1;Date d2 = d1;Date d3(d1);//這兩種等價,都是拷貝構造函數,并且d2不是賦值運算符重載
}
拷貝構造函數的參數必須是引用類型
原因如下:
函數具有以下特性:
- 函數調用過程中,非引用類型的參數需要進行拷貝初始化。
- 函數具有非引用的返回類型時,返回值會被用來初始化調用方的結果。
根據上述特性可知:
- 拷貝構造函數被用來初始化函數的非引用類類型參數
- 如果拷貝函數本身的參數不是引用類型,為了調用拷貝構造函數,我們必須拷貝他的實參,為了拷貝實參,又需要調用拷貝構造函數,如此無限循環。
以本例來講:
當我們要用d1來初始化d2的時候,需要將d1先傳遞給形參d,再用形參d進行賦值,但是d1傳遞給d的時候又會再次調用一個拷貝構造函數,這個d又會給它的拷貝構造函數的形參d傳參,這又會調用新的拷貝構造函數,就導致了一個無限循環, 所以要加上引用。
編譯器合成的拷貝構造函數
如果我們不去定義一個拷貝構造函數,編譯器也會默認創建一個。默認的拷貝構造函數對象按內存存儲按字節序完成拷貝, 這種拷貝我們叫做淺拷貝,或者值拷貝。 如果是上面這個日期類,當然沒問題,但如果涉及到了動態開辟的數據,就會有問題了。
假設在堆上開辟了某個大小的一個數據,默認的拷貝構造函數會按照字節序來拷貝這個數據,這一步其實沒問題,問題就出在析構函數上。因為析構函數會在類的生命周期結束后將類中所有成員變量釋放,這時淺拷貝的數據就會存在問題,因為他們指向的是同一塊空間。
(合成拷貝構造函數做的是用“別的對象”來為本對象賦值,本對象只是創建了一個指針再指向“別的對象”動態開辟的空間,而非用new再開辟一個新空間,此時就出現了多個指針指向同一個空間的情況)
而原對象和拷貝的對象會分別對它釋放一次,就導致了重復釋放同一塊內存空間(double free)。
所以對于動態開辟的數據,我們需要使用深拷貝。
構造函數體內賦值和列表初始化的效率差異
代碼如下:
class A{int test = 666;
public:A(int i):test(i){cout << "a列表初始化" << endl;}A(){cout << this << endl;cout << "a默認構造函數" << endl;cout << endl;}A(const A& a2){test = a2.test;cout << "調用者 " << this << endl;cout << "被調用者 " << &a2 << endl;cout << "a拷貝構造函數" << endl;cout << endl;}A& operator=(const A& a4){cout << "調用者: " << this << endl;cout << "被調用者: " << &a4 << endl;cout << "a賦值運算符重載" << endl;if(this != &a4){test = a4.test;}return *this;}int gett(){return this->test;}~A(){cout << "a析構函數 " << this << endl;}
};class Date
{
public:Date(int year, int month, int day, A a1)//構造函數{cout << "執行函數體前" << endl;_year = year;_month = month;_day = day;_a = a1;cout << endl;cout << "成員_a " << &_a << endl;cout << "a1 " << &a1 << endl;cout << "d構造函數 " << endl;}Date(int flag,A a3):_year(flag), _month(flag), _day(flag), _a(a3){cout << "a3: " << &a3 << endl;cout << "_a: " << &_a << endl;cout << "d列表初始化" << endl;}Date(){cout << this << endl;cout << "d默認構造函數" << endl;}Date(const Date& d){cout << "before" << endl;_year = d._year;_month = d._month;_day = d._day;_a = d._a;cout << endl;cout << "_a: " << &_a << endl;cout << "d._a: " << &d._a << endl;cout << "d拷貝構造函數" << endl;}Date& operator=(const Date& d){if(this != &d){_year = d._year;_month = d._month;_day = d._day;_a = d._a;}cout << "調用者: " << this << endl;cout << "被調用者: " << &d << endl;cout << "d賦值運算符重載" << endl;return *this;}~Date(){ cout << "d析構函數 " << this << endl; }private:int _year = 2001;int _month = 13;int _day = 250;A _a;
};
兩者運行結果如下:
構造函數體內賦值
int main()
{A a;// 調用有參構造函數cout << "d1:" << endl;Date d1(2021,3,20,a);cout << endl;return 0;
}
從d1的運行結果可知,我們進行構造函數體內賦值操作時,
- 編譯器在調用d的構造函數之前(執行d的構造函數體之前),首先調用A的拷貝構造函數,用實參a初始化a1(d構造函數的形參)。
- 然后調用A的默認構造函數創建數據成員_a。
- 創建好數據成員_a之后,開始執行b的構造函數函數體(即調用b的構造函數),使用A的重載賦值運算符將a1的值賦給數據成員_a。
- 執行完d1的構造操作后,編譯器調用a的析構函數,釋放之前創建的形參a1。
列表初始化
int main()
{A a;// 調用列表初始化的構造函數cout << "d5:" << endl;Date d5(888,a);
}
而對比d5的運行結果可知,我們進行列表初始化時,編譯器兩次調用拷貝構造函數,
- 一次用實參a初始化形參a3。
- 一次用形參a3初始化數據成員_a。
- 之后執行a的析構函數,釋放創建的形參a3。
總結
列表初始化的第2點在函數體內賦值中被拆分成了2、3點。換言之,默認構造函數、重載的賦值運算符兩步完成的操作
與 拷貝構造函數一步完成的操作
是等價的,而我們說過
拷貝構造函數和賦值運算符重載是C++為我們準備的兩種能夠通過其他對象的值來初始化另一個對象的默認成員函數。
它們起到的功能是一樣的,因此我們說減少了一次調用默認構造函數的時間。
拷貝構造函數的兩種調用方式及易錯問題
調用方式一
int main()
{A a;// 調用拷貝構造函數的方式一cout << "d2:" << endl;Date d2(d1);cout << endl;
}
調用方式二
int main()
{A a;//調用拷貝構造函數的方式二cout << "d4:" << endl;Date d4 = d1;cout << endl;
}
可以發現兩種調用方式執行的底層操作都是一樣的:
1. 函數聲明階段,也就是執行拷貝構造函數函數體之前(圖中的before之前),調用a的默認構造函數創建數據成員_a
2. 執行函數體,用被調用的Date類(d1)初始化調用的Date類(d2,d4),(執行到數據成員_a的初始化時,用A的重載賦值運算符將d._a賦給_a)
易錯問題
但是如以下形式的代碼,看起來類似第二種調用方式,但其實不然:
int main()
{A a;cout << "d3:" << endl;Date d3;cout << "賦值之前" << endl;d3 = d1;cout << endl;
}
其執行步驟如下:
1. 先用默認構造函數創建d3(創建類內數據成員_a時調用A的默認構造函數)
2. 再使用重載賦值運算符進行賦值(為類內數據成員_a賦值時調用A的重載賦值運算符)
區別:
粗略理解的話,就是兩者一個是由拷貝構造函數直接創建對象(d2,d4),一個是用默認構造函數創建對象(d3)之后再用重載賦值運算符進行賦值。類比內置類型的初始化和定義再賦值可能更便于理解。
重載運算符
類的賦值運算符實際上是對賦值運算符的重載,因此我們先介紹一下重載運算符。
概念
函數原型:
返回值類型 operator操作符(參數列表)
關于重載運算符:
- 某些運算符(如賦值運算符)必須定義為成員函數。
- 如果一個運算符是一個成員函數,其左側運算對象就綁定到隱式的
this
參數。
規則:
- 不能通過連接其他符號來創建新的操作符:比如operator@
- 重載操作符必須有一個類類型或者枚舉類型的操作數
- 用于內置類型的操作符,其含義不能改變,例如:內置的整型+,不 能改變其含義
- 作為類成員的重載函數時,其形參看起來比操作數數目少1,成員函數的操作符有一個默認的形參this,限定為第一個形參
::
、*
、?
、:
、.
注意以上5個運算符不能重載。
通常情況下,不應該重載逗號、取地址、邏輯與和邏輯或運算符。
選擇重載運算符作為成員函數or非成員函數是很重要的
- 賦值(
=
)、下標([ ]
)、調用(()
)和成員訪問箭頭(->
)運算符必須是成員。 - 復合賦值運算符一般來說應該是成員,但并非必須,這一點與賦值運算符略有不同。
- 改變對象狀態的運算符或者與給定類型密切相關的運算符,如遞增、遞減和解引用運算符,通常應該是成員。
- 具有對稱性的運算符可能轉換任意一端的運算對象,例如算術、相等性、關系和位運算符等,因此它們通常應該是普通的非成員函數。
什么叫對稱性運算符?其實就是形如 +
這樣的 int + double
和 double + int
是一樣的。
如果對稱性運算符是成員函數呢?假設 operator+
是 string類
的成員(實際上是非成員):
operator+
是string類
的成員,上面的第一個加法等價于s.operator+("!")
。"hi"+s
等價于"hi".operator+(s)
。"hi"
的類型是const char*
,這是一種內置類型,內置類型根本就沒有成員函數。
因為 string
將 +
定義成了普通的非成員函數,所以 "hi"+s
等價于 operator+("hi",s)
。和任何其他函數調用一樣,每個實參都能被轉換成形參類型**。唯一的要求是至少有一個運算對象是類類型**,并且兩個運算對象都能準確無誤地轉換成 string
。
==運算符的重載
//成員函數的操作符重載bool operator==(const Date& d2){return _year == d2._year&& _month == d2._month&& _day == d2._day;}//如果寫成普通的函數bool operator==(const Date& d1, const Date& d2)
{return d1._year == d2._year&& d1._month == d2._month&& d1._day == d2._day;
}int main()
{bool isSame = (d1 == d2)//調用時等價于 isSame = d1.operator==(d2);//或者 isSame = operator==(d1, d2);
}
賦值運算符的重載
分清拷貝構造函數和賦值運算符
上面說過,有兩種方法能夠實現用其他類來拷貝一個類,一個是拷貝構造函數,一個是賦值運算符重載
int main()
{Date d;Date d1(d);Date d2 = d;//在聲明的時候初始化,用d初始化d2,調用的是拷貝構造函數d1 = d2;//是在對象d1已經存在的情況下,用d2來為d1賦值,這才是賦值運算符重載//聲明階段的“=”都自動調用了拷貝構造函數,只有不是聲明階段的“=”才是賦值運算符重載return 0;
}
Date& operator=(const Date& d){if(this != &d){_year = d._year;_month = d._month;_day = d._day;}return *this;}
注意
需要注意的有幾點
- 返回
*this
。原因有兩點:- 避免了返回非引用所需的拷貝操作,提高效率;
- 當出現形如這樣的操作時:
(a=b)=c
,如果返回類型不是引用,則對括號內a=b
得到的結果進行一次拷貝初始化,得到一個匿名對象(臨時對象),這個匿名對象是一個右值,對其進行=c
的賦值操作是未定義行為。
- 檢測是否是自己給自己賦值,如果是則忽略
- 因為不需要修改任何參數,所以參數都需要加上const,并且為了不花費多余的空間去拷貝數據,都采取引用
- 一個類如果沒有顯式定義賦值運算符重載,編譯器也會生成一個,完成對象按字節序的值拷貝。
實例
舉一個包含指針成員的類的重載賦值運算符該怎么寫的例子:
class A
{
public:A(const string &s = string()):ps(new string(s)){ }A& operator=(const A&);void getps() {cout << ps << endl;}private:int i = 0;string* ps;
};A& A::operator=(const A& a)
{string* newps = new string(*a.ps);cout << *a.ps << endl;cout << a.ps << endl;// 拷貝指針指向的對象// 不加解引用符就成了拷貝指針本身delete ps; // 銷毀ps指向的內存,避免內存泄漏ps = newps; // 將newps指向的內存賦給ps// newps和ps現在指向同一塊內存i = a.i;return *this; // 返回此對象的引用
}
int main() {A a;string s1 = "hello";A a1(s1);a = a1;
}
輸出結果:
下面思考一個問題
A& A::operator=(const A& a)
{string* newps = new string(*a.ps);delete ps; // 銷毀ps指向的內存,避免內存泄漏ps = newps; i = a.i;return *this; // 返回此對象的引用
}A& A::operator=(const A& a)
{delete ps; // 銷毀ps指向的內存,避免內存泄漏ps = new string(*(a.ps)); i = a.i;return *this; // 返回此對象的引用
}
為什么我們先將
a.ps
拷貝到一個局部臨時對象中(newps
),然后銷毀*this的ps
(釋放舊內存),再將newps
賦值給*this.ps
?而不是像第二種寫法那樣先釋放舊內存,再直接將a.ps
拷貝給*this.ps
?
這是因為如果 a
和 *this
是 同一個對象,delete ps
會釋放 *this
和 a
指向的 string
。接下來,當我們在 new表達式
中試圖拷貝*(a.ps)
時,就會訪問一個指向無效內存的指針,其行為和結果是未定義的。
示例中的 A類 在 《C++Primer》中也被稱為行為像指針的類,這個概念我將在另一篇博客中細講。
取地址運算符重載、對const對象取地址運算符的重載
取地址運算符也有兩個默認的成員函數,編譯器默認生成,不需要我們定義,一般只有想讓別人獲取指定內容的時候才自己定義一個。
class Date
{
public:Date(int year = 0, int month = 1, int day = 1){_year = year;_month = month;_day = day;}//默認取地址重載Date* operator&(){return this;}//const取地址重載const Date* operator&()const{return this;}int _year;int _month;int _day;
};
析構函數
概念
析構函數也是一個特殊的成員函數,它的功能是:
- 釋放對象在生存期分配的的所有資源
- 銷毀對象的非static數據成員
特征:
- 析構函數名是在類名前加上字符 ~。
- 無參數無返回值。
- 因為不接受參數因此不能被重載。
- 一個類有且只有一個析構函數。若未顯式定義,系統會自動生成默認的析構函數。
- 對象生命周期結束時,C++編譯系統系統自動調用析構函數。
構造函數和析構函數的類比
- 構造函數有一個初始化部分和一個函數體。
- 析構函數有一個析構部分和一個函數體。
- 構造函數中,成員初始化是在函數體執行之前完成的,按照在類中出現的順序進行初始化。
- 析構函數中,首先執行函數體,然后銷毀成員。 成員按照初始化順序的逆序銷毀。
邏輯上:
- 析構函數體一般負責銷毀對象引用的內存(持有的資源)。
- 析構部分則是負責對象本身成員的析構。
- 析構部分會逐個調用類類型成員的析構函數(調用順序與聲明順序相反),除此之外,析構部分還負責調用父類析構函數。
實現上:
- 只有析構函數體是對程序員可見的,析構部分是隱式的。
- 所謂隱式的,是指這部分代碼(即調用類成員析構函數和父類析構函數的代碼)是由編譯器合成的。
- 成員銷毀時發生什么完全依賴于成員的類型:銷毀類類型的成員需要執行成員自己的析構函數。內置類型沒有析構函數,因此銷毀內置類型成員什么也不需要做。
銷毀普通指針和銷毀智能指針的不同:
關于智能指針的知識在這里
基于上述紅字部分:
- 隱式銷毀一個內置指針類型的成員不會delete它所指向的對象。也就是要格外注意類內指針的釋放,避免內存泄漏。
- 與普通指針不同,智能指針是類類型,所以具有析構函數。因此,智能指針成員在析構階段會被自動銷毀。
怎樣調用析構函數
無論何時一個對象被銷毀,就會自動調用其析構函數:
- 變量在離開其作用域時被銷毀。
- 當一個對象被銷毀時,其成員被銷毀。
- 容器(無論是標準庫容器還是數組)被銷毀時,其元素被銷毀。
- 對于動態分配的對象,當對指向它的指針應用delete運算符時被銷毀。
- 對于臨時對象,當創建它的完整表達式結束時被銷毀。
具體如下:
當指向一個對象的引用或指針離開作用域時,析構函數不會執行。 因此上述代碼唯一需要直接管理的內存就是直接分配的Sales_data對象,只需要直接釋放綁定到 p
的動態分配對象。
下面代碼中會調用幾次析構函數?
這段代碼中會發生三次析構函數調用:
- 函數結束時,局部變量item1的生命期結束,被銷毀,Sales_data的析構函數被調用。
- 類似的,item2在函數結束時被銷毀,Sales_data的析構函數被調用。
- 函數結束時,參數accum的生命期結束,被銷毀,Sales_data的析構函數被調用。
在函數結束時,trans的生命期也結束了,但并不是它指向的Sales_data對象的生命期結束(只有delete指針時,指向的動態對象的生命期才結束),所以不會引起析構函數的調用。
析構函數實例
class A
{
public:A(const char* str = "hello world", int num = 3){_str = (char*)malloc(sizeof(str));strcpy(_str, str);_num = num;cout << "constructor function" << endl;}~A(){free(_str);_str = nullptr;_num = 0;cout << "destructor function" << endl;}char* getstr(){return this->_str;}int getnum(){return this->_num;}
private:char* _str;int _num;
};
int main(int argc, char const *argv[]) {A a = A();cout << a.getstr() << endl;cout << a.getnum() << endl;return 0;
}
從結果可以看到,析構函數的執行在return語句之前。
=default
可以通過使用 =default
來顯式的要求編譯器生成合成的拷貝控制成員函數。
但值得注意的是:
- 當我們在類內用=default修飾成員的聲明時,合成的函數將隱式地聲明為內聯的,就像任何其他類內聲明的成員函數一樣。
- 如果我們不希望合成的成員是內聯函數,應該只對成員的類外定義使用=default,就像對拷貝賦值運算符所做的那樣。
=delete
雖然大部分情況下都需要拷貝構造函數和拷貝賦值運算符,但是對于某些類來講,這些操作沒有合理的意義,此時應該使用 =delete 將無意義的操作定義為刪除的函數。其含義是:雖然該函數被定義,但無法被使用。如:iostream類阻止了拷貝,以避免多個對象寫入或讀取相同的IO緩沖。
struct A{A() = default; // 使用合成的默認構造函數A(const A&) = delete; // 阻止拷貝A &operator=(const A&) = delete; // 阻止賦值
};
與=default不同,=delete必須出現在函數第一次聲明的時候。
- 從邏輯上講,默認的成員只影響為這個成員而生的代碼,因此=default直到編譯器調用默認成員時才需要。
- 而編譯器需要在第一時間知道一個函數是刪除的,以便禁止試圖使用它的操作。
兩者另一個不同之處是,我們可以對任何函數指定=delete,但是只能對編譯器可以合成的函數(默認構造函數或拷貝控制成員)使用=default。
在舊標準中我們用聲明成private但不定義的方法來起到新標準中 =delete 的作用,此時試圖使用該種函數的用戶代碼將在編譯階段被標記為鏈接錯誤。
三/五法則
由于拷貝控制操作是由三個特殊的成員函數來完成的:
- 拷貝構造函數定義了當用同類型的另一個對象初始化新對象時做什么
- 拷貝賦值運算符定義了將一個對象賦予同類型的另一個對象時做什么
- 析構函數定義了此類型的對象銷毀時做什么。
所以我們稱此為“C++三法則”。在較新的 C++11 標準中,為了支持移動語義,又增加了移動構造函數和移動賦值運算符,這樣共有五個特殊的成員函數,所以又稱為“C++五法則”。也就是說,“三法則”是針對較舊的 C++89 標準說的,“五法則”是針對較新的 C++11 標準說的。為了統一稱呼,后來人們把它叫做“C++ 三/五法則”。
- 需要析構函數的類也需要拷貝構造函數和拷貝賦值運算符。
從“需要析構函數”可知,類中必然出現了指針類型的成員(否則不需要我們寫析構函數,默認的析構函數就夠了,一般是內置指針類型,類類型的話一般直接調用該類的析構函數,不用我們自己再實現一個析構函數),所以,我們需要自己寫析構函數來釋放給指針所分配的內存來防止內存泄漏。
那么為什么說“也需要拷貝構造函數和賦值操作”呢?原因是:類中出現了指針類型的成員這樣的外部資源,合成的拷貝構造函數和合成的拷貝賦值運算符是對外部資源的淺拷貝,因此析構函數執行delete運算符時會出現double free的錯誤。 - 拷貝構造函數和拷貝賦值運算符要么都是合成版本,要么都是自定義版本。
拷貝構造函數用已有對象構造新對象,函數體內類成員的構造方法就是利用拷貝賦值運算符。 - 析構函數不能是刪除的,否則便無法銷毀此類型的對象了。
同時,編譯器不允許定義該類型的變量或創建該類的臨時對象。可以動態分配該對象并獲得其指針,但無法銷毀這個動態分配的對象(delete 失效)。 - 如果一個類有私有的或不可訪問的析構函數,那么其默認和拷貝構造函數會被定義為私有的。
- 如果一個類有const或引用成員,則不能使用默認的拷貝賦值操作。
原因很簡單,const或引用成員只能在初始化時被賦值一次,而默認的拷貝賦值操作會對所有成員都進行賦值。顯然,它不能賦值const和引用成員,所以默認的拷貝構造函數不能被使用,即會被定義為私有的。
//關于第三點的代碼
struct A{A() = default;~A() = delete;
};
A a; //ERROR:A的析構函數是刪除的。
A *p = new A(); //正確:但無法delete p;
delete p; // ERROR:A類沒有析構函數,無法釋放指向A類動態分配對象的指針。