C++自动定义的成员函数

  • Post author:
  • Post category:其他




C++自动提供了下面这些成员函数:

  • 默认构造函数,如果没有定义构造函数
  • 默认析构函数,如果没有定义
  • 复制构造函数,如果没有定义
  • 赋值运算符,如果没有定义
  • 地址运算符,如果没有定义

另有移动构造函数和移动赋值运算符

将一个对象赋给另一个对象,编译器将提供赋值运算符的定义,地址运算符的定义,自动生成

复制构造函数,因为它创建对象的一个副本



1.默认构造函数

如果没有提供任何构造函数,C++将创建默认构造函数。例如,假如定义了一个Klunk类,但没有提供任何构造函数,则编译器将提供下述默认构造函数:

Klunk::Klunk(){}   //implicit default constructor

也就是说,编译器将提供一个不接受任何参数,也不执行任何操作的构造函数(默认的默认构造函数),这是因为创建对象时总是会调用构造函数:

Klunk lunk;  // invokes default constructor

默认构造函数使Lunk类似于一个常规的自动变量,也就是说,它的值在初始化是未知的。

如果定义了构造函数,C++将不会定义默认构造函数。如果希望在创建对象时不显式地对它进行初始化,则必须显式地定义默认构造函数。这种构造函数没有任何参数,但可以使用它来设置特定的值:

Klunk::Klunk() //explicit default constructor
{
	klunk_ct  = 0;
	...
}   

带参数的构造函数也可以是默认构造函数,只要所有参数都有默认值。例如,Klunk类可以包含下述内联构造函数:

Klunk(int n = 0) {klunk_ct = n;}

但只能有一个默认构造函数。也就是说,不能这样做:

Klunk() {klunk_ct = n;}     // constructor  #1
Klunk(int n = 0) {klunk_ct = n;}  // ambiguous constructor #2 

这为何有二义性呢?请看下面两个声明:

Klunk kar(10);     // clearly matches Klunk(int n)
Klunk bus;  // could match either constructor  

第二个声明既与构造函数#1(没有参数)匹配,也与构造函数#2(使用默认参数0)匹配。这将导致编译器发出一条错误信息。



2.复制构造函数

复制构造函数用于将一个对象复制到新创建的对象中。也就是说,它用于初始化过程中(包括按值传递参数),而不是常规的赋值过程中。类的复制构造函数原型通常如下:

Class_name(const Class_name &);

它接受一个指向类对象的常量引用作为参数。例如,String类的复制构造函数的原型如下:

StringBad (const StringBad &);

对于复制构造函数,需要知道两点:何时调用和有何功能。



3.何时调用复制构造函数

新建一个对象并将其初始化为同类现有对象时, 复制构造函数都将被调用。 这在很多情况下都可能发生, 最常见的情况是将新对象显式地初始化为现有的对象。 例如, 假设motto是一个StringBad对象, 则下面4种声明都将调用复制构造函数:

在这里插入图片描述

其中中间的2种声明可能会使用复制构造函数直接创建metoo和also, 也可能使用复制构造函数生成一个临时对象, 然后将临时对象的内容赋给metoo和also, 这取决于具体的实现。 最后一种声明使用motto初始化一个匿名对象, 并将新对象的地址赋给pstring指针。

每当程序生成了对象副本时, 编译器都将使用复制构造函数。 具体地说, 当函数按值传递对象(如程序清单12.3中的callme2()) 或函数返回对象时, 都将使用复制构造函数。 记住, 按值传递意味着创建原始变量的一个副本。 编译器生成临时对象时, 也将使用复制构造函数。 例如, 将3个Vector对象相加时, 编译器可能生成临时的Vector对象来保存中间结果。 何时生成临时对象随编译器而异, 但无论是哪种编译器, 当按值传递和返回对象时, 都将调用复制构造函数。 具体地说, 程序清单12.3中的函数调用将调用下面的复制构造函数:

callme2(headline2);

程序使用复制构造函数初始化sb——callme2()函数的StringBad型形参。

由于按值传递对象将调用复制构造函数, 因此应该按引用传递对象。 这样可以节省调用构造函数的时间以及存储新对象的空间。



4.默认的复制构造函数的功能

默认的复制构造函数逐个复制非静态成员( 成员复制也称为浅复制) , 复制的是成员的值。 在程序清单12.3中, 下述语句:

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

程序输出如下:

在这里插入图片描述

StringBad sailor = sports;

与下面的代码等效( 只是由于私有成员是无法访问的, 因此这些代码不能通过编译) :

StringBad sailor;
StringBad.str = sports.str;
StringBad.len = sports.len;



