类的6个默认成员函数:构造函数、析构函数、拷贝构造函数、重载运算符、三/五法则

文章目录

  • 6个默认成员函数
  • 构造函数
    • 概念
    • 默认构造函数的类型
    • 默认实参
      • 概念
      • 默认实参的使用
      • 默认实参声明
      • 全局变量作为默认实参
    • 某些类不能依赖于编译器合成的默认构造函数
      • 第一个原因
      • 第二个原因
      • 第三个原因
    • 构造函数初始化
      • 构造函数里面的“=”是初始化吗?
      • 为什么要使用列表初始化?
      • 列表初始化
        • 成员初始化的顺序
      • 类内成员的默认初始化
      • 赋值和初始化的效率差异
  • 拷贝构造函数
    • 概念
    • 拷贝构造函数的参数必须是引用类型
    • 编译器合成的拷贝构造函数
    • 构造函数体内赋值和列表初始化的效率差异
      • 构造函数体内赋值
      • 列表初始化
      • 总结
    • 拷贝构造函数的两种调用方式及易错问题
      • 调用方式一
      • 调用方式二
      • 易错问题
  • 重载运算符
    • 概念
    • ==运算符的重载
    • 赋值运算符的重载
      • 分清拷贝构造函数和赋值运算符
      • 注意
      • 实例
    • 取地址运算符重载、对const对象取地址运算符的重载
  • 析构函数
    • 概念
    • 构造函数和析构函数的类比
    • 怎样调用析构函数
    • 下面代码中会调用几次析构函数?
    • 析构函数实例
  • =default
  • =delete
  • 三/五法则


6个默认成员函数

 class Date
{
};

可以看到,上面那个类没有任何成员,是一个空类,但是它真的什么都没有吗?

其实一个类在我们不写的情况下,也会生成6个默认的成员函数,分别是:构造函数,析构函数,拷贝构造函数,赋值运算符重载,取地址运算符重载,对const对象取地址运算符的重载

在这里插入图片描述



构造函数

概念

特征:

  1. 函数名与类名相同。
  2. 无返回值。
  3. 对象实例化时编译器自动调用对应的构造函数。
  4. 构造函数可以重载。
  5. 不同于其他成员函数,构造函数不能被声明成const的。

不能被声明成const的原因:

构造函数的作用就是为了初始化对象的成员参数,如果被声明为const则会认为自己无法修改调用对象的值,也就剥夺了构造函数的作用。

但构造函数仍可以用来初始化const对象:

当我们需要创建类的一个const对象时,直到构造函数完成初始化过程,对象才能真正取得其“常量”属性(构造函数在进行初始化时对象的const属性不生效)。因此,构造函数在const对象的构造过程中可以向其写值,并且构造函数不必(实则不能)被声明为const。


默认构造函数的类型

可分成两类:

  • 编译器合成的默认构造函数
  • 程序员定义的默认构造函数

合成的默认构造函数按照如下规则初始化类的数据成员:

  1. 如果存在类内的初始值,用它来初始化成员
  2. 否则,默认初始化该成员

如果我们要自己定义一个默认构造函数,那我们有两种方法:

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;
}

默认实参

概念

默认实参:某些函数有这样一种形参,在函数的很多次调用中它们都被赋予一个相同的值,此时我们把这个反复出现的值成为函数的默认实参

默认实参作为形参的初始值出现在形参列表中。

需要注意的是:

  1. 我们可以为一个或多个形参定义默认值,但一旦某个形参被赋予了默认值,它后面的所有形参都必须有默认值。
  2. 局部变量不能作为默认形参。
  3. 类型可以转化成形参所需类型的表达式也能作为默认实参。(比如函数的返回类型)

第一点:

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来初始化它,这明显不成立,因为初始化只能一次,而函数体内的赋值可以多次, 所以我们可以将函数体内的赋值理解为赋初值,而非初始化。


为什么要使用列表初始化?

有时我们可以忽略数据成员初始化和赋值之间的差异,但并非总能这样。

