这是一篇对什么是C++的The Rule of Three的错误更正和详细说明。
阅读时间7分钟。难度⭐⭐⭐
虽然上一篇文章的阅读量只有凄惨的两位数,但是怀着对小伙伴负责的目的,必须保证代码的正确性。这是大厨做技术自媒体的态度。
前文最后一段代码是这样的:
class Dog {
private:
char* name;
int age;
public:
'...省略构造和拷贝构造函数...'
//拷贝赋值函数
Dog& operator=(const Dog& that) {
name = new char[strlen(that.name)+1];
strcpy(name, that.name);
age = that.age;
}
'...省略析构函数...'
};
先不谈异常安全,这段拷贝赋值函数的代码本身有什么问题?
有3个问题:
没有释放原对象指针成员指向的内容
没有返回值
没有自赋值检查
下面我们一个一个分析。
1 没有释放原对象指针
这个问题很严重,因为一定会造成内存泄露。
原因是指针所指的内存未被释放,而指针又指向了别处。
例子如下,我们写了一个main函数,长这样:
int main(int argc, char* argv[]) {
Dog D1("Bobby", 2);
Dog D2("Teddy", 3);
Dog D2 = D1;
}
D1和D2分别是是Dog的对象。根据构造函数的定义,D2中的name指针指向了字符数组“Teddy”。而当进行D2 = D1操作时,name = new char[strlen(that.name)+1]这一步会在D2中重新创建一个名字为name且指向“Bobby”的指针。
这么做也许编译器不会报错,但是会有问题。
因为在new一个name指针之前,原本的name指针指向的内存并没有被释放。而新的name指针只对新创建的内存负责,老的内存已经变成无主之地。看来内存泄露是逃不掉了。
这个问题看着复杂,解决的办法倒是简单,只需要在拷贝赋值函数体第一行加上 delete[] name就可以了。
class Dog {
private:
char* name;
int age;
public:
'...省略构造和拷贝构造函数...'
//拷贝赋值函数
Dog& operator=(const Dog& that) {
delete[] name; //释放原对象指针成员指向的内容
name = new char[strlen(that.name)+1];
strcpy(name, that.name);
age = that.age;
}
'...省略析构函数...'
};
2 没有返回值第二个问题犯的错很低级,拷贝赋值函数的行为和普通函数一样需要一个返回值。而返回值的类型通常是类的对象的引用。
参照常用的写法,这里返回*this(this是C++类的隐藏成员,表示对象本身)。
class Dog {
private:
char* name;
int age;
public:
'...省略构造和拷贝构造函数...'
//拷贝赋值函数
Dog& operator=(const Dog& that) {
delete[] name; //释放原对象指针成员指向的内容
name = new char[strlen(that.name)+1];
strcpy(name, that.name);
age = that.age;
return *this; //返回对象引用
}
'...省略析构函数...'
};
另外大家可能有疑问为什么返回值是一个引用而不是一个值呢?
答案是只有引用才能进行连续赋值。
假设有3个Dog对象:D1、D2、D3,如果返回值不是引用,那么类似D1 = D2 = D3将不能通过编译。
3 没有自赋值检查
什么叫做自赋值?
就是两个相同对象之间用等号连接,比如:
int main(int argc, char* argv[]) {
Dog D1("Bobby", 2);
Dog D1 = D1; //同一个D1相互赋值
}
当然,一般不会有人写出这样的代码来。这里只是举个简单的例子,但是如果在大型项目中不同开发者对同一对象取了不同的别名,那么自赋值的情况是有可能发生的。
对于上面的Dog类而言,如果执行D1 = D1,那么会发生下面的事情:
首先,对象D1中的name指针被析构,name指向的内存被释放;
然后,下一行中的strlen(that.name)又用到了D1的name所指向的内存。
重点来了:这时你会惊讶地发现编译器提示你name已经不存在了!!!
因为在编译器看来,你在做对同一对象先释放了内存再使用的非法事情!
就好比你是拆迁大队的,你没有确认拆的是不是自己的房子就不管三七二十一直接拆了,然而你晚上还要回家住......
C++真的烧脑,仅仅是不小心把自己赋值给了自己就把自己的一部分给搞丢了,这在其他语言中似乎是天方夜谭。但是C++似乎很情愿把事情搞复杂。
幸好,自赋值问题也很容易修复,只需要在delete指针之前做一个自赋值的判断。
完整代码如下:
class Dog {
private:
char* name;
int age;
public:
'...省略构造和拷贝构造函数...'
//拷贝赋值函数
Dog& operator=(const Dog& that) {
if(this != &that) { //判断是否自赋值
delete[] name; //释放原对象的指针指向的内容
name = new char[strlen(that.name)+1];
strcpy(name, that.name);
age = that.age;
}
return *this;
}
'...省略析构函数...'
};
this != &that这个判断的写法看上去莫名其妙,大厨来给大家分析一下:
this代表D1=D1中等号左边的D1,&that代表等号右边的D1的引用(本质上还是D1)。this和&that二者如果相等就说明是同一个对象,那么拷贝赋值函数就直接返回对象的引用。
至此,三个问题终于都解决了
4 总结时刻
通过以上问题的剖析可以发现,C++一大半奇奇怪怪行为的背后都有一个处理不当的指针。
另外,写一个正确的类真的一点都不简单,需要考虑内存泄露,返回值类型,自赋值等等情况。
打住,再说下去大厨真的转行成C++专业劝退师了。