- 内容提要
- 前言
- 第 1 章 预备知识
- 第 2 章 开始学习 C++
- 第 3 章 处理数据
- 第 4 章 复合类型
- 第 5 章 循环和关系表达式
- 第 6 章 分支语句和逻辑运算符
- 第 7 章 函数——C++的编程模块
- 第 8 章 函数探幽
- 第 9 章 内存模型和名称空间
- 第 10 章 对象和类
- 第 11 章 使用类
- 第 12 章 类和动态内存分配
- 第 13 章 类继承
- 第 14 章 C++中的代码重用
- 第 15 章 友元、异常和其他
- 第 16 章 string 类和标准模板库
- 第 17 章 输入、输出和文件
- 第 18 章 探讨 C++新标准
- 附录 A 计数系统
- 附录 B C++保留字
- 附录 C ASCII 字符集
- 附录 D 运算符优先级
- 附录 E 其他运算符
- 附录 F 模板类 string
- 附录 G 标准模板库方法和函数
- 附录 H 精选读物和网上资源
- 附录 I 转换为 ISO 标准 C++
- 附录 J 复习题答案
12.1 动态内存和类
您希望下个月的早餐、午餐和晚餐吃些什么?在第三天的晚餐喝多少盎司的牛奶?在第 15 天的早餐中需要在谷类食品添加多少葡萄干?如果您与大多数人一样,就会等到进餐时再做决定。C++在分配内存时采取的部分策略与此相同,让程序在运行时决定内存分配,而不是在编译时决定。这样,可根据程序的需要,而不是根据一系列严格的存储类型规则来使用内存。C++使用 new 和 delete 运算符来动态控制内存。遗憾的是,在类中使用这些运算符将导致许多新的编程问题。在这种情况下,析构函数将是必不可少的,而不再是可有可无的。有时候,还必须重载赋值运算符,以保证程序正常运行。下面来看一看这些问题。
12.1.1 复习示例和静态类成员
我们已经有一段时间没有使用 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
为何将这个类命名为 StringBad 呢?这旨在告诉您,这是一个不太完整的类。它是使用动态内存分配来开发类的第一个阶段,正确地完成了一些显而易见的工作,例如,在构造函数和析构函数中正确地使用了 new 和 delete。这个类并没有什么错误,但忽略了一些不明显却必不可少的东西。通过了解这个类存在的问题,将有助于您理解并记住后面将其转换为功能更强大的 String 类时,所做的不明显的修改。
对这个声明,需要注意的有两点。首先,它使用 char 指针(而不是 char 数组)来表示姓名。这意味着类声明没有为字符串本身分配存储空间,而是在构造函数中使用 new 来为字符串分配空间。这避免了在类声明中预先定义字符串的长度。
其次,将 num_strings 成员声明为静态存储类。静态类成员有一个特点:无论创建了多少对象,程序都只创建一个静态类变量副本。也就是说,类的所有对象共享同一个静态成员,就像家中的电话可供全体家庭成员共享一样。假设创建了 10 个 StringBad 对象,将有 10 个 str 成员和 10 个 len 成员,但只有一个共享的 num_strings 成员(参见图 12.1)。这对于所有类对象都具有相同值的类私有数据是非常方便的。例如,num_strings 成员可以记录所创建的对象数目。
随便说一句,程序清单 21.1 使用 num_strings 成员,只是为了方便说明静态数据成员,并指出潜在的编程问题,字符串类通常并不需要这样的成员。
来看一看程序清单 12.2 中的类方法实现,它演示了如何使用指针和静态成员。
图 12.1 静态数据成员
程序清单 12.2 strngbad.cpp
首先,请注意程序清单 12.2 中的下面一条语句:
这条语句将静态成员 num_strings 的值初始化为零。请注意,不能在类声明中初始化静态成员变量,这是因为声明描述了如何分配内存,但并不分配内存。您可以使用这种格式来创建对象,从而分配和初始化内存。对于静态类成员,可以在类声明之外使用单独的语句来进行初始化,这是因为静态类成员是单独存储的,而不是对象的组成部分。请注意,初始化语句指出了类型,并使用了作用域运算符,但没有使用关键字 static。
初始化是在方法文件中,而不是在类声明文件中进行的,这是因为类声明位于头文件中,程序可能将头文件包括在其他几个文件中。如果在头文件中进行初始化,将出现多个初始化语句副本,从而引发错误。
对于不能在类声明中初始化静态数据成员的一种例外情况(见第 10 章)是,静态数据成员为整型或枚举型 const。
注意:
静态数据成员在类声明中声明,在包含类方法的文件中初始化。初始化时使用作用域运算符来指出静态成员所属的类。但如果静态成员是整型或枚举型 const,则可以在类声明中初始化。
接下来,注意到每个构造函数都包含表达式 num_strings++,这确保程序每创建一个新对象,共享变量 num_strings 的值都将增加 1,从而记录 String 对象的总数。另外,析构函数包含表达式--num_strings,因此 String 类也将跟踪对象被删除的情况,从而使 num_string 成员的值是最新的。
现在来看程序清单 12.2 中的第一个构造函数,它使用一个常规 C 字符串来初始化 String 对象:
类成员 str 是一个指针,因此构造函数必须提供内存来存储字符串。初始化对象时,可以给构造函数传递一个字符串指针:
构造函数必须分配足够的内存来存储字符串,然后将字符串复制到内存中。下面介绍其中的每一个步骤。
首先,使用 strlen() 函数计算字符串的长度,并对 len 成员进行初始化。接着,使用 new 分配足够的空间来保存字符串,然后将新内存的地址赋给 str 成员。(strlen() 返回字符串长度,但不包括末尾的空字符,因此构造函数将 len 加 1,使分配的内存能够存储包含空字符的字符串。)
接着,构造函数使用 strcpy() 将传递的字符串复制到新的内存中,并更新对象计数。最后,构造函数显示当前的对象数目和当前对象中存储的字符串,以助于掌握程序运行情况。稍后故意使 Stringbad 出错时,该特性将派上用场。
要理解这种方法,必须知道字符串并不保存在对象中。字符串单独保存在堆内存中,对象仅保存了指出到哪里去查找字符串的信息。
不能这样做:
这只保存了地址,而没有创建字符串副本。
默认构造函数与此相似,但它提供了一个默认字符串:“C++”。
析构函数中包含了示例中对处理类来说最重要的东西:
该析构函数首先指出自己何时被调用。这部分包含了丰富的信息,但并不是必不可少的。然而,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
注意:
StringBad 的第一个版本有许多故意留下的缺陷,这些缺陷使得输出是不确定的。例如,有些编译器无法编译它。虽然输出的具体内容有所差别,但基本问题和解决方法(稍后将介绍)是相同的。
下面是使用 Borland C++5.5 命令行编译器进行编译时,该程序的输出:
输出中出现的各种非标准字符随系统而异,这些字符表明,StringBad 类名副其实(是一个糟糕的类)。另一种迹象是对象计数为负。在使用较新的编译器和操作系统的机器上运行时,该程序通常会在显示有关还有−1 个对象的信息之前中断,而有些这样的机器将报告通用保护错误(GPF)。GPF 表明程序试图访问禁止它访问的内存单元,这是另一种糟糕的信号。
程序说明
程序清单 12.3 中的程序开始时还是正常的,但逐渐变得异常,最终导致了灾难性结果。首先来看正常的部分。构造函数指出自己创建了 3 个 StringBad 对象,并为这些对象进行了编号,然后程序使用重载运算符>>列出了这些对象:
然后,程序将 headline1 传递给 callme1( ) 函数,并在调用后重新显示 headline1。代码如下:
下面是运行结果:
这部分代码看起来也正常。
但随后程序执行了如下代码:
这里,callme2() 按值(而不是按引用)传递 headline2,结果表明这是一个严重的问题!
首先,将 headline2 作为函数参数来传递从而导致析构函数被调用。其次,虽然按值传递可以防止原始参数被修改,但实际上函数已使原始字符串无法识别,导致显示一些非标准字符(显示的文本取决于内存中包含的内容)。
请看输出结果,在为每一个创建的对象自动调用析构函数时,情况更糟糕:
因为自动存储对象被删除的顺序与创建顺序相反,所以最先删除的 3 个对象是 knots、sailor 和 sport。删除 knots 和 sailor 时是正常的,但在删除 sport 时,Dollars 变成了 Doll8。对于 sport,程序只使用它来初始化 sailor,但这种操作修改了 sport。最后被删除的两个对象(headline2 和 headline1)已经无法识别。这些字符串在被删除之前,有些操作将它们搞乱了。另外,计数也很奇怪,如何会余下−2 个对象呢?
实际上,计数异常是一条线索。因为每个对象被构造和析构一次,因此调用构造函数的次数应当与析构函数的调用次数相同。对象计数(num_strings)递减的次数比递增次数多 2,这表明使用了不将 num_string 递增的构造函数创建了两个对象。类定义声明并定义了两个构造函数(这两个构造函数都使 num_string 递增),但结果表明程序使用了 3 个构造函数。例如,请看下面的代码:
这使用的是哪个构造函数呢?不是默认构造函数,也不是参数为 const char *的构造函数。记住,这种形式的初始化等效于下面的语句:
因为 sports 的类型为 StringBad,因此相应的构造函数原型应该如下:
当您使用一个对象来初始化另一个对象时,编译器将自动生成上述构造函数(称为复制构造函数,因为它创建对象的一个副本)。自动生成的构造函数不知道需要更新静态变量 num_string,因此会将计数方案搞乱。实际上,这个例子说明的所有问题都是由编译器自动生成的成员函数引起的,下面介绍这个主题。
12.1.2 特殊成员函数
StringBad 类的问题是由特殊成员函数引起的。这些成员函数是自动定义的,就 StringBad 而言,这些函数的行为与类设计不符。具体地说,C++自动提供了下面这些成员函数:
- 默认构造函数,如果没有定义构造函数;
- 默认析构函数,如果没有定义;
- 复制构造函数,如果没有定义;
- 赋值运算符,如果没有定义;
- 地址运算符,如果没有定义。
更准确地说,编译器将生成上述最后三个函数的定义——如果程序使用对象的方式要求这样做。例如,如果您将一个对象赋给另一个对象,编译器将提供赋值运算符的定义。
结果表明,StringBad 类中的问题是由隐式复制构造函数和隐式赋值运算符引起的。
隐式地址运算符返回调用对象的地址(即 this 指针的值)。这与我们的初衷是一致的,在此不详细讨论该成员函数。默认析构函数不执行任何操作,因此这里也不讨论,但需要指出的是,这个类已经提供默认构造函数。至于其他成员函数还需要进一步讨论。
C++11 提供了另外两个特殊成员函数:移动构造函数(move constructor)和移动赋值运算符(move assignment operator),这将在第 18 章讨论。
1.默认构造函数
如果没有提供任何构造函数,C++将创建默认构造函数。例如,假如定义了一个 Klunk 类,但没有提供任何构造函数,则编译器将提供下述默认构造函数:
也就是说,编译器将提供一个不接受任何参数,也不执行任何操作的构造函数(默认的默认构造函数),这是因为创建对象时总是会调用构造函数:
默认构造函数使 Lunk 类似于一个常规的自动变量,也就是说,它的值在初始化时是未知的。
如果定义了构造函数,C++将不会定义默认构造函数。如果希望在创建对象时不显式地对它进行初始化,则必须显式地定义默认构造函数。这种构造函数没有任何参数,但可以使用它来设置特定的值:
带参数的构造函数也可以是默认构造函数,只要所有参数都有默认值。例如,Klunk 类可以包含下述内联构造函数:
但只能有一个默认构造函数。也就是说,不能这样做:
这为何有二义性呢?请看下面两个声明:
第二个声明既与构造函数#1(没有参数)匹配,也与构造函数#2(使用默认参数 0)匹配。这将导致编译器发出一条错误消息。
2.复制构造函数
复制构造函数用于将一个对象复制到新创建的对象中。也就是说,它用于初始化过程中(包括按值传递参数),而不是常规的赋值过程中。类的复制构造函数原型通常如下:
它接受一个指向类对象的常量引用作为参数。例如,String 类的复制构造函数的原型如下:
对于复制构造函数,需要知道两点:何时调用和有何功能。
3.何时调用复制构造函数
新建一个对象并将其初始化为同类现有对象时,复制构造函数都将被调用。这在很多情况下都可能发生,最常见的情况是将新对象显式地初始化为现有的对象。例如,假设 motto 是一个 StringBad 对象,则下面 4 种声明都将调用复制构造函数:
其中中间的 2 种声明可能会使用复制构造函数直接创建 metoo 和 also,也可能使用复制构造函数生成一个临时对象,然后将临时对象的内容赋给 metoo 和 also,这取决于具体的实现。最后一种声明使用 motto 初始化一个匿名对象,并将新对象的地址赋给 pstring 指针。
每当程序生成了对象副本时,编译器都将使用复制构造函数。具体地说,当函数按值传递对象(如程序清单 12.3 中的 callme2())或函数返回对象时,都将使用复制构造函数。记住,按值传递意味着创建原始变量的一个副本。编译器生成临时对象时,也将使用复制构造函数。例如,将 3 个 Vector 对象相加时,编译器可能生成临时的 Vector 对象来保存中间结果。何时生成临时对象随编译器而异,但无论是哪种编译器,当按值传递和返回对象时,都将调用复制构造函数。具体地说,程序清单 12.3 中的函数调用将调用下面的复制构造函数:
程序使用复制构造函数初始化 sb——callme2() 函数的 StringBad 型形参。
由于按值传递对象将调用复制构造函数,因此应该按引用传递对象。这样可以节省调用构造函数的时间以及存储新对象的空间。
4.默认的复制构造函数的功能
默认的复制构造函数逐个复制非静态成员(成员复制也称为浅复制),复制的是成员的值。在程序清单 12.3 中,下述语句:
与下面的代码等效(只是由于私有成员是无法访问的,因此这些代码不能通过编译):
如果成员本身就是类对象,则将使用这个类的复制构造函数来复制成员对象。静态函数(如 num_strings)不受影响,因为它们属于整个类,而不是各个对象。图 12.2 说明了隐式复制构造函数执行的操作。
图 12.2 逐个复制成员
12.1.3 回到 Stringbad:复制构造函数的哪里出了问题
现在介绍程序清单 12.3 的两个异常之处(假设输出为该程序清单后面列出的)。首先,程序的输出表明,析构函数的调用次数比构造函数的调用次数多 2,原因可能是程序确实使用默认的复制构造函数另外创建了两个对象。当 callme2() 被调用时,复制构造函数被用来初始化 callme2() 的形参,还被用来将对象 sailor 初始化为对象 sports。默认的复制构造函数不说明其行为,因此它不指出创建过程,也不增加计数器 num_strings 的值。但析构函数更新了计数,并且在任何对象过期时都将被调用,而不管对象是如何被创建的。这是一个问题,因为这意味着程序无法准确地记录对象计数。解决办法是提供一个对计数进行更新的显式复制构造函数:
提示:
如果类中包含这样的静态数据成员,即其值将在新对象被创建时发生变化,则应该提供一个显式复制构造函数来处理计数问题。
第二个异常之处更微妙,也更危险,其症状之一是字符串内容出现乱码:
原因在于隐式复制构造函数是按值进行复制的。例如,对于程序清单 12.3,隐式复制构造函数的功能相当于:
这里复制的并不是字符串,而是一个指向字符串的指针。也就是说,将 sailor 初始化为 sports 后,得到的是两个指向同一个字符串的指针。当 operator <<() 函数使用指针来显示字符串时,这并不会出现问题。但当析构函数被调用时,这将引发问题。析构函数 StringBad 释放 str 指针指向的内存,因此释放 sailor 的效果如下:
sailor.str 指针指向“Spinach Leaves Bowl for Dollars”,因为它被赋值为 sports.str,而 sports.str 指向的正是上述字符串。所以 delete 语句将释放字符串“Spinach Leaves Bowl for Dollars”占用的内存。
然后,释放 sports 的效果如下:
sports.str 指向的内存已经被 sailor 的析构函数释放,这将导致不确定的、可能有害的后果。程序清单 12.3 中的程序生成受损的字符串,这通常是内存管理不善的表现。
另一个症状是,试图释放内存两次可能导致程序异常终止。例如,Microsoft Visual C++ 2010(调试模式)显示一个错误消息窗口,指出“Debug Assertion Failed!”;而在 Linux 中,g++ 4.4.1 显示消息“double free or corruption”并终止程序运行。其他系统可能提供不同的消息,甚至不提供任何消息,但程序中的错误是相同的。
1.定义一个显式复制构造函数以解决问题
解决类设计中这种问题的方法是进行深度复制(deep copy)。也就是说,复制构造函数应当复制字符串并将副本的地址赋给 str 成员,而不仅仅是复制字符串地址。这样每个对象都有自己的字符串,而不是引用另一个对象的字符串。调用析构函数时都将释放不同的字符串,而不会试图去释放已经被释放的字符串。可以这样编写 String 的复制构造函数:
必须定义复制构造函数的原因在于,一些类成员是使用 new 初始化的、指向数据的指针,而不是数据本身。图 12.3 说明了深度复制。
图 12.3 深度复制
警告:
如果类中包含了使用 new 初始化的指针成员,应当定义一个复制构造函数,以复制指向的数据,而不是指针,这被称为深度复制。复制的另一种形式(成员复制或浅复制)只是复制指针值。浅复制仅浅浅地复制指针信息,而不会深入“挖掘”以复制指针引用的结构。
12.1.4 Stringbad 的其他问题:赋值运算符
并不是程序清单 12.3 的所有问题都可以归咎于默认的复制构造函数,还需要看一看默认的赋值运算符。ANSI C 允许结构赋值,而 C++允许类对象赋值,这是通过自动为类重载赋值运算符实现的。这种运算符的原型如下:
它接受并返回一个指向类对象的引用。例如,StringBad 类的赋值运算符的原型如下:
1.赋值运算符的功能以及何时使用它
将已有的对象赋给另一个对象时,将使用重载的赋值运算符:
初始化对象时,并不一定会使用赋值运算符:
这里,metoo 是一个新创建的对象,被初始化为 knot 的值,因此使用复制构造函数。然而,正如前面指出的,实现时也可能分两步来处理这条语句:使用复制构造函数创建一个临时对象,然后通过赋值将临时对象的值复制到新对象中。这就是说,初始化总是会调用复制构造函数,而使用=运算符时也可能调用赋值运算符。
与复制构造函数相似,赋值运算符的隐式实现也对成员进行逐个复制。如果成员本身就是类对象,则程序将使用为这个类定义的赋值运算符来复制该成员,但静态数据成员不受影响。
2.赋值的问题出在哪里
程序清单 12.3 将 headline1 赋给 knot:
为 knot 调用析构函数时,将显示下面的消息:
为 Headline1 调用析构函数时,显示如下消息(有些实现方式在此之前就异常终止了):
出现的问题与隐式复制构造函数相同:数据受损。这也是成员复制的问题,即导致 headline1.str 和 knot.str 指向相同的地址。因此,当对 knot 调用析构函数时,将删除字符串“Celery Stalks at Midnight”;当对 headline1 调用析构函数时,将试图删除前面已经删除的字符串。正如前面指出的,试图删除已经删除的数据导致的结果是不确定的,因此可能改变内存中的内容,导致程序异常终止。要指出的是,如果操作结果是不确定的,则执行的操作将随编译器而异,包括显示独立声明(Declaration of Independence)或释放隐藏文件占用的硬盘空间。当然,编译器开发人员通常不会花时间添加这样的行为。
3.解决赋值的问题
对于由于默认赋值运算符不合适而导致的问题,解决办法是提供赋值运算符(进行深度复制)定义。其实现与复制构造函数相似,但也有一些差别。
- 由于目标对象可能引用了以前分配的数据,所以函数应使用 delete[ ]来释放这些数据。
- 函数应当避免将对象赋给自身;否则,给对象重新赋值前,释放内存操作可能删除对象的内容。
- 函数返回一个指向调用对象的引用。
通过返回一个对象,函数可以像常规赋值操作那样,连续进行赋值,即如果 S0、S1 和 S2 都是 StringBad 对象,则可以编写这样的代码:
使用函数表示法时,上述代码为:
因此,S1.operator=(S2)的返回值是函数 S0.operator=() 的参数。
因为返回值是一个指向 StringBad 对象的引用,因此参数类型是正确的。
下面的代码说明了如何为 StringBad 类编写赋值运算符:
代码首先检查自我复制,这是通过查看赋值运算符右边的地址(&s)是否与接收对象(this)的地址相同来完成的。如果相同,程序将返回*this,然后结束。第 10 章介绍过,赋值运算符是只能由类成员函数重载的运算符之一。
如果地址不同,函数将释放 str 指向的内存,这是因为稍后将把一个新字符串的地址赋给 str。如果不首先使用 delete 运算符,则上述字符串将保留在内存中。由于程序中不再包含指向该字符串的指针,因此这些内存被浪费掉。
接下来的操作与复制构造函数相似,即为新字符串分配足够的内存空间,然后将赋值运算符右边的对象中的字符串复制到新的内存单元中。
上述操作完成后,程序返回*this 并结束。
赋值操作并不创建新的对象,因此不需要调整静态数据成员 num_strings 的值。
将前面介绍的复制构造函数和赋值运算符添加到 StringBad 类中后,所有的问题都解决了。例如,下面是在完成上述修改后,程序输出的最后几行:
现在,对象计数是正确的,字符串也没有被损坏。
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论