当类的成员是以下三种时,必须通过构造函数初始值列表为它们提供初值(列表初始化):

  1. 引用成员变量,引用必须在定义的时候初始化,并且不能重新赋值,所以也要写在初始化列表中;
  2. const成员变量,因为常量只能在初始化,不能赋值,所以必须放在初始化列表中;
  3. 未提供默认构造函数的类类型,因为使用初始化列表可以不必调用默认构造函数来初始化,而是直接调用拷贝构造函数;

通过一个例子来理解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;
}

形式上初始值列表的顺序来讲:

  1. 先用形参month初始化成员_day
  2. 再用初始化成功的_day去初始化成员_month

但实际上真的是这样吗? 我们来看看运行结果:

在这里插入图片描述
可以看到初始化成功的只有成员_day,实际上,初始化的顺序是按照参数在类中的声明顺序来的:

  1. 也就是先用形参year初始化成员_year
  2. 再用成员_day初始化成员_month但由于此时成员_day尚未被形参month初始化,因此成员_month值是未定义的。
  3. 接下来用形参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;
}

在这里插入图片描述
从结果可知,没有在初始值列表中显式初始化的数据成员,如果其也没有类内初始值,则其值是未定义的,试图拷贝或以其他形式访问此类值将引发错误。

综述:

  1. 构造函数不应该轻易覆盖掉类内初始值,除非新赋的值与原值不同。
  2. 构造函数使用类内初始值不失为一种好的选择,因为这样能确保为成员赋予了一个正确的值。
  3. 如果不能使用类内初始值(编译器不支持或其他原因),则所有构造函数都应该显式地初始化每一个内置类型的成员。

赋值和初始化的效率差异

很多类中,赋值和初始化的区别事关底层效率问题(对于内置类型而言赋值和初始化在效率上的区别不是很大):

  • 赋值首先会用默认构造函数来构造对象,再通过重载后的赋值运算符进行赋值。
  • 列表初始化会直接调用拷贝构造函数,减少了一次调用默认构造函数的时间。

具体详解在下面的拷贝构造函数中。



拷贝构造函数

概念

如果构造函数的

  • 第一个参数是自身类类型的引用
  • 其他参数(如果有的话)都有默认值。

则此构造函数是拷贝构造函数。

拷贝构造函数赋值运算符重载是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不是赋值运算符重载
}

拷贝构造函数的参数必须是引用类型

原因如下:

函数具有以下特性:

  • 函数调用过程中,非引用类型的参数需要进行拷贝初始化。
  • 函数具有非引用的返回类型时,返回值会被用来初始化调用方的结果。

根据上述特性可知:

  1. 拷贝构造函数被用来初始化函数的非引用类类型参数
  2. 如果拷贝函数本身的参数不是引用类型,为了调用拷贝构造函数,我们必须拷贝他的实参,为了拷贝实参,又需要调用拷贝构造函数,如此无限循环。

以本例来讲:
在这里插入图片描述
当我们要用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的运行结果可知,我们进行构造函数体内赋值操作时,

  1. 编译器在调用d的构造函数之前(执行d的构造函数体之前),首先调用A的拷贝构造函数,用实参a初始化a1(d构造函数的形参)。
  2. 然后调用A的默认构造函数创建数据成员_a。
  3. 创建好数据成员_a之后,开始执行b的构造函数函数体(即调用b的构造函数),使用A的重载赋值运算符将a1的值赋给数据成员_a。
  4. 执行完d1的构造操作后,编译器调用a的析构函数,释放之前创建的形参a1。

列表初始化

int main()
{A a;// 调用列表初始化的构造函数cout << "d5:" << endl;Date d5(888,a);
}

在这里插入图片描述
而对比d5的运行结果可知,我们进行列表初始化时,编译器两次调用拷贝构造函数,

  1. 一次用实参a初始化形参a3。
  2. 一次用形参a3初始化数据成员_a。
  3. 之后执行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操作符(参数列表)

关于重载运算符:

  1. 某些运算符(如赋值运算符)必须定义为成员函数。
  2. 如果一个运算符是一个成员函数,其左侧运算对象就绑定到隐式的 this 参数。

在这里插入图片描述

规则:

  • 不能通过连接其他符号来创建新的操作符:比如operator@
  • 重载操作符必须有一个类类型或者枚举类型的操作数
  • 用于内置类型的操作符,其含义不能改变,例如:内置的整型+,不 能改变其含义
  • 作为类成员的重载函数时,其形参看起来比操作数数目少1,成员函数的操作符有一个默认的形参this,限定为第一个形参
  • ::*?:. 注意以上5个运算符不能重载。

