俺来写笔记了,哈哈哈,浅浅介绍类和对象的知识点!
1.类的6个默认成员函数
俺们定义一个空类:
class N
{
};
似乎这个类N里面什么都没有,其实不是这样子的。这个空类有6个默认的成员函数 。
默认成员函数:用户没有显式实现,编译器会生成的成员函数称为默认成员函数。
其实不单止是空类,任何类在什么默认成员函数都不写时,编译器会自动生成以上6个默认成员函数。任何类如果没有显式实现上面的6个默认成员函数的某些个默认成员函数,编译器就会自动生成那些没有显式实现的默认成员函数。
如果懵懵的,没关系,下面鼠鼠会详细介绍!
2.构造函数
注意:构造函数是默认成员函数之一。
2.1.构造函数的概念
我们创建对象很多时候都希望对象使用前能被初始化,如果还是用之前学的Init方法初始化,如下:
class people
{
const char* _name;
int _age;
public:
void Init(const char*name,int age)
{
this->_name = name;
this->_age = age;
}
};
int main()
{
people HD;
HD.Init("HD", 20);
people LCD;
LCD.Init("LCD", 20);
return 0;
}
每次创建对象都要调用Init,太麻烦,所以C++的类有了构造函数:
构造函数简单来说就是初始化用的:构造函数是一个特殊的成员函数,名字与类名相同,创建类类型对象(类的实例化或者对象实例化)时由编译器自动调用,以保证每个数据成员都有一个合适的初始值,并且在对象整个生命周期内只调用一次。而且C++规定,对象实例化必须调用构造函数。
构造函数是特殊的成员函数,需要注意的是,构造函数虽然名称叫构造,但是构造函数的主要任 务并不是开空间创建对象,而是初始化对象。开空间创建对象那是类的实例化(或者说对象实例化)。
附:类的实例化具体知识请看【C++】类和对象1.0
2.2.构造函数的特性
特性如下,有7点:
- 特性1:函数名与类名相同。
- 特性2:构造函数可以重载,可以有参数也可以没有参数。
- 特性3:无返回值,这里不是void,是就不需要写返回值。
- 特性4:对象实例化(也就是类的实例化)时编译器自动调用对应的构造函数。
举个栗子来印证上面4点特性:
class people
{
const char* _name;
int _age;
public:
people()//无参的构造函数
{
this->_name = "HD";
this->_age = 20;
}
people(const char* name,int age)//带参的构造函数
{
this->_name = name;
this->_age = age;
}
};
int main()
{
people HD;//调用无参的构造函数
people LCD("LCD",20);//调用带参的构造函数
return 0;
}
我们看people类里面本鼠显示实现了2个构造函数:一个无参、另一个带参, 函数名与类名(people)相同,无返回值,这2个构造函数构成函数重载。对象实例化时自动调用相应的构造函数。
附:函数重载知识请看【C++】C++入门1.0
同志们不相信对象实例化时自动调用相应的构造函数的话,大可去调试一下。本鼠就将调试模式下的监视窗口打开给同志们看看,可以看到确实初始化了:
我们再注意主函数内的写法:
int main()
{
people HD;//调用无参的构造函数
people LCD("LCD",20);//调用带参的构造函数
return 0;
}
奇奇怪怪的吧?但是语法就是这样的。 第一条语句类实例化对象HD时会自动调用无参的构造函数,因为没有像第二条语句那样给参数。第二条语句类实例化对象LCD时会自动调用带参的构造函数,因为对象名后面跟了("LCD",20),这个东西就是传递给带参的构造函数做形参的。
注意:如果通过无参构造函数创建对象时,对象后面不用跟括号,否则就成了函数声明。
错误写法:
class people
{
const char* _name;
int _age;
public:
people()//无参的构造函数
{
this->_name = "HD";
this->_age = 20;
}
};
int main()
{
people HD();//warning C4930: “people HD(void)”: 未调用原型函数(是否是有意用变量定义的?)
return 0;
}
其实上面的2个构造函数我们可以合成1个有全缺省参数的构造函数,功能也是一模一样的,如下:
附:缺省参数知识请看【C++】C++入门1.0
class people
{
const char* _name;
int _age;
public:
people(const char* name="HD", int age=20)//全缺省参数的构造函数
{
this->_name = name;
this->_age = age;
}
};
int main()
{
people HD;
people LCD("LCD",20);
return 0;
}
需要注意的是:全缺省参数的构造函数和无参的构造函数理论上是构成函数重载,是可以同时存在的。但实际上它们2个函数却不能同时存在,因为对象实例化时可能存在歧义。比如:
class people
{
const char* _name;
int _age;
public:
people(const char* name = "HD", int age = 20)//全缺省参数的构造函数
{
this->_name = name;
this->_age = age;
}
people()
{
this->_name = "HD";
this->_age = 20;
}
};
int main()
{
people HD;//error C2668: “people::people”: 对重载函数的调用不明确
people LCD("LCD", 20);
return 0;
}
编译报错,因为实例化对象HD时,编译器不知道该调用哪一个构造函数!!
- 特性5:如果类中没有显式定义任何一个构造函数,则C++编译器会自动生成一个无参的默认构造函数,一旦用户显式定义了任何一个构造函数,编译器将不再生成。
class people
{
const char* _name;
int _age;
public:
people(const char* name , int age )
{
this->_name = name;
this->_age = age;
}
};
int main()
{
people HD;//error C2512: “people”: 没有合适的默认构造函数可用
return 0;
}
看到这里报错了:已经显示定义了一个构造函数,那么编译器不再生成无参的默认构造函数。导致对象HD实例化时没有合适的默认构造函数可用。
- 特性6:无参的构造函数和全缺省参数的构造函数都称为默认构造函数,所以默认构造函数有3个:无参的构造函数、全缺省参数的构造函数、我们没写编译器默认生成的构造函数(无参的,特性5介绍过)。并且默认构造函数只能有一个。
为什么默认构造函数只能有一个?
前面我们介绍了全缺省参数的构造函数和无参的构造函数不能共存,并且一旦用户显式定义了任何一个构造函数,编译器不再生成默认的构造函数。那么这3个默认构造函数两两互斥,所以只能有一个。
那么我们来探讨一下编译器生成的默认构造函数对于对象内不同类型的成员会有什么行为?
- 特性7:编译器生成的默认构造函数对于对象内内置类型成员不做处理(不排除有些编译器会去处理对象内内置类型成员,处理方式未知),对于对象内自定义类型成员会去调用它的默认构造函数(俺可没说对于对象自定义类型成员会去调用它的编译器生成的默认构造函数哈,俺说的是会去调用它的默认构造函数)。
补充知识:C++把类型分成内置类型(基本类型)和自定义类型。内置类型就是语言提供的数据类 型,如:int/char...自定义类型就是我们使用class/struct/union等自己定义的类型。
class Time
{
int _hour;
int _minute;
int _second;
public:
Time()
{
this->_hour = 12;
this->_minute = 20;
this->_second = 5;
}
};
class Date
{
//内置类型
int _year;
int _month;
int _day;
//自定义类型
Time _t;
};
int main()
{
Date d;
return 0;
}
同志们看好了。编译器生成的默认构造函数对于对象d的内置类型成员不做处理,所以可以看到_year、_month、_day被初始化成了随机值;对于自定义类型成员_t回去调用了它的默认构造函数Time(),所以可以看到_hour、_minute、_second被初始成了12、20、5。
注意:C++11 中针对对象内置类型成员不做处理的缺陷,又打了补丁,即:内置类型成员变量在类中声明时可以给默认值(缺省值)。
class Time
{
int _hour;
int _minute;
int _second;
public:
Time()
{
this->_hour = 12;
this->_minute = 20;
this->_second = 5;
}
};
class Date
{
//内置类型
int _year = 2024;
int _month = 6;
int _day = 17;
//自定义类型
Time _t;
};
int main()
{
Date d;
return 0;
}
同志们看:因为内置类型成员在类中声明时给了默认值,所以编译器生成的默认构造函数将对象内置类型成员初始化成了默认值。。。
介绍完构造函数,我们要明白需要具体分析对象的初始需求,需要我们自己显式定义构造函数我们就自己定义,不需要就让编译器自动生成。但是大多数情况下都是要我们显式定义的。
2.3.构造函数的调用顺序
对象先定义的先构造,后定义的后构造。
比如:
#include<iostream>
using namespace std;
class Date
{
int _year;
public:
Date(int n)//构造函数
{
cout << "Date:" << n << endl;
}
};
void Test()
{
static Date d9(9);
Date d10(10);
}
const Date d1(1);
static Date d2(2);
Date d3(3);
static Date d4(4);
int main()
{
Date d5(5);
const Date d6(6);
static Date d7(7);
Date d8(8);
Test();
const Date d11(11);
return 0;
}
看结果,没问题的,意料之中:
3.析构函数
注意:析构函数是默认成员函数之一。
3.1.析构函数的概念
析构函数:与构造函数功能相反,析构函数不是完成对对象本身的销毁,局部对象销毁工作是由 编译器完成的。而对象在销毁时会自动调用析构函数,完成对象中资源的清理工作。
对象的销毁是当它的生命周期结束时编译器销毁的。
3.2.析构函数的特性
特性如下,有6点:
- 特性1:析构函数名是在类名前加上字符 ~。
- 特性2:无参数无返回值类型。这里的返回值不是void,就是不需要写。
- 特性3:一个类只能有一个析构函数。若未显式定义,系统会自动生成默认的析构函数。注意:析构函数不能重载。
- 特性4:对象生命周期结束时,C++编译系统系统自动调用析构函数。
俺拿之前介绍过的栈举例印证以上4点特性:
#include<stdlib.h>
typedef int Stdatatype;
class Stack
{
int _top;
int _capacity;
Stdatatype* _a;
public:
Stack()//构造函数
{
this->_a = nullptr;
this->_capacity = this->_top = 0;
}
void Push(const Stdatatype& n)//入栈
{
if (this->_top == this->_capacity)
{
int newcapacity = (this->_capacity == 0) ? 4 : (this->_capacity * 2);
Stdatatype* tmp = (Stdatatype*)realloc(this->_a, newcapacity * sizeof(Stdatatype));
if (tmp == nullptr)
{
perror("realloc fail");
exit(-1);
}
this->_capacity = newcapacity;
this->_a = tmp;
}
this->_a[this->_top++] = n;
}
//Pop、Top、Size……函数略
~Stack()//析构函数
{
free(this->_a);
this->_a = nullptr;
this->_capacity = this->_top = 0;
}
};
int main()
{
Stack s;
s.Push(1);
s.Push(2);
return 0;
}
同志们请看,当s.Push(2);执行完成后对象s的成员如图:
当执行完return 0;后,对象s的成员资源就被清理掉了,比如realloc在堆区申请的空间就被free掉了!!
其实说白了,这个析构函数的功能大抵上就跟前面博客介绍过的栈的销毁栈一样。析构函数本身就是清理资源的,不同的是析构函数当对象生命周期结束时,C++编译系统系统自动调用析构函数,但是销毁栈的StackDestroy函数却需要手动调用。。。
- 特性5:编译器自动生成的析构函数(当我们没有显式定义时会生成)跟编译器生成的默认构造函数类似,对于对象内内置类型成员不做处理,对于对象内自定义类型成员会去调用它的析构函数。
看这个代码:
#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;
}
运行结果打印了~Time()。就说明了编译器自动生成的析构函数对象内自定义类型成员会去调用它的析构函数。注意:析构函数当对象生命周期结束时,C++编译系统系统自动调用析构函数。
同志们请看,编译器自动生成的析构函数对对象内内置类型成员,销毁时不需要资源清理,最后系统直接将其内存回收即可。
- 特性6:如果类中没有申请资源时,析构函数可以不写(显式定义),直接使用编译器生成的默认析构函数。有资源申请时,一定要写,否则会造成资源泄漏。
本鼠举一个不用显式定义析构函数的栗子:
class Date
{
int _year;
int _month;
int _day;
public:
Date(int year=2024, int month=6, int day=17)
{
this->_year = year;
this->_month = month;
this->_day = day;
}
};
int main()
{
Date d1;
Date d2(203, 12, 12);
return 0;
}
这个Date类中并没有申请资源,我们用编译器生成的默认析构函数就行,不用自己写。
再举一个需要显式定义析构函数的栗子:
#include<stdlib.h>
typedef int Stdatatype;
class Stack
{
int _top;
int _capacity;
Stdatatype* _a;
public:
Stack()//构造函数
{
this->_a = nullptr;
this->_capacity = this->_top = 0;
}
void Push(const Stdatatype& n)//入栈
{
if (this->_top == this->_capacity)
{
int newcapacity = (this->_capacity == 0) ? 4 : (this->_capacity * 2);
Stdatatype* tmp = (Stdatatype*)realloc(this->_a, newcapacity * sizeof(Stdatatype));
if (tmp == nullptr)
{
perror("realloc fail");
exit(-1);
}
this->_capacity = newcapacity;
this->_a = tmp;
}
this->_a[this->_top++] = n;
}
//Pop、Top、Size……函数略
~Stack()//析构函数
{
free(this->_a);
this->_a = nullptr;
this->_capacity = this->_top = 0;
}
};
int main()
{
Stack s;
s.Push(1);
s.Push(2);
return 0;
}
这里为什么要自己写析构函数?
我们来看上面代码映射到内存上的想象图就很明白了:
如果我们不手动写析构函数,那么编译器就会自动生成默认的析构函数。当对象s生命周期结束时会调用编译器自动生成的析构函数。该函数对对象内内置类型成员,销毁时不需要资源清理,最后系统直接将其内存回收,那么对象s在栈区的空间的使用权就还给操作系统了,但是堆区的资源还没有释放,这样子不就造成内存泄漏了吗!!!
3.3.析构函数的调用顺序
析构函数的调用顺序满足:局部非静态对象(先定义的后析构(相对的))——>局部静态对象(先定义的后析构(绝对的))——>全局对象(先定义的后析构(绝对的))。举例证明:
#include<iostream>
using namespace std;
class Date
{
int _year;
public:
~Date()//析构函数
{
cout << this->_year << endl;
}
Date(int n)//构造函数
{
_year = n;
}
};
void Test()
{
static Date d7(7);
Date d1(1);
}
const Date d12(12);
static Date d11(11);
Date d10(10);
static Date d9(9);
int main()
{
Date d5(5);
const Date d4(4);
static Date d8(8);
Date d3(3);
Test();
const Date d2(2);
static Date d6(6);
return 0;
}
意料之中,多观察可以发现规律的:
4.拷贝构造函数
注意:这是构造函数之一,也是默认成员函数之一。
4.1.拷贝构造函数的概念
拷贝构造函数的大概作用是:在创建新对象时,使得新对象与已经存在的对象一模一样。
拷贝构造函数:只有单个形参,该形参是对本类类型对象的引用(一般常用const修饰),在用已存 在的类类型对象创建新对象时由编译器自动调用。
4.2.拷贝构造函数的特性
特性如下,有4点:
- 特性1:拷贝构造函数是构造函数的一个重载形式。也就是说拷贝构造函数也是一种构造函数,形象的说拷贝构造函数是构造函数的一个子集。
- 特性2:拷贝构造函数的参数只有一个且必须是类类型对象的引用(条件允许的情况下最好用常引用),使用传值方式编译器直接报错, 因为会引发无穷递归调用。
class Date
{
int _year;
int _month;
int _day;
public:
Date(int year=2024, int month=6, int day=17)//(全缺省)构造函数
{
this->_year = year;
this->_month = month;
this->_day = day;
}
Date(const Date& d)//拷贝构造函数
{
this->_year = d._year;
this->_month = d._month;
this->_day = d._day;
}
};
int main()
{
Date d1;//调用(全缺省)构造函数
Date d2(d1);//调用拷贝构造函数,写法1
Date d3 = d2;//调用拷贝构造函数,写法2
return 0;
}
因为拷贝构造函数是构造函数的一个子集,所以拷贝构造函数拥有构造函数的共性: 函数名与类名相同、无返回值、创建新对象时由编译器自动调用。
想通过拷贝构造函数创建对象有两种写法,如上代码主函数内可见。要注意这两种写法所面临的同类型对象都是一个已经存在的对象和一个正在创建的对象。
调试结果如图:
至于特性2所说的拷贝构造函数的参数使用传值方式编译器直接报错, 因为会引发无穷递归调用。为什么会引发无穷递归调用呢?
因为C++规定自定义类型的拷贝都会通过调用拷贝构造函数来拷贝。如果将拷贝构造函数的参数写成传值方式,传值的方式中形参是实参的一份临时拷贝。那么当同志们想要调用拷贝构造函数,就要先传参,传参又要拷贝实参给形参,既然是自定义类型拷贝又要调用拷贝构造函数,调用拷贝构造函数又要传参,传参又要拷贝实参给形参……这不就无穷递归调用了吗?
而如果拷贝构造函数的参数使用传引用传参,就不存在形参是实参的拷贝问题,就不会无穷递归调用。
- 特性3:若未显式定义,编译器会生成默认的拷贝构造函数。默认的拷贝构造函数对象按内存存储按字节序完成拷贝,这种拷贝叫做浅拷贝,或者值拷贝。
注意:在编译器生成的默认拷贝构造函数中,内置类型是按照字节方式直接拷贝的,而自定义类型是调用其拷贝构造函数完成拷贝的。
举个栗子编译器生成默认的拷贝构造函数完成浅拷贝的栗子:
#include<iostream>
using namespace std;
class Time
{
int _hour;
int _minute;
int _second;
public:
Time()
{
this->_hour = 14;
this->_minute = 57;
this->_second = 30;
}
Time(const Time& d)
{
this->_hour = d._hour;
this->_minute = d._minute;
this->_second = d._second;
cout << "真的调用了这个函数捏!" << endl;
}
};
class Date
{
//内置类型
int _year = 2024;
int _month = 6;
int _day = 18;
//自定义类型
Time _t;
};
int main()
{
Date d1;//调用编译器生成的默认构造函数
Date d2 = d1;//调用编译器生成的默认拷贝构造函数
return 0;
}
本鼠先浅浅介绍一下这个代码:
看主函数第一条语句创建Date类类型的对象d1,由于Date类中没有显式定义构造函数,就通过编译器生成的默认构造函数创建对象d1,由于内置类型成员变量在类的声明中给了缺省值,这个编译器生成的默认构造函数就将d1的内置类型成员变量初始化成了缺省值,对于d1的自定义类型成员变量_t,去调用它的默认构造函数Time()(本鼠显式定义了),所以d1的自定义类型成员变量_t的成员变量分别被初始化成了14、57、30。
看主函数第二条语句通过拷贝构造函数创建与已存在对象d1一模一样的同类型对象d2,由于Date没有显式定义拷贝构造函数,就通过编译器生成的默认拷贝构造函数创建对象d2,这个编译器生成的默认拷贝构造函数对对象d1的内置类型成员浅拷贝给新对象d2的内置类型成员,对于对象d1的自定义类型成员_t去调用它的拷贝构造函数Time(const Time&)(本鼠显示定义了)进行浅拷贝,所以d2的自定义类型成员变量_t的成员变量分别为14、57、30。
第三条语句结束后编译器生成的默认析构函数先后作用于d2和d1。
同志们请看,确实是完成了浅拷贝:
同志们请看,也确实调用了内置类型成员变量_t的拷贝构造函数:
问题又来了:编译器生成的默认拷贝构造函数已经可以完成字节序的值拷贝了,还需要自己显式实现吗?
答案是有些需要,有些不需要。类中如果没有涉及资源申请时,拷贝构造函数是否写都可以;一旦涉及到资源申请时,则拷贝构造函数是一定要写的,否则就是浅拷贝,会出问题。
看下面的代码:
#include<stdlib.h>
typedef int Stdatatype;
class Stack
{
int _top;
int _capacity;
Stdatatype* _a;
public:
Stack()//构造函数
{
this->_a = nullptr;
this->_capacity = this->_top = 0;
}
void Push(const Stdatatype& n)//入栈
{
if (this->_top == this->_capacity)
{
int newcapacity = (this->_capacity == 0) ? 4 : (this->_capacity * 2);
Stdatatype* tmp = (Stdatatype*)realloc(this->_a, newcapacity * sizeof(Stdatatype));
if (tmp == nullptr)
{
perror("realloc fail");
exit(-1);
}
this->_capacity = newcapacity;
this->_a = tmp;
}
this->_a[this->_top++] = n;
}
//Pop、Top、Size……函数略
~Stack()//析构函数
{
free(this->_a);
this->_a = nullptr;
this->_capacity = this->_top = 0;
}
};
int main()
{
Stack s1;
s1.Push(1);
Stack s2(s1);
return 0;
}
编译运行如图,为什么会这样子呢?
其实就是我们没有显式定义拷贝构造函数,导致通过编译器生成的默认拷贝构造函数创建对象s2,这个编译器生成的默认拷贝构造函数是对s1浅拷贝给s2的,我们可以调试看看浅拷贝:
浅拷贝在这里导致对象s1和对象s2的成员变量_a都指向同一块空间,想象一下:
这里浅拷贝显然不是我们想要的。而且当析构函数作用于对象s2时,堆区动态开辟的那块空间已经被free掉了,此时s1._a就是野指针,再当析构函数于对象s1时,那块空间再次被free,同一块空间被free两次导致报错!
所以像这种类中如果涉及资源申请的,我们需要自己写拷贝构造函数以实现深拷贝,而不是使用编译器自动生成的默认拷贝构造函数,正确写法如下:
#include<stdlib.h>
#include<string.h>
typedef int Stdatatype;
class Stack
{
int _top;
int _capacity;
Stdatatype* _a;
public:
Stack()//构造函数
{
this->_a = nullptr;
this->_capacity = this->_top = 0;
}
void Push(const Stdatatype& n)//入栈
{
if (this->_top == this->_capacity)
{
int newcapacity = (this->_capacity == 0) ? 4 : (this->_capacity * 2);
Stdatatype* tmp = (Stdatatype*)realloc(this->_a, newcapacity * sizeof(Stdatatype));
if (tmp == nullptr)
{
perror("realloc fail");
exit(-1);
}
this->_capacity = newcapacity;
this->_a = tmp;
}
this->_a[this->_top++] = n;
}
//Pop、Top、Size……函数略
Stack(const Stack& s)//拷贝构造函数
{
Stdatatype* tmp = (Stdatatype*)malloc(sizeof(Stdatatype) * s._capacity);
if (tmp == NULL)
{
perror("malloc fail");
exit(-1);
}
memcpy(tmp, s._a, sizeof(Stdatatype) * s._top);
this->_a = tmp;
this->_capacity = s._capacity;
this->_top = s._top;
}
~Stack()//析构函数
{
free(this->_a);
this->_a = nullptr;
this->_capacity = this->_top = 0;
}
};
int main()
{
Stack s1;
s1.Push(1);
Stack s2(s1);
return 0;
}
自己写的拷贝构造函数完成深拷贝,想象图如下:
显然这样的拷贝才是我们想要的。。
- 特性4:拷贝构造函数典型调用场景:
- 使用已存在对象创建新对象
- 函数参数类型为类类型对象
- 函数返回值类型为类类型对象
#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;
}
在x86环境下运行结果:
运行结果打印了6条语句,因为:
第1条语句:主函数第1条语句Date d1(2022,1,13):调用构造函数创建对象d1;
第2条语句:主函数第2条语句Test(d2):d1作为实参以传值方式传递给Test函数的形参,调用了拷贝构造函数;
第3条语句:Test函数第1条语句Date temp(d):调用构造函数创建对象temp;
第4条语句:析构函数作用于Test函数中的对象temp;
第5条语句:析构函数作用于Test函数返回时创建的临时对象;
第6条语句:析构函数作用于主函数中的对象d1。
我们看到Test函数以传值返回,按道理说应该调用拷贝构造函数拷贝以temp为蓝本的临时对象用于返回,但是这里没有调用拷贝构造函数,因为这个过程被编译器优化了,检测到构建的局部变量temp用于返回,直接就在上一个函数栈构造这个对象,用于返回,这样就只调用了一次构造,而不需要进行拷贝构造。
5.赋值运算符重载
注意:赋值运算符重载是默认成员函数之一。
赋值运算符重载是运算符重载的一种,所以本鼠先要介绍运算符重载的知识!
5.1.运算符重载
5.1.1.运算符重载
我们来看一个代码:
#include<iostream>
using namespace std;
int main()
{
int a = 10;
int b = 20;
cout << (a < b) << endl;//<<优先级高于<,所以使用()控制
return 0;
}
内置类型的对象想要使用运算符实现运算符的功能可以直接使用,如本代码中想要知道a是否小于b就直接用运算符<呗。运行结果如图:
但是自定义类型对象却无法直接使用这些现成的运算符,比如:
#include<iostream>
using namespace std;
class Date
{
int _year;
int _month;
int _day;
};
int main()
{
Date a;
Date b;
cout << (a < b) << endl;
return 0;
}
编译绝对会报错!因为操作数a和b对于操作符<来说该有怎么样的行为是不明确的。换句话说,操作符<不知道该如何比较a和b。
但是我们不乏需要比较自定义类型对象的行为,我们当然可以定义一个函数来比较,比如:
#include<iostream>
#include<stdbool.h>
using namespace std;
class Date
{
public:
int _year;
int _month;
int _day;
Date(int year,int month,int day)
{
this->_year = year;
this->_month = month;
this->_day = day;
}
};
bool Lessthan(const Date& a, const Date& b)
{
if (a._year < b._year)
{
return true;
}
else if (a._year == b._year)
{
if (a._month < a._month)
{
return true;
}
else if (a._month == b._month)
{
return a._day < b._day;
}
}
return false;
}
int main()
{
Date a(2003, 12, 12);
Date b(2003, 12, 5);
cout << Lessthan(a, b) << endl;
return 0;
}
我们看运行结果就可以知道a“不小于”b:
可是这样子的代码可读性就不是很好了。我们要分析Lessthan函数的行为才知道原来这个函数是检测形参a是否小于形参b的。如果我们对于自定义类型对象能直接使用操作符(例如<)就好了,一眼定真就知道在干啥。
同志们,所以C++有了运算符重载,请看:
C++为了增强代码的可读性引入了运算符重载,运算符重载是具有特殊函数名的函数,也具有其 返回值类型,函数名字以及参数列表,其返回值类型与参数列表与普通的函数类似。
函数名字为:关键字operator后面接需要重载的运算符符号。
函数原型:返回值类型 operator操作符(参数列表)。
一个使用运算符重载的栗子:
#include<iostream>
#include<stdbool.h>
using namespace std;
class Date
{
public:
int _year;
int _month;
int _day;
Date(int year,int month,int day)
{
this->_year = year;
this->_month = month;
this->_day = day;
}
};
bool operator<(const Date& a, const Date& b)
{
if (a._year < b._year)
{
return true;
}
else if (a._year == b._year)
{
if (a._month < a._month)
{
return true;
}
else if (a._month == b._month)
{
return a._day < b._day;
}
}
return false;
}
int main()
{
Date a(2003, 12, 12);
Date b(2003, 12, 5);
cout << operator<(a, b) << endl;//正常的函数显示调用
cout << (a < b) << endl;//在编译器看来与上一条语句一模一样,效果相同。
return 0;
}
运行结果:
通过运算符重载,对于自定义类型对象可以直接使用操作符(比如<),代码可读性大大提升!
对于这个使用运算符重载的栗子,我们还可以分析:
这里会发现运算符重载成全局的函数就需要成员变量是公有的,不然受到访问限定符的作用无法访问类对象的成员变量,那么问题来了,封装性如何保证?
这里其实可以用以下办法解决:
- 办法1:友元声明解决(本鼠后面博客会介绍的)。
- 办法2:或者干脆重载成类成员函数。
- 办法3:或者定义一些类成员函数用于“调出”成员变量。
使用办法3,本鼠举例:
#include<iostream>
#include<stdbool.h>
using namespace std;
class Date
{
int _year;
int _month;
int _day;
public:
int& Getyear()
{
return _year;
}
int& Getmonth()
{
return _month;
}
int& Getday()
{
return _day;
}
Date(int year,int month,int day)
{
this->_year = year;
this->_month = month;
this->_day = day;
}
};
bool operator<( Date& a, Date& b)
{
if (a.Getyear()<b.Getyear())
{
return true;
}
else if (a.Getyear() == b.Getyear())
{
if (a.Getmonth() < a.Getmonth())
{
return true;
}
else if (a.Getmonth() == b.Getmonth())
{
return a.Getday() < b.Getday();
}
}
return false;
}
int main()
{
Date a(2003, 12, 12);
Date b(2003, 12, 5);
cout << operator<(a, b) << endl;
cout << (a < b) << endl;//在编译器看来与上一条语句一模一样,效果相同。
return 0;
}
运行结果:
运算符重载有几个注意事项:
- 不能通过连接其他符号来创建新的操作符:比如operator@,就是说重载的操作符必须是C/C++语法存在的。
- 重载操作符必须有一个类类型参数,就是说不能通过运算符重载来改变运算符对内置类型的行为。
- 用于内置类型的运算符,其含义不能改变,例如:内置的整型+,不 能改变其含义(虽然语法上可以编译通过,但是这样子的话可读性还不如之前了)。
- 作为类成员函数重载时,其形参看起来比操作数数目少1,因为成员函数的第一个参数为隐藏的this指针。
- .* 和::和 sizeof和 ?: 和. 这5个运算符不能重载。
举个栗子:
#include<iostream>
using namespace std;
class exmple
{
int _data1;
int _data2;
public:
exmple (int data1,int data2)
{
_data1 = data1;
_data2 = data2;
}
exmple operator+(const exmple& n)
{
exmple tmp(0, 0);
tmp._data1 = this->_data1 - n._data1;
tmp._data2 = this->_data2 - n._data2;
return tmp;
}
void Print()
{
cout << _data1 << ' ' << _data2;
}
};
int main()
{
exmple a(10, 10);
exmple b(5, 5);
exmple c = a + b;//this指针传递&a,形参n是b的引用
c.Print();
return 0;
}
我们看类成员函数operator+的形参为啥只有一个,那是因为隐藏的this指针帮忙传递了一个。 看结果:
结果是两个5。上面的代码就是一个错误示范,我们想要重载运算符+,但是内部逻辑却写成了-,导致可读性大大降低,犯了注意事项3的错误。
5.1.2.++和--重载
为什么要强调这两个运算符的重载呢?
拿++来说,前置++和后置++都是一样的运算符,那么运算符重载的时候如何形成正确重载?函数名都写成operator++的话无法区分啊!
所以,C++规定:后置++重载时多增加一个int类型的参数,但当我们不显示调用函数时该参数不用传递,编译器自动传递。这个参数的参数名我们甚至可以不显示写。
#include<iostream>
using namespace std;
class exmple
{
int _data1;
int _data2;
public:
exmple& operator++()//前置++
{
_data1++;
_data2++;
return *this;
}
exmple operator++(int)//后置++,没显示写参数名,也可以写成exmple operator++(int n)
{
exmple tmp = *this;//拷贝构造
++*this;
return tmp;
}
exmple(int data1, int data2)//构造函数
{
_data1 = data1;
_data2 = data2;
}
void Print()
{
cout << _data1 << ' ' << _data2 << endl;
}
};
int main()
{
exmple a(10, 10);
exmple b(10, 10);
exmple c = a++;
exmple d = ++b;
c.Print();
d.Print();
return 0;
}
上面有一些注意事项:
- 如果我们打算显示调用后置++这个函数,那么一定要传入一个任意int整形以区分前置++,不然编译器就会去调用前置++了。
显示调用后置++时正确写法:
exmple c = a.operator++(10);//相当于exmple c = a++;
- 后置++是先使用后+1,因此需要返回+1之前的旧值,故需在实现时需要用临时对象先将*this保存 一份,然后给*this+1,返回临时对象。因为返回是临时对象,因此只能以值的方式返回,不能返回引用。
前置--和后置--同理,不解释了!!
5.2.赋值运算符重载
介绍了运算符重载,那么赋值运算符重载的意思就很简单了。赋值运算符重载格式:
- 参数类型:const T&,传递引用可以提高传参效率(T是自定义类型,下同)。
- 返回值类型:T&,返回引用可以提高返回的效率,有返回值目的是为了支持连续赋值。
- 检测是否自己给自己赋值(可有可无,最好有)。
- 返回*this :要复合连续赋值的含义。
我们看赋值运算符应该支持连续赋值:
int x, y, z;
x = y = z = 10;//连续赋值
这里连续赋值的结合性是从右向左的 ,表达式z=10返回值是z本身(左操作数作为返回值),返回值z再作为表达式y=z的右操作数,表达式y=z返回值是y本身……
#include<stdio.h>
int main()
{
int z = 10;
int x = 20;
printf("%p\n", &z);
printf("%p\n", &(z = x));
return 0;
}
真的是返回左操作数本身啊!!
所以赋值运算符重载后也要支持连续赋值,要返回左操作数本身(其实返回其拷贝也行)。
#include<iostream>
using namespace std;
class exmple
{
int _data1;
int _data2;
public:
exmple& operator=(const exmple& n)//赋值运算符重载
{
if (this != &n)//if用于检测是否自己给自己赋值
{
this->_data1 = n._data1;
this->_data2 = n._data2;
return *this;
}
}
exmple(int data1, int data2)//构造函数
{
_data1 = data1;
_data2 = data2;
}
void Print()
{
cout << _data1 << ' ' << _data2 << endl;
}
};
int main()
{
exmple a(10, 10);
exmple b(20, 20);
exmple c(30, 30);
a = b = c;//连续赋值
a.Print();
b.Print();
c.Print();
return 0;
}
赋值运算符重载所面临的同类型对象都是已经存在的对象。而拷贝构造函数所面临的对象一个是已经存在的对象,另一个是正在创建的对象。
还有:
- 赋值运算符只能重载成类的成员函数不能重载成全局函数。原因:赋值运算符如果在类中不显式实现,编译器会生成一个默认的。此时用户再在类外自己实现 一个全局的赋值运算符重载,就和编译器在类中生成的默认赋值运算符重载冲突了,故赋值运算符重载只能是类的成员函数。
- 用户没有显式实现时,编译器会生成一个默认赋值运算符重载,以值的方式逐字节拷贝(浅拷贝)。注意:内置类型成员变量是直接赋值的,而自定义类型成员变量需要调用对应类的赋值运算符重载完成赋值。跟拷贝构造函数类似,不解释了。
- 如果类中未涉及到资源管理,赋值运算符是否实现都可以;一旦涉及到资源管理则必须要实现,否则浅拷贝会出现一大堆问题,跟拷贝构造函数类似,不解释了。
6.const成员函数
看一个代码:
#include<iostream>
using namespace std;
class exmple
{
int _data1=10;
int _data2=20;
public:
void Print()
{
cout << _data1 << ' ' << _data2 << endl;
}
};
int main()
{
const exmple a;
a.Print();//error C2662: “void exmple::Print(void)”: 不能将“this”指针从“const exmple”转换为“exmple &”
return 0;
}
编译不通过,为什么?
很简单,对象a是被const修饰的常对象 ,调用Print函数隐藏的this指针接收a的地址。如果编译通过,就可以通过this指针(类型是exmple*)间接修改常对象a,权限被放大了。
怎么解决呢?很简单,用const修饰this指针,被修饰后的this指针类型是const exmple*,这样指针指向的值就不可改了。
所以将const修饰的“成员函数”称之为const成员函数,const修饰类成员函数,实际修饰该成员函数隐含的this指针,表明在该成员函数中不能对类的任何成员进行修改。
可是this指针是隐藏的,我们如何将其用const修饰呢?很简单,举个栗子:
#include<iostream>
using namespace std;
class exmple
{
int _data1 = 10;
int _data2 = 20;
public:
void Print()const//const成员函数,this指针类型是const exmple *
{
cout << _data1 << ' ' << _data2 << endl;
}
};
int main()
{
const exmple a;
a.Print();
return 0;
}
没问题吧!
编译器对于const成员函数的处理用下代码举例:
需要注意的是:
- 如果类中const成员函数的声明和定义分离了,我们在声明和定义处都要”加上“const。
- 不是所有成员函数的第一个形参this指针都能被const修饰的。如果是一个对成员变量要进行读写访问的成员函数,不能”加“const,因为如果this指针被const修饰了就不能对类的任何成员进行修改了。
7.取地址及const取地址操作符重载
注意:取地址操作符重载和const取地址操作符重载都是默认成员函数之一。
这两个运算符一般不需要重载,使用编译器生成的默认取地址的重载即可,因为无非就是返回对象地址罢了。只有特殊情况,才需要重载,比如想让别人获取到指定的内容!
取地址操作符重载举例:
#include<iostream>
using namespace std;
class exmple
{
int _data1 = 10;
int _data2 = 20;
public:
exmple* operator&()//取地址操作符重载
{
return this;
}
};
int main()
{
exmple a;
cout << &a << endl;//&a相当于a.operator()
return 0;
}
const取地址操作符重载:
我们要知道运算符重载本质是一个函数。所有取地址操作符重载也是一个函数。const取地址操作符重载的意思是取地址操作符重载的第一个形参this指针被const修饰了,const取地址操作符重载是一个const成员函数。
const取地址操作符重载举例:
#include<iostream>
using namespace std;
class exmple
{
int _data1 = 10;
int _data2 = 20;
public:
const exmple* operator&()const//const取地址操作符重载
{
return this;
}
};
int main()
{
exmple a;
cout << &a << endl;//&a相当于a.operator()
return 0;
}
为啥const取地址操作符重载的返回值也要被const修饰呢?因为this指针是被const修饰的,将this指针返回,那么返回值当然要被const修饰了,不然就是权限的放大,编译会报错的。。
感谢阅读!!!如有错误,恳请斧正!!