本篇笔记将深入浅出地探讨C++类的特殊成员函数相关知识。这些特殊成员函数也许你不经常见到,但是它们会自动生成和定义。这些可能隐式生成的特殊成员函数有:隐式默认构造函数,隐式默认析构函数,隐式默认拷贝构造函数,隐式赋值运算符以及隐式地址运算符。其中,隐式的拷贝构造函数和赋值运算符将可能带来一些问题。为了很好的理解其中的原理要点,特别的从一个实际工程中遇到的例子入手。
一、工程中的一个例子
在具体的一个软件工程中,有这样一段代码:
struct CharArray
{
CharArray()
{
strData = 0;
}
CharArray(const char* data)
{
if (strData)
{
delete[] strData;
strData = 0;
}
if (data)
{
//size_t len = std::strlen(data);
size_t len = strlen(data);
if (len > 0)
{
strData = new char[len+1];
memcpy(strData, data, len*sizeof(char));
strData[len] = '\0';
}
}
}
~CharArray()
{
if (strData)
{
delete[] strData;
strData = 0;
}
}
const char* GetData() const
{
return strData;
}
char* strData;
};
这段代码原来不是一个类,而是struct(把上述struct关键字直接换成class即为类的声明),作者的大致意图是写一个字符串结构体(类),至于它为什么不用标准库中的string或着C++11中的array,我也不得而知,因为我并不是这段代码的原作者。在代码被送去做第三方测试时,静态分析问题单中有个问题是这段代码不安全,应编写拷贝构造函数。当时的我甚至没听过“拷贝构造函数”这个名词。这里,为了适应我们研究的主题——类,我姑且把它改造为一个看上去舒服点的类。
CharArray.h
#ifndef CHARARRAY_H
#define CHARARRAY_H
class CharArray
{
public:
CharArray();//显式声明默认构造函数
CharArray(const char* data);//带参数的构造函数
~CharArray();//析构函数
private: //私有成员
char* strData;
public: //公有接口
const char* GetData() const ;
};
#endif
CharArray.cpp:
#include "CharArray.h"
#include <cstring>
CharArray::CharArray()
{
strData = 0;
}
CharArray::CharArray(const char* data)
{
int len = strlen(data);
strData = new char[len + 1];
strcpy(strData, data);
strData[len] = '\0';
}
CharArray::~CharArray()
{
if (strData)
{
delete[] strData;
strData = 0;
}
}
const char* CharArray::GetData() const
{
return strData;
}
这个类编译没问题,编写测试函数,在main中:
CharArray HelloWord("hello, world!");
CharArray sss = "ssss";
都可以编译通过,调试还可以发现对象初始化成功,为了能够打印其值,按照上篇笔记的做法,为这个类添加一个重载<<的友元函数:
friend std::ostream & operator<<(std::ostream & os, const CharArray & str);
std::ostream & operator<<(std::ostream &os, const CharArray &str)
{
if (str.strData)
{
os<<str.strData;
}
return os;
}
在main函数中打印该字符串,运行后,可以在终端中看到打印的信息。且程序能够正常退出。
cout<<"Before:"<<HelloWord<<endl;
如果这个类的应用仅限于此,确实是不会出现问题的。这也是为什么我们的软件没有因为这个问题单提出的问题而导致bug。但,问题单中描述的问题确实是存在的,下面慢慢揭开它的面纱。
二、可能会出现的三个问题
1.类的对象当然很可能被当做函数参数传递。假如,在main.cpp中编写一个函数,该函数用HelloWorld作为参数:
void TestStr(CharArray str)//将类的对象作为函数参数按值传递
{
//do sth
}
在main函数中:
TestStr(HelloWord);
cout<<"After:"<<HelloWord<<endl;
如果在debug下调试,程序将很有可能崩溃,如果只运行不调试,则第二行打印的应该是乱码:
Before:hello, world!
After:葺葺葺葺葺葺葺葺葺葺葺葺!
2.类对象也可以直接赋值,就像两个int型的对象可以互相赋值一样。在main函数中编写如下测试代码:
CharArray Str1("Hi,Str!");
cout<<"Before:"<<Str1<<endl;
{
CharArray Str2 = Str1;
cout<<Str2<<endl;
}
cout<<"After::"<<Str1<<endl;
这里的中括号表示Str2是在中括号内部起作用的临时变量。这意味着在离开中括号后,Str2将被清理。运行程序出现第三行重新打印Str1乱码:
Before:Hi,Str!
Hi,Str!
After::葺葺葺葺葺葺葺葺"
3.在main函数中添加如下代码,实现两个字符串的赋值操作:
{
CharArray Str3("Hi, Wayne!");
CharArray str4;
str4 = Str3;
cout<<"After::"<<Str3<<" "<<str4<<endl;
}
这段代码和问题二中的代码的区别是,问题二中类似于初始化的操作,问题三中是单纯的赋值。这在C++的运行机理上是不同的,下面会具体分析。这里运行后,程序可以打印两个字符串,但是你却发现你的程序“跑飞”了,你只能强制关闭它。
三、再谈拷贝构造函数
在《初识类》中涉及到了类的构造函数和类的拷贝构造函数。我们知道如果没有提供显式的构造函数,编译器将添加一个没有任何操作的默认构造函数。同样,如果没有提供显式的拷贝构造函数,编译器也将添加一个默认构造函数,它将实现复制每个非静态成员,复制的成员的值,这被称为浅复制。
那么什么时候程序会调用这个拷贝构造函数呢?新建一个对象并将其初始化为同类现有对象时,都将调用拷贝构造函数。如问题2中的情况,诸如下面的形式都将调用拷贝构造函数:
CharArray S1(“Test”);
CharArray S1(S2);
CharArray S1 = S2;
CharArray S1 = (CharArray)S2;
CharArray *S1 = new CharArray(S2);
再有,每当程序生成了对象副本时,编译器也将使用拷贝构造函数实现创建副本的过程。比如,问题1中的将对象按值传递时,将创建一个HelloWorld的副本,此时编译器将调用拷贝构造函数实现这一过程。
由于默认构造函数是浅复制,按值复制,在问题1中是将HelloWorld实参复制为HelloWorld形参,问题2中将Str1复制到Str2,复制的是strData指针,是个地址值,并没有复制字符串。离开变量的作用域后,则会执行析构函数。也即当退出TestStr函数后,HelloWorld对象执行析构函数,将delete[] StrData,由于实参和形参的StrData地址一样,因此再次打印实参HelloWorld时,出现了乱码。同理,问题2中离开代码块后,临时变量Str2将执行析构函数,而Str2中和Str1中StrData的地址也是一样的,析构后,Str1中的字符串也即被清理。
解决问题的办法很简单,就是提供一个显式的拷贝构造函数。这也就是静态问题单中给出的建议。这个显式的拷贝构造函数实现深度复制,也即不是复制成员的值,而是复制成员的数据。这个函数中,将重新new出一段内存空间,以存放新的对象的成员数据,进而将原有的数据拷贝进这段地址中。CharArray的拷贝构造函数可以如此写:
CharArray(const CharArray &str);//声明拷贝构造函数
CharArray::CharArray(const CharArray &str)
{
strLen = str.strLen;
strData = new char[strLen + 1];
strcpy(strData, str.strData);
strData[strLen] = '\0';
}
这里将之前的临时变量len升级为类的成员变量,其意义不言自喻。重新编译运行,应该可以看到可以正确打印HelloWorld和Str1的值了。
Before:hello, world!
After:hello, world!
Before:Hi,Str!
Hi,Str!
After::Hi,Str!
四、重载赋值运算符
当一个对象赋值给另外一个对象,将使用重载的赋值运算符。这就像我们为CharArray编写的重载<<运算符一样。问题是,我们不是必须显式的提供这个重载接口函数。也即上述代码中并没有编写operator=的函数接口,但我们仍然可以用Str4=Str3的方式来实现赋值。这说明,赋值运算符是可以自动生成的。这种隐式的赋值运算符实现的操作也是浅复制,即复制成员值,而非数据。回到问题3,对象赋值操作后,程序跑飞了,同样是因为数据受损。因为浅复制导致Str4中复制的是Str3的StrData地址,离开程序块时,将依次调用Str4和Str3的析构函数。我们知道,如果对同一地址执行多次delete会带来异常,因此程序跑飞了。
解决办法同样是提供显式的重载赋值运算符的函数,从而进行深度复制。这个函数的声明为:
CharArray & CharArray::operator=(const CharArray & str);
由于被赋值的对象可能之前已经被初始化或赋值,所以首先要用delete来清理这些数据。另外,要避免把对象赋给自身,否则对象重新赋值前,释放内存操作可能删除对象的内容。也即如果判断是Str3=Str3,则没必要重新分配内存,直接返回它自己即可。为了能够实现连续赋值Str4=Str3=Str2,则要将返回值定义为类的引用类型。具体实现如下:
CharArray & CharArray::operator=(const CharArray & str)
{
if (this == &str)
{
return *this;
}
delete[] strData;
strLen = str.strLen;
strData = new char[strLen + 1];
strcpy(strData, str.strData);
return *this;
}
重新编译运行,可以安全无误的运行和输出结果了:
Before:hello, world!
Call Destroy
After:hello, world!
Before:Hi,Str!
Hi,Str!
Call Destroy
After::Hi,Str!
After::Hi, Wayne! Hi, Wayne!
Call Destroy
Call Destroy
Call Destroy
Call Destroy
请按任意键继续. . .
这里我在析构函数中添加了一条打印消息“Call Destroy”来大概确定何时调用了析构函数。你可以自己对应一下上面的每条“Call Destroy”对应哪个对象的析构函数。
五、总结
至此,我们必须清楚使用new初始化对象的指针成员必须特别小心:
1.前面的《初始类》中总结了在构造函数中new的内存,必须显式地在析构函数中用delete清理。
2.new和delete对应,new[]和delete[]对应。如CharArray类的默认构造函数中对成员变量初始化可以:
strData = new char[1];
strData[0] = '\0';
不能够
strData = new char;
虽然开辟的空间是一样的,但是第二种方法和析构函数中的delete[]不匹配。
这里可以还可以用
strData = 0;//空指针
应为对于空指针,可以同时用delete或delete[]清理。
3.应定义一个显式拷贝构造函数,通过深度复制将一个对象初始化为另一个对象。
4.应定义一个重载赋值运算符函数,通过深度复制将一个对象复制给另外一个对象。
六、上源码
//CharArray.h
#ifndef CHARARRAY_H
#define CHARARRAY_H
#include <iostream>
class CharArray
{
public:
CharArray();//显式声明默认构造函数
CharArray(const char* data);//带参数的构造函数
CharArray(const CharArray &str);//声明拷贝构造函数
~CharArray();//析构函数
private: //私有成员
char* strData;
int strLen;
public: //公有接口
const char* GetData() const ;
//重载赋值运算符
CharArray & CharArray::operator=(const CharArray & str);
//友元重载<<运算符
friend std::ostream & operator<<(std::ostream & os, const CharArray & str);
};
#endif
//CharArray.cpp
#include "CharArray.h"
#include <cstring>
CharArray::CharArray()
{
strLen = 0;
// strData = new char[1];
// strData[0] = '\0';
strData = 0;//空指针
}
CharArray::CharArray(const char* data)
{
strLen = strlen(data);
strData = new char[strLen + 1];
strcpy(strData, data);
strData[strLen] = '\0';
}
CharArray::CharArray(const CharArray &str)
{
strLen = str.strLen;
strData = new char[strLen + 1];
strcpy(strData, str.strData);
strData[strLen] = '\0';
}
CharArray::~CharArray()
{
if (strData)
{
delete[] strData;
strData = 0;
}
std::cout<<"Call Destroy"<<std::endl;
}
const char* CharArray::GetData() const
{
return strData;
}
CharArray & CharArray::operator=(const CharArray & str)
{
if (this == &str)
{
return *this;
}
delete[] strData;
strLen = str.strLen;
strData = new char[strLen + 1];
strcpy(strData, str.strData);
return *this;
}
std::ostream & operator<<(std::ostream &os, const CharArray &str)
{
if (str.strData)
{
os<<str.strData;
}
return os;
}
#include "CharArray.h"
//main.cpp
#include <iostream>
using namespace std;
void TestStr(CharArray str);
int main()
{
//static CharArray sss = "ssss";
CharArray HelloWord("hello, world!");
cout<<"Before:"<<HelloWord<<endl;
TestStr(HelloWord);
cout<<"After:"<<HelloWord<<endl;
cout<<endl;
CharArray Str1("Hi,Str!");
cout<<"Before:"<<Str1<<endl;
{
CharArray Str2 = Str1;
cout<<Str2<<endl;
}
cout<<"After::"<<Str1<<endl;
cout<<endl;
{
CharArray Str3("Hi, Wayne!");
CharArray str4;
str4 = Str3;
cout<<"After::"<<Str3<<" "<<str4<<endl;
}
cin.get();
return 0;
}
void TestStr(CharArray str)//将类的对象作为函数参数按值传递
{
//do sth
}