通常情况下,不应该重载逗号、取地址、逻辑与和逻辑或运算符。

选择重载运算符作为成员函数or非成员函数是很重要的

  • 赋值(=)、下标([ ])、调用(())和成员访问箭头(->)运算符必须是成员。
  • 复合赋值运算符一般来说应该是成员,但并非必须,这一点与赋值运算符略有不同。
  • 改变对象状态的运算符或者与给定类型密切相关的运算符,如递增、递减和解引用运算符,通常应该是成员。
  • 具有对称性的运算符可能转换任意一端的运算对象,例如算术、相等性、关系和位运算符等,因此它们通常应该是普通的非成员函数。

什么叫对称性运算符?其实就是形如 + 这样的 int + doubledouble + 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;}

注意

需要注意的有几点

  1. 返回 *this。原因有两点:
    • 避免了返回非引用所需的拷贝操作,提高效率;
    • 当出现形如这样的操作时:(a=b)=c,如果返回类型不是引用,则对括号内 a=b 得到的结果进行一次拷贝初始化,得到一个匿名对象(临时对象),这个匿名对象是一个右值,对其进行=c的赋值操作是未定义行为。
  2. 检测是否是自己给自己赋值,如果是则忽略
  3. 因为不需要修改任何参数,所以参数都需要加上const,并且为了不花费多余的空间去拷贝数据,都采取引用
  4. 一个类如果没有显式定义赋值运算符重载,编译器也会生成一个,完成对象按字节序的值拷贝

实例

举一个包含指针成员的类的重载赋值运算符该怎么写的例子:

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 会释放 *thisa 指向的 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;
};


析构函数

概念

析构函数也是一个特殊的成员函数,它的功能是:

  1. 释放对象在生存期分配的的所有资源
  2. 销毁对象的非static数据成员

特征:

  • 析构函数名是在类名前加上字符 ~。
  • 无参数无返回值。
  • 因为不接受参数因此不能被重载。
  • 一个类有且只有一个析构函数。若未显式定义,系统会自动生成默认的析构函数。
  • 对象生命周期结束时,C++编译系统系统自动调用析构函数

构造函数和析构函数的类比

  1. 构造函数有一个初始化部分和一个函数体。
  2. 析构函数有一个析构部分和一个函数体。
  3. 构造函数中,成员初始化是在函数体执行之前完成的,按照在类中出现的顺序进行初始化
  4. 析构函数中,首先执行函数体,然后销毁成员。 成员按照初始化顺序的逆序销毁

逻辑上:

  • 析构函数体一般负责销毁对象引用的内存(持有的资源)
  • 析构部分则是负责对象本身成员的析构
  • 析构部分会逐个调用类类型成员的析构函数(调用顺序与声明顺序相反),除此之外,析构部分还负责调用父类析构函数。

实现上:

  • 只有析构函数体是对程序员可见的,析构部分是隐式的。
  • 所谓隐式的,是指这部分代码(即调用类成员析构函数和父类析构函数的代码)是由编译器合成的。
  • 成员销毁时发生什么完全依赖于成员的类型:销毁类类型的成员需要执行成员自己的析构函数。内置类型没有析构函数,因此销毁内置类型成员什么也不需要做。

销毁普通指针和销毁智能指针的不同:

关于智能指针的知识在这里

基于上述红字部分:

  • 隐式销毁一个内置指针类型的成员不会delete它所指向的对象。也就是要格外注意类内指针的释放,避免内存泄漏
  • 与普通指针不同,智能指针是类类型,所以具有析构函数。因此,智能指针成员在析构阶段会被自动销毁。

怎样调用析构函数

无论何时一个对象被销毁,就会自动调用其析构函数:

  • 变量在离开其作用域时被销毁。
  • 当一个对象被销毁时,其成员被销毁。
  • 容器(无论是标准库容器还是数组)被销毁时,其元素被销毁。
  • 对于动态分配的对象,当对指向它的指针应用delete运算符时被销毁。
  • 对于临时对象,当创建它的完整表达式结束时被销毁。

