【面向对象】类和动态内存分配

您希望下个月的早餐、午餐和晚餐吃些什么?在第三天的晚餐喝多少盎司的牛奶?在第15天的早餐中需要在谷类食品添加多少葡萄干?如果您和大多数人一样,就会等到进餐时再做决定。C++在分配内存时采取的部分策略与此相同,让程序在运行时决定内存分配,而不是在编译时决定。这样,可以根据程序的需要,而不是根据一系列严格的存储类型规则来使用内存。C++使用new和delete运算符来动态控制内存。遗憾的是,在类中使用这些运算符将导致许多新的编程问题。在这种情况下,析构函数是必不可少的,而不再是可有可无的。有时候,还必须重载赋值运算符,以保证程序正常运行。下面来看一看这些问题。

下面的程序中我们将使用new和delete。并且,引入了一种新的存储类型:静态类成员。首先设计一个StringBad类,然后设计一个功能稍强的MyString类。StringBad类对象将包含一个字符串指针和一个表示字符串长度的值。这里使用StringBad类,主要是为了深入了解new、delete和静态类成员的工作原理。因此,构造函数和析构函数调用时将显示一些信息,以便您能按照提示完成一些操作。

那么为什么将它命名为StringBad呢?这是为了表示提醒,StringBad是一个还没有开发好的示例。这是使用动态内存分配来开发的第一步,它正确地完成了一些显而易见的工作,例如,它在构造函数和析构函数中正确的使用new和delete。它其实不执行有害的操作,但省略了一些有益的功能,这些功能是必需的,但却不是显而易见的。通过说明这个类存在的问题,有助于稍后将它转换为一个功能更强的MyString类时,理解和牢记所做的一些并不明显的修改。

#ifndef STRINGBAD_H_
#define STRINGBAD_H_

#include <iostream>
using namespace std; 

class StringBad
{
	private:
		char * str; 				// pointer to string
		int len; 					// length of string
		static int num_strings; 	// number of objects
		
	public:
		StringBad(const char * s); 	// constructor
		StringBad(); 				// default constructor
		~StringBad(); 				// destructor
		
		// friend function
		friend ostream & operator << (ostream & os, const StringBad & st); 
}; 

#endif
对这个声明,需要注意的有两点:

首先,它使用char指针(而不是char数组)来表示姓名。这意味着类声明没有为字符串本身分配存储空间,而是在构造函数中使用new来为字符串分配空间。这避免了在类声明中预先定义字符串长度。

其次,将num_strings成员声明为静态存储类。静态类成员有一个特点:无论创建多少个对象,程序都只创建一个静态类变量副本。也就是说,类的所有对象共享一个静态成员,就像家中的电话可供全体家族成员共享一样。这对于所有类对象都具有相同值的私有数据是非常方便的。例如,num_strings成员可以记录所创建的对象数目。

下面的程序清单中类方法的实现,它演示了如何使用指针和静态成员。

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

int StringBad::num_strings = 0; 

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 [len+1];
	strcpy(str, "C++"); 
	num_strings++; 
	cout << num_strings << ": \"" << str << "\" default 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; 
}
首先,请注意下面的语句:

int StringBad::num_strings = 0; 
这条语句将静态成员num_strings的值初始化为0。请注意,不能在类声明中初始化静态成员变量,这是因为声明描述了如何分配内存,但却并不分配内存。您可以使用这种格式来创建对象,从而分配和初始化内存。对于静态类成员,可以在类声明之外使用单独的语句来进行初始化,这是因为静态类成员是单独存储的,而不是对象的组成部分。请注意,初始化语句指出了类型,并使用了作用域运算符,但没有使用关键字static。

初始化是在方法文件中,而不是在类的头文件中进行的,这是因为类声明位于头文件中,程序可能将头文件包括在其他几个文件中。如果在头文件中进行初始化,将出现多个初始化语句副本,从而引发错误。

注意:静态数据成员在类声明中声明,在包含类的方法的文件中初始化。初始化时使用作用域运算符来指出静态成员所属的类。但如果静态成员是整型const或枚举型const,则可以在类声明中初始化。

接下来,注意到每个构造函数都包含表达式num_strings++,这确保程序每创建一个新的对象,共享变量的值都将加1,从而记录对象的总数。另外,析构函数包含表达式num_strings--,因此该类也将跟踪对象被删除的情况,从而使num_strings成员的值是最新的。

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

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

