动态内存
动态分配内存——防止大量浪费或者增加计算机内存负载。也就是程序运行时(而不是编译时)确定使用多少内存的问题。在类构造函数中使用new,在这种情况下,析构函数是必不可少的。有时候,还必须重载赋值运算符。
为了学习动态内存分配在类设计中的相关知识,我们先设计一个StringBad类,然后设计一个功能稍强的String类。
1 | //stringbad.h -- 有缺陷的string类声明 |
1 | void callme1(StringBad &);//按引用传递的函数 |
执行结果如下:
从执行结果来看,StringBad类是一个名副其实的,有缺陷的类。存在以下问题:
- 按值传递的函数,使得对象作为函数参数来传递时会导致析构函数被调用,会使得原始字符串无法识别。a2过早的被delete。
- main里删除对象和建立对象的顺序相反,所以最先删除的三个对象会是a5、a4和a3。a5中出现了很奇怪的字符串
↓h
下面的语句:StringBad a4 = a3;
这用的是哪个构造函数呢?显然不是默认构造函数,也不是参数为const char *
的构造函数,而是复制构造函数,原型如下:
1 | StringBad(const StringBad &); |
上述main代码测试出的问题都是由编译器自动形成的特殊成员函数引起的。下面介绍这个主题。
特殊成员函数
对于C++的类,在没有提供这种类型的函数时,会自动提供如下的成员函数:
• 默认构造函数
• 默认析构函数
• 复制构造函数
• 赋值运算符
• 地址运算符
默认复制构造函数(copy constructor)
定义
原型如下:1
Class_name(const Class_name &);
调用时机:新建一个对象并将其初始化为同类现有对象时。例如a1是一个StringBad对象,则下面四种声明都会调用复制构造函数
1 | StringBad a2(a1); |
还有就是每当程序生成对象副本(按值传递时意味着创建原始变量的一个副本),编译器都将使用复制构造函数。具体地说,当函数按值传递对象,(如上一章的callme2()
函数)或按值传递的函数返回对象时,都将调用复制构造函数。
由于按值传递对象将调用复制构造函数,因此应该按引用传递对象。这样可以节省调用构造函数的时间空间。
功能和存在的问题
默认的复制构造函数逐个复制非静态成员,复制的是成员的值。
callme2()
被调用时,复制构造函数被用来初始化callme2()
的形参,以及语句:StringBad a4 = a3;
也会用到复制构造函数。
第一个异常是计数器的异常。由于是默认的复制构造函数,所以不会增加计数器num_strings
的值(num_strings
的值是在构造函数中自增的)。但是析构函数会在任何对象过期时调用,而不管对象是如何被创建的。
第二个异常更加危险。由于隐式复制构造函数是按值复制的。所以复制的并不是字符串,而是一个指向字符串的指针。也就是说,将a4初始化为a3后,得到的是两个指向同一个字符串的指针。当析构函数被调用的时候,会出现问题。当a4先被删除时,会被正常删除,但a3对象过期时,调用析构函数时会把已经释放的内存再次释放,对同一块内存delete两次,这有可能导致程序崩溃。
解决方法:进行深度复制,而不是浅度复制。也就是说,复制的时候应当创建副本,并将副本赋给str成员,而不仅仅复制字符串地址。这样每个对象都有自己的字符串,而不是引用另一个对象的字符串。
浅复制
浅复制的原理图:
深度复制
深度复制的原理图:
定义新的复制构造函数,实现深度复制
如果类中包含使用了new初始化的指针成员,应当定义一个复制构造函数,用来复制指向的数据,而不是复制指向数据的指针(默认复制构造函数就是复制成员的值,导致错误)。
1 | StringBad(const StringBad & st); |
赋值运算符(assignment)
定义
将已有的对象赋值给另一个对象将使用重载的赋值运算符。1
2StringBad a5;
a5 = a1;
如果在一条语句内初始化对象,不一定会使用赋值运算符,而是复制构造函数。1
String a4 = a3;
这个是使用复制构造函数的例子。但实际也可能分两步来实现,就是使用复制构造函数创建临时对象,再把临时对象赋值给新对象。
也就是说,初始化一定会调用复制构造函数,而使用=
运算符时允许调用赋值运算符。
默认重载的赋值运算符对StringBad类有什么问题?
和隐式复制构造函数一样,存在数据受损的问题,原理也是成员复制的问题。也会进行浅度复制。也会造成同一段内存被释放两次的错误。
提供赋值运算符的深度复制的定义。与复制构造函数有一些差别是:
• 由于目标对象可能引用了以前分配的数据,所以应先使用delete[]
。
• 应当避免对象赋值给自身;否则释放内存的操作可能删除对象的内容。
• 返回一个引用。使得可以连续赋值。
为StringBad类编写赋值运算符
赋值运算符是只能用成员函数重载的几个运算符之一。详见重载限制。1
2
3
4
5
6
7
8
9
10
11StringBad & StringBad::operator=(const StringBad & st)
{
if(this==&st) //避免赋值给自身
return *this;
delete [] str; //释放原来的内存
len = str.len;
str= new char [len + 1];
std::strcpy(str,st.str);
return *this;
}
总结
如果在构造函数中使用new来初始化指针对象,则
• 析构函数不再是可有可无的了,必须在析构函数中使用delete。
• new和delete必须互相兼容。new对应delete,new []对应delete[];
• 如果有多个构造函数,则必须用相同的方法使用new。要么都带中括号,要么都不带。因为只能有一个析构函数,要与所有构造函数兼容。
• 应定义一个复制构造函数,通过深度复制将一个对象初始化给另一个对象。
• 应定义一个赋值运算符,通过深度复制将一个对象赋值给另一个对象。
改进后的String类
1 | //string1.h -- 改进后的string类 |