具体如下:
在这里插入图片描述
当指向一个对象的引用或指针离开作用域时,析构函数不会执行。 因此上述代码唯一需要直接管理的内存就是直接分配的Sales_data对象,只需要直接释放绑定到 p 的动态分配对象。


下面代码中会调用几次析构函数?

在这里插入图片描述
这段代码中会发生三次析构函数调用:

  1. 函数结束时,局部变量item1的生命期结束,被销毁,Sales_data的析构函数被调用。
  2. 类似的,item2在函数结束时被销毁,Sales_data的析构函数被调用。
  3. 函数结束时,参数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 来显式的要求编译器生成合成的拷贝控制成员函数。在这里插入图片描述
但值得注意的是:

  1. 当我们在类内用=default修饰成员的声明时,合成的函数将隐式地声明为内联的,就像任何其他类内声明的成员函数一样。
  2. 如果我们不希望合成的成员是内联函数,应该只对成员的类外定义使用=default,就像对拷贝赋值运算符所做的那样。

=delete

虽然大部分情况下都需要拷贝构造函数和拷贝赋值运算符,但是对于某些类来讲,这些操作没有合理的意义,此时应该使用 =delete 将无意义的操作定义为删除的函数。其含义是:虽然该函数被定义,但无法被使用。如:iostream类阻止了拷贝,以避免多个对象写入或读取相同的IO缓冲。

struct A{A() = default; // 使用合成的默认构造函数A(const A&) = delete; // 阻止拷贝A &operator=(const A&) = delete; // 阻止赋值
};

与=default不同,=delete必须出现在函数第一次声明的时候。

  1. 从逻辑上讲,默认的成员只影响为这个成员而生的代码,因此=default直到编译器调用默认成员时才需要。
  2. 而编译器需要在第一时间知道一个函数是删除的,以便禁止试图使用它的操作。

两者另一个不同之处是,我们可以对任何函数指定=delete,但是只能对编译器可以合成的函数(默认构造函数或拷贝控制成员)使用=default。

在旧标准中我们用声明成private但不定义的方法来起到新标准中 =delete 的作用,此时试图使用该种函数的用户代码将在编译阶段被标记为链接错误。



三/五法则

由于拷贝控制操作是由三个特殊的成员函数来完成的:

  1. 拷贝构造函数定义了当用同类型的另一个对象初始化新对象时做什么
  2. 拷贝赋值运算符定义了将一个对象赋予同类型的另一个对象时做什么
  3. 析构函数定义了此类型的对象销毁时做什么。

所以我们称此为“C++三法则”。在较新的 C++11 标准中,为了支持移动语义,又增加了移动构造函数和移动赋值运算符,这样共有五个特殊的成员函数,所以又称为“C++五法则”。也就是说,“三法则”是针对较旧的 C++89 标准说的,“五法则”是针对较新的 C++11 标准说的。为了统一称呼,后来人们把它叫做“C++ 三/五法则”。

  1. 需要析构函数的类也需要拷贝构造函数和拷贝赋值运算符。
    从“需要析构函数”可知,类中必然出现了指针类型的成员(否则不需要我们写析构函数,默认的析构函数就够了,一般是内置指针类型,类类型的话一般直接调用该类的析构函数,不用我们自己再实现一个析构函数),所以,我们需要自己写析构函数来释放给指针所分配的内存来防止内存泄漏。
    那么为什么说“也需要拷贝构造函数和赋值操作”呢?原因是:类中出现了指针类型的成员这样的外部资源,合成的拷贝构造函数和合成的拷贝赋值运算符是对外部资源的浅拷贝,因此析构函数执行delete运算符时会出现double free的错误。
  2. 拷贝构造函数和拷贝赋值运算符要么都是合成版本,要么都是自定义版本。
    拷贝构造函数用已有对象构造新对象,函数体内类成员的构造方法就是利用拷贝赋值运算符。
  3. 析构函数不能是删除的,否则便无法销毁此类型的对象了。
    同时,编译器不允许定义该类型的变量或创建该类的临时对象。可以动态分配该对象并获得其指针,但无法销毁这个动态分配的对象(delete 失效)。
  4. 如果一个类有私有的或不可访问的析构函数,那么其默认和拷贝构造函数会被定义为私有的。
  5. 如果一个类有const或引用成员,则不能使用默认的拷贝赋值操作。
    原因很简单,const或引用成员只能在初始化时被赋值一次,而默认的拷贝赋值操作会对所有成员都进行赋值。显然,它不能赋值const和引用成员,所以默认的拷贝构造函数不能被使用,即会被定义为私有的。
