C++ Primer Plus 学习笔记
第 12 章 类和动态内存分配
本章将介绍如何对类使用new
和delete
以及如何处理由于使用动态内存而引起的一些微妙的问题。这里涉及的主题好像不多,但它们将影响构造函数和析构函数的设计以及运算符的重载。
来看一个具体的例子 —— C++ 如何增加内存负载。假设要创建一个类,其一个成员表示某人的姓。最简单的方法是使用字符数组成员来保存姓,但这种方法有一些缺陷。开始也许会使用一个 14 个字符的数组,然后发现数组太小,更保险的方法是,使用一个 40 个字符的数组。然而,如果创建包含 2000 个这种对象的数组,就会由于字符数组只有部分被使用而浪费大量的内存(在这种情况下,增加了计算机的内存负载)。但可以采取另一种方法。
通常,最好是在程序运行时(而不是编译时)确定诸如使用多少内存等问题。对于在对象中保存姓名来说,通常的 C++ 方法是,在类构造函数中使用new
运算符在程序运行时分配所需的内存。为此,通常的方法是使用string
类,它将为您处理内存管理细节。但这样您就没有机会更深入地学习内存管理了,因此这里将直接对问题发起攻击。除非同时执行一系列额外步骤,如扩展类析构函数、使所有的构造函数与new
析构函数协调一致、编写额外的类方法来帮助正确完成初始化和赋值(当然,本章将介绍这些步骤),否则,在类构造函数中使用new
将导致新问题。
动态内存和类
您希望下个月的早餐、午餐和晚餐吃些什么?在第三天的晚餐喝多少盎司的牛奶?在第 115 天的早餐中需要在谷类食品添加多少葡萄干?如果您与大多数人一样,就会等到进餐时再做决定。C++ 在分配内存时采取的部分策略与此相同,让程序在运行时决定内存分配,而不是在编译时决定。这样,可根据程序的需要,而不是根据一系列严格的存储类型规则来使用内存。C++ 使用new
和delete
运算符来动态控制内存。遗憾的是,在类中使用这些运算符将导致许多新的编程问题。在这种情况下,析构函数将是必不可少的,而不再是可有可无的。有时候,还必须重载赋值运算符,以保证程序正常运行。下面来看一看这些问题。
复习示例和静态类成员
我们已经有一段时间没有使用new
和delete
了,所以这里使用一个小程序来复习它们。这个程序使用了一个新的存储类型:静态类成员。首先设计一个StringBad
类,然后设计一个功能稍强的String
类(本书前面介绍过 C++ 标准string
类,第 16 章将更深入地讨论它;而本章的StringBad
和String
类将介绍这个类的底层结构,提供这种友好的接口涉及大量的编程技术)。
StringBad
和String
类对象将包含一个字符串指针和一个表示字符串长度的值。这里使用StringBad
和String
类,主要是为了深入了解new
、delete
和静态类成员的工作原理。因此,构造函数和析构函数调用时将显示一些消息,以便您能够按照提示来完成操作。另外,将省路一些有用的成员和友元函数,如重载的++
和>>
运算符以及转换函数,以简化类接口(但本章的复习题将要求您添加这些函数)。程序清单 12.1 列出了这个类的声明。
为什么将它命名为StringBad
呢?这是为了表示提醒,StringBad
是一个还没有开发好的示例。这是使用动态内存分配来开发类的第一步,它正确地完成了一些显而易见的工作,例如,它在构造函数和析构函数中正确地使用了new
和delete
。它其实不会执行有害的操作,但省略了一些有益的功能,这些功能是必需的,但却不是显而易见的。通过说明这个类存在的问题,存助于在稍后将它转换为一个功能更强的String
类时,理解和牢记所做的一些并不明显的修改。
程序清单 12.1 strngbad.h
// strngbad.h -- flawed string class definition
#include <iostream>
#ifndef STRNGBAD_H_
#define STRNGBAD_H_
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 std::ostream & operator<<(std::ostream & os,
const StringBad & st);
};
#endif
为何将这个类命名为StringBad
呢?这旨在告诉您,这是一个不太完整的类。它是使用动态内存分配来开发类的第一个阶段,正确地完成了一些显而易见的工作,例如,在构造函数和析构函数中正确地使用了new
和delete
。这个类并没有什么错误,但忽略了一些不明显却必不可少的东西。通过了解这个类存在的问题,将有助于您理解并记住后面将其转换为功能更强大的String
类时,所做的不明显的修改。
对这个声明,需要注意的有两点。首先,它使用char
指针(而不是char
数组)来表示姓名。这意味着类声明没有为字符串本身分配存储空间,而是在构造函数中使用new
来为字符串分配空间。这避免了在类声明中预先定义字符串的长度。
其次,将num_strings
成员声明为静态存储类。静态类成员有一个特点:无论创建了多少对象,程序都只创建一个静态类变量副本。也就是说,类的所有对象共享同一个静态成员,就像家中的电话可供全体家庭成员共享一样。假设创建了 10 个StringBad
对象,将有 10 个str
成员和 10 个len
成员,但只有一个共享的num_strings
成员。这对于所有类对象都具有相同值的类私有数据是非常方便的。例如,num_strings
成员可以记录所创建的对象数目。
随便说一句,程序清单 12.1 使用num_strings
成员,只是为了方便说明静态数据成员,并指出潜在的编程问题,字符串类通常并不需要这样的成员。
来看一看程序清单 12.2 中的类方法实现,它演示了如何使用指针和静态成员。
程序清单 12.2 strngbak.cpp
// strngbad.cpp -- StringBad class methods
#include <cstring> // string.h for some
#include "Strngbad.h"
using std::cout;
// initializing static class member
int StringBad::num_strings = 0;
// class methods
// construct StringBab from C string
StringBad::StringBad(const char * s)
{
len = std::strlen(s); // set size
str = new char[len + 1]; // allot storage
std::strcpy(str, s); // initialize pointer
num_strings++; // set object count
cout << num_strings << ": \"" << str
<< "\" object created\n";// For Yout Information
}
StringBad::StringBad() // default constructor
{
len = 4;
str = new char[4];
std::strcpy(str, "C++"); // default string
num_strings++;
cout << num_strings << ": \"" << str
<< "\" default object created\n"; // FYI
}
StringBad::~StringBad() // necessary destructor
{
cout << "\"" << str << "\" object deleted, "; // FYI
--num_strings; // required
cout << num_strings << " left\n"; // FYI
delete [] str; // required
}
std::ostream & operator<<(std::ostream & os, const StringBad & st)
{
os << st.str;
return os;
}
首先,请注意程序清单 12.2 中的下面一条语句:
int StringBad::num_strings = 0;
这条语句将静态成员num_strings
的值初始化为零。请注意,不能在类声明中初始化静态成员变量,这是因为声明描述了如何分配内存,但并不分配内存。您可以使用这种格式来创建对象,从而分配和初始化内存。对于静态类成员,可以在类声明之外使用单独的语句来进行初始化,这是因为静态类成员是单独存储的,而不是对象的组成部分。请注意,初始化语句指出了类型,并使用了作用域运算符,但没有使用关键字static
。
初始化是在方法文件中,而不是在类声明文件中进行的,这是因为类声明位于头文件中,程序可能将头文件包括在其他几个文件中。如果在头文件中进行初始化,将出现多个初始化语句副本,从而引发错误。
对于不能在类声明中初始化静态数据成员的一种例外情况(见第 10 章)是,静态数据成员为整型或枚
举型const
。
注意:静态数据成员在类声明中声明,在包含类方法的文件中初始化。初始化时使用作用域运算符来指出静态成员所属的类。但如果静态成员是整型或枚举型const
,则可以在类声明中初始化。
接下来,注意到每个构造函数都包含表达式num_strings++
,这确保程序每创建一个新对象,共享变量num_strings
的值都将增加1
,从而记录String
对象的总数。另外,析构函数包含表达式--num_strings
,因此String
类也将跟踪对象被删除的情况,从而使num_string
成员的值是最新的。
现在来看程序清单 12.2 中的第一个构造函数,它使用一个常规 C 字符串来初始化String
对象:
StringBad::StringBad(const char * s)
{
len = std::strlen(s); // set size
str = new char[len+l]; // allot storage
std::strcpy(str, s); // initialize pointer
num_strings++; // set object count
cout << num_strings <<": \"" << str
<< "\" object created\n"; // For Your Information
}
类成员str
本是一个指针,因此构造函数必须提供内存来存储字符串。初始化对象时,可以给构造函数传递一个字符串指针:
String boston("Boston");
构造函数必须分配足够的内存来存储字符串,然后将字符串复制到内存中。下面介绍其中的每一个步骤。
首先,使用strlen()
函数计算字符串的长度,并对len
成员进行初始化。接着,使用new
分配足够的空间来保存字符串,然后将新内存的地址赋给str
成员。(strler()
返回字符串长度,但不包括末尾的空字符,因此构造函数将len
加1
,使分配的内存能够存储包含空字符的字符串。)
接着,构造函数使用strcpy()
将传递的字符串复制到新的内存中,并更新对象计数。最后,构造函数显示当前的对象数目和当前对象中存储的字符串,以助于掌握程序运行情况。稍后故意使Stringbad
出错时,该特性将派上用场。
要理解这种方法,必须知道字符串并不保存在对象中。字符串单独保存在堆内存中,对象仅保存了指
出到哪里去查找字符串的信息。
不能这样做:
str = s; // not the way to go
这只保存了地址,而没有创建字符串副本。
默认构造函数与此相似,但它提供了一个默认字符串:“C++”。
析构函数中包含了示例中对处理类来说最重要的东西:
StringBad::~StringBad() // necessary destructor
{
cout << "\"" << str << "\" object deleted, "; // FYI
--num_strings; // required
cout << num_strings << " left\n"; // FYI
delete [] str; // required
}
该析构函数首先指出自己何时被调用。这部分包含了丰富的信息,但并不是必不可少的。然而,delete
语句却是至关重要的。str
成员指向new
分配的内存。当StringBad
对象过期时,str
指针也将过期。但str
指向的内存仍被分配,除非使用delete
将其释放。删除对象可以释放对象本身占用的内存,但并不能自动释放属于对象成员的指针指向的内存。因此,必须使用析构函数。在析构函数中使用delete
语句可确保对象过期时,由构造函数使用new
分配的内存被释放。
警告:在构造函数中使用new
来分配内存时,必须在相应的析构函数中使用delete
来释放内存。如果使用new[]
(包括中括号)来分配内存,则应使用delete[]
(包括中括号)来释放内存。
程序清单 12.3 是从处于开发阶段的 Daily Vegetable 程序中摘录出来的,演示了StringBad
的构造函数和析构函数何时运行及如何运行。该程序将对象声明放在一个内部代码块中,因为析构函数将在定义对象的代码块执行完毕时调用。如果不这样做,析构函数将在main()
函数执行完毕时调导致您无法在执行窗口关闭前看到析构函数显示的消息。请务必将程序清单 12.2 和程序清单 12.3 一起编译。
程序清单 12.3 vegnews.cpp
// vegnews.cpp -- using new and delete with classes
// compile with strngbad.cpp
#include <iostream>
using std::cout;
#include "strngbad.h"
void callme1(StringBad &); // pass by reference
void callme2(StringBad); // pass by value
int main()
{
using std::endl;
{
cout << "Starting an inner block.\n";
StringBad headline1("Celery Stalks at Midnight");
StringBad headline2("Lettuce Prey");
StringBad sports("Spinach Leaves Bow1 for Dollars");
cout << "headline1: " << headline1 << endl;
cout << "headline2: " << headline2 << endl;
cout << "Sports: " << sports << endl;
callme1(headline1);
cout << "headline1: " << headline1 << endl;
callme2(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 pass by value:\n";
cout << " \"" << sb << "\"\n";
}
// 下面是使用 Borland C++ 命令行编译器进行编译时,该程序的输出:
Starting an inner block.
1: "Celery Stalks at Midnight" object created
2: "Lettuce Prey" object created
3: "Spinach Leaves Bow1 for Dollars" object created
headline1: Celery Stalks at Midnight
headline2: Lettuce Prey
Sports: Spinach Leaves Bow1 for Dollars
String passed by reference:
"Celery Stalks at Midnight"
headline1: Celery Stalks at Midnight
String passed by value:
"Lettuce-Prey"
"Lettuce Prey" object deleted, 2 left
headline2: Dǔ°
Initialize one object to another:
sailor: Spinach Leaves Bowl for Dollars
Assign one object to another:
3: "C++" default object created
knot:Celery Stalks at Midnight
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()
注意:StringBad
的第一个版本有许多故意留下的缺陷,这些缺陷使得输出是不确定的。例如:有些编译器无法编译它。虽然输出的具体内容有所差别,但基本问题和解决方法(稍后将介绍)是相同的。
输出中出现的各种非标准字符随系统而异,这些字符表明,StringBad类名副其实(是一个糟糕的类)。另一种迹象是对象计数为负。在使用较新的编译器和操作系统的机器上运行时,该程序通常会在显示有关还有 -1 个对象的信息之前中断,而有些这样的机器将报告通用保护错误(GPF)。GPF 表明程序试图访问禁止它访问的内存单元,这是另一种糟糕的信号。
程序说明 12.3
程序清单 12.3 中的程序开始时还是正常的,但逐渐变得异常,最终导致了灾难性结果。首先来看正常的部分。构造函数指出自己创建了 3 个StringBad
对象,并为这些对象进行了编号,然后程序使用重载运算符>>
列出了这些对象:
1: "Celery Stalks at Midnight" object created
2: "Lettuce Prey" object created
3: "Spinach Leaves Bow1 for Dollars" object created
headline1: Celery Stalks at Midnight
headline2: Lettuce Prey
Sports: Spinach Leaves Bow1 for Dollars
然后,程序将headline1
传递给callme1()
函数,并在调用后重新显示headline1
。代码如下:
callme1(headline1);
cout << "headline1: " << headline1 << endl;
下面是运行结果:
String passed by reference:
"Celery Stalks at Midnight"
headline1: Celery Stalks at Midnight
这部分代码看起来也正常。
但随后程序执行了如下代码:
callme2(headline2);
cout <> "headline2: " << headline2 << endl;
这里,callme2()
按值(而不是按引用)传递headline2
,结果表明这是一个严重的问题!
String pass by value:
"Lettuce Prey"
"Lettuce Prey" object deleted, 2 left
headline2: Dǔ°
首先,将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
)已经无法识别。这些字符串在被删除之前,有些操作将它们搞乱了。另外,计数也很奇怪,如何会余下-2个对象呢?
实际上,计数异常是一条线索。因为每个对象被构造和析构一次,因此调用构造函数的次数应当与析
构函数的调用次数相同。对象计数(num_strings)递减的次数比递增次数多2
,这表明使用了不将num_string
递增的构造函数创建了两个对象。类定义声明并定义了两个构造函数(这两个构造函数都使num_strings
递增),但结果表明程序使用了3
个构造函数。例如,请看下面的代码:
StringBad sailor = sports;
这使用的是哪个构造函数呢?不是默认构造函数,也不是参数为const char *
的构造函数。记住,这种形式的初始化等效于下面的语句:
StringBad sailor = StringBad(sports); // constructor using sports
因为sports
的类型为StringBad
,因此相应的构造函数原型应该如下:
StringBad(const StringBad &);
当您使用一个对象来初始化另一个对象时,编译器将自动生成上述构造函数(称为复制构造函数,因
为它创建对象的一个副本)。自动生成的构造函数不知道需要更新静态变量num_strings
,因此会将计数方案搞乱。实际上,这个例子说明的所有问题都是由编译器自动生成的成员函数引起的,下面介绍这个主题。
特殊成员函数
StringBad
类的问题是由特殊成员函数引起的。这些成员函数是自动定义的,就StringBad
而言,这些函数的行为与类设计不符。具体地说,C++ 自动提供了下面这些成员函数:
- 默认构造函数,如果没有定义构造函数;
- 默认析构函数,如果没有定义;
- 复制构造函数,如果没有定义;
- 赋值运算符,如果没有定义;
- 地址运算符,如果没有定义。
更准确地说,编译器将生成上述最后三个函数的定义 —— 如果程序使用对象的方式要求这样做。例如,如果您将一个对象赋给另一个对象,编译器将提供赋值运算符的定义。
结果表明,StringBad
类中的问题是由隐式复制构造函数和隐式赋值运算符引起的。
隐式地址运算符返回调用对象的地址(即this
指针的值)。这与我们的初衷是一致的,在此不详细讨论该成员函数。默认析构函数不执行任何操作,因此这里也不讨论,但需要指出的是,这个类已经提供默认构造函数。至于其他成员函数还需要进一步讨论。
C++11 提供了另外两个特殊成员函数:移动构造函数(move constructor)和移动赋值运算符(move assignment operator),这将在第 l8 章讨论。
默认的构造函数
如果没有提供任何构造函数,C++ 将创建默认构造函数。例如,假如定义了一个Klunk
类,但没有提
供任何构造函数,则编译器将提供下述默认构造函数:
Klunk::Klunk(){} // implicit default constructor
也就是说,编译器将提供一个不接受任何参数,也不执行任何操作的构造函数(默认的默认构造函数),这是因为创建对象时总是会调用构造函数:
Klunk lunk; // invokes default constructor
默认构造函数使Lunk
类似于一个常规的自动变量,也就是说,它的值在初始化时是未知的。
如果定义了构造函数,C++ 将不会定义默认构造函数。如果希望在创建对象时不显式地对它进行初始
化,则必须显式地定义默认构造函数。这种构造函数没有任何参数,但可以使用它来设置特定的值:
Klunk::Klunk () // explicit default constructor
{
klunk_ct = 0;
}
带参数的构造函数也可以是默认构造函数,只要所有参数都有默认值。例如,Klunk
类可以包含下述
内联构造函数:
Klunk(int n = 0) {klunk_ct = n; }
但只能有一个默认构造函数。也就是说,不能这样做:
Klunk() { klunk_ct = 0; } // constructor #1
Klunk (int n = 0) { klunk_ct = n; } // ambiguous constructor #2
这为何有二义性呢?请看下面两个声明:
Klunk kar(10); // clearly matches Klunt(int n)
Klunk bus; // could match either construetor
第二个声明既与构造函数 #1(没有参数)匹配,也与构造函数 #2(使用默认参数0
)匹配。这将导致编译器发出一条错误消息。
复制构造函数
复制构造函数用于将二个对象复制到新创建的对象中。也就是说,它用于初始化过程中(包括按值传
递参数),而不是常规的赋值过程中。类的复制构造函数原型通常如下:
Class_name (const class_name &);
它接受一个指向类对象的常量引用作为参数。例如:String
类的复制构造函数的原型如下:
StringBad(const StringBad &);
对于复制构造函数,需要知道两点:何时调用和有何功能。
何时调用复制构造函数
新建一个对象并将其初始化为同类现有对象时,复制构造函数都将被调用。这在很多情况下都可能发生,最常见的情况是将新对象显式地初始化为现有的对象。例如,假设motto
是个StringBad
对象,则下面 4 种声明都将调用复制构造函数:
StringBad ditto(motto); // calls StringBad(const StringBad &)
StringBad metoo = motto; // calls StringBad(const StringBad &)
StringBad also = StringBad(motto);
// calls StringBad(const StringBad &)
stringBad * pStringBad = new StringBad(motto);
// calls StringBad(const StringBad &)
其中中间的 2 种声明可能会使用复制构造函数直接创建metoo
和also
,也可能使用复制构造函数生成一个临时对象,然后将临时对象的内容赋给metoo
和also
,这取决于具体的实现。最后一种声明使用motto
初始化一个匿名对象,并将新对象的地址赋给pStringBad
指针。
每当程序生成了对象副本时,编译器都将使用复制构造函数。具体地说,当函数按值传递对象(如程序清单 12.3 中的callme2()
)或函数返回对象时,都将使用复制构造函数。记住,按值传递意味着创建原始变量的个副本。编译器生成临时对象时,也将使用复制构造函数。例如:将 3 个Vector
对象相加时,编译器可能生成临时的Vector
对象来保存中间结果。何时生成临时对象随编译器而异,但无论是哪种编译器,当按值传递和返回对象时,都将调用复制构造函数。具体地说,程序清单 12.3 中的函数调用将调用下面的复制构造函数:
callme2(headline2);
程序使用复制构造函数初始化sb
,callme2()
函数的StringBad
型形参。
由于按值传递对象将调用复制构造函数,因此应该按引用传递对象。这样可以节省调用构