#include "stringbad.h"

void callme1(StringBad &); 		// pass by reference
void callme2(StringBad); 		// pass by value

int main(int argc, char** argv)
{
	{
		cout << "Starting an inner block:\n\n"; 
		
		StringBad headline1("Celery Stalks at Midnight"); 
		StringBad headline2("Lettuce Prey"); 
		StringBad sports("Spinach Leaves Bowl for Dollars"); 
		cout << endl; 
		
		cout << "headline1: " << headline1 << endl; 
		cout << "headline2: " << headline2 << endl; 
		cout << "sports: " << sports << endl; 
		cout << endl; 
		
		callme1(headline1); 
		cout << "headline1: " << headline1 << endl; 
		cout << endl; 
		
		callme2(headline2); 
		cout << "headline2: " << headline2 << endl; 
		cout << endl; 
		
		cout << "Initialize one object to another: \n"; 
		StringBad sailor = sports; 
		cout << "sailor: " << sailor << endl; 
		cout << endl; 
		
		cout << "Assign one object to another: \n"; 
		StringBad knot; 
		knot = headline1; 
		cout << "knot: " << knot << endl; 
		cout << endl; 
		
		cout << "Exiting the block. \n\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"; 
}
注意:StringBad的第一个版本有许多故意留下的缺陷,这些缺陷使得输出是不确定的。例如,有些编译器无法编译它。虽然输出的内容有所差别,但基本问题和解决方法是相同的。

如下是使用DEV C++编译器进行编译时,该程序的输出:


程序在显示还有-1对象的信息之前中断,而报告通用保护错误(GPF)。GPF表明程序试图访问禁止它访问的内存单元,这是另一种糟糕的信号。

如上程序开始时还是正常的,但逐渐变得异常,最终导致了灾难性的结果。

callme2()按值(而不是按引用)传递headline2,这是一个严重的问题!


首先,将headline2作为函数参数来传递从而导致析构函数被调用。其次,虽然按值传递可以防止原始参数被修改,但实际上函数已使原始字符串无法识别,导致显示一些非标准字符(显示的文本取决于内存中包含的内容)。

为什么会这样呢?

实际上,计数异常是一条线索。因为每个对象被构造和析构一次,因此调用构造函数和析构函数的次数应当相同。

事实上,当您使用一个对象来初始化另一个对象时,编译器将自动生成复制构造函数。自动生成的构造函数并不知道需要更新静态变量num_strings,因此会将计数方案搞乱。实际上,这个例子说明的所有问题都是编译器自动 生成的成员函数引起的,下面介绍这个主题。

1. 特殊成员函数

StringBad类的问题是由特殊成员函数引起的。这些成员函数是自动定义的,就StringBad而言,这些函数的行为与类设计不符。具体地说,C++自动提供了下面这些成员函数:

默认构造函数,如果没有定义构造函数;

默认析构函数,如果没有定义;

复制构造函数,如果没有定义;

赋值运算符,如果没有定义;

地址运算符,如果没有定义。

更准确地说,编译器生成上述最后三个函数的定义——如果程序使用对象的方式要求这样做。例如,如果您将一个对象赋给另一个对象,编译器将提供赋值运算符的定义。

结果表明,StringBad类中的问题是由隐式复制构造函数和隐式赋值运算符引起的。

(1) 默认构造函数

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

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

但只能有一个默认构造函数,否则,会引起二义性的错误!

(2) 复制构造函数

复制构造函数用于将一个对象复制到新创建的对象中。也就是说,它用于初始化过程中(包括按值传递参数),而不是常规的赋值过程中。类的复制构造函数原型通常如下:

Class_name(const Class_name &);

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

对于复制构造函数,需要知道两点:何时调用和有何功能。

(3) 何时调用复制构造函数

新建一个对象并将其初始化为同类现有对象时,复制构造函数都将被调用。这在很多情况下都可能发生,最常见的情况是将新对象显式地初始化为现有的对象。例如,假设motto是一个StringBad对象,则下面4种声明都将调用复制构造函数:

StringBad ditto(motto); 							// calls StringBad(const StringBad &)
StringBad metoo = metto; 							// calls StringBad(const StringBad &)
StringBad also = StringBad(motto); 					// calls StringBad(const StringBad &)
StirngBad * pStringBad = new StringBad(motto); 		// calls StringBad(const StringBad &)
每当程序生成了对象副本时,编译器都将使用复制构造函数。具体地说,当函数按值传递对象或函数返回对象时,都将使用复制构造函数。记住,按值传递意味着创建原始变量的一个副本。编译器生成临时对象时,也将使用复制构造函数。

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

(4) 默认的复制构造函数的功能

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

StringBad sailor = sports;

与下面的代码等效:

StringBad sailor; 

sailor.str = sports.str;

sailor.len = sports.len;

2. 回到StringBad类:复制构造函数哪里出了问题?

首先,程序的输出表明,析构函数的调用次数比构造函数的调用次数多2,原因可能是程序确实使用默认的复制构造函数另外创建了两个对象。默认的复制构造函数不说明其行为,因此它不指出创建过程,也不增加计数器num_strings的值。但析构函数更新了计数器,并且在任何对象过期时都将被调用,而不管对象是如何被创建的。解决的办法是提供一个对计数进行更新的显式复制构造函数。

第二个异常之处更微妙,其症状之一是字符串的内容出现乱码。原因在于隐式复制构造函数是按值进行复制的。

sailor.str = sports.str;

这里复制的不是字符串,而是一个指向字符串的指针。得到的是两个指向同一个字符串的指针。当析构函数被调用时,这将引发问题:sports.str指向的内存已经被sailor的析构函数释放,这将导致不确定的、可能有害的后果。

另一种症状是,试图释放内存两次可能导致程序异常终止。

解决类设计中这种问题的方法是进行深度复制(deep copy)。也就是说,复制构造函数应当复制字符串并将副本的地址赋给str成员,而不仅仅是复制字符串地址。这样每个对象都有自己的字符串,而不是引用另一个对象的字符串。调用析构函数时都将释放不同的字符串,而不会试图去释放已经被释放的字符串。可以这样编写StringBad的复制构造函数:

StringBad::StringBad(const StringBad & sb)
{
	num_strings++; 
	len = sb.len;
	str = new char [len+1]; 
	strcpy(str, sb.str); 
	cout << num_strings << ": \"" << str << "\"object created. \n"; 
}
警告:如果类中包含了使用new初始化的指针成员,应当定义一个复制构造函数,以复制指向的数据,而不是指针,这被称为深度复制。复制的另一种形式(成员复制或浅复制)只是复制指针值。浅复制仅浅浅地复制指针信息,而不会深入“挖掘”以复制指针引用的结构。

3. StringBad的其他问题:赋值运算符

上述程序存在的问题,并不是所有问题都归咎于默认的复制构造函数,还需要看一看默认的赋值运算符。ANSI C允许结构复制,而C++允许类对象赋值,这是通过自动为类重载赋值运算符实现的。这种运算符的原型为:

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

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

(1) 赋值运算符的功能以及如何作用它

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

初始化对象时,并不一定会使用赋值运算符,也可能使用复制构造函数。

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

(2) 赋值的问题出现在哪里

出现的问题与隐式复制构造函数相同:数据受损。这也是成员复制的问题。

(3) 解决赋值的问题

对于由默认赋值运算符不合适而导致的问题,解决的办法是提供赋值运算符定义(进行深度复制)。其实现与复制构造函数相似,但也有一些差别。

由于目标对象可能引用了以前分配的数据,所以函数应使用delete[]来释放这些数据;

函数应当避免将对象赋给自身;否则,给对象重新赋值前,释放内存操作可能删除对象的内容;

函数返回一个指向调用对象的引用。

下面的代码说明了如何为StringBad类编写赋值运算符:

StringBad & StringBad::operator = (const StringBad & sb)
{
	if (this == &sb)
		return *this; 
	delete [] str; 
	len = sb.len; 
	str = new char [len+1]; 
	strcpy(str, sb.str); 
	return *this;
}
有了更丰富的知识后,可以对StringBad类进行修订,将它重命名为MyString了。如下程序清单列出修订后的类声明:

// mystring.h -- fixed and augmented string class definition

#ifndef MYSTRING_H_
#define MYSTRING_H_

#include <iostream>
using namespace std; 

class MyString 
{
	private:
		char * str;		// pointer to string 
		int len; 		// length of string
		static int num_strings; 			// number of objects
		static const int CINLIM = 80; 		// cin input limit 
	
	public:
		// constructors and other methods 
		MyString(const char * s); 			// constructor
		MyString(); 						// default constructor
		MyString(const MyString & s); 		// copy constructor
		~MyString(); 						// destructor
		int length() const { return len; }
		
		// overloaded operator methods
		MyString & operator = (const MyString &); 
		MyString & operator = (const char *); 
		char & operator [] (int i); 
		const char & operator [] (int i) const; 
		
		// overloaded operator friends 
		friend bool operator < (const MyString & st1, const MyString & st2); 
		friend bool operator > (const MyString & st1, const MyString & st2); 
		friend bool operator == (const MyString & st1, const MyString & st2); 
		friend ostream & operator << (ostream & os, const MyString & st); 
		friend istream & operator >> (istream & is, MyString & st); 
		
		// static function 
		static int HowMany(); 
};

#endif
如下程序清单给出了修订后方法定义:

// mystring.cpp -- MyString class methods 
#include <cstring>
#include "mystring.h"
using namespace std; 

// initializing static class member
int MyString::num_strings = 0;

// static method 
int MyString::HowMany()
{
	return num_strings; 
}

// class methods 
MyString::MyString(const char *s) 
{
	len = strlen(s); 
	str = new char [len+1]; 
	strcpy(str, s); 
	num_strings++; 
}

MyString::MyString()
{
	len = 4; 
	str = new char [1]; 
	str[0] = '\0'; 
	num_strings++; 
}

MyString::MyString(const MyString & st)
{
	len = st.len; 
	str = new char [len+1]; 
	strcpy(str, st.str); 
	num_strings++; 
}

MyString::~MyString()
{
	num_strings--; 
	delete [] str; 
}

// overloading operator methods

// assign a MyString to a MyString 
MyString & MyString::operator = (const MyString & st) 
{
	if (this == &st)
		return *this; 
	delete [] str; 
	len = st.len; 
	str = new char [len+1]; 
	strcpy(str, st.str); 
	return *this;
}

// assign a C string to a MyString 
MyString & MyString::operator = (const char * s) 
{
	delete [] str; 
	len = strlen(s); 
	str = new char [len+1]; 
	strcpy(str, s); 
	return *this; 
}

char & MyString::operator [] (int i) 
{
	return str[i]; 
}

const char & MyString::operator [] (int i) const 
{
	return str[i]; 
}

bool operator < (const MyString & str1, const MyString & str2)
{
	return (strcmp(str1.str, str2.str) < 0); 
} 

bool operator > (const MyString & str1, const MyString & str2)
{
	return str2 < str1; 
}

bool operator == (const MyString & str1, const MyString & str2)
{
	return (strcmp(str1.str, str2.str) == 0);
}

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

istream & operator >> (istream & is, MyString & st)
{
	char temp [MyString::CINLIM]; 
	is.get(temp, MyString::CINLIM); 
	if (is)
		st = temp; 
	while (is && is.get() != '\n')
		continue; 
	return is; 
}
对于改进后的StringBad类,我们对MyString作如下说明:

1. 静态类成员函数

可以将成员函数声明为静态的(函数声明必须包含关键字static,但如果函数定义是独立的,则其中不能包含关键字static),这样做有两个重要的后果。

首先,不能通过对象调用静态成员函数:实际上,静态成员函数甚至不能使用this指针。如果静态成员函数是在公有部分声明的,则可以使用类名和作用域解析运算符来调用它。

其次,由于静态成员函数不与特定的对象相关,因此只能使用静态数据成员。

2. 在构造函数中使用new时应注意的事项

如果在构造函数中使用new来初始化指针成员,则应在析构函数中使用delete。

new和delete必须相互兼容。new对应于delete,new[]对应于delete[]。

如果有多个构造函数,则必须以相同的方式使用new,要么都带中括号,要么都不带。因为只有一个析构函数,所有的构造函数都必须与它兼容。然而,可以在一个构造函数中使用new初始化指针,而在另一个构造函数中将指针初始化为空,这是因为delete可以用于空指针。

应定义一个复制构造函数,通过深度复制将一个对象初始化为另一个对象。

应定义一个赋值运算符,通过深度复制将一个对象复制给另一个对象。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值