//关于第三点的代码
struct A{A() = default;~A() = delete;
};
A a; //ERROR:A的析构函数是删除的。
A *p = new A(); //正确:但无法delete p;
delete p; // ERROR:A类没有析构函数,无法释放指向A类动态分配对象的指针。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/443823.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

C++ 类的知识 | 构造函数再探、匿名对象、友元函数、内部类、类的const成员、类的static成员

文章目录构造函数再探以下代码共调用多少次拷贝构造函数委托构造函数概念形式匿名对象友元友元的声明友元类令成员函数作为友元函数重载和友元注意内部类特性类的const成员可变数据成员类的static成员概念关于static静态成员的类内初始化静态成员能用于某些普通成员不能的场景构…

顺序容器(vector、list、string、deque、forward_list)及迭代器、容器适配器

文章目录概述所有容器都支持的操作迭代器迭代器支持的操作迭代器支持的算术运算容器类型size_typeiterator 和 const_iterator容器定义和初始化拷贝初始化顺序容器独有的构造函数&#xff08;array除外&#xff09;array的初始化与内置数组类型的区别6种初始化方法&#xff08;…

jQuery实现表格隔行换颜色:

jQuery实现表格各行换颜色&#xff1a; 截图如下&#xff1a; 代码如下&#xff1a; <span style"font-family:Microsoft YaHei;font-size:14px;"><% page language"java" import"java.util.*" pageEncoding"UTF-8"%> &…

用stack处理中缀表达式【+、-、*、/、()】

文章目录题目描述思路使用getline()存储输入的字符串边读取边压栈完整代码题目描述 使用stack处理括号化的表达式。当你看到一个左括号&#xff0c;将其记录下来。当你在一个左括号之后看到一个右括号&#xff0c;从stack中pop对象&#xff0c;直至遇到左括号&#xff0c;将左括…

二维数组的查找

文章目录题目描述思路注意代码题目描述 在一个 n * m 的二维数组中&#xff0c;每一行都按照从左到右递增的顺序排序&#xff0c;每一列都按照从上到下递增的顺序排序。请完成一个高效的函数&#xff0c;输入这样的一个二维数组和一个整数&#xff0c;判断数组中是否含有该整数…

Springmvc,Spring MVC文件上传

Springmvc文件上传&#xff1a; 1.代码截图如下&#xff1a; 2.UploadController.java: package cn.csdn.controller;import java.io.File;import javax.servlet.http.HttpServletRequest;import org.springframework.stereotype.Controller; import org.springframework.ui.…

插入迭代器、流迭代器、反向迭代器、移动迭代器

文章目录前言插入迭代器inserterfront_inserterback_inserteriostream迭代器istream_iterator 读取输入流istream_iterator允许使用懒惰求值ostream_iterator操作反向迭代器reverse_iterator的base成员函数前言 除了为每个容器定义的迭代器之外&#xff0c;标准库在头文件iter…

泛型算法(lambda表达式、function类模板、bind函数适配器、迭代器类别、链表数据结构独有的算法)

文章目录概念find()函数迭代器令算法不依赖于容器但算法依赖于元素类型的操作算法永远不会执行容器的操作只读算法accumulate()函数从两个序列中读取元素&#xff08;equal函数为例&#xff09;迭代器作为参数形成两个序列equal()写容器元素的算法概念fill()fill_n()插入迭代器…

jsp,div 限制字数,超出部分用省略号代替

1.我是用struts2标签做的&#xff1a;如下&#xff1a; <% page language"java" import"java.util.*" pageEncoding"UTF-8"%> <% taglib prefix"s" uri"/struts-tags"%> <%String path request.getContext…

C++之关联容器