如果成员本身就是类对象, 则将使用这个类的复制构造函数来复制成员对象。 静态函数( 如num_strings) 不受影响, 因为它们属于整个类, 而不是各个对象。 图12.2说明了隐式复制构造函数执行的操作。

在这里插入图片描述

二、回到Stringbad: 复制构造函数的哪里出了问题

现在介绍程序清单12.3的两个异常之处(假设输出为该程序清单后面列出的) 。 首先, 程序的输出表明, 析构函数的调用次数比构造函数的调用次数多2, 原因可能是

程序确实使用默认的复制构造函数另外创建了两个对象

。 当callme2()被调用时, 复制构造函数

被用来初始化callme2()的形参

, 还

被用来将对象sailor初始化为对象sports

。 默认的复制构造函数

不说明其行为, 因此它不指出创建过程, 也不增加计数器num_strings的值

。 但析构函数更新了计数, 并且在任何对象过期时都将被调用, 而不管对象是如何被创建的。 这是一个问题, 因为这意味着程序无法准确地记录对象计数。 解决办法是提供一个对计数进行更新的显式复制构造函数:

StringBad::StringBad(const String & s)
{
	num_strings++;
	...//import stuff to go here
}



提示:


如果类中包含这样的静态数据成员, 即其值将在新对象被创建时发生变化, 则应该提供一个显式复制构造函数来处理计数问题

第二个异常之处更微妙, 也更危险, 其症状之一是字符串内容出现乱码:

在这里插入图片描述

原因在于

隐式复制构造函数是按值进行复制

的。 例如, 对于程序清单12.3, 隐式复制构造函数的功能相当于:

sailor.str = sport.str;

这里

复制的并不是字符串, 而是一个指向字符串的指针

。 也就是说, 将sailor初始化为sports后,

得到的是两个指向同一个字符串的指针

。 当operator <<()函数使用指针来显示字符串时, 这并不会出现问题。 但当析构函数被调用时, 这将引发问题。 析构函数StringBad释放str指针指向的内存, 因此释放sailor的效果如下:

delete [] sailor.str;  // delete the string that ditto.str points to

sailor.str指针指向“Spinach Leaves Bowl for Dollars”,因为它被赋值为sport.str,而sport.str指向的正是上述字符串。所以delete语句将释放字符串“Spinach Leaves Bowl for Dollars”占用的内存。

然后, 释放sports的效果如下:

delete [] sport.str;  // effect is undefined

sports.str指向的内存已经被sailor的析构函数释放, 这将导致不确定的、 可能有害的后果。 程序清单12.3中的程序生成受损的字符串, 这通常是内存管理不善的表现。

另一个症状是, 试图释放内存两次可能导致程序异常终止。 例如,Microsoft Visual C++ 2010(调试模式) 显示一个错误消息窗口, 指出“Debug Assertion Failed!”; 而在Linux中, g++ 4.4.1显示消息“double free or corruption”并终止程序运行。 其他系统可能提供不同的消息, 甚至不提供任何消息, 但程序中的错误是相同的。


1. 定义一个显式复制构造函数以解决问题

解决类设计中这种问题的方法是进行深度复制(deep copy) 。 也就是说, 复制构造函数应当复制字符串并将副本的地址赋给str成员, 而不仅仅是复制字符串地址。 这样每个对象都有自己的字符串, 而不是引用另一个对象的字符串。 调用析构函数时都将释放不同的字符串, 而不会试图去释放已经被释放的字符串。 可以这样编写String的复制构造函数:

StringBad::StringBad(const StringBad & st)
{
	num_strings++;   //handle static member update
	len = st.len;    //same length
	str = new char [len + 1]; //allot space
	std::strcpy(str,st.str); //copy string to new location
	cout << num_string << ":\" "<< str
			<< "\"object created\n";//For Your Information
}



在这里插入图片描述

必须定义复制构造函数的原因在于, 一些类成员是使用new初始化的、 指向数据的指针, 而不是数据本身。 图12.3说明了深度复制。

在这里插入图片描述

警告:

如果类中包含了使用new初始化的指针成员, 应当定义一个复制构造函数, 以复制指向的数据, 而不是指针, 这被称为深度复制。 复制的另一种形式(成员复制或浅复制) 只是复制指针值。 浅复制仅浅浅地复制指针信息, 而不会深入“挖掘”以复制指针引用的结构。



三 Stringbad的其他问题: 赋值运算符

并不是程序清单12.3的所有问题都可以归咎于默认的复制构造函数, 还需要看一看默认的赋值运算符。

ANSIC允许结构赋值

, 而

C++允许类对象赋值

, 这是

通过自动为类重载赋值运算符实现

的。 这种运算符的原型如下:

Class_name & Class_name::operator = (const Class_name &);



接受并返回一个指向类对象的引用

