8 this指針
8.1 this指針的引入
我們先來定義一個日期的類Date
:
#include <iostream>
using namespace std;
class Date
{
public:void Init(int year, int month, int day){_year = year;_month = month;_day = day;}void Print(){cout << _year << "-" << _month << "-" << _day << endl;}
private:int _year; // 年int _month; // 月int _day; // 日
};
int main()
{Date d1, d2;d1.Init(2022, 1, 11);d2.Init(2022, 1, 12);d1.Print();d2.Print();return 0;
}
對于上面一個類,有這樣一個問題:
Date
類中有Init
與Print
兩個成員函數,函數體中并沒有關于不同對象的區分,那么當d1
調用Init
函數時,該函數是如何知道應該設置d1
對象,而不是設置d2
對象的呢?
C++通過引入this
指針來解決這個問題。實際上,C++編譯器給每個非靜態的成員函數增加了一個隱藏的指針參數,讓該指針指向當前對象(函數運行時調用該函數的對象),在函數體中所有“成員變量” 的操作,都是通過該指針去訪問,只不過所有的操作對用戶是透明的,即用戶不需要來傳遞,編譯器自動完成。
8.2 this指針的特性
this
指針的類型:類的類型 const*,所以成員函數中,不能給this
指針賦值。this
指針只能在成員函數的內部使用。this
指針本質上是成員函數的形參,所以this
指針是存儲在棧中的。當對象調用成員函數時,函數將對象地址作為實參傳遞給this
形參。所以對象中不存儲this
指針。this
指針是成員函數第一個隱含的指針形參,一般情況下由編譯器通過ecx
寄存器自動傳遞,不需要用戶傳遞。
this
指針
例1:下面程序編譯運行的結果是什么?
#include <iostream>
using namespace std;
class A
{
public:void Print(){cout << "Print()" << endl;}
private:int _a;
};
void test1()
{A* p = nullptr;//空指針p->Print();
}
void test2()
{A* p = nullptr;//空指針(*p).Print();
}
int main()
{test1();test2();return 0;
}
輸出結果:
從輸出結果可以看到,程序正常運行了,這是為什么呢?
這是因為成員函數Print
實際上在公共的代碼段而并不在對象里面,所以雖然p
是一個空指針,但p->Print()
在這里并不代表解引用,而是直接去公共區域調用了函數Print
,(*p).Print()
也同理。
如果是這樣的話,那能不能不用對象直接調用Print
函數呢?
#include <iostream>
using namespace std;
class A
{
public:void Print(){cout << "Print()" << endl;}
private:int _a;
};
int main()
{Print();return 0;
}
運行結果:
可以看到,編譯器報錯了。這是因為Print
會受到類域的限制,如果不用對象直接調用Print
函數那么編譯器將無法找到Print
函數。
例2:下面程序編譯運行的結果是什么?
#include <iostream>
using namespace std;
class A
{
public:void PrintA(){cout << _a << endl;}
private:int _a;
};
int main()
{A* p = nullptr;p->PrintA();return 0;
}
輸出結果:
從輸出結果可以看到,程序崩潰了。這是因為PrintA
函數體內部的cout << _a << endl
語句等價于cout << this->_a << endl
而此時PrintA
函數的參數為空指針,那么對空指針進行解引用自然就會發生崩潰了。
9 C語言和C++實現Stack的對比
9.1 C語言實現
#include <stdio.h>
#include <assert.h>
#include <stdlib.h>
typedef int DataType;
typedef struct Stack
{DataType* array;int capacity;int size;
}Stack;void StackInit(Stack* ps)
{assert(ps);ps->array = (DataType*)malloc(sizeof(DataType) * 3);if (NULL == ps->array){assert(0);return;}ps->capacity = 3;ps->size = 0;
}void StackDestroy(Stack* ps)
{assert(ps);if (ps->array){free(ps->array);ps->array = NULL;ps->capacity = 0;ps->size = 0;}
}void CheckCapacity(Stack* ps)
{if (ps->size == ps->capacity){int newcapacity = ps->capacity * 2;DataType* temp = (DataType*)realloc(ps->array,newcapacity * sizeof(DataType));if (temp == NULL){perror("realloc申請空間失敗!!!");return;}ps->array = temp;ps->capacity = newcapacity;}
}void StackPush(Stack* ps, DataType data)
{assert(ps);CheckCapacity(ps);ps->array[ps->size] = data;ps->size++;
}int StackEmpty(Stack* ps)
{assert(ps);return 0 == ps->size;
}void StackPop(Stack* ps)
{if (StackEmpty(ps))return;ps->size--;
}DataType StackTop(Stack* ps)
{assert(!StackEmpty(ps));return ps->array[ps->size - 1];
}int StackSize(Stack* ps)
{assert(ps);return ps->size;
}int main()
{Stack s;StackInit(&s);StackPush(&s, 1);StackPush(&s, 2);StackPush(&s, 3);StackPush(&s, 4);printf("%d\n", StackTop(&s));printf("%d\n", StackSize(&s));StackPop(&s);StackPop(&s);printf("%d\n", StackTop(&s));printf("%d\n", StackSize(&s));StackDestroy(&s);return 0;
}
可以看到,在用C語言實現Stack
時,Stack
相關操作函數有以下共性:
- 每個函數的第一個參數都是
Stack*
。 - 函數中必須要對第一個參數檢測,因為該參數可能會為
NULL
。 - 函數中都是通過
Stack*
參數操作棧的. - 調用時必須傳遞
Stack
結構體變量的地址。
結論:C語言中結構體只能定義存放數據的結構,而操作數據的方法不能放在結構體中,即數據和操作數據的方式是分離開的,而且實現上相對復雜,涉及到大量指針操作,稍不注意可能就會出錯。
9.2 C++實現
#include <iostream>
#include <stdlib.h>
using namespace std;
typedef int DataType;
class Stack
{
public:void Init(){_array = (DataType*)malloc(sizeof(DataType) * 3);if (NULL == _array){perror("malloc申請空間失敗!!!");return;}_capacity = 3;_size = 0;}void Push(DataType data){CheckCapacity();_array[_size] = data;_size++;}void Pop(){if (Empty())return;_size--;}DataType Top() { return _array[_size - 1]; }int Empty() { return 0 == _size; }int Size() { return _size; }void Destroy(){if (_array){free(_array);_array = NULL;_capacity = 0;_size = 0;}}
private:void CheckCapacity(){if (_size == _capacity){int newcapacity = _capacity * 2;DataType* temp = (DataType*)realloc(_array, newcapacity *sizeof(DataType));if (temp == NULL){perror("realloc申請空間失敗!!!");return;}_array = temp;_capacity = newcapacity;}}
private:DataType* _array;int _capacity;int _size;
};
int main()
{Stack s;s.Init();s.Push(1);s.Push(2);s.Push(3);s.Push(4);printf("%d\n", s.Top());printf("%d\n", s.Size());s.Pop();s.Pop();printf("%d\n", s.Top());printf("%d\n", s.Size());s.Destroy();return 0;
}
在C++中,通過類可以將數據以及操作數據的方法進行完美結合,通過訪問權限可以控制哪些方法在類外可以被調用,即封裝。在使用時就像使用自己的成員一樣,更符合人對一件事物的認知。 而且和C語言相比,每個方法不需要傳遞Stack*
的參數,編譯器在編譯之后會將該參數自動還原,即C++中Stack*
參數是編譯器維護的,C語言中需要用戶自己維護。
10 類的默認成員函數
之前我們說過,如果一個類中什么成員都沒有,簡稱為空類。任何類在什么都不寫時,編譯器會自動生成以下6個默認成員函數。 默認成員函數指的就是用戶沒有顯式實現,但是編譯器會生成的成員函數。
11 構造函數
11.1 構造函數的概念
我們以下面一個描述日期的類Date
為例:
#include <iostream>
using namespace std;
class Date
{
public:void Init(int year, int month, int day){_year = year;_month = month;_day = day;}void Print(){cout << _year << "-" << _month << "-" << _day << endl;}
private:int _year;int _month;int _day;
};
int main()
{Date d1;d1.Init(2022, 7, 5);d1.Print();Date d2;d2.Init(2022, 7, 6);d2.Print();return 0;
}
對于Date
類,可以通過公有方法Init
給對象設置日期,但如果每次創建對象時都調用該方法設置信息,還是有點麻煩。那能否在對象創建時,就將信息設置進去呢? C++中,引入了構造函數來解決這個問題。
構造函數是一個特殊的成員函數,名字與類名相同,創建類類型對象時由編譯器自動調用,以保證每個數據成員都有一個合適的初始值,并且在對象整個生命周期內只調用一次。
需要注意的是,構造函數雖然名稱叫構造,但是構造函數的主要任務并不是開辟空間創建對象,而是初始化對象。
11.2 構造函數的特性
- 函數名與類名相同。
- 無返回值。
- 對象實例化時編譯器自動調用對應的構造函數。
- 構造函數可以重載,也就是說構造函數允許對象有多種初始化的方式。
例:
#include <iostream>
using namespace std;
class Date
{
public:// 1.無參構造函數Date(){}// 2.帶參構造函數Date(int year, int month, int day){_year = year;_month = month;_day = day;}void Print(){cout << _year << "-" << _month << "-" << _day << endl;}
private:int _year;int _month;int _day;
};int main()
{Date d1; // 調用無參構造函數Date d2(2015, 1, 1); // 調用帶參的構造函數Date d3();d1.Print();d2.Print();// 注意:如果通過無參構造函數創建對象時,對象后面不用跟括號,否則就成了函數聲明// 以下代碼的函數:聲明了d3函數,該函數無參,返回一個日期類型的對象//d3.Print(); // warning C4930: “Date d3(void)”: 未調用原型函數(是否是有意用變量定義的?)return 0;
}
輸出結果:
- 如果類中沒有顯式定義構造函數,則C++編譯器會自動生成一個無參的默認構造函數,一旦用戶顯式定義編譯器將不再生成。
例:
#include <iostream>
using namespace std;
class Date
{
public:// 如果用戶顯式定義了構造函數,編譯器將不再生成/* Date(int year, int month, int day){_year = year;_month = month;_day = day;}*/void Print(){cout << _year << "-" << _month << "-" << _day << endl;}private:int _year;int _month;int _day;
};int main()
{// 將Date類中構造函數屏蔽后,代碼可以通過編譯,因為編譯器生成了一個無參的默認構造函數// 將Date類中構造函數放開,代碼編譯失敗,因為一旦顯式定義任何構造函數,編譯器將不再生成// 無參構造函數,放開后報錯:error C2512: “Date”: 沒有合適的默認構造函數可用Date d1;d1.Print();return 0;
}
放開前運行結果:
放開后運行結果:
- 由于C++把類型分成內置類型(如:
int/char
等)和自定義類型(如使用class/struct/union
等自己定義的類型),而C++的語法又規定編譯器生成的默認構造函數不會對內置類型進行處理,也就是說對于內置類型的成員,雖然調用了默認構造函數但是依舊是隨機值,而對于自定義類型的成員則會去調用它的默認構造函數。
注意:不傳參數就可以調用的構造函數就叫默認構造函數,一般建議每個類都提供一個默認構造函數。
例:
#include <iostream>
using namespace std;
class Time
{
public:Time(){cout << "Time()" << endl;_hour = 0;_minute = 0;_second = 0;}void Print(){cout << _hour << "時" << _minute << "分" << _second << "秒" << endl;}
private:int _hour;int _minute;int _second;
};
class Date
{
public:void Print(){cout << _year << "年" << _month << "月" << _day << "日";this->_t.Print();}/*Date(){cout << "Date()" << endl;}*/
private://基本類型int _year;int _month;int _day;//自定義類型Time _t;
};
int main()
{Date d;d.Print();return 0;
}
輸出結果:
從輸出結果可以看到,編譯器生成默認的構造函數會對自定類型成員_t
調用的它的默認成員函數。
注意:C++11中針對內置類型成員不初始化的缺陷打了補丁,打了補丁后內置類型成員變量在類中聲明時可以給默認值。
例:
#define _CRT_SECURE_NO_WARNINGS 1
//構造函數缺陷
#include <iostream>
using namespace std;
class Time
{
public:Time(){cout << "Time()" << endl;_hour = 0;_minute = 0;_second = 0;}void Print(){cout << _hour << "時" << _minute << "分" << _second << "秒" << endl;}
private:int _hour;int _minute;int _second;
};
class Date
{
public:void Print(){cout << _year << "年" << _month << "月" << _day << "日";this->_t.Print();}/*Date(){cout << "Date()" << endl;}*/
private://基本類型型(內置類型)int _year = 2023;int _month = 10;int _day = 3;//自定義類型Time _t;
};
int main()
{Date d;d.Print();return 0;
}
輸出結果:
- 無參的構造函數和全缺省的構造函數都稱為默認構造函數,并且默認構造函數只能有一個。 也就是說,如果既寫了無參構造函數又寫了全缺省的構造函數,那么編譯的時候編譯器會報錯。
例:
#include <iostream>
using namespace std;
class Date
{
public://無參的構造函數Date(){_year = 1900;_month = 1;_day = 1;}//全缺省的構造函數Date(int year = 1900, int month = 1, int day = 1){_year = year;_month = month;_day = day;}
private:int _year;int _month;int _day;
};void Test()
{Date d1;
}
int main()
{Test();return 0;
}
運行結果:
12 析構函數
12.1 析構函數的概念
通過前面構造函數的學習,我們知道一個對象是怎么來的,那一個對象又是怎么沒的呢?
與構造函數功能相反,析構函數不是完成對對象本身的銷毀,局部對象銷毀工作是由編譯器完成的,而對象在銷毀時會自動調用析構函數,完成對象中資源的清理工作。
12.2 析構函數的特性
- 析構函數名是在類名前加上字符
~
。 - 無參數無返回值。
- 一個類只能有一個析構函數。若未顯式定義,系統會自動生成默認的析構函數,也就是說,析構函數不能重載。
- 對象生命周期結束時,C++編譯系統會自動調用析構函數。
例:
#include <iostream>
using namespace std;
typedef int DataType;
class Stack
{
public:Stack(size_t capacity = 3){_array = (DataType*)malloc(sizeof(DataType) * capacity);if (NULL == _array){perror("malloc申請空間失敗!!!");return;}_capacity = capacity;_size = 0;}void Push(DataType data){// CheckCapacity();_array[_size] = data;_size++;}// 其他方法...~Stack(){if (_array){free(_array);_array = NULL;_capacity = 0;_size = 0;}}
private:DataType* _array;int _capacity;int _size;
};
void TestStack()
{Stack s;s.Push(1);s.Push(2);
}
int main()
{TestStack();return 0;
}
- 由于內置類型成員的銷毀不需要資源清理,是最后由系統直接將其內存回收,所以不需要調用析構函數;而對于自定義類型的成員則需要調用它的析構函數,不過這個自定義類型成員的析構函數不能被直接調用,而是由包含這個自定義類型成員的類的析構函數調用。換言之,如果類中沒有申請資源時,析構函數可以不寫,直接使用編譯器生成的默認析構函數即可;而有資源申請時,一定要寫,否則會造成資源泄漏,比如
Stack
類。
例:
#include <iostream>
using namespace std;
class Time
{
public:~Time(){cout << "~Time()" << endl;}
private:int _hour;int _minute;int _second;
};
class Date
{
private://基本類型型(內置類型)int _year = 1970;int _month = 1;int _day = 1;//自定義類型Time _t;
};int main()
{Date d;return 0;
}
輸出結果:
從輸出結果可以看到,在main
函數中根本沒有直接創建Time
類的對象,但是最后還是調用了Time
類的析構函數,這就是因為main
函數中創建了Date
類對象d
,而d
中包含了4個成員變量,其中_year
、_month
, _day
三個是內置類型成員,銷毀時不需要資源清理,而_t
是Time
類對象,所以在銷毀d
時,要將其內部包含的Time
類的_t
對象銷毀,所以要調用Time
類的析構函數。但是:main
函數中不能直接調用Time
類的析構函數,實際要釋放的是Date
類對象,所以編譯器會調用Date
類的析構函數,而Date
沒有顯式提供,所以編譯器會給Date
類生成一個默認的析構函數,目的是在其內部調用Time
類的析構函數,也就是說當Date
的對象銷毀時,要保證其內部每個自定義對象都能被正確銷毀。
總結:創建哪個類的對象則調用該類的析構函數,銷毀哪個類的對象則調用該類的析構函數。
13 拷貝構造函數
13.1 拷貝構造函數的概念
在現實生活中,可能存在一個與你一樣的自己,我們稱其為雙胞胎。
而在C++中,拷貝構造函數就可以實現創建一個與已存在對象一模一樣的新對象。
13.2 拷貝構造函數的特性
- 拷貝構造函數是構造函數的一個重載形式。
- 拷貝構造函數只有單個形參,該形參只能是對本類類型對象的引用(一般常用
const
修飾),而且在用已存在的類類型對象創建新對象時由編譯器自動調用,如果使用傳值方式進行傳參那么編譯器會直接報錯,因為C++規定自定義類型的傳值需要去調用拷貝構造函數,也就是在使用傳值方式進行傳參的過程中會調用拷貝構造函數,而由于這個拷貝構造函數是以傳值方式實現的受C++語法的限制會又調用拷貝構造函數,層層調用最終導致無窮遞歸調用。
例:
#include <iostream>
using namespace std;
class Date
{
public:Date(int year = 1900, int month = 1, int day = 1){_year = year;_month = month;_day = day;}Date(const Date d) //錯誤寫法:編譯報錯,會引發無窮遞歸//{// _year = d._year;// _month = d._month;// _day = d._day;// cout << "Date(const Date d)" << endl;//}Date(const Date& d) // 正確寫法{_year = d._year;_month = d._month;_day = d._day;cout << "Date(const Date& d)" << endl;}
private:int _year;int _month;int _day;
};
int main()
{Date d1;Date d2(d1);return 0;
}
錯誤寫法運行結果:
原因圖解:
正確寫法運行結果:
- 如果沒有顯式定義拷貝構造函數,那么編譯器會生成默認的拷貝構造函數。默認的拷貝構造函數對象按內存存儲按字節序完成拷貝,這種拷貝叫做淺拷貝,或者值拷貝。其中內置類型按照字節方式直接拷貝,而自定義類型則調用其拷貝構造函數完成拷貝。
例:
#include <iostream>
using namespace std;
class Time
{
public:Time(){_hour = 1;_minute = 1;_second = 1;}Time(const Time& t){_hour = t._hour;_minute = t._minute;_second = t._second;cout << "Time::Time(const Time&)" << endl;}void Print(){cout << _hour << "時" << _minute << "分" << _second << "秒" << endl;}
private:int _hour;int _minute;int _second;
};
class Date
{
public:void Print(){cout << _year << "年" << _month << "月" << _day << "日";this->_t.Print();}
private://基本類型(內置類型)int _year = 1970;int _month = 1;int _day = 1;//自定義類型Time _t;
};int main()
{Date d1;// 用已經存在的d1拷貝構造d2,此處會調用Date類的拷貝構造函數// 但Date類并沒有顯式定義拷貝構造函數,則編譯器會給Date類生成一個默認的拷貝構造函數Date d2(d1);d2.Print();return 0;
}
輸出結果:
既然編譯器生成的默認拷貝構造函數已經可以完成字節序的值拷貝,那么對于所有的類是不是都不需要自己來顯式實現呢?我們可以通過下面的類來感受一下:
#include <iostream>
using namespace std;
typedef int DataType;
class Stack
{
public:Stack(size_t capacity = 10){_array = (DataType*)malloc(capacity * sizeof(DataType));if (nullptr == _array){perror("malloc申請空間失敗");return;}_size = 0;_capacity = capacity;}void Push(const DataType& data){// CheckCapacity();_array[_size] = data;_size++;}~Stack(){if (_array){free(_array);_array = nullptr;_capacity = 0;_size = 0;}}
private:DataType* _array;size_t _size;size_t _capacity;
};
int main()
{Stack s1;s1.Push(1);s1.Push(2);s1.Push(3);s1.Push(4);Stack s2(s1);return 0;
}
運行結果:
可以看到,當我們以同樣的方式對Stack
類的對象s2
進行拷貝構造時,程序崩潰了,這是什么原因呢?
我們可以通過上圖幫助我們理解崩潰的原因。在main
函數中,s1
對象通過調用構造函數創建,而在構造函數中,默認申請了10個元素的空間,然后將1、2、3、4存了進去。
在后續構造s2
對象的過程中,由于s2
對象使用s1
拷貝構造,而Stack
類沒有顯式定義拷貝構造函數,所以編譯器會給Stack
類生成一份默認的拷貝構造函數,而又因為默認拷貝構造函數是按照值進行拷貝的,也就是說默認拷貝構造函數會將s1
中的內容原封不動地拷貝到s2
中,所以s1
和s2
指向了同一塊內存空間。
當程序退出時,s2
和s1
都要銷毀。而根據析構“后進先出”(即后創建的先銷毀)的原則,s2
將先被銷毀,此時s2
銷毀時調用析構函數已經將0x11223344
的空間釋放了,但是s1
中仍然指向0x11223344
這塊空間,到s1
銷毀時,會將0x11223344
的空間再釋放一次,一塊內存空間多次釋放,必然會造成程序崩潰。
結論:類中一旦涉及到資源申請時,一定要寫拷貝構造函數,否則就是淺拷貝;而類中沒有涉及資源申請時,寫還是不寫拷貝構造函數都可以。
- 拷貝構造函數典型調用場景:
- 使用已存在對象創建新對象
- 函數參數類型為類類型對象
- 函數返回值類型為類類型對象
例:
#include <iostream>
using namespace std;
class Date
{
public:Date(int year, int minute, int day){cout << "Date(int,int,int):" << this << endl;}Date(const Date& d){cout << "Date(const Date& d):" << this << endl;}~Date(){cout << "~Date():" << this << endl;}
private:int _year;int _month;int _day;
};
Date Test(Date d)
{Date temp(d);return temp;
}
int main()
{Date d1(2022, 1, 13);Test(d1);return 0;
}
輸出結果:
程序解讀:
總結:為了提高程序效率,一般對象傳參時,盡量使用引用類型;返回時根據實際場景,能用引用盡量使用引用。
14 賦值運算符重載
14.1 運算符重載
C++為了增強代碼的可讀性引入了運算符重載,讓自定義類型對象也可以使用運算符。
運算符重載是具有特殊函數名的函數,也具有其返回值類型、函數名以及參數列表,其返回值類型與參數列表與普通的函數類似。
函數名為:operator + 需要重載的運算符符號
函數原型:返回值類型 + operator +(參數列表)
注意:
- 不能通過連接其他符號來創建新的操作符,比如operator@。
- 重載操作符必須有一個類類型參數。
- 用于內置類型的運算符,其含義不能改變,例如:內置的整型
+
,不能改變其含義。 - 作為類成員函數重載時,其形參看起來比實際操作數數目少
1
,但是成員函數里還隱藏了一個this
參數。 - 特別注意:
.*
、::
、sizeof
、?:
、.
這5個運算符不能重載,這個經常在筆試選擇題中出現。
例:用全局的operator==
實現判斷Date
類相等:
//全局的operator==
#include <iostream>
using namespace std;
class Date
{
public:Date(int year = 1900, int month = 1, int day = 1){_year = year;_month = month;_day = day;}
//private:int _year;int _month;int _day;
};
bool operator==(const Date& d1, const Date& d2)//第一個參數為左操作符,第二個參數為右操作符
{return d1._year == d2._year&& d1._month == d2._month&& d1._day == d2._day;
}
void Test()
{Date d1(2023, 9, 27);Date d2(2023, 9, 27);Date d3(2023, 9, 27);Date d4(2023, 9, 26);cout << (d1 == d2) << endl;//d1 == d2會被轉換成operator==(d1,d2)cout << (d3 == d4) << endl;
}int main()
{Test();return 0;
}
成員變量為私有時運行結果:
成員變量為公有時運行結果:
這里會發現運算符重載成全局的就需要成員變量是公有的,但如果這樣的話封裝性就無法得到保證了。這里其實可以用我們后面學習的友元解決,或者干脆重載為成員函數。
例:
//重載為成員函數
#include <iostream>
using namespace std;
class Date
{
public:Date(int year = 1900, int month = 1, int day = 1){_year = year;_month = month;_day = day;}// bool operator==(Date* this, const Date& d2)// 這里需要注意的是,成員函數都有一個默認的隱藏參數,即左操作數是this,指向調用函數的對象bool operator==(const Date & d2){return _year == d2._year&& _month == d2._month&& _day == d2._day;}
private:int _year;int _month;int _day;
};
void Test()
{Date d1(2023, 9, 27);Date d2(2023, 9, 27);Date d3(2023, 9, 27);Date d4(2023, 9, 26);cout << (d1 == d2) << endl;cout << (d3 == d4) << endl;
}
int main()
{Test();return 0;
}
運行結果:
14.1.1 運算符重載的復用
剛才我們實現了判斷Date
類相等的函數operator==
,那當我們還想實現諸如operator>
、operator<
、operator>=
這樣邏輯相似的函數時,如果每一個函數都要單獨寫一段代碼進行實現,那未免也太麻煩了,有沒有什么簡化的方法呢?
這里我們就可以通過對運算符重載的復用來實現,還是以Date
類為例,要實現所有的比較關系的話,我們實際上只需在實現operator==
的基礎上,再實現一個operator<
或者operator>
即可:
bool operator==(const Date & d2){return _year == d2._year&& _month == d2._month&& _day == d2._day;}bool operator<(const Date& d){return _year < d._year|| (_year == d._year && _month < d._month)|| (_year == d._year && _month == d._month && _day < d._day); }bool operator<=(const Date& d){return *this < d || *this == d;}bool operator>(const Date& d){return !(*this <= d);}bool operator>=(const Date& d){return !(*this < d);}bool operator!=(const Date& d){return !(*this == d);}
可以看到,上面的代碼只具體實現了operator==
和operator<
,其他的關系直接通過這兩個函數的復用就實現了,以operator>
為例,operator>
就是通過復用operator<=
,然后對它的判斷結果進行取反來進行實現的。
實際上,上面這一套判斷邏輯,對所有的類均適用。
14.2 賦值運算符重載
以往賦值運算符=
只能在內置類型之間使用,而如果要讓自定義類型也能通過=
進行賦值,就需要對賦值運算符進行重載。
有了剛才實現運算符重載的經驗,那我們實現賦值運算符的重載實際上也沒有什么難度。
例:
#include <iostream>
using namespace std;
class Date
{
public:Date(int year = 1900, int month = 1, int day = 1){_year = year;_month = month;_day = day;}void operator=(const Date& d)//賦值運算符重載{_year = d._year;_month = d._month;_day = d._day;}void Print(){cout << _year << "年" << _month << "月" << _day << "日" << endl;}
private:int _year;int _month;int _day;
};
void Test()
{Date d1(2023, 9, 27);Date d2;d1.Print();d2.Print();d2 = d1;d1.Print();d2.Print();
}
int main()
{Test();return 0;
}
運行結果:
可以看到,我們設計的賦值運算符重載實現了它的功能,但實際上當前設計的還是存在缺陷的,比較突出的一點就是它不支持連續賦值,因為它的返回類型是void
。
要實現連續賦值,那么它應該返回當前被賦值的對象,也就是返回左操作數的值。除此之外,我們還應該考慮到自己給自己賦值的情況,尤其在需要深拷貝時,會降低程序運行的效率,所以遇到這種情況時,我們直接返回即可。那么對于剛才的operator=
函數我們可以進行如下改造:
#include <iostream>
using namespace std;
class Date
{
public:Date(int year = 1900, int month = 1, int day = 1){_year = year;_month = month;_day = day;}/* void operator=(const Date& d){_year = d._year;_month = d._month;_day = d._day;}*/bool operator==(const Date& d2){return _year == d2._year&& _month == d2._month&& _day == d2._day;}bool operator!=(const Date& d){return !(*this == d);}Date& operator=(const Date& d)//支持連續賦值的重載賦值運算符{if (this != &d)//地址不一樣時才賦值{_year = d._year;_month = d._month;_day = d._day;return *this;} }void Print(){cout << _year << "年" << _month << "月" << _day << "日" << endl;}
private:int _year;int _month;int _day;
};
void Test()
{Date d1(2023, 9, 27);Date d2;Date d3;d1.Print();d2.Print(); d3.Print();d3 = d2 = d1;d1.Print();d2.Print();d3.Print();
}
int main()
{Test();return 0;
}
輸出結果:
可以看到,改造后的operator=
函數就支持連續賦值了。
需要注意的是,賦值運算符只能重載成類的成員函數而不能重載成全局函數,原因在于賦值運算符如果不顯式實現,那么編譯器就會生成一個默認的賦值運算符重載,此時如果用戶再在類外自己實現一個全局的賦值運算符重載,那么就和編譯器在類中生成的默認賦值運算符重載沖突了,所以賦值運算符重載只能是類的成員函數。
例:
#include <iostream>
using namespace std;
class Date
{
public:Date(int year = 1900, int month = 1, int day = 1){_year = year;_month = month;_day = day;}int _year;int _month;int _day;
};
// 賦值運算符重載成全局函數,注意重載成全局函數時沒有this指針了,需要給兩個參數
Date& operator=(Date& left, const Date& right)
{if (&left != &right){left._year = right._year;left._month = right._month;left._day = right._day;}return left;
}
void Test()
{Date d1(2023, 9, 27);Date d2;Date d3;d3 = d2 = d1;
}
int main()
{Test();return 0;
}
運行結果:
這里還需要注意的是,由編譯器生成的默認賦值運算符重載,是以值的方式逐字節拷貝,也就是說,對于內置類型成員變量是直接賦值的,但是對于自定義類型成員變量則需要調用對應類的賦值運算符重載才能完成賦值。
例:
#include <iostream>
using namespace std;
class Time
{
public:Time(){_hour = 1;_minute = 1;_second = 1;}//Time& operator=(const Time& t) ////{// if (this != &t)// {// _hour = t._hour;// _minute = t._minute;// _second = t._second;// }// return *this;//}void Print(){cout << _hour << "時" << _minute << "分" << _second << "秒" << endl;}
private:int _hour;int _minute;int _second;
};
class Date
{
public:Date(int year = 1900, int month = 1, int day = 1){_year = year;_month = month;_day = day;}void Print(){cout << _year << "年" << _month << "月" << _day << "日";this->_t.Print();}
private:// 基本類型(內置類型)int _year;int _month;int _day;// 自定義類型Time _t;
};
int main()
{Date d1;Date d2(2023, 10, 4);d1.Print();d2.Print();d1 = d2;d1.Print();d2.Print();return 0;
}
運行結果:
所以,雖然編譯器生成的默認賦值運算符重載函數已經可以完成字節序的值拷貝了,但是對于一些涉及到資源管理的類,則必須要自己實現賦值運算符的重載,否則會出現無法預料的結果。
例:
#include <iostream>
using namespace std;
typedef int DataType;
class Stack
{
public:Stack(size_t capacity = 10){_array = (DataType*)malloc(capacity * sizeof(DataType));if (nullptr == _array){perror("malloc申請空間失敗");return;}_size = 0;_capacity = capacity;}void Push(const DataType& data){// CheckCapacity();_array[_size] = data;_size++;}~Stack(){if (_array){free(_array);_array = nullptr;_capacity = 0;_size = 0;}}
private:DataType* _array;size_t _size;size_t _capacity;
};
int main()
{Stack s1;s1.Push(1);s1.Push(2);s1.Push(3);s1.Push(4);Stack s2;s2 = s1;return 0;
}
運行結果:
可以看到,當我們以同樣的方式對Stack
類的對象s2
進行拷貝構造時,程序崩潰了,原因就在于Stack
類中涉及到了資源管理,而Stack
的賦值運算符重載又是依靠編譯器實現的。
圖解:
結論:
- 賦值運算符重載格式:
- 參數類型:const T&,傳遞引用可以提高傳參效率。
- 返回值類型:T&,返回引用可以提高返回的效率,有返回值目的是為了支持連續賦值。
- 檢測是否自己給自己賦值。
- 返回
*this
:要復合連續賦值的含義。
- 賦值運算符只能重載成類的成員函數而不能重載成全局函數。
- 用戶沒有顯式實現時,編譯器會生成一個默認賦值運算符重載,以值的方式逐字節拷貝。
- 如果類中未涉及到資源管理,那么賦值運算符是否實現都可以;一旦涉及到資源管理則必須要自行實現。
14.2.1 賦值運算符重載和拷貝構造之間的辨析
我們通過下面這段代碼來感受一下賦值運算符重載和拷貝構造的區別:
#include <iostream>
using namespace std;
class Date
{
public:Date(int year = 1900, int month = 1, int day = 1){_year = year;_month = month;_day = day;}bool operator==(const Date& d2){return _year == d2._year&& _month == d2._month&& _day == d2._day;}bool operator!=(const Date& d){return !(*this == d);}Date& operator=(const Date& d)//支持連續賦值的重載賦值運算符{if (this != &d)//地址不一樣時才賦值{_year = d._year;_month = d._month;_day = d._day;return *this;} }void Print(){cout << _year << "年" << _month << "月" << _day << "日" << endl;}
private:int _year;int _month;int _day;
};
void Test()
{Date d1(2023, 9, 27);Date d2 = d1;//拷貝構造d1.Print();d2.Print();Date d3;d3 = d1;//賦值重載cout << "--------------------------------" << endl;d1.Print();d2.Print();d3.Print();
}
int main()
{Test();return 0;
}
運行結果:
由于賦值重載是在兩個已經定義好的對象之間進行的,雖然Date d2 = d1;
這條語句中用了賦值重載運算符=
,但是這條語句的意思是用d1
來初始化d2
,也就是用一個已經定義好的對象來初始化一個正在定義的對象,所以Date d2 = d1;
這條語句實際上是拷貝構造,而d3 = d1;
這條語句才是賦值重載。
14.3 前置++和后置++重載
前置++
和后置++
的重載之所以要單獨拎出來講,是因為它們和運算符+
、-
相比,有需要注意的地方。
由于前置++
和后置++
都是一元運算符,為了讓前置++
與后置++
能正確重載,C++規定:后置++
重載時多增加一個int
類型的參數,但調用函數時該參數不用傳遞,編譯器自動傳遞。
例:
#include <iostream>
using namespace std;
class Date
{
public:Date(int year = 1900, int month = 1, int day = 1){_year = year;_month = month;_day = day;}// 前置++:返回+1之后的結果// 注意:this指向的對象函數結束后不會銷毀,故以引用方式返回提高效率Date& operator++(){_day += 1;return *this;}//后置++:返回+1之前的結果// 注意:后置++是先使用后+1,因此需要返回+1之前的舊值,故需在實現時需要先將this保存一份,然后給this + 1// 而temp是臨時對象,因此只能以值的方式返回,不能返回引用Date operator++(int){Date temp(*this);_day += 1;return temp;}void Print(){cout << _year << "年" << _month << "月" << _day << "日" << endl;}
private:int _year;int _month;int _day;
};
int main()
{Date d;Date d1(2022, 1, 13);d = d1++;d.Print();d1.Print();cout << "-------------------------" << endl;d = ++d1;d.Print();d1.Print();return 0;
}
運行結果:
15 const成員函數
在引出const
函數之前,我們先來看下面這種情況:
#include <iostream>
using namespace std;
class A
{
public:void Print(){cout << _a << endl;}
private:int _a = 10;
};
int main()
{A aa;//const A aa;aa.Print();return 0;
}
const
修飾aa
前的運行結果:
const
修飾aa
后的運行結果:
可以看到,當對象aa
沒有被const
修飾時,它能夠順利運行,但是當aa
被const
修飾后再運行編譯器就報錯了。
之所以會報錯,是因為這里涉及到一個權限被放大的問題。
在這個例子中,當我們把aa
傳進Print
函數時,本質上傳的是aa
的地址,aa
在沒有被const
修飾前,&aa
的類型為A*
,而Print
的隱藏參數this
的類型為A* const
,也就是說當把aa
傳進Print
函數后this
的指向是不能被改變的,這是個權限縮小的過程,所以編譯器允許;而當aa
被const
修飾后,&aa
的類型就成了const A*
,也就是說這個時候aa
是不能被修改的,但傳進Print
函數后卻反而可以被修改了,這個過程就把this
的權限放大了,而這是不被編譯器所允許的。
又由于this
是隱藏的參數,我們沒有辦法進行修改,所以我們只能對函數用const
進行修飾,那么我們就將const
修飾的成員函數稱為const
成員函數,這個const
實際修飾的是成員函數隱藏的this
指針,修飾后this
的類型就變成了const A*
。
雖然我們實際情況下很少在定義的時候用const
修飾變量,但是像下面的情況卻并不少見:
#include <iostream>
using namespace std;
class A
{
public:void Print() const{cout << _a << endl;}
private:int _a = 10;
};
void Func(const A& x)
{x.Print();
}
int main()
{A aa;Func(aa);return 0;
}
const
修飾Print
函數前的運行結果:
const
修飾Print
函數后的運行結果:
可以看到,當我們把對象傳給某個參數被const
修飾的函數,而這個函數的內部所調用的函數卻沒有被const
修飾時,就容易出錯。
因此,只要函數內部不對成員變量進行改變一般都建議用const
修飾一下,加上之后const
對象和普通對象都可以調用。
16 取地址及const取地址操作符重載
對于這兩個操作符一般不需要重載,使用編譯器生成的默認取地址的重載即可。
例:
#include <iostream>
using namespace std;
class A
{
public:/*A* operator&(){cout << "My &:";return this;}const A* operator&() const{cout << "My const&:";return this;}*/
private:int _a = 10;
};
int main()
{A aa;const A bb;cout << &aa << endl;cout << &bb << endl;return 0;
}
使用編譯器默認生成的:
使用自己寫的:
只有特殊情況,才需要重載,比如想讓別人獲取到指定的內容:
#include <iostream>
using namespace std;
class A
{
public:A* operator&(){return nullptr;//拒絕取地址}const A* operator&() const{return nullptr;//拒絕取地址}
private:int _a = 10;
};
int main()
{A aa;const A bb;cout << &aa << endl;cout << &bb << endl;return 0;
}
運行結果: