12.1 动态内存和类

类和动态内存分配

在类构造函数中使用new运算符在程序运行时分配所需的内存。所有的构造函数与new析构函数协调一致、编写额外的类方法来帮助正确完成初始化和赋值,否则,在类构造函数中使用new将导致新问题。

delete语句至关重要,删除对象可以释放对象本身占用的内存,但并不能自动释放属于对成员的指针指向的内存。因此,必须使用析构函数。在析构函数中使用delete语句可确保对象过期时,由构造函数使用new分配的内存被释放。

警告:在构造函数中使用new来分配内存时,必须在相应的析构函数中使用delete来释放内存。如果使用new[](包括中括号)来分配内存,则应使用delete[](包括中括号)来释放内存。

静态类成员

静态类成员有一个特点:无论创建了多少对象,程序都只创建一个静态类变量副本。类的所有对象共享同一个静态成员,就像家中的电话可供全体家庭成员共享一样。

不能在类声明中初始化静态成员变量,可以在类声明之外使用单独的语句来进行初始化。因为静态类成员是单独存储的,而不是对象的组成部分。

初始化语句指出类型,并使用作用域运算符,但没有使用关键字static。

如果静态成员是const整数类类型或枚举型,则可以在类声明中初始化。

stringbad.h

StringBad类,它是一个不太完整的类。它是使用动态内存分配来开发类的第一阶段,正确地完成一些显而易见的工作。

#include <iostream>
#ifndef STRINGBAD_H_
#define STRINGBAD_H_
using namespace std;

class StringBad
{
public:
	StringBad(const char *s); //构造函数
	StringBad(); //默认构造函数
	~StringBad(); //析构函数
	friend ostream & operator<<(ostream &os, const StringBad &st); //友元函数

private:
	char *str; //字符串指针
	int len; //字符串长度
	static int num_strings; //对象个数
};
#endif // !STRINGBAD_H_

使用char指针来表示姓名。类声明没有为字符串本身分配存储空间,而是在构造函数中使用new来为字符串分配空间。避免在类声明中预先定义字符串的长度。

将num_strings成员声明为静态存储类。假设创建了10个StringBad对象,将有10个str成员和10个len成员,但只有一个共享的num_strings成员。这对于所有类对象都具有相同值的类私有数据是非常方便的。num_strings成员可以记录所创建的对象数目。

stringbad.cpp

#include <cstring>
#include "stringbad.h"

//定义静态类成员
int StringBad::num_strings = 0;

//StringBad构造函数
StringBad::StringBad(const char * s)
{
	len = strlen(s); //设置长度
	str = new char[len + 1]; //分配内存
	strcpy(str, s); //初始化指针
	num_strings++; //记录对象数量
	cout << num_strings << ": \"" << str
		 << "\" object created\n"; //显示对象被创建个数
}

StringBad::StringBad()
{
	len = 4;
	str = new char[4];
	strcpy(str, "C++"); //默认情况下的字符串
	num_strings;
	cout << num_strings << ": \"" << str
		<< "\" object created\n";
}

StringBad::~StringBad() //必要的析构函数
{
	cout << "\"" << str << "\" object deleted, "; //对象被析构的个数
	--num_strings; //剩余的
	cout << num_strings << " left\n"; //剩余的个数
	delete[]str;
}

ostream & operator<<(ostream & os, const StringBad & st)
{
	os << st.str;
	return os;
}

将静态成员num_strings的值初始化为0。

每个构造函数都包含表达式num_strings++,这确保程序创建一个新对象,能共享变量num_strings的值都将增加1,从而记录String对象的总数。

析构函数包含表达式–num_strings,String类也将跟踪对象被删除的情况,从而使num_strings成员的值是最新的。

使用一个常规C字符串来初始化String对象:

StringBad::StringBad(const char * s)
{
	len = strlen(s); //设置长度
	str = new char[len + 1]; //分配内存
	strcpy(str, s); //初始化指针
	num_strings++; //记录对象数量
	cout << num_strings << ": \"" << str
		 << "\" object created\n"; //显示对象被创建个数
}

类成员str是一个指针,因此构造函数必须提供内存来存储字符串。初始化对象时,可以给构造函数传递一个字符串指针。

String boston("Boston");

构造函数必须分配足够的内存来存储字符串,然后将字符串复制到内存中。

首先,使用strlen()函数计算字符串的长度,并对len成员进行初始化。接着,使用new分配足够的空间来保存字符串,然后将新内存的地址赋给str成员。(strlen()返回字符串长度,但不包括末尾的空字符,因此构造函数将len加1,使分配的内存能够存储包含空字符的字符串)

接着,构造函数使用strcpy()将传递的字符串复制到新的内存中,并更新对象计数。

最后,构造函数显示当前的对象数目和当前对象中存储的字符串,以助于掌握程序运行情况。稍后故意使Stringbad出错时,该特性将派上用场。

字符串并不保存在对象中,字符串单独保存在堆内存中,对象仅保存了指出到哪里去查看字符串的信息。

这只保存了地址,而没有创建字符串副本:

str = s; //错误的

默认构造函数提供一个默认字符串:“C++”。

析构函数中包含对处理类来说最重要的东西:

StringBad::~StringBad() //必要的析构函数
{
	cout << "\"" << str << "\" object deleted, "; //对象被析构的个数
	--num_strings; //剩余的
	cout << num_strings << " left\n"; //剩余的个数
	delete[]str;
}

析构函数首先指出自己何时被调用。str成员指向new分配的内存。当StringBad对象过期时,str指针也将过期。但str指向的内存仍被分配,除非使用delete将其释放。

main.cpp

程序将对象声明放在一个内部代码块中,因为析构函数将在定义对象的代码块执行完毕时调用。如果不这样做,析构函数将在main()函数执行完毕时调用,导致无法在执行窗口关闭前看到析构函数显示的消息。

#include <iostream>
#include "stringbad.h"
using namespace std;

void callme1(StringBad &);
void callme2(StringBad);

int main()
{
	{
		cout << "Starting an inner block.\n";
		StringBad headline1("Celery Stalks at Midnight");
		StringBad headline2("Lettuce Prey");
		StringBad sports("Spinach Leaves Bowl for Dollars");
		cout << "headline1: " << headline1 << endl;
		cout << "headline2: " << headline2 << endl;
		cout << "sports: " << sports << endl;
		callme1(headline1);
		cout << "headline1: " << headline1 << endl;
		callme1(headline2);
		cout << "headline2: " << headline2 << endl;
		cout << "Initialize one object to another:\n";
		StringBad sailor = sports;
		cout << "sailor:" << sailor << endl;
		cout << "Assign one object to another:\n";
		StringBad knot;
		knot = headline1;
		cout << "knot: " << knot << endl;
		cout << "Exiting the block.\n";
	}
	cout << "End of main()\n";
	return 0;
}

void callme1(StringBad &rsb)
{
	cout << "String passed by reference:\n";
	cout << "   \"" << rsb << "\"\n";
}

void callme2(StringBad sb)
{
	cout << "String passed by value:\n";
	cout << "    \"" << sb << "\"\n";
}

构造函数指出自己创建了3个StringBad对象,并为这些对象进行编号,然后程序使用重载运算符>>列出了这些对象:

1: "Celery Stalks at Midnight" object created
2: "Lettuce Prey" object created
3: "Spinach Leaves Bowl for Dollars" object created
headline1: Celery Stalks at Midnight
headline2: Lettuce Prey
sports: Spinach Leaves Bowl for Dollars

然后,程序将headline1传递给callme1()函数,并在调用后重新显示headline1:

callme1(headline1);
cout << "headline1: " << headline1 << endl;

运行结果:

String passed by reference:
   "Celery Stalks at Midnight"
headline1: Celery Stalks at Midnight

但随后程序如下代码:

callme1(headline2);
cout << "headline2: " << headline2 << endl;

callme2()按值传递headline2,结果表明这是一个严重的问题:

String passed by reference:
   "Lettuce Prey"
headline2: Lettuce Prey

将headline2作为函数参数来传递从而导致析构函数被调用。虽然按值传递可以防止原始参数被修改,但实际上函数已使原始字符串无法识别,导致显示一些非标准字符。

在为每一个创建的对象自动调用析构函数时,情况更糟:

Exiting the block.
"Celery Stalks at Midnight" object deleted, 2 left
"Spinach Leaves Bowl for Dollars" object deleted, 1 left
"Spinach Leaves Bowl for Doll8" object deleted, 0 left
"@g" object deleted, -1 left
"-|" object deleted, -2 left
End of main()

因为自动存储对象被删除的顺序与创建顺序相反。所以最先删除的3个对象时knots、sailor、sport。

删除knots和sailor时是正常的,但删除sport时,Dollars变成Doll8。对于sport,程序只使用它来初始化sailor,但这种操作修改了sport。最后被删除的两个对象(headline2和headline1)已经无法识别。这些字符串在被删除之前,有些操作将它们搞乱了。

计数异常是一条线索。因为每个对象被构造和析构一次,因此调用构造函数的次数应当与析构函数的调用次数相同。对象计数(num_strings)递减的次数比递增次数多2,这表明使用了不将num_string递增的构造函数创建了两个对象。类定义声明并定义了两个构造函数(使num_string递增)但结果表明程序使用了3个构造函数。

例如,请看下面的代码:

StringBad sailor = sports;

这种形式的初始化等效于下面的语句:

StringBad sailor = StringBad(sports);

因为sports的类型为StringBad,因此相应的构造函数原型:

StringBad(const StringBad &);

当使用一个对象初始化另一个对象时,编译器将自动生成上述构造函数(复制构造函数,因为它创建对象的一个副本)。自动生成的构造函数不知道需要更新静态变量num_string,因此会将计数方案搞乱。
在这里插入图片描述

特殊成员函数

StringBad类的问题是由特殊成员函数引起的,这些成员函数是自动定义的。
● 默认构造函数,如果没有定义构造函数。
● 默认析构函数,如果没有定义。
● 复制构造函数,如果没有定义。
● 赋值构造函数,如果没有定义。
● 地址运算符,如果没有定义。
编译器将生成上述最后三个函数的定义。将一个对象赋给另一个对象,编译器 将提供赋值运算符的定义。

StringBad类中的问题是由隐式复制构造函数和隐式赋值运算符引起的。隐式地址运算符返回调用对象的地址(this指针的值)。默认析构函数不执行任何操作。

默认构造函数

如果没有提供任何构造函数,C++将创建默认构造函数。

假如定义一个Klunk类,但没有提供任何构造函数,则编译器将提供默认构造函数:

Klunk::Klunk() { } //隐式默认构造函数

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

Klunk lunk; //调用默认构造函数

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

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

Klunk::Klunk()
{
    klunk_ct = 0;
    ...
}

带参数的构造函数也可以是默认构造函数,只要所有参数都有默认值。

Klunk类可以包含下述内联构造函数:

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

只能有一个默认构造函数。

不能这样做:

Klunk::Klunk(){ klunk_ct = 0; } //构造函数#1
Klunk(int n = 0) { klunk_ct = n; } //构造函数#2

为何有二义性:

Klunk kar(10); //很清晰匹配Klunt(int n)
Klunk bus; //两个都能匹配

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

复制构造函数

复制构造函数用于将一个对象复制到新创建的对象中。在初始化过程中(包括按值传递参数),而不是常规的赋值过程。

类的复制构造函数原型:

Class_name(const Class_name &);

它接受一个指向类对象的常量引用作为参数。

StringBad类的复制构造函数的原型:

StringBad(const StringBad &);

何时调用复制构造函数

新建一个对象并将其初始化为同类现有对象时,复制构造函数都将被调用。将新对象显式地初始化为现有的对象。

motto是一个StringBad对象,下面4种声明都将调用复制构造函数:

StringBad ditto(motto);
StringBad metoo = motto;
StringBad also = StringBad(motto);
StringBad *pStringBad = new StringBad(motto);

中间的2个声明可能会使用复制构造函数直接创建metoo和also,也可能使用复制构造函数生成一个临时对象,然后将临时对象的内容赋给metoo和also,这取决于具体实现。

最后一种声明使用motto初始化一个匿名对象,并将新对象的地址赋给pstring指针。

每当程序生成了对象副本时,编译器都将使用复制构造函数。当函数按值传递对象或函数返回对象时,都将使用复制构造函数。按值传递意味着创建原始变量的一个副本。编译器生成临时对象时,也将使用复制构造函数。所以,当按值传递和返回对象时,都将调用复制构造函数。

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

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

默认的复制构造函数逐个复制非静态成员(成员复制称为浅复制),复制的是成员的值。

StringBad sailor = sports;

与下面的代码等效:

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

如果成员本身就是类对象,则将使用这个类的复制构造函数来复制成员对象。静态函数不受影响,因为它们属于整个类,而不是各个对象。

回到StringBad:复制构造函数的哪里出问题

程序输出表明,析构函数的调用次数比构造函数的调用次数多2,原因是可能是程序确实使用默认的复制构造函数另外创建了两个对象。当callme2()被调用时,复制构造函数被用来初始化callme2()的形参,还被用来将对象sailor初始化为对象sports。默认的复制构造函数不说明其行为,因此它不指出创建过程,也不增加计数器num_strings的值。但析构函数更新了技数,并且在任何对象过期时都将被调用,而不管对象是如何被创建的。这意味着程序无法准确地记录对象计数。

解决办法,提供一个对计数进行更新的显式复制构造函数:

StringBad::StringBad(const String &s)
{
   num_strings++;
   ...
}

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

第二个异常,字符串出现乱码:

headline2:

因为隐式复制构造函数是按值进行复制的。

sailor.str = sport.str;

这里复制的不是字符串,而是一个指向字符串的指针。将sailor初始化为sports后,得到是两个指向同一个字符串的指针。

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

delete []sailor.str; 

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

然后释放sports:

delete []sports.str;

sports.str指向的内存已经被sailor的析构函数释放,这将导致不确定的,可能有害的后果。

企图释放内存两次可能导致程序异常终止。

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

深度复制,复制构造函数应当复制字符串并将副本的地址赋给str成员,而不仅仅是复制字符串地址。这样每个对象都有自己的字符串,而不是引用另一个对象的字符串。调用析构函数时都将释放不同的字符串,而不会试图去释放已经被释放的字符串。

编写StringBad的复制构造函数:

StringBad::StringBad(const StringBad &st)
{
    num_strings++; //更新静态成员
    len = st.len; //相同的长度
    str = new char[len+1]; //分配空间
    strcpy(str, st.str); //将字符串复制到新位置
    cout << num_strings << ": \"" << str
         << "\" object created\n";
}

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

深拷贝与浅拷贝

如果类中包含了使用new初始化的指针成员,应当定义一个复制构造函数,以复制指向的数据,而不是指针,被称为深度复制。

复制的另一种形式(成员复制或浅复制)只是复制指针值。浅复制仅浅浅地复制指针信息,而不会深入挖掘以复制指针引用的结构。

赋值运算符

C++允许类对象赋值,通过自动为类重载赋值运算符实现的。

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

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

StringBad类的赋值运算符:

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

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

将已有的对象赋给另一个对象时,将使用重载的赋值运算符:

StringBad headline1("Celery Stalks at Midnight");
...
StringBad knot;
knot = headline1; //调用赋值运算符

初始化对象时,并不一定会使用赋值运算符:

StringBad metoo = knot;

metoo是一个新创建的对象,被初始化为knot的值,因此使用复制构造函数。实现时可能分两步来处理这条语句:使用复制构造函数创建一个临时对象,然后通过赋值将临时对象的值复制到新对象中。初始化总是会调用复制构造函数,而使用=运算符时也允许调用赋值运算符。

赋值运算符的隐式实现对成员进行逐个复制。如果成员本身是类对象,则程序将使用为这个类定义的赋值运算符来复制该成员,但静态数据成员不受影响。

赋值的问题出在哪里

headline1赋给knot:

knot = headline1;

为knot调用析构函数时,显示:

"Celery Stalks at Midnight" object deleted, 2 left

为Headline1调用析构函数时,显示:

"-|" object deleted, -2 left

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

解决赋值的问题

对于默认赋值运算符不合适导致的问题,解决方法是提供赋值运算符(进行深度复制)定义。
● 由于目标对象可能引用了以前分配的数据,所以函数应使用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) //是否是自身对象
       return *this; //结束,完成
    delete[] str; //释放之前的字符串
    len = st.len; 
    str = new char[len+1]; //申请空间给新的字符串
    strcpy(str, st.str); //字符串拷贝
    return *this; //返回调用对象本身
}

代码首先检查自我复制,这是通过查看赋值运算符右边的地址(&s)是否与接收对象(this)的地址相同来完成。如果相同,程序将返回*this,然后结束。

如果地址不同,函数将释放str指向的内存,因为稍后将把一个新字符串的地址赋给str。如果不首先使用delete运算符,则上述字符串将保留在内存中。由于程序中不再包含指向该字符串的指针,因此这些内存被浪费掉。

接下来为新字符串分配足够的内存空间,然后将赋值运算符右边的对象中的字符串复制到新的内存单元中。

最后返回*this结束。

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

将复制构造函数和赋值运算符添加到StringBad类中后:

"Celery Stalks at Midnight" object deleted, 4 left
"Spinach Leaves Bowl for Dollars" object deleted, 3 left
"Spinach Leaves Bowl for Dollars" object deleted, 2 left
"Celery Stalks at Midnight" object deleted, 1 left
"Lettuce Prey" object deleted, 0 left

对象计数正确,字符串没有被破损。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

阳光开朗男孩

你的鼓励是我最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值