12章 类和动态内存分配
1. 动态分配内存的原因
为了避免大量的内存被浪费,一般采用在程序运行时,而不是编译时,确定诸如使用多少内存的问题。
C++使用new
和delete
运算符来动态控制内存。
2. 在构造函数中使用new的注意事项
2.1 在构造函数中使用new
,在析构函数中必须使用delete
。
1.为何需要分配内存:
如果不分配内存,直接用str=s
,只会保存参数字符串的地址,并没有创建备份。
2.使用new
分配内存的位置:
使用new
分配的内存,位置在堆中;对象中仅保留了该位置的地址信息。
3.使用析构函数的原因:
当对象被删除时,对象本身所占用的内存被释放,但是对象成员指针所指向的内存并不会被自动释放。因此,需要在析构函数中使用delete
语句,从而在对象过期析构函数被调用时,释放函数中new
分配的内存。
4.new
和delete
的使用方法:
/*声明*/
private:
char * str; //字符串指针
int len; //字符串长度
static int num_strings; //对象的个数
/*构造函数使用new*/
StringBad::StringBad(const char* s)
{
len = strlen(s);
str = new char[len+1]; //分配内存
strcpy(str, s);
num_strings++; //设置对象的个数
}
/*析构函数使用delete*/
StringBad::~StringBad()
{
--num_strings; //设置对象的个数
delete [] str; //释放内存
}
2.2 new
与delete
必须兼容。new
对应delete
,new []
对应delete []
。
如果析构函数使用delete []
,那么默认构造函数即使只对字符串赋空值,也要使用new []
:
String::String()
{
len = 0;
str = new char[1];
str[0] = '\0'; //等同于str = nullptr;
}
2.3 多个构造函数必须使用相同的方式使用new
。
2.4 应定义一个复制构造函数,通过深度复制将一个对象初始化为另一个对象。
1.复制构造函数的形式:StringBad(const StringBad &);
如果没有定义的话,C++会自动提供复制构造函数。
2.复制构造函数的调用时机:
一般来说是用现有的对象初始化一个新的对象时:
//将新对象显式地初始化为现有的对象
StringBad string1(string2); //1
StringBad string1 = string2; //2
StringBad string1 = StringBad(string2); //3
StringBad *pStringBad = new StringBad(string2); //4
//函数按值传递对象,或函数返回对象时
void callme2(StringBad sb) {} //5
{return sb;}
第2或3种,可能会用复制构造函数直接创建string1
;也可能先用复制构造函数生成一个临时对象,再将临时对象的内容复制到string1
中。
第4种,是使用复制构造函数先创建一个匿名对象,再将新对象的地址赋值给string1
。
第5种,sb
通过复制构造函数初始化。采用复制构造函数初始化会花费时间和空间,因此一般采用引用传递对象。
3.默认复制构造函数的功能:
默认复制构造函数会逐个复制非静态成员(也成为浅复制)的值。
如果成员本身是类对象,那么就会调用成员类的复制构造函数来复制该成员。
4.浅复制:
上图展示了在浅复制中,数据成员被逐个复制。
浅复制可能带来的后果是:当释放ditto
对象时,析构函数被调用,导致str
指向的字符串Home Sweet Home
占用的内存被释放。在之后释放motto
对象时,析构函数再次被调用,再次释放str
指向的已经被释放过的内存,可能会导致不确定的、有害的后果。
例如,使用浅复制时,函数参数按值传递就会出现问题:
将headline2
直接作为函数参数来传递,会将headline2
逐成员复制到sb
中,包括headline2
中成员指针指向的数据地址。
当函数调用结束,局部变量sb
过期,其析构函数被调用,就会释放掉sb
的成员指针所指向的数据。这数据也同样是headline2
的成员指针指向的数据。
因此在headline2
过期时,调用析构函数,就会再次释放成员指针指向的数据,导致不确定的、有害的后果。
void callme2(StringBad sb)
{
cout << sb << endl;
}
callme2(headline2); //使得析构函数被调用
5.深复制:
由于默认复制构造函数是在进行浅复制,在对象的析构函数被调用时会出现问题。因此,需要定义显式的复制构造函数以进行深度复制。
深度复制是指,应该复制数据形成副本,并将副本的地址赋给指针成员。这样使得每个对象都有自己的数据,而不是引用另一个对象的数据。
可以看出,深度复制的必要性在于:
一些类成员使用 new
初始化的、指向数据的指针,而不是数据本身。
6.新的复制构造函数:
StringBad::StringBad(const StringBad & st)
{
len = st.len;
str = new char [len+1];
strcpy(str, st.str);
num_strings++;
}
2.5 应定义一个赋值运算符,通过深度复制将一个对象复制给另一个对象。
1.C++会提供隐式的赋值运算实现。
2.赋值运算符的使用时机:
将已有的对象赋值给另一个对象时:
StringBad headline1("This is headline1");
StringBad headline2;
headline2 = headline1; //使用赋值运算符
StringBad headline3 = headline1; //使用复制构造函数或赋值运算符
在初始化对象时,不一定会使用赋值运算符。
3.赋值运算符的隐式实现:
赋值运算符的隐式实现也是进行浅复制,只逐个复制非静态成员的值。因此,也会导致对同一片内存多次释放的问题。
如果成员本身是类对象,将调用该成员类的赋值运算符来为该成员赋值。
4.赋值运算符的重载:
深度复制版本的赋值运算符所需的工作:
1.避免自我赋值
2.释放以前指向的内存
3.复制数据,而不是复制引用
4.返回调用对象的引用, 使得能够实现连续赋值
StringBad & StringBad::operator=(const StringBad & st)
{
if(this == &st)
return *this;
delete [] str;
len = st.len;
str = new char[len+1];
strcpy(str, st.str);
return *this;
}
3.静态类成员
3.1 静态类成员的特点:
静态类成员前有static
关键字。
无论创建了多少对象,程序只创建一个静态类变量副本。
所有的类对象共享同一个静态成员。
3.2 静态类数据成员的初始化:
静态成员在类声明中声明,在包含类方法的文件中初始化。
如果静态数据成员是 const
整数类型或枚举型,可以在类声明中初始化。
初始化语句指明了类型、作用域解析运算符,但没有使用关键字static
。
//类声明文件中
private:
static int num_strings;
//类方法定义文件中
int StringBad::num_strings = 0;
3.3 静态类函数成员:
1.声明与定义:
函数声明必须加上static
关键字,如果函数定义独立于函数声明,则函数定义不能包含关键字static
。
静态成员函数只能使用静态数据成员,因为它不与特定的对象相关联。
2.调用:
静态成员函数通过类名和作用域解析运算符来调用(如果公有),不能通过对象调用,不能使用this
指针。
//函数的声明与定义
public:
static int HowMany() {return num_strings;}
//函数的调用
int count = String::HowMany();
4.其它内容
4.1 使用 new
创建对象的过程
4.2 析构函数的调用时机
如果对象是动态变量,执行完定义该对象的代码块时,将调用对象的析构函数。
如果对象是静态变量,则在程序结束时,调用对象的析构函数。
如果对象是用new
创建的,仅当显式使用delete
删除对象时,析构函数才会被调用。
4.3 函数的返回对象
如果要返回局部对象,那么必须返回对象。这种情况下,将通过复制构造函数生成返回的对象。
如果要返回一个没有公有复制构造函数的类(如ostream
)的对象,那么必须返回引用。
如果既可以返回对象,又可以返回对象的引用,那么应该首选引用,因为引用效率更高。
4.4 特殊成员函数
如果没有定义的话,C++会自动的提供下面的成员函数:
- 默认构造函数(如果没有提供构造函数);
大致形式StringBad::StringBad() {}
-
默认析构函数;
-
复制构造函数;
-
赋值运算符;
-
地址运算符。
隐式地址运算符返回调用对象的地址,一般与期望一致。
4.5 友元形式的比较函数
将比较函数作为友元,便于其他类型与类类型的比较:
friend bool operator==(const String &st1, const String &st2);
对于if("love" == answer)
这样的情况, 可以先转换成operator==("love", answer)
,再通过只接受一个const char*
类型参数的类构造函数进行转换,得到operator==(String("love"), answer)
。
如果不是友元函数,符号左侧的操作数类型必须为类类型,否则无法参与比较。
4.6 重载中括号运算符
将返回类型声明为char &
,可以对特定元素进行赋值。
char & String::operator[](int i)
{
return str[i];
}
上述方法可以实现字符的索引和修改,但没有使用const
关键字,无法保证不对调用对象进行修改。
因此对于常量字符串,如果想要通过中括号进行索引,还需要提供一个新的版本:
const char & String::operator[](int i) const
{
return str[i];
}
4.7 指向对象的指针
1.指针指向已有的对象
Shortest
和first
不创建新的对象,只是指向已有的对象,因此不需要new
来分配内存,也不需要delete
来释放内存。
String *shortest = &sayings[0];
String *first = &sayings[0];
for(i=1; i<total; i++)
{
if(sayings[i].length() < shortest->length())
shortest = &sayings[i];
if(sayings[i] < *first)
first = &sayings[i];
}
2.指针指向新创建的匿名对象
favorite
使用new
为整个对象分配内存,调用复制构造函数创建了新对象,需要使用delete
来释放内存。
String *favorite = new String(sayings[choice]);
delete favorite;
4.8 将键盘输入行读入String对象的方法
istream & operator(istream & is, String & st)
{
char temp[String:CINLIM];
is.get(temp, String:CINLIM);
//除去输入失败的情况
if(is)
st = temp; //调用重载的赋值运算符
//省略多余字符
while(is && is.get()!='\n')
continue;
return is;
}
4.9 使用定位new
运算符
char * buffer = new char[BUFF];
JustTesting *pc1, *pc2;
//指定内存位置
pc1 = new (buffer) JustTesting;
//确保两个内存单元不重叠
pc2 = new (buffer + sizeof(JustTesting)) JustTesting("Better Idea", 6);
//显式地为使用定位new运算符创建的对象调用析构函数
//需要用正确的删除顺序——与创建顺序相反的顺序
pc2->~JustTesting();
pc1->~JustTesting();
//释放buffer所在区域
delete [] buffer;