類和對象 (拷貝構造函數和運算符重載)上
拷貝構造函數存在的原因及解決的 C 語言問題
1. 淺拷貝帶來的問題
在 C 語言里,當對結構體或者數組進行拷貝操作時,執行的是淺拷貝。所謂淺拷貝,就是單純地把一個對象的所有成員變量的值復制到另一個對象中。要是成員變量包含指針,那么僅僅是復制指針的值,也就是地址,并非復制指針所指向的內容。這就可能引發一些問題,比如多個指針指向同一塊內存區域,在釋放內存時就會出現重復釋放的情況,進而導致程序崩潰。
下面是一個 C 語言的示例代碼,展示了淺拷貝帶來的問題:
#include <stdio.h>
#include <stdlib.h>typedef struct {int *data;int size;
} MyArray;// 初始化數組
void initArray(MyArray *arr, int size) {arr->size = size;arr->data = (int *)malloc(size * sizeof(int));for (int i = 0; i < size; i++) {arr->data[i] = i;}
}// 釋放數組內存
void freeArray(MyArray *arr) {free(arr->data);
}int main() {MyArray arr1;initArray(&arr1, 5);// 淺拷貝MyArray arr2 = arr1;// 釋放arr1的內存freeArray(&arr1);// 嘗試訪問arr2的數據,會導致未定義行為for (int i = 0; i < arr2.size; i++) {printf("%d ", arr2.data[i]);}return 0;
}
在這個例子中,arr2
是 arr1
的淺拷貝,它們的 data
指針指向同一塊內存區域。當釋放 arr1
的內存后,arr2
的 data
指針就變成了懸空指針,此時再訪問 arr2.data
就會引發未定義行為。
2. C++ 拷貝構造函數的作用
C++ 的拷貝構造函數能夠解決淺拷貝帶來的問題。拷貝構造函數是一種特殊的構造函數(也就是和構造函數構成重載),當用一個已存在的對象來初始化另一個新對象時會被調用。在拷貝構造函數里,能夠實現深拷貝,也就是復制指針所指向的內容,而不只是復制指針的值。
以下是一個 C++ 的示例代碼,使用拷貝構造函數實現深拷貝:
#include <iostream>class MyArray {
private:int *data;int size;
public:// 構造函數MyArray(int size) : size(size) {data = new int[size];for (int i = 0; i < size; i++) {data[i] = i;}}// 拷貝構造函數,實現深拷貝MyArray(const MyArray& other) : size(other.size) {data = new int[size];//這里新開了一個大小和arr1中data數組相同的空間for (int i = 0; i < size; i++) {data[i] = other.data[i];}}// 析構函數~MyArray() {delete[] data;}// 打印數組元素void print() const {for (int i = 0; i < size; i++) {std::cout << data[i] << " ";}std::cout << std::endl;}
};int main() {MyArray arr1(5);MyArray arr2(arr1); // 自動調用拷貝構造函數arr1.print();arr2.print();return 0;
}
在這個例子中,MyArray
類的拷貝構造函數實現了深拷貝,確保 arr2
和 arr1
的 data
指針指向不同的內存區域,這樣在釋放內存時就不會出現重復釋放的問題。
C 語言中的拷貝和 C++ 中的拷貝的區別
1. 拷貝方式
- C 語言:主要采用淺拷貝,對于結構體和數組,只是簡單地按位復制,不考慮指針指向的內容。
- C++:默認情況下也是淺拷貝,但可以通過定義拷貝構造函數來實現深拷貝,從而避免淺拷貝帶來的問題。
2. 語法和靈活性
- C 語言:拷貝操作通常使用賦值語句或者自定義的拷貝函數,語法較為簡單,但缺乏靈活性,需要手動處理內存管理。
- C++:拷貝操作可以通過拷貝構造函數和賦值運算符重載來實現,語法更加靈活,能夠自動處理內存管理,提高代碼的安全性和可維護性。
3. 資源管理
- C 語言:需要手動管理內存,在進行拷貝操作時容易出現內存泄漏和重復釋放的問題。
- C++:可以利用拷貝構造函數和析構函數來自動管理資源,減少內存管理的錯誤。
拷貝構造函數的特征
語法
拷貝構造函數的一般形式如下:
class ClassName {
public:// 拷貝構造函數ClassName(const ClassName& other) {// 拷貝操作}
};
在上述代碼中,ClassName
是類名,other
是已存在對象的引用,通常使用 const
修飾,避免在拷貝過程中修改原對象。
調用場景
拷貝構造函數在以下幾種常見情況下會被(自動)調用:
- 用一個對象初始化另一個對象:
ClassName obj1;
ClassName obj2(obj1); // 調用拷貝構造函數
- 對象作為實參傳遞給函數:
void func(ClassName obj) {// 函數體
}
ClassName obj;
func(obj); // 調用拷貝構造函數
我們前面說過了,形參是實參的拷貝,傳參數的時候會對實參進行一個拷貝復制,復制出來的那個參數就是形參。
- 函數返回對象:
ClassName func() {ClassName obj;return obj; // 調用拷貝構造函數
}
要知道是, 當函數調用結束.函數里面所有開的空間都是要歸還系統的,我們函數中return的變量也都是函數里面的。這里用上面的代碼說明就是:返回的變量并不是函數中obj而是,obj這個變量的拷貝,也就是說,函數返回的變量是要返回變量的克隆體。明白吧,當這個變量類型是“某某類”(也就是自定義類型)的時候,這時候就會自動調用拷貝構造函數。
特征和性質(第一點我會畫圖解釋)
- 參數類型為引用:拷貝構造函數的參數必須是引用類型,通常是
const
引用(const ClassName&
)。如果使用值傳遞,會引發無限遞歸調用,因為值傳遞會再次調用拷貝構造函數來復制參數,從而導致棧溢出。 - 沒有返回值:和其他構造函數一樣,拷貝構造函數沒有返回值,也不使用
void
聲明。它的主要作用是初始化新對象,所以不需要返回任何值。 - 默認生成:若沒有為類顯式定義拷貝構造函數**(也就是我們沒有寫拷貝構造函數),編譯器會自動生成一個默認的拷貝構造函數。(就類似構造函數一樣,編譯器會自動生成,但是編譯器生成的只能處理一些非常非常簡單類型)默認拷貝構造函數執行淺拷貝,即逐個復制對象的成員變量(比如日期類這些,我們就可以不用寫拷貝構造函數,用編譯器自動生成的就可以了)**。不過,當類包含動態分配的資源(如動態內存、文件句柄等)時,淺拷貝可能會引發問題,此時需要顯式定義拷貝構造函數來實現深拷貝。
- 支持自定義操作:(這一點先不管)在顯式定義的拷貝構造函數中,可以執行自定義的操作,比如深拷貝、更新計數器、記錄日志等。這使得拷貝對象時可以根據類的具體需求進行特殊處理。
- 與析構函數和賦值運算符的關聯:(這一點也先不管)遵循三 / 五 / 零法則。如果類需要顯式定義析構函數、拷貝構造函數或拷貝賦值運算符中的任何一個,那么它很可能也需要顯式定義另外兩個。在 C++11 及以后,還需要考慮移動構造函數和移動賦值運算符。
運算符重載
運算符重載的基本概念
運算符重載本質上是一種函數,它的函數名由關鍵字operator
和運算符組成。通過運算符重載,你能夠讓自定義類型的對象像內置類型對象那樣使用運算符。
運算符重載存在的意義和解決的問題
1. 增強代碼的可讀性和可維護性
在處理自定義類型時,若沒有運算符重載,你需要調用特定的成員函數來完成操作,這樣會讓代碼顯得冗長且難以閱讀。運算符重載能讓自定義類型的操作和內置類型操作保持一致,從而提高代碼的可讀性和可維護性。
2. 實現自定義類型的運算
內置運算符只能處理內置類型,而對于自定義類型,你可以通過運算符重載來定義適合它們的運算規則。
運算符重載的示例代碼
以下是一個簡單的示例,展示了如何重載>
運算符來實現自定義類對象的加法:(用日期類來舉例)
#include <iostream>
#include <cassert>
using std::cout;
using std::endl;class Date
{
public:Date(int year = 1900, int month = 1, int day = 1){_year = year;_month = month;_day = day;}Date(const Date& dd){_year = dd._year;_month = dd._month;_day = dd._day;}void Print(){cout << _year << "/" << _month << "/" << _day << endl;}bool operator>(const Date& other)//這是比較日期大小的{assert(_year>=1&&other._year>=1);assert(_month>=1 && _month<=12 && other._month>=1 && other._month<=12);assert(_day>=1 && other._month>=1);if(_year>other._year)return true;if(_year==other._year && _month>other._month)return true;if(_year==other._year && _month==other._month && _day>other._day)return true;return false;}bool operator==(const Date& other)//這是判斷兩日期是否相同的{assert(_year>=1&&other._year>=1);assert(_month>=1 && _month<=12 && other._month>=1 && other._month<=12);assert(_day>=1 && other._month>=1);if(_year==other._year &&_month==other._month &&_day==other._day)return true;return false;}private:int _year;int _month;int _day;
};int main() {Date d1;Date d2(2004,4,4);Date d3(d1);bool ret1= d2>d1;bool ret2= d1>d2;d2.Print();d1.Print();cout<<ret1<<endl<<ret2<<endl;bool ret3= d1==d3;d1.Print();d3.Print();cout<<ret3<<endl;return 0;
}
運算符重載的語法這里其實沒多少東西:
返回類型 operator 運算符(參數列表) {// 函數體
}
它就是為了讓你寫的自定義類型比較的時候大家都能直觀的看懂,不用費勁吧咧的想一個函數名。
其中,返回類型
是運算符重載函數的返回值類型,operator
是關鍵字,運算符
是要重載的運算符,參數列表
是傳遞給運算符函數的參數。對于二元運算符,參數通常是另一個操作數;對于一元運算符,通常沒有顯式參數。
運算符重載我們一般都寫在類里面,為啥呢?因為成員變量絕大多數的時候都是私有的,你在類外定義函數雖然也可以,但是你要額外的寫幾個獲取成員變量的函數,就是那些什么int getx()
什么的。當然了,你也可以聲明為友元函數,但是其實也沒必要。所以為了方便,我們一般直接在類里面定義運算符重載,那么類里面定義的就屬于成員函數。
二元運算符
二元運算符需要兩個操作數才能完成運算,像 +
、-
、*
、/
這類。當使用成員函數重載二元運算符時,該函數是類的一個成員,而對象自身會隱式地作為運算符的左操作數,所以參數列表里通常只需傳入另一個操作數(右操作數)。
別忘了,我們前面說過this指針就是其中的一個隱藏參數,所以類里面的成員函數中其實都是有一個隱藏參數的,所以在這里,我們只需要傳另一個參數就可以了。一元運算符同理。
一元運算符
一元運算符僅需一個操作數就能完成運算,例如 ++
、--
、!
等。當使用成員函數重載一元運算符時,對象自身會隱式地作為運算符的操作數,所以通常不需要顯式的參數。
類外定義運算符重載
在 C++ 中,除了可以在類內定義運算符重載函數,也能在類外進行定義。類外定義運算符重載函數通常有兩種情況,一種是普通的非成員函數,另一種是使用友元函數。下面為你詳細介紹這兩種方式。
普通非成員函數重載運算符
當使用普通非成員函數重載運算符時,需要將所有操作數作為參數傳遞給運算符函數(也就是說,該有幾個參數就是幾個參數)。因為非成員函數沒有隱含的 this
指針,所以不能直接訪問類的私有成員,除非類提供了相應的公有訪問函數。
友元函數重載運算符(這個先不管)
若運算符重載函數需要訪問類的私有成員,可將其聲明為類的友元函數。友元函數雖然不是類的成員函數,但它可以訪問類的私有和保護成員。
運算符重載的優先級和結合性
在 C++ 中,運算符重載時,其優先級和結合性是由所重載的運算符本身決定的,而非由重載函數的定義決定。也就是說,重載后的運算符會保持其在原生運算符中的優先級和結合性。
優先級
運算符優先級規定了在一個表達式中不同運算符執行的先后順序。例如,乘法運算符 *
的優先級高于加法運算符 +
,所以在表達式 2 + 3 * 4
中,會先計算 3 * 4
,再將結果與 2
相加。當你重載這些運算符時,它們的優先級依然保持不變。
結合性
結合性決定了相同優先級的運算符在表達式中是從左到右還是從右到左進行計算。例如,加法運算符 +
是左結合的,在表達式 2 + 3 + 4
中,會先計算 2 + 3
,再將結果與 4
相加;而賦值運算符 =
是右結合的,在表達式 a = b = c
中,會先將 c
的值賦給 b
,再將 b
的值賦給 a
。重載運算符時,結合性同樣保持不變。
改變優先級和結合性
如果你想改變優先級和結合性,那就加()唄,2 + 3 * 4
加上括號( 2 + 3 ) * 4
優先級就改變了,結合性同理哈。
注意事項
- 并不是所有運算符都可以重載,例如
.
、::
、?:
等運算符不能被重載。 - 重載運算符時,其操作數的數量、優先級和結合性不能改變。
- 重載運算符的目的是提高代碼的可讀性和可維護性,應該避免過度使用或濫用運算符重載。
- 運算符重載和函數重載沒有任何關系。
日期類的實現
前面我已經給大家實現了比較日期的大小,和判斷是否相等。那么接下來就是我們實現一些對日期類有意義的運算符重載。什么叫有意義呢?有意義就是對現實生活有意義的,比如+=天數之后是什么日期,比如2025年4月29日+=1,結果就是2025年4月30日。明白沒。實現+天數也可以的,不過不改變日期類本身,+=是改變被加數本身的嘛。
那哪些是沒有意義的呢?你覺得一個日期*
一個日期或者一個日期/
一個日期,這樣有啥作用不? 這個對現實世界是不是沒啥作用?所以我們就沒必要去寫,我們寫一下對現實世界有意義的就行。
這個計算我們沒學運算符重載也可以寫,運算符重載只是為了我們使用的時候看起來更簡潔,更易懂一些,更直觀,明白嘛?
#include <iostream>
#include <cassert>
using std::cout;
using std::endl;class Date
{
public:Date(int year = 2025, int month = 4, int day = 29){_year = year;_month = month;_day = day;}Date(const Date& dd){_year = dd._year;_month = dd._month;_day = dd._day;}void Print(){cout << _year << "/" << _month << "/" << _day << endl;}bool operator>(const Date& other){assert(_year>=1&&other._year>=1);assert(_month>=1 && _month<=12 && other._month>=1 && other._month<=12);assert(_day>=1 && other._month>=1);if(_year>other._year)return true;if(_year==other._year && _month>other._month)return true;if(_year==other._year && _month==other._month && _day>other._day)return true;return false;}bool operator==(const Date& other){assert(_year>=1&&other._year>=1);assert(_month>=1 && _month<=12 && other._month>=1 && other._month<=12);assert(_day>=1 && other._month>=1);if(_year==other._year &&_month==other._month &&_day==other._day)return true;return false;}int monthday(){assert(_month>=1 && _month<=12);int day[13]={0,31,28,31,30,31,30,31,31,30,31,30,31};if(_month==2 && ((_year%4==0 && _year%100!=0) || _year%400==0)){return 29;}else{return day[_month];}}Date& operator+=(int day){_day+=day;while (_day>monthday()){_day-=monthday();_month+=1;if(_month>12){_year+=1;_month=1;}}return *this;}Date operator+(int day){Date tmp(*this);tmp+=day;return tmp;}
private:int _year;int _month;int _day;
};int main() {Date d1;Date d2=d1+100;d1.Print();d2.Print();return 0;
}
這里新增了一個運算符+和運算符+=的重載,邏輯其實很簡單,需要注意一下主要是:
賦值運算符=重載
這個運算符重載有點特殊,不過不難理解。這里直接以日期類舉例://運行一遍代碼再往下看
class Date
{
public:Date(int year = 2025, int month = 4, int day = 29){_year = year;_month = month;_day = day;}Date(const Date& dd){_year = dd._year;_month = dd._month;_day = dd._day;}Date& operator=(const Date& other){if (this != &other){_year=other._year;_month=other._month;_day=other._day;}return *this;}void Print(){cout << _year << "/" << _month << "/" << _day << endl;}
private:int _year;int _month;int _day;
};int main() {Date d1;Date d2(1999,9,9);d2.Print();d2=d1;d2.Print();return 0;
}
區別
1. 調用時機不同
- 拷貝構造函數:在創建新對象時,使用另一個同類型的對象來初始化它時調用。常見的調用場景包括用一個對象初始化另一個對象、對象作為實參傳遞給函數、函數返回對象等。
class MyClass {
public:MyClass(const MyClass& other) {// 拷貝操作}
};MyClass obj1;
MyClass obj2(obj1); // 調用拷貝構造函數
- 賦值運算符重載:在對象已經創建后,將一個對象的值賦給另一個對象時調用。
class MyClass {
public:MyClass& operator=(const MyClass& other) {if (this != &other) {// 賦值操作}return *this;}
};MyClass obj1, obj2;
obj1 = obj2; // 調用賦值運算符重載
2. 語法形式不同
- 拷貝構造函數:是一種特殊的構造函數,沒有返回值,參數通常是
const
引用。
MyClass(const MyClass& other);
- 賦值運算符重載:是一個成員函數,返回值通常是對象的引用(
MyClass&
),以支持連續賦值操作,參數也通常是const
引用。
MyClass& operator=(const MyClass& other);
3. 處理自我賦值的方式不同
在賦值運算符重載中,需要考慮自我賦值的情況(即 obj = obj
),并進行相應的處理,避免不必要的操作或錯誤。而拷貝構造函數不會涉及自我賦值的問題,因為它是在創建新對象時調用的。
MyClass& operator=(const MyClass& other) {if (this != &other) {// 避免自我賦值,進行賦值操作}return *this;
}
未完待續.