文章目录概述及类型mapsetpair类型概念初始化默认初始化提供初始化器允许的操作可以创建一个pair类的函数可以作为容器的类型关联容器迭代器概念map的迭代器set的迭代器是const的初始化map and setmultimap and multiset关联容器的操作额外的类型别名关联容器和算法删除元素添加…

动态内存、智能指针(shared_ptr、unique_ptr、weak_ptr)、动态数组

文章目录三种对象的分类三种内存的区别动态内存概念智能指针允许的操作智能指针的使用规范new概念内存耗尽/定位new初始化默认初始化直接初始化值初始化delete概念手动释放动态对象空悬指针shared_ptr类格式独有的操作make_shared函数shared_ptr的计数器通过new用普通指针初始化…

动态数组的简单应用

文章目录连接两个字符串字面常量题目注意代码输出结果处理输入的变长字符串题目注意代码连接两个字符串字面常量 题目 连接两个字符串字面常量&#xff0c;将结果保存在一个动态分配的char数组中。重写&#xff0c;连接两个标准库string对象。 注意 使用头文件cstring的str…

二分查找算法实现

文章目录思路代码以二分区间作为while判定条件以给定值作为while判定条件主函数思路 while判定条件的选择&#xff0c;注意最外层的return的内容就好。下面提供了两个代码版本。计算 mid 时需要防止溢出&#xff08;对应类型【如本例中的int】可能存不下&#xff09;&#xff…

根据中序、前序遍历重建二叉树

文章目录题目递归思路细节易错代码复杂度分析迭代思路细节易错代码复杂度分析题目 输入某二叉树的前序遍历和中序遍历的结果&#xff0c;请重建该二叉树。假设输入的前序遍历和中序遍历的结果中都不含重复的数字。 例如&#xff0c;给出 前序遍历 preorder [3,9,20,15,7] 中…

深搜+剪枝

文章目录题目思路注意代码复杂度分析题目 给定一个 m x n 二维字符网格 board 和一个字符串单词 word 。如果 word 存在于网格中&#xff0c;返回 true &#xff1b;否则&#xff0c;返回 false 。 单词必须按照字母顺序&#xff0c;通过相邻的单元格内的字母构成&#xff0c…

搜索+回溯问题(DFS\BFS详解)

文章目录题目思路DFS思路代码复杂度分析BFS思路代码复杂度分析题目 地上有一个m行n列的方格&#xff0c;从坐标 [0,0] 到坐标 [m-1,n-1] 。一个机器人从坐标 [0, 0] 的格子开始移动&#xff0c;它每次可以向左、右、上、下移动一格&#xff08;不能移动到方格外&#xff09;&am…

快速幂实现pow函数(从二分和二进制两种角度理解快速幂)

文章目录迭代实现快速幂思路int的取值范围快速幂从二进制的角度来理解从二分法的角度来理解代码复杂度分析进阶——超级次方思路倒序快速幂正序快速幂代码复杂度分析迭代实现快速幂 实现 pow(x, n) &#xff0c;即计算 x 的 n 次幂函数&#xff08;即&#xff0c;xn&#xff0…

n位数的全排列(需要考虑大数的情况)

文章目录题目思路代码题目 输入数字 n&#xff0c;按顺序打印出从 1 到最大的 n 位十进制数。比如输入 3&#xff0c;则打印出 1、2、3 一直到最大的 3 位数 999。 示例 1: 输入: n 1 输出: [1,2,3,4,5,6,7,8,9] 说明&#xff1a; 用返回一个整数列表来代替打印 n 为正整数 …

正则表达式匹配(动规)

文章目录题目思路转移方程特征再探 i 和 j代码题目 请实现一个函数用来匹配包含 . 和 * 的正则表达式。模式中的字符 . 表示任意一个字符&#xff0c;而 * 表示它前面的字符可以出现任意次&#xff08;含0次&#xff09;。在本题中&#xff0c;匹配是指字符串的所有字符匹配整…

在循环递增一次的数组中插入元素

文章目录题目思路如何建立左右区间&#xff1f;如何查找最高点&#xff1f;那我们怎么判断 num 到底处于什么样的位置呢&#xff1f;如何确定插入位置&#xff1f;插入元素代码题目 给一个只循环递增一次的数组 res&#xff0c;res 满足首元素大于等于尾元素&#xff0c;形如&…