文章目录
- 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类动态分配对象的指针。