一文說盡C++賦值運算子過載函式(operator=)
寫在前面:
關於C++的賦值運算子過載函式(operator=),網路以及各種教材上都有很多介紹,但可惜的是,內容大多雷同且不全面。面對這一局面,在下在整合各種資源及融入個人理解的基礎上,整理出一篇較為全面/詳盡的文章,以饗讀者。
正文:
Ⅰ.舉例
例1
#include<iostream> #include<string> using namespace std; class MyStr { private: char *name; int id; public: MyStr() {} MyStr(int _id, char *_name) //constructor { cout << "constructor" << endl; id = _id; name = new char[strlen(_name) + 1]; strcpy_s(name, strlen(_name) + 1, _name); } MyStr(const MyStr& str) { cout << "copy constructor" << endl; id = str.id; if (name != NULL) delete[] name; name = new char[strlen(str.name) + 1]; strcpy_s(name, strlen(str.name) + 1, str.name); } MyStr& operator =(const MyStr& str)//賦值運算子 { cout << "operator =" << endl; if (this != &str) { if (name != NULL) delete[] name; this->id = str.id; int len = strlen(str.name); name = new char[len + 1]; strcpy_s(name, strlen(str.name) + 1, str.name); } return *this; } ~MyStr() { delete[] name; } }; int main() { MyStr str1(1, "hhxx"); cout << "====================" << endl; MyStr str2; str2 = str1; cout << "====================" << endl; MyStr str3 = str2; return 0; }
結果:
Ⅱ.引數
一般地,賦值運算子過載函式的引數是函式所在類的const型別的引用(如上面例1),加const是因為:
①我們不希望在這個函式中對用來進行賦值的“原版”做任何修改。
②加上const,對於const的和非const的實參,函式就能接受;如果不加,就只能接受非const的實參。
用引用是因為:
這樣可以避免在函式呼叫時對實參的一次拷貝,提高了效率。
注意:
上面的規定都不是強制的,可以不加const,也可以沒有引用,甚至引數可以不是函式所在的物件,正如後面例2中的那樣。
Ⅲ.返回值
一般地,返回值是被賦值者的引用,即*this(如上面例1),原因是
①這樣在函式返回時避免一次拷貝,提高了效率。
②更重要的,這樣可以實現連續賦值,即類似a=b=c這樣。如果不是返回引用而是返回值型別,那麼,執行a=b時,呼叫賦值運算子過載函式,在函式返回時,由於返回的是值型別,所以要對return後邊的“東西”進行一次拷貝,得到一個未命名的副本(有些資料上稱之為“匿名物件”),然後將這個副本返回,而這個副本是右值,所以,執行a=b後,得到的是一個右值,再執行=c就會出錯。
注意:
這也不是強制的,我們可以將函式返回值宣告為void,然後什麼也不返回,只不過這樣就不能夠連續賦值了。
Ⅳ.呼叫時機
當為一個類物件賦值(注意:可以用本類物件為其賦值(如上面例1),也可以用其它型別(如內建型別)的值為其賦值,關於這一點,見後面的例2)時,會由該物件呼叫該類的賦值運算子過載函式。
如上邊程式碼中
str2 = str1;
一句,用str1為str2賦值,會由str2呼叫MyStr類的賦值運算子過載函式。
需要注意的是,
MyStr str2;
str2 = str1;
和
MyStr str3 = str2;
在呼叫函式上是有區別的。正如我們在上面結果中看到的那樣。
前者MyStr str2;一句是str2的宣告加定義,呼叫無參建構函式,所以str2 = str1;一句是在str2已經存在的情況下,用str1來為str2賦值,呼叫的是拷貝賦值運算子過載函式;而後者,是用str2來初始化str3,呼叫的是拷貝建構函式。
Ⅴ.提供預設賦值運算子過載函式的時機
當程式沒有顯式地提供一個以本類或本類的引用為引數的賦值運算子過載函式時,編譯器會自動生成這樣一個賦值運算子過載函式。注意我們的限定條件,不是說只要程式中有了顯式的賦值運算子過載函式,編譯器就一定不再提供預設的版本,而是說只有程式顯式提供了以本類或本類的引用為引數的賦值運算子過載函式時,編譯器才不會提供預設的版本。可見,所謂預設,就是“以本類或本類的引用為引數”的意思。
見下面的例2
#include<iostream> #include<string> using namespace std; class Data { private: int data; public: Data() {}; Data(int _data) :data(_data) { cout << "constructor" << endl; } Data& operator=(const int _data) { cout << "operator=(int _data)" << endl; data = _data; return *this; } }; int main() { Data data1(1); Data data2,data3; cout << "=====================" << endl; data2 = 1; cout << "=====================" << endl; data3 = data2; return 0; }
結果:
上面的例子中,我們提供了一個帶int型引數的賦值運算子過載函式,data2 = 1;一句呼叫了該函式,如果編譯器不再提供預設的賦值運算子過載函式,那麼,data3 = data2;一句將不會編譯通過,但我們看到事實並非如此。所以,這個例子有力地證明了我們的結論。
Ⅵ.建構函式還是賦值運算子過載函式
如果我們將上面例子中的賦值運算子過載函式註釋掉,main函式中的程式碼依然可以編譯通過。只不過結論變成了
可見,當用一個非類A的值(如上面的int型值)為類A的物件賦值時
①如果匹配的建構函式和賦值運算子過載函式同時存在(如例2),會呼叫賦值運算子過載函式。
②如果只有匹配的建構函式存在,就會呼叫這個建構函式。
Ⅶ.顯式提供賦值運算子過載函式的時機
①用非類A型別的值為類A的物件賦值時(當然,從Ⅵ中可以看出,這種情況下我們可以不提供相應的賦值運算子過載函式而只提供相應的建構函式來完成任務)。
②當用類A型別的值為類A的物件賦值且類A的成員變數中含有指標時,為避免淺拷貝(關於淺拷貝和深拷貝,下面會講到),必須顯式提供賦值運算子過載函式(如例1)。
Ⅷ.淺拷貝和深拷貝
拷貝建構函式和賦值運算子過載函式都會涉及到這個問題。
所謂淺拷貝,就是說編譯器提供的預設的拷貝建構函式和賦值運算子過載函式,僅僅是將物件a中各個資料成員的值拷貝給物件b中對應的資料成員(這裡假設a、b為同一個類的兩個物件,且用a拷貝出b或用a來給b賦值),而不做其它任何事。
假設我們將例1中顯式提供的拷貝建構函式註釋掉,然後同樣執行MyStr str3 = str2;語句,此時呼叫預設的拷貝建構函式,它只是將str2的id值和nane值拷貝到str3,這樣,str2和str3中的name值是相同的,即它們指向記憶體中的同一區域(在例1中,是字串”hhxx”)。如下圖
這樣,會有兩個致命的錯誤
①當我們通過str2修改它的name時,str3的name也會被修改!
②當執行str2和str3的解構函式時,會導致同一記憶體區域釋放兩次,程式崩潰!
這是萬萬不可行的,所以我們必須通過顯式提供拷貝建構函式以避免這樣的問題。就像我們在例1中做的那樣,先判斷被拷貝者的name是否為空,若否,delete[] name(後面會解釋為什麼要這麼做),然後,為name重新申請空間,再將拷貝者name中的資料拷貝到被拷貝者的name中。執行後,如圖
這樣,str2.name和str3.name各自獨立,避免了上面兩個致命錯誤。
我們是以拷貝建構函式為例說明的,賦值運算子過載函式也是同樣的道理。
Ⅸ.賦值運算子過載函式只能是類的非靜態的成員函式
C++規定,賦值運算子過載函式只能是類的非靜態的成員函式,不能是靜態成員函式,也不能是友元函式。關於原因,有人說,賦值運算子過載函式往往要返回*this,而無論是靜態成員函式還是友元函式都沒有this指標。這乍看起來很有道理,但仔細一想,我們完全可以寫出這樣的程式碼
static friend MyStr& operator=(const MyStr str1,const MyStr str2) { …… return str1; }
可見,這種說法並不能揭露C++這麼規定的原因。
其實,之所以不是靜態成員函式,是因為靜態成員函式只能操作類的靜態成員,不能操作非靜態成員。如果我們將賦值運算子過載函式定義為靜態成員函式,那麼,該函式將無法操作類的非靜態成員,這顯然是不可行的。
在前面的講述中我們說過,當程式沒有顯式地提供一個以本類或本類的引用為引數的賦值運算子過載函式時,編譯器會自動提供一個。現在,假設C++允許將賦值運算子過載函式定義為友元函式並且我們也確實這麼做了,而且以類的引用為引數。與此同時,我們在類內卻沒有顯式提供一個以本類或本類的引用為引數的賦值運算子過載函式。由於友元函式並不屬於這個類,所以,此時編譯器一看,類內並沒有一個以本類或本類的引用為引數的賦值運算子過載函式,所以會自動提供一個。此時,我們再執行類似於str2=str1這樣的程式碼,那麼,編譯器是該執行它提供的預設版本呢,還是執行我們定義的友元函式版本呢?
為了避免這樣的二義性,C++強制規定,賦值運算子過載函式只能定義為類的成員函式,這樣,編譯器就能夠判定是否要提供預設版本了,也不會再出現二義性。
Ⅹ. 賦值運算子過載函式不能被繼承
見下面的例3
#include<iostream> #include<string> using namespace std; class A { public: int X; A() {} A& operator =(const int x) { X = x; return *this; } }; class B :public A { public: B(void) :A() {} }; int main() { A a; B b; a = 45; //b = 67; (A)b = 67; return 0; }
註釋掉的一句無法編譯通過。報錯提示:沒有與這些運算元匹配的”=”運算子。對於b = 67;一句,首先,沒有可供呼叫的建構函式(前面說過,在沒有匹配的賦值運算子過載函式時,類似於該句的程式碼可以呼叫匹配的建構函式),此時,程式碼不能編譯通過,說明父類的operator =函式並沒有被子類繼承。
為什麼賦值運算子過載函式不能被繼承呢?
因為相較於基類,派生類往往要新增一些自己的資料成員和成員函式,如果允許派生類繼承基類的賦值運算子過載函式,那麼,在派生類不提供自己的賦值運算子過載函式時,就只能呼叫基類的,但基類版本只能處理基類的資料成員,在這種情況下,派生類自己的資料成員怎麼辦?
所以,C++規定,賦值運算子過載函式不能被繼承。
上面程式碼中, (A)b = 67; 一句可以編譯通過,原因是我們將B類物件b強制轉換成了A類物件。
Ⅺ.賦值運算子過載函式要避免自賦值
對於賦值運算子過載函式,我們要避免自賦值情況(即自己給自己賦值)的發生,一般地,我們通過比較賦值者與被賦值者的地址是否相同來判斷兩者是否是同一物件(正如例1中的if (this != &str)一句)。
為什麼要避免自賦值呢?
①為了效率。顯然,自己給自己賦值完全是毫無意義的無用功,特別地,對於基類資料成員間的賦值,還會呼叫基類的賦值運算子過載函式,開銷是很大的。如果我們一旦判定是自賦值,就立即return *this,會避免對其它函式的呼叫。
②如果類的資料成員中含有指標,自賦值有時會導致災難性的後果。對於指標間的賦值(注意這裡指的是指標所指內容間的賦值,這裡假設用_p給p賦值),先要將p所指向的空間delete掉(為什麼要這麼做呢?因為指標p所指的空間通常是new來的,如果在為p重新分配空間前沒有將p原來的空間delete掉,會造成記憶體洩露),然後再為p重新分配空間,將_p所指的內容拷貝到p所指的空間。如果是自賦值,那麼p和_p是同一指標,在賦值操作前對p的delete操作,將導致p所指的資料同時被銷燬。那麼重新賦值時,拿什麼來賦?
所以,對於賦值運算子過載函式,一定要先檢查是否是自賦值,如果是,直接return *this。
結束語:
至此,本文的所有內容都介紹完了。由於在下才疏學淺,錯誤紕漏之處在所難免,如果您在閱讀的過程中發現了在下的錯誤和不足,請您務必指出。您的批評指正就是在下前進的