。 例如, StringBad类的赋值运算符的原型如下:

StringBad & StringBad::operator = (const StringBad &);


1. 赋值运算符的功能以及何时使用它

  • 将已有的对象赋给另一个对象时, 将使用重载的赋值运算符:
StringBad headline1("Celery Stalks at Midnight");
...
StringBad knot;
knot = headline1;   // assignment operator invoked
初始化对象时,并不一定会使用赋值运算符:
StringBad metoo = knot; // use copy constructor,possibly assignment ,too

这里,metoo是一个新创建的对象,被初始化为knot的值, 因此

使用复制构造函数

。 然而, 正如前面指出的, 实现时也可能分两步来处理这条语句:

使用复制构造函数创建一个临时对象

, 然后

通过赋值将临时对象的值复制到新对象中

。 这就是说,

初始化总是会调用复制构造函数, 而使用=运算符时也可能调用赋值运算符

与复制构造函数相似,

赋值运算符的隐式实现也对成员进行逐个复制

。 如果

成员本身就是类对象

, 则程序将

使用为这个类定义的赋值运算符来复制该成员

, 但

静态数据成员不受影响


2. 赋值的问题出在哪里

  • 程序清单12.3将headline1赋给knot:
knot = headline1;

为knot调用析构函数时, 将显示下面的消息:

在这里插入图片描述

为Headline1调用析构函数时, 显示如下消息( 有些

实现方式在此之前就异常终止了

) :

在这里插入图片描述

出现的问题与隐式复制构造函数相同: 数据受损。 这也是成员复制的问题, 即导致headline1.str和knot.str指向相同的地址。 因此, 当对knot调用析构函数时, 将删除字符串“Celery Stalks at Midnight”; 当对headline1调用析构函数时, 将试图删除前面已经删除的字符串。 正如前面指出的, 试图删除已经删除的数据导致的结果是不确定的, 因此可能改变内存中的内容, 导致程序异常终止。 要指出的是, 如果操作结果是不确定的, 则执行的操作将随编译器而异, 包括显示独立声明( Declaration of Independence) 或释放隐藏文件占用的硬盘空间。 当然, 编译器开发人员通常不会花时间添加这样的行为。


3. 解决赋值的问题

对于由于默认赋值运算符不合适而导致的问题, 解决办法是提供赋值运算符( 进行深度复制) 定义。 其实现与复制构造函数相似, 但也有一些差别。

  • 由于目标对象可能引用了以前分配的数据, 所以函数应使用delete[]来释放这些数据。
  • 函数应当避免将对象赋给自身; 否则, 给对象重新赋值前, 释放内

    存操作可能删除对象的内容。
  • 函数返回一个指向调用对象的引用。

通过返回一个对象, 函数可以像常规赋值操作那样, 连续进行赋值, 即如果S0、 S1和S2都是StringBad对象, 则可以编写这样的代码:

S0 = S1 = S2;

使用函数表示法时, 上述代码为:

S0.operator=(s1.operator=(S2));

因此, S1.operator=(S2) 的返回值是函数S0.operator=()的参数。因为返回值是一个指向StringBad对象的引用, 因此参数类型是正确的。

下面的代码说明了如何为StringBad类编写赋值运算符:

StringBad & StringBad::operator=(const StringBad & st)
{
	if (this == &st)      // object assigned to itself
		return *this;     // all done
	delete [] str;        // free old string
	len = st.len;
	str = new char [len + 1]; //get sapce for new string
	std::strcpy(str,st.str);  //copy the string
	return *this;             // return reference to invoking object
}
代码首先检查自我复制, 这是通过查看赋值运算符右边的地址(&s) 是否与接收对象(this) 的地址相同来完成的。 如果相同, 程序将返回*this, 然后结束。 第10章介绍过, 赋值运算符是只能由类成员函数重载的运算符之一。
如果地址不同, 函数将释放str指向的内存, 这是因为稍后将把一个新字符串的地址赋给str。 如果不首先使用delete运算符, 则上述字符串

将保留在内存中。 由于程序中不再包含指向该字符串的指针, 因此这些内存被浪费掉。

接下来的操作与复制构造函数相似, 即为新字符串分配足够的内存空间, 然后将赋值运算符右边的对象中的字符串复制到新的内存单元中。

上述操作完成后, 程序返回*this并结束。

赋值操作并不创建新的对象, 因此不需要调整静态数据成员num_strings的值。

将前面介绍的复制构造函数和赋值运算符添加到StringBad类中后,所有的问题都解决了。 例如, 下面是在完成上述修改后, 程序输出的最后几行:

在这里插入图片描述

现在, 对象计数是正确的, 字符串也没有被损坏。



版权声明:本文为Tsehooo原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。