12.1 动态内存和类

当类需要使用 new 在自由存储区(堆)上分配内存时,情况会变得比之前我们看到的类(如 Stock, Time, Vector)更复杂。这些类的数据成员本身(如 int, double, std::string)要么大小固定,要么像 std::string 那样自己管理内存。

但是,如果你的类直接使用指针来管理通过 new 分配的内存(例如,自定义一个字符串类来管理 char* 指针指向的内存),那么 C++ 编译器自动生成的某些默认行为(特别是对象复制和赋值)可能会导致严重的问题,如内存泄漏和程序崩溃。本章将探讨这些问题以及如何通过定义特殊的成员函数来解决它们。

12.1.1 复习示例和静态类成员

让我们从一个简单的、故意设计得有问题的字符串类 StringBad 开始,它使用 char* 指针来指向动态分配的内存。

StringBad 类定义 (stringbad.h)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#ifndef STRINGBAD_H_
#define STRINGBAD_H_
#include <iostream>

class StringBad {
private:
char * str; // 指向字符串的指针
int len; // 字符串长度
// 静态数据成员: 用于跟踪对象数量
static int num_strings;

public:
// 构造函数
StringBad(const char * s); // 从 C 字符串构造
StringBad(); // 默认构造函数
// 析构函数
~StringBad(); // 非常重要!

// 友元函数: 重载 <<
friend std::ostream & operator<<(std::ostream & os, const StringBad & st);
};
#endif // STRINGBAD_H_

StringBad 类实现 (stringbad.cpp)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#include <cstring> // 为了使用 strlen, strcpy
#include "stringbad.h"

// 初始化静态类成员 num_strings
// 注意:静态数据成员必须在类定义外部进行定义和初始化
int StringBad::num_strings = 0; // 初始化为 0

// 构造函数: 从 C 字符串构造
StringBad::StringBad(const char * s) {
len = std::strlen(s); // 获取长度
str = new char[len + 1]; // 分配内存 (+1 为了存储末尾的 '\0')
std::strcpy(str, s); // 复制字符串到新内存
num_strings++; // 对象计数增加
std::cout << num_strings << ": \"" << str << "\" object created\n"; // 调试信息
}

// 默认构造函数
StringBad::StringBad() {
len = 4;
str = new char[4];
std::strcpy(str, "C++"); // 默认值
num_strings++;
std::cout << num_strings << ": \"" << str << "\" default object created\n"; // 调试信息
}

// 析构函数
StringBad::~StringBad() {
std::cout << "\"" << str << "\" object deleted, "; // 调试信息
--num_strings; // 对象计数减少
std::cout << num_strings << " left\n"; // 调试信息
delete [] str; // 释放内存!
}

// 友元函数: 重载 <<
std::ostream & operator<<(std::ostream & os, const StringBad & st) {
os << st.str;
return os;
}

这个类看起来似乎能工作:它在构造时分配内存,在析构时释放内存。但是,它缺少一些关键的东西,我们稍后会看到。

静态类成员 (Static Class Members)

StringBad 中,我们引入了一个 static int num_strings;。这是一个静态数据成员

  • 共享性: 静态数据成员不属于任何单个对象,而是被类的所有对象共享。无论创建多少个 StringBad 对象,num_strings 只有一个副本。
  • 生命周期: 静态数据成员在程序启动时创建,在程序结束时销毁,其生命周期与程序的运行时间相同,即使没有创建任何类对象,它也存在。
  • 初始化: 静态数据成员必须在类定义之外进行初始化(通常在对应的 .cpp 文件中),如 int StringBad::num_strings = 0;。初始化时需要使用类名和作用域解析运算符 ::,并且不再需要 static 关键字。
    • 例外:const 的整型或枚举类型的静态成员(以及 C++11 中的 constexpr 静态成员)可以在类定义内部初始化。
  • 用途: 常用于跟踪类的实例数量、共享类范围内的常量或标志等。

除了静态数据成员,还有**静态成员函数 (Static Member Functions)**。

  • 声明: 在函数声明前加上 static 关键字。
  • this 指针: 静态成员函数不与任何特定对象关联,因此它们没有 this 指针
  • 访问限制: 它们只能直接访问类的静态数据成员或其他静态成员函数。它们不能直接访问非静态成员(数据或函数),因为非静态成员需要通过对象(this 指针)来访问。
  • 调用: 可以通过类名和作用域解析运算符调用(ClassName::static_func()),也可以通过类的对象调用(object.static_func()),但推荐使用前者,因为它更清晰地表明了函数的静态属性。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 示例:添加一个静态成员函数到 StringBad
// stringbad.h
class StringBad {
// ... existing private members ...
static int num_strings;
public:
// ... existing public members ...
// 静态成员函数
static int HowMany();
};

// stringbad.cpp
// ... existing code ...
// 定义静态成员函数
int StringBad::HowMany() {
return num_strings; // 可以访问静态成员 num_strings
// return len; // 错误!不能直接访问非静态成员 len
}

// main.cpp
#include "stringbad.h"
#include <iostream>

int main() {
StringBad s1("Hello");
StringBad s2("World");
std::cout << "Number of objects: " << StringBad::HowMany() << std::endl; // 通过类名调用
std::cout << "Number of objects: " << s1.HowMany() << std::endl; // 通过对象调用 (不推荐)
return 0;
}

12.1.2 特殊成员函数

C++ 类有一些特殊的成员函数,如果程序员没有显式定义它们,编译器可能会自动生成默认版本。这些函数对于控制对象的创建、销毁、复制和移动至关重要:

  1. 默认构造函数 (Default Constructor): 如果你没有定义任何构造函数,编译器会生成一个。它通常什么也不做(对于内置类型成员)或调用成员对象的默认构造函数。
  2. 析构函数 (Destructor): 如果你没有定义析构函数,编译器会生成一个。它通常什么也不做或调用成员对象的析构函数。对于需要释放资源的类(如 StringBad),必须定义自己的析构函数。
  3. 复制构造函数 (Copy Constructor): 如果你没有定义,编译器会生成一个。它的参数通常是同类对象的 const 引用(例如 StringBad(const StringBad&))。默认的复制构造函数执行成员逐一复制 (memberwise copy) 或称**浅复制 (shallow copy)**。
  4. 复制赋值运算符 (Copy Assignment Operator): 如果你没有定义,编译器会生成一个。它的参数通常是同类对象的 const 引用,并返回一个指向调用对象的引用(例如 StringBad& operator=(const StringBad&))。默认的赋值运算符也执行**成员逐一复制 (浅复制)**。
  5. 移动构造函数 (Move Constructor) (C++11): 如果你没有定义任何复制/移动操作或析构函数,编译器可能会生成一个。参数是同类对象的右值引用(StringBad(StringBad&&))。默认执行成员逐一移动。
  6. 移动赋值运算符 (Move Assignment Operator) (C++11): 如果你没有定义任何复制/移动操作或析构函数,编译器可能会生成一个。参数是同类对象的右值引用,返回对象引用(StringBad& operator=(StringBad&&))。默认执行成员逐一移动。

关键问题:成员逐一复制 (浅复制)

对于像 intdouble 这样的简单数据成员,成员逐一复制工作得很好。但是,对于指针成员(如 StringBad 中的 char * str),成员逐一复制仅仅是复制指针的值(地址),而不是指针所指向的数据。这就是所谓的浅复制

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

考虑以下代码,它会隐式地调用复制构造函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// main_problem.cpp
#include <iostream>
#include "stringbad.h" // 假设 stringbad.h/cpp 如上定义 (没有自定义复制构造函数)

void callme1(StringBad sb) { // 按值传递,调用复制构造函数
std::cout << "String passed by value:\n";
std::cout << " \"" << sb << "\"\n";
}

int main() {
using std::cout;
using std::endl;
{
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); // 调用 callme1,headline1 被复制到参数 sb

cout << "headline1: " << headline1 << endl; // 再次打印 headline1

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 inner block.\n";
} // 作用域结束,headline1, headline2, sports, sailor, knot 的析构函数被调用
cout << "End of main()\n";
return 0;
}

当你运行这段代码时(假设编译器生成了默认的复制构造函数和赋值运算符),你会遇到严重的问题,很可能程序会崩溃。原因如下:

  1. callme1(headline1):headline1 按值传递给 callme1 时,会调用复制构造函数来创建参数 sb。默认的复制构造函数执行浅复制:sb.str 被设置为与 headline1.str 相同的内存地址
  2. callme1 结束:callme1 函数返回时,其局部变量 sb 被销毁,调用 sb 的析构函数 ~StringBad()。这个析构函数执行 delete [] sb.str;,释放了 headline1.str 指向的内存!
  3. headline1 的后续使用: 回到 main 函数后,headline1.str 现在是一个悬挂指针 (dangling pointer)**,它指向已经被释放的内存。任何试图访问 headline1 内容的操作(如 cout << headline1knot = headline1)都将导致未定义行为**(通常是崩溃)。
  4. StringBad sailor = sports;: 同样,默认复制构造函数使 sailor.strsports.str 指向同一块内存。
  5. 作用域结束:main 函数的内部作用域 {} 结束时,所有局部对象(headline1, headline2, sports, sailor, knot)的析构函数被调用。
    • ~StringBad() for knot 可能尝试删除已经被 callme1sb 的析构函数删除的内存(如果 knot = headline1 发生在 callme1 之后)。
    • ~StringBad() for sailor 会删除 sports.str 指向的内存。
    • ~StringBad() for sports再次尝试删除同一块内存。
    • ~StringBad() for headline2 正常工作。
    • ~StringBad() for headline1 尝试删除已经被 callme1sb 的析构函数删除的内存。

这种重复删除 (double deletion) 同一块内存的行为是导致程序崩溃的常见原因。

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

默认的赋值运算符 operator= 同样执行浅复制,也会导致问题:

1
2
3
4
StringBad knot;        // knot.str 指向 "C++" 的内存
StringBad headline1("Celery Stalks at Midnight"); // headline1.str 指向 "Celery..." 的内存

knot = headline1; // 调用默认赋值运算符

执行 knot = headline1; 后:

  1. knot.len 被设置为 headline1.len
  2. knot.str 被设置为 headline1.str 的值(内存地址)。

结果:

  • knot.strheadline1.str 指向同一块内存 (“Celery…”)。
  • knot 原来指向的内存 (“C++”) 的地址丢失了,这块内存没有被 delete,造成了**内存泄漏 (memory leak)**。
  • knotheadline1 的析构函数被调用时,会发生重复删除

结论:

对于管理动态内存的类(即类内部有指针成员,并且类负责 newdelete 这些指针指向的内存),编译器生成的默认复制构造函数和默认赋值运算符是不安全的,会导致浅复制、内存泄漏和重复删除等问题。

为了解决这些问题,我们需要为这样的类提供我们自己的、正确实现的:

  1. 析构函数: 确保释放所有通过 new 分配的资源。
  2. 复制构造函数: 执行**深复制 (deep copy)**,即为新对象分配自己的内存,并将原始对象的数据复制到新内存中。
  3. 复制赋值运算符: 执行深复制,并正确处理自我赋值(obj = obj;)和释放旧资源。

这通常被称为复制控制 (Copy Control) 或遵循**三/五/零法则 (Rule of Three/Five/Zero)**。如果一个类需要自定义析构函数、复制构造函数或复制赋值运算符中的任何一个,那么它很可能需要自定义所有这三个(C++11 前的三法则)。在 C++11 中,如果需要自定义这三个中的任何一个或移动构造函数/移动赋值运算符,则可能需要考虑所有五个(五法则)。或者,通过使用 RAII(资源获取即初始化)原则和智能指针等现代 C++ 技术,可能可以避免手动管理内存,从而不需要自定义这些特殊成员函数(零法则)。

12.2 改进后的新 String 类

上一节我们看到了 StringBad 类的问题:由于它管理动态内存,编译器自动生成的默认复制构造函数和赋值运算符执行的浅复制(只复制指针地址)导致了内存泄漏和重复删除等严重错误。

为了解决这些问题,我们需要遵循复制控制原则(或称三/五法则),为管理动态内存的类提供自定义的特殊成员函数。本节我们创建一个改进的 String 类(放在 string1.hstring1.cpp 中),它能正确处理动态内存。

核心改进:深复制 (Deep Copy)

关键在于实现深复制,而不是浅复制。深复制意味着当复制对象时,不仅复制普通成员的值,还要为指针成员指向的数据分配新的内存,并将原始数据复制到这块新内存中。

1. 复制构造函数 (Copy Constructor)

当一个对象需要通过同类型的另一个对象来初始化时(例如 String s2 = s1; 或按值传递参数),复制构造函数会被调用。我们的自定义版本必须执行深复制:

1
2
3
4
5
6
7
8
9
// string1.cpp excerpt
// Copy Constructor (Deep Copy)
String::String(const String & st) {
num_strings++; // 更新静态计数器
len = st.len; // 复制长度
str = new char [len + 1]; // *** 为新对象分配自己的内存 ***
std::strcpy(str, st.str); // *** 将数据复制到新内存中 ***
// std::cout << "COPY constructor called for " << str << "\n"; // 调试信息
}

现在,当 String s2 = s1; 执行时,s2 会拥有自己独立的一块内存,其中包含与 s1 相同内容的字符串副本。s1s2str 指针将指向不同的内存地址。

2. 赋值运算符 (Assignment Operator)

当将一个已存在的对象赋值给另一个已存在的对象时(例如 s2 = s1;),赋值运算符会被调用。它需要做更多工作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// string1.cpp excerpt
// Assignment Operator (Deep Copy)
String & String::operator=(const String & st) {
// std::cout << "Assignment operator called for " << str << "\n"; // 调试信息
// 1. 自我赋值检查: 防止 obj = obj; 导致意外删除
if (this == &st)
return *this; // 返回当前对象

// 2. 释放旧内存: 释放当前对象 (*this) 原来占用的内存
delete [] str;

// 3. 深复制: 与复制构造函数类似
len = st.len;
str = new char [len + 1]; // 分配新内存
std::strcpy(str, st.str); // 复制数据

// 4. 返回引用: 返回对调用对象 (*this) 的引用,以支持链式赋值 (a = b = c)
return *this;
}

这个实现确保了:

  • 自我赋值安全: 如果尝试 s1 = s1;,操作会直接返回,不会错误地释放内存。
  • 内存管理: 在复制新数据之前,正确释放了对象原来占用的内存,防止内存泄漏。
  • 深复制: 为对象分配了新的内存并复制了数据。

12.2.1 修订后的默认构造函数

默认构造函数现在创建一个空字符串,而不是像 StringBad 那样创建一个 “C++” 字符串。这通常更有用。

1
2
3
4
5
6
7
// string1.cpp excerpt
String::String() { // default constructor
len = 0;
str = new char[1]; // 分配足够存储空字符 '\0' 的空间
str[0] = '\0'; // 设置为空字符串
num_strings++;
}

12.2.2 比较成员函数

为了能够比较 String 对象,我们重载了关系运算符 ==, <, >。这些通常实现为友元函数,因为我们希望能够比较 string1 == string2,并且它们需要访问私有成员 str。我们利用 C 库函数 strcmp() 来进行比较。

1
2
3
4
5
6
7
8
9
10
11
12
// string1.cpp excerpt (友元函数定义)
bool operator<(const String &st1, const String &st2) {
return (std::strcmp(st1.str, st2.str) < 0);
}

bool operator>(const String &st1, const String &st2) {
return st2 < st1; // 利用已定义的 operator<
}

bool operator==(const String &st1, const String &st2) {
return (std::strcmp(st1.str, st2.str) == 0);
}

12.2.3 使用中括号表示法访问字符

为了像访问普通 C 字符串数组一样访问 String 对象中的单个字符(例如 myString[0]),我们重载了下标运算符 []。通常需要提供两个版本:

  • const 版本: char & operator[](int i); 返回一个 char引用,允许修改字符(例如 myString[0] = 'H';)。
  • const 版本: const char & operator[](int i) const; 用于 const String 对象,返回一个 const char 的引用,只允许读取字符(例如 const String greeting = "Hi"; char c = greeting[0];)。
1
2
3
4
5
6
7
8
9
10
11
12
// string1.cpp excerpt
// read-write char access for non-const String
char & String::operator[](int i) {
// 注意:为了简化,这里没有添加边界检查
return str[i];
}

// read-only char access for const String
const char & String::operator[](int i) const {
// 注意:为了简化,这里没有添加边界检查
return str[i];
}

实际应用中通常需要添加边界检查(检查 i 是否在 0len-1 的有效范围内)。

12.2.4 静态类成员函数

我们保留了静态成员 num_strings 和静态成员函数 HowMany() 来跟踪已创建的 String 对象数量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// string1.h excerpt
class String {
// ...
static int num_strings;
public:
// ...
static int HowMany();
};

// string1.cpp excerpt
int String::num_strings = 0; // 初始化

int String::HowMany() {
return num_strings;
}

12.2.5 进一步重载赋值运算符

除了从另一个 String 对象赋值,我们通常还希望能够直接从 C 风格字符串 (const char*) 赋值,例如 myString = "Hello";。为此,我们重载了另一个版本的赋值运算符。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// string1.h excerpt
class String {
// ...
public:
// ...
String & operator=(const String &); // String = String
String & operator=(const char *); // String = "C-string"
// ...
};

// string1.cpp excerpt
String & String::operator=(const char * s) {
delete [] str; // 释放旧内存
len = std::strlen(s);
str = new char[len + 1];
std::strcpy(str, s);
return *this; // 返回引用
}

使用改进后的 String 类

现在,使用这个改进后的 String 类(定义在 string1.hstring1.cpp 中),之前导致问题的代码可以安全运行了。下面的示例程序 (sayings1.cpp) 展示了如何使用这个类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
// sayings1.cpp -- 使用改进的 String 类
#include <iostream>
#include "string1.h" // 使用 string1.h

const int ArSize = 10;
const int MaxLen = 81;

int main() {
using std::cout;
using std::cin;
using std::endl;
String name; // 调用默认构造函数

cout << "Hi, what's your name?\n>> ";
cin >> name; // 调用 operator>>

cout << name << ", please enter up to " << ArSize
<< " short sayings <empty line to quit>:\n";
String sayings[ArSize]; // 调用 ArSize 次默认构造函数
char temp[MaxLen];
int i;
for (i = 0; i < ArSize; i++) {
cout << i + 1 << ": ";
cin.get(temp, MaxLen); // 读取一行
while (cin && cin.get() != '\n') // 清除行尾换行符
continue;
if (!cin || temp[0] == '\0') // 检查空行或输入错误
break; // i 未增加
else
sayings[i] = temp; // 调用 operator=(const char*)
}
int total = i; // 记录实际输入的数量

if (total > 0) {
cout << "Here are your sayings:\n";
for (i = 0; i < total; i++)
cout << sayings[i][0] << ": " << sayings[i] << endl; // 使用 operator[] 和 operator<<

// 找到最短和第一个字母顺序最小的字符串
int shortest = 0;
int first = 0;
for (i = 1; i < total; i++) {
if (sayings[i].length() < sayings[shortest].length()) // 使用 length()
shortest = i;
if (sayings[i] < sayings[first]) // 使用 operator<
first = i;
}
cout << "Shortest saying:\n" << sayings[shortest] << endl;
cout << "First alphabetically:\n" << sayings[first] << endl;

// 使用复制构造函数
String favorite = sayings[shortest];
cout << "My favorite saying:\n" << favorite << endl;

cout << "This program used " << String::HowMany() << " String objects. Bye.\n"; // 使用 HowMany()
} else {
cout << "No input! Bye.\n";
}

return 0;
}

这个程序现在可以正确地创建、复制、赋值和销毁 String 对象,而不会出现内存泄漏或崩溃,因为我们提供了正确的析构函数、复制构造函数和赋值运算符来处理动态内存。

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

当类的构造函数使用 new 来分配动态内存时,需要特别注意内存管理和潜在的错误情况,以确保程序的健壮性和避免资源泄漏。

12.3.1 应该和不应该

应该做的事:

  1. 在析构函数中使用 delete: 如果构造函数使用 new 分配了内存,那么必须在析构函数中使用 delete(或 delete[] 如果分配的是数组)来释放这些内存。这是防止内存泄漏的基本要求。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    class MyClass {
    private:
    int * data;
    size_t size;
    public:
    MyClass(size_t n) : size(n) {
    data = new int[size]; // 分配内存
    // ... 初始化 data ...
    std::cout << "MyClass constructed, allocated memory.\n";
    }
    ~MyClass() {
    delete [] data; // *** 必须在析构函数中释放内存 ***
    std::cout << "MyClass destructed, freed memory.\n";
    }
    // *** 还需要复制构造函数和赋值运算符 (Rule of Three/Five) ***
    // ...
    };
  2. 使用 delete[] 释放数组: 如果构造函数使用 new T[size] 分配了数组,析构函数必须使用 delete[] 来释放。如果使用 new T 分配单个对象,则使用 delete。混用会导致未定义行为。

  3. 实现深复制: 如果类管理动态内存,必须提供自定义的复制构造函数和复制赋值运算符来执行深复制,以避免浅复制带来的问题(如重复删除、悬挂指针)。

不应该做的事 (或需要注意):

  1. 忘记 delete: 不在析构函数中释放构造函数分配的内存会导致内存泄漏。每次对象销毁时,它占用的动态内存都不会被回收。

  2. 构造函数中 new 失败: new 运算符在无法分配所需内存时,默认会抛出 std::bad_alloc 异常。

    • 如果 new 抛出异常,对象的构造过程会立即终止

    • 重要的是,对象的析构函数不会被调用,因为对象从未被完全构造。

    • 这意味着,如果在抛出异常的 new 之前,构造函数已经成功分配了其他动态资源(例如,通过另一个 new),那么这些资源可能会泄漏,因为没有析构函数来清理它们。

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      class Problematic {
      private:
      int * data1;
      char * data2;
      public:
      Problematic(size_t n1, size_t n2) : data1(nullptr), data2(nullptr) { // 初始化为 nullptr
      try {
      data1 = new int[n1];
      std::cout << "Allocated data1\n";
      // *** 如果下面的 new 失败 ***
      data2 = new char[n2];
      std::cout << "Allocated data2\n";
      } catch (const std::bad_alloc & ba) {
      std::cerr << "Memory allocation failed: " << ba.what() << std::endl;
      // *** 析构函数不会调用,需要在这里手动清理 data1 (如果已分配) ***
      delete [] data1; // 如果 data1 分配成功但 data2 失败,需要释放 data1
      data1 = nullptr; // 避免悬挂指针 (虽然对象构造失败了)
      // 重新抛出异常或处理错误
      throw; // 重新抛出,让调用者知道构造失败
      }
      }
      ~Problematic() {
      std::cout << "Problematic destructor called\n";
      delete [] data1;
      delete [] data2;
      }
      // ... 复制控制 ...
      };

      int main() {
      try {
      // 尝试分配非常大的内存,可能会失败
      Problematic p(100, 1000000000000ULL);
      } catch (...) {
      std::cerr << "Failed to create Problematic object.\n";
      }
      return 0;
      }
    • 更好的方法: 使用 RAII(资源获取即初始化)原则,例如使用智能指针(如 std::unique_ptr)来管理动态内存。智能指针会在其自身被销毁时自动释放所管理的内存,即使发生异常导致栈展开,也能保证资源被释放。

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      #include <memory> // for std::unique_ptr

      class BetterClass {
      private:
      std::unique_ptr<int[]> data1; // 使用智能指针管理数组
      std::unique_ptr<char[]> data2;
      public:
      BetterClass(size_t n1, size_t n2)
      : data1(new int[n1]), data2(new char[n2]) // 直接在初始化列表中分配
      {
      std::cout << "BetterClass constructed.\n";
      // 如果 new 失败,异常会抛出,已成功构造的 unique_ptr (如果有) 会自动释放内存
      // 不需要 try-catch 来手动 delete
      }
      // 不需要显式析构函数来 delete data1/data2,unique_ptr 会自动处理
      // 但仍然需要遵循 Rule of Three/Five/Zero (使用 unique_ptr 通常遵循 Rule of Zero)
      ~BetterClass() {
      std::cout << "BetterClass destructed.\n";
      }
      // 可能需要自定义复制/移动操作,或者禁用它们,因为 unique_ptr 默认不可复制
      BetterClass(const BetterClass&) = delete;
      BetterClass& operator=(const BetterClass&) = delete;
      // 可以添加移动操作
      BetterClass(BetterClass&&) = default;
      BetterClass& operator=(BetterClass&&) = default;
      };

12.3.2 包含类成员的类的逐成员复制

当一个类(称为包含类容器类)包含其他类的对象作为其成员时,默认的复制构造函数和赋值运算符的行为仍然是成员逐一复制

这意味着对于容器类中的每个成员

  • 如果是内置类型(int, double, 指针等),则按值复制。
  • 如果是类对象,则调用该成员对象的复制构造函数(对于容器的复制构造)或赋值运算符(对于容器的赋值运算)。

示例:

假设我们有一个 Gadget 类,它包含一个我们之前改进过的 String 对象(来自 string1.h)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// gadget.h
#ifndef GADGET_H_
#define GADGET_H_
#include "string1.h" // 包含改进的 String 类

class Gadget {
private:
String name; // 成员是 String 对象
int id;
public:
Gadget(const char * s = "Default Gadget", int i = 0) : name(s), id(i) {}
Gadget(const String& s, int i = 0) : name(s), id(i) {}

// *** 没有显式定义复制构造函数和赋值运算符 ***

void show() const { std::cout << "ID: " << id << ", Name: " << name << std::endl; }
};
#endif // GADGET_H_

// main.cpp
#include <iostream>
#include "gadget.h"

int main() {
Gadget g1("Flux Capacitor", 121);
g1.show();

std::cout << "\n--- Copying g1 to g2 ---\n";
Gadget g2 = g1; // 调用 Gadget 的默认复制构造函数
g2.show();

std::cout << "\n--- Assigning g1 to g3 ---\n";
Gadget g3; // 调用 Gadget 默认构造函数 (name 会调用 String 默认构造函数)
g3.show();
g3 = g1; // 调用 Gadget 的默认赋值运算符
g3.show();

std::cout << "\n--- End of main ---\n";
return 0; // g1, g2, g3 的析构函数被调用
}

发生了什么?

  1. Gadget g2 = g1; (默认复制构造函数):

    • 编译器生成的 Gadget 复制构造函数会执行成员逐一复制。
    • 对于 int id 成员:g2.id 被设置为 g1.id 的值 (121)。
    • 对于 String name 成员:调用 String 类的复制构造函数 String(const String&) 来初始化 g2.name,使用 g1.name 作为源。因为我们的 String 类有正确的深复制构造函数,所以 g2.name 会获得 g1.name 内容的独立副本。
  2. g3 = g1; (默认赋值运算符):

    • 编译器生成的 Gadget 赋值运算符会执行成员逐一赋值。
    • 对于 int id 成员:g3.id 被设置为 g1.id 的值。
    • 对于 String name 成员:调用 String 类的赋值运算符 operator=(const String&),将 g1.name 赋给 g3.name (g3.name = g1.name;)。因为 String 类有正确的深复制赋值运算符(它会先释放 g3.name 的旧内存,然后分配新内存并复制内容),所以赋值操作是安全的。

结论:

如果一个类包含其他类的对象作为成员,并且这些成员对象所属的类已经正确地实现了它们自己的复制控制(即它们能安全地进行深复制和内存管理,像我们的 String 类或标准库类 std::string, std::vector 等),那么对于这些成员来说,包含类的默认成员逐一复制行为通常是足够且安全的。

但是,如果包含类本身还直接管理其他动态内存(例如,Gadget 类除了 String name 之外,还有一个 int* extra_data 指针,并且 Gadget 负责 new/delete 这个指针),那么这个包含类仍然需要提供自己的自定义复制构造函数和赋值运算符来处理 extra_data 的深复制,即使 String name 成员的复制可以由 String 类自己处理。

这就是零法则 (Rule of Zero) 发挥作用的地方:如果你的类只使用那些自身能正确管理资源的成员(如 std::string, std::vector, std::unique_ptr 等),并且不直接进行手动的 new/delete,那么你通常不需要提供任何自定义的析构函数、复制/移动构造函数或赋值运算符,编译器生成的默认版本就能很好地工作。

12.4 有关返回对象的说明

当函数或方法需要返回一个类对象时,有几种不同的方式可以实现,每种方式都有其适用的场景和潜在的效率或安全 implications。主要的方式包括:返回指向对象的引用(const 或非 const)和返回对象本身(按值返回,const 或非 const)。

12.4.1 返回指向 const 对象的引用

语法: const ClassName & functionName(parameters);

何时使用:
当函数需要返回一个已经存在的对象(例如,类的成员、通过引用传递给函数的对象),并且不希望调用者通过返回的引用修改这个对象时。

优点:

  • 高效: 避免了创建对象的副本。返回的只是对象的地址(引用)。
  • 安全: const 保证了调用者不能意外地修改返回的对象。

缺点/注意事项:

  • 不能返回局部对象的引用: 函数不能返回在函数内部创建的局部变量的引用。当函数结束时,局部变量会被销毁,返回的引用将成为**悬挂引用 (dangling reference)**,访问它会导致未定义行为。
  • 返回的对象必须在函数调用结束后仍然存在。

示例:
假设我们有一个 Store 类,包含多个 Product 对象,我们想提供一个方法来通过 ID 获取某个产品的信息,但不允许修改它。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
class Product {
private:
std::string name;
double price;
public:
Product(const std::string& n = "N/A", double p = 0.0) : name(n), price(p) {}
void show() const { std::cout << name << ": $" << price; }
// ... 其他方法 ...
};

class Store {
private:
std::vector<Product> products;
Product defaultProduct; // 用于找不到时返回
public:
// ... 方法添加产品 ...
// 通过 ID 查找产品,返回 const 引用
const Product & findProductById(int id) const {
if (id >= 0 && id < products.size()) {
return products[id]; // 返回 vector 中已存在对象的 const 引用
} else {
return defaultProduct; // 返回一个已存在的默认对象的 const 引用
}
// 错误示例:
// Product temp("Temporary", 0.0);
// return temp; // 错误!不能返回局部变量的引用
}
};

int main() {
Store myStore;
// ... 添加产品到 myStore ...
const Product & found = myStore.findProductById(1); // 高效,无复制
found.show(); // OK: 调用 const 方法
// found.setPrice(9.99); // 错误!不能通过 const 引用调用非 const 方法
return 0;
}

12.4.2 返回指向非 const 对象的引用

语法: ClassName & functionName(parameters);

何时使用:
当函数需要返回一个已经存在的对象,并且允许调用者通过返回的引用修改这个对象时。最常见的例子是重载某些运算符,如 [] (下标) 或 += (复合赋值)。

优点:

  • 高效: 同样避免了创建对象的副本。
  • 允许修改: 可以用于实现链式调用或允许直接修改返回的对象。

缺点/注意事项:

  • 不能返回局部对象的引用: 与返回 const 引用一样,绝对不能返回函数内部局部变量的引用。
  • 破坏封装 (可能): 如果返回的是类内部私有成员的非 const 引用,可能会破坏类的封装性,允许外部代码直接修改内部状态,应谨慎使用。

示例:
重载 String 类的下标运算符 [],允许修改字符。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 在 String 类中 (来自 string1.h)
class String {
// ...
public:
// ...
// 返回非 const 引用,允许修改
char & operator[](int i);
// 返回 const 引用,用于 const 对象
const char & operator[](int i) const;
};

// 实现
char & String::operator[](int i) {
return str[i]; // 返回内部 char 数组元素的引用
}
const char & String::operator[](int i) const {
return str[i];
}

// 使用
String greeting("Hello");
greeting[0] = 'J'; // OK: 通过返回的引用修改了第一个字符
cout << greeting << endl; // 输出 Jello

const String constGreeting("Hi");
char firstChar = constGreeting[0]; // OK: 读取字符
// constGreeting[0] = 'B'; // 错误!不能修改 const 对象

另一个例子是 operator+=,它修改对象自身并返回自身的引用以支持链式操作(虽然链式赋值不常见)。

1
2
3
4
5
// 在 Time 类中
Time& Time::operator+=(const Time& t) {
// ... 实现加法逻辑,修改 *this ...
return *this; // 返回调用对象自身的引用
}

12.4.3 返回对象 (按值返回)

语法: ClassName functionName(parameters);

何时使用:
当函数需要返回一个新创建的对象(在函数内部计算或构造得到),或者需要返回一个现有对象的副本而不是其本身时。这是最常见和最安全的返回对象的方式,特别是对于局部对象。

优点:

  • 安全: 不会返回悬挂引用。返回的是一个独立的副本(或移动后的对象)。
  • 简单: 易于理解和实现。

缺点/注意事项:

  • 可能有效率开销: 传统上,按值返回会调用复制构造函数来创建返回值的副本,这可能涉及大量数据的复制和内存分配/释放,效率较低。
  • 返回值优化 (RVO/NRVO): 现代 C++ 编译器通常会应用返回值优化 (Return Value Optimization, RVO)命名返回值优化 (Named Return Value Optimization, NRVO)**。这些优化可以完全避免**复制构造函数的调用,直接在调用者指定的内存位置上构造返回的对象,从而大大提高按值返回的效率。
  • 移动语义 (C++11): 如果 RVO/NRVO 不适用,但类有移动构造函数,编译器可能会选择调用移动构造函数而不是复制构造函数来转移资源所有权,这也比深复制高效得多。

示例:
重载 Vector 类的 + 运算符。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 在 Vector 类中
class Vector {
// ...
public:
// ...
// 按值返回一个新的 Vector 对象
Vector operator+(const Vector & b) const;
};

// 实现
Vector Vector::operator+(const Vector & b) const {
// 方法 1: 创建命名对象并返回 (可能触发 NRVO)
Vector sum(x + b.x, y + b.y);
return sum;

// 方法 2: 直接返回临时对象 (可能触发 RVO)
// return Vector(x + b.x, y + b.y);
}

// 使用
Vector v1(1, 2), v2(3, 4);
Vector v3 = v1 + v2; // v1 + v2 返回一个临时 Vector 对象
// 这个临时对象被用来初始化 v3 (可能通过 RVO/NRVO 或移动/复制构造)

由于 RVO/NRVO 和移动语义的存在,按值返回在现代 C++ 中通常是高效且推荐的方式,特别是对于那些表示“值”而非“身份”的类型。

12.4.4 返回 const 对象 (按值返回)

语法: const ClassName functionName(parameters);

何时使用:
这是一种不太常见的返回方式。它按值返回一个对象,但这个返回的临时对象是 const 的。

优点:

  • 阻止对返回的临时对象调用非 const 方法: 如果一个函数按值返回一个对象,调用者可以立即对这个返回的临时对象调用其成员函数。如果返回类型是 const ClassName,则只能调用该对象的 const 成员函数。

缺点/注意事项:

  • 通常意义不大: 对于类类型,将按值返回的对象声明为 const 通常没什么必要。因为返回的是一个临时对象(右值),对其进行修改通常没有意义,而且 C++11 的移动语义通常更关心对象是否是右值,而不是它是否 const。在某些情况下,返回 const 对象甚至可能阻止移动语义的应用,导致不必要的复制。
  • 对于内置类型(如 int),返回 const int 几乎没有任何作用。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
const String getName() {
String temp = "Temporary Name";
return temp; // 返回 const String 对象
}

int main() {
// getName()[0] = 'X'; // 错误!不能对返回的 const 临时对象调用非 const 的 operator[]
char first = getName()[0]; // OK: 调用 const 的 operator[]

String nameCopy = getName(); // OK: const 临时对象可以用来初始化非 const 对象 (通过复制/移动构造)
return 0;
}

总的来说,除非有非常特殊的原因需要阻止对返回的临时对象调用非 const 方法,否则通常不推荐按值返回 const 对象。直接按值返回非 const 对象通常更灵活,并且更能受益于 RVO 和移动语义。

总结:

  • 返回引用 (&const &): 高效,无复制。用于返回已存在的、生命周期足够长的对象。绝不能返回局部变量的引用。const & 更安全,& 允许修改。
  • 按值返回 (ClassName): 安全,返回副本或移动后的对象。适用于返回函数内创建的新对象。效率通常由 RVO/NRVO 和移动语义保证。
  • 按值返回 const 对象 (const ClassName): 不常用,可能阻止对临时对象的修改,但通常意义不大,甚至可能影响优化。

12.5 使用指向对象的指针

就像可以使用指向内置类型(如 int*)或结构(如 MyStruct*)的指针一样,也可以声明和使用指向类对象的指针。指针本身存储的是对象的内存地址。

声明指向对象的指针:

1
ClassName * pointerName;

例如,要声明一个指向 String 对象的指针:

1
2
3
#include "string1.h" // 包含 String 类定义

String * p_string; // 声明一个指向 String 对象的指针

12.5.1 再读 new 和 delete

使用 new 运算符可以在自由存储区(堆)上动态地创建对象,并返回该对象的地址。这个地址可以存储在相应类型的指针中。

使用 new 创建对象:

1
2
pointerName = new ClassName; // 调用默认构造函数
pointerName = new ClassName(arguments); // 调用匹配参数的构造函数

当使用 new ClassName(...) 时,会发生两件事:

  1. 在自由存储区分配足够容纳 ClassName 对象的内存。
  2. 调用相应的构造函数来初始化这块内存中的对象。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include "string1.h"
#include <iostream>

int main() {
using std::cout;
using std::endl;

String * p1 = new String("Dynamically allocated string"); // 调用 String(const char*)
String * p2 = new String; // 调用默认构造函数 String()

cout << "p1 points to: " << *p1 << endl; // 使用 * 解引用指针访问对象
cout << "p2 points to: " << *p2 << endl;

// ... 使用 p1 和 p2 指向的对象 ...
}

使用 delete 销毁对象:

通过 new 创建的对象必须使用 delete 来销毁,以释放内存并执行清理工作。

1
delete pointerName;

当使用 delete pointerName 时,会发生两件事:

  1. 调用 pointerName 指向的对象的析构函数 (~ClassName())。
  2. 释放该对象占用的内存。

示例 (续):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// main.cpp (续)
int main() {
// ... (创建 p1, p2 的代码) ...

cout << "\nDeleting objects...\n";
delete p1; // 调用 ~String() for *p1,然后释放内存
delete p2; // 调用 ~String() for *p2,然后释放内存

// p1 和 p2 现在是悬挂指针,不应再使用
p1 = nullptr;
p2 = nullptr;

return 0;
}

忘记 delete 通过 new 创建的对象会导致内存泄漏

new[]delete[] 用于对象数组:

同样可以动态分配对象数组。

1
2
3
4
5
6
int size = 5;
String * favorites = new String[size]; // 调用 size 次默认构造函数

// ... 使用数组 ...

delete [] favorites; // 调用 size 次析构函数,然后释放整个数组内存
  • new ClassName[size] 会为数组中的每个元素调用默认构造函数。如果类没有默认构造函数,这种分配方式将失败。
  • 必须使用 delete [] 来释放通过 new[] 分配的数组。使用 delete (不带 []) 会导致未定义行为(通常只调用第一个元素的析构函数,并可能导致内存损坏)。

12.5.2 指针和对象小结

  • 声明: ClassName * ptr;
  • 动态创建: ptr = new ClassName(args); (调用构造函数)
  • 动态销毁: delete ptr; (调用析构函数)
  • 访问成员:
    • 解引用和点号: (*ptr).memberName(*ptr).methodName(args)
    • 箭头运算符 (常用): ptr->memberNameptr->methodName(args)。箭头运算符 -> 是专门为指向对象的指针访问其成员而设计的,它等价于先解引用再用点号访问。

示例 (使用箭头运算符):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include "string1.h"
#include <iostream>

int main() {
String * glamour = new String("Glamorous");

// 使用箭头运算符访问成员
std::cout << "String: " << glamour->operator const char *() << std::endl; // 假设有转换函数
std::cout << "Length: " << glamour->length() << std::endl; // 调用 length() 方法
std::cout << "First char: " << (*glamour)[0] << std::endl; // 混合使用 * 和 []
std::cout << "First char again: " << glamour->operator[](0) << std::endl; // 使用 -> 调用 operator[]

delete glamour;
return 0;
}

12.5.3 再读定位 new 运算符

标准 new 在自由存储区(堆)上查找内存块。C++ 还允许通过另一种形式的 new——**定位 new (placement new)**——来指定分配对象的位置。

前提: 你需要提供一个指向已分配好的内存块的地址,并确保该内存块足够大以容纳要创建的对象。

语法:

1
2
3
4
5
6
#include <new> // 必须包含 <new> 头文件

BufferType * buffer = /* ... 获取指向已分配内存的指针 ... */;
ClassName * ptr;

ptr = new (buffer) ClassName(arguments); // 在 buffer 指向的内存上构造对象
  • new (buffer): 这部分是定位 new 运算符。它不分配新内存,而是告诉编译器在 buffer 指向的地址处构造对象。
  • ClassName(arguments): 调用相应的构造函数来初始化 buffer 指向的内存区域。

何时使用定位 new?

  1. 内存池管理: 当你需要自己管理一块大的内存区域(内存池),并在其中反复创建和销毁对象时,可以避免频繁调用标准 newdelete 带来的开销和内存碎片。
  2. 特定硬件地址: 在某些嵌入式系统或底层编程中,可能需要在特定的硬件地址上创建对象。
  3. 优化: 在性能要求极高的场景下,如果能预先分配好内存,定位 new 可以省去标准 new 的内存查找开销。

销毁定位 new 创建的对象:

定位 new 只负责调用构造函数,它负责内存管理。因此,你不能对通过定位 new 获取的指针使用标准的 delete。标准的 delete 会尝试释放内存,但这块内存不是由标准 new 分配的(或者你打算重用它)。

要销毁通过定位 new 创建的对象,你需要显式地调用该对象的析构函数

1
ptr->~ClassName(); // 显式调用析构函数
  • ptr->~ClassName(): 这是调用析构函数的语法。它执行对象的清理工作,但不释放内存
  • 内存的释放由管理 buffer 的代码负责(例如,如果 buffer 是一个大的 char 数组,它会在数组生命周期结束时自动释放;如果是通过标准 new 分配的,则需要对应的 delete [])。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
#include <iostream>
#include <new> // for placement new
#include "string1.h" // 假设 String 类有默认和 C-string 构造函数及析构函数

const int BUF = 512;
char buffer[BUF]; // 预先分配的内存缓冲区 (静态存储)

int main() {
using std::cout;
using std::endl;

String *p1, *p2;

cout << "Block 1: placement new in buffer:\n";
p1 = new (buffer) String("Hello"); // 在 buffer 开始处构造 String("Hello")
// 计算 buffer 中下一个可用地址
// 注意:这只是一个简化示例,实际内存池管理需要更复杂的对齐和大小计算
size_t available_space = BUF - sizeof(String);
void * next_loc = buffer + sizeof(String);

if (available_space >= sizeof(String)) {
p2 = new (next_loc) String("World"); // 在 buffer 的下一个位置构造 String("World")
} else {
cout << "Not enough space for p2.\n";
p2 = nullptr;
}


cout << "Memory addresses:\n" << "buffer: " << (void *) buffer
<< " p1: " << p1 << " p2: " << p2 << endl;

cout << "p1: " << *p1 << endl;
if (p2) cout << "p2: " << *p2 << endl;

// 显式销毁对象 (按构造相反顺序通常是好习惯)
cout << "\nDestroying objects:\n";
if (p2) {
p2->~String(); // 调用 p2 指向对象的析构函数
cout << "p2 destroyed.\n";
}
p1->~String(); // 调用 p1 指向对象的析构函数
cout << "p1 destroyed.\n";

// buffer 内存本身会在 main 结束时自动释放 (因为它是栈上的数组)

cout << "\nBlock 2: Standard new/delete:\n";
String * p3 = new String("Standard new");
cout << "p3: " << *p3 << " at " << p3 << endl;
delete p3; // 使用标准 delete,它会调用析构函数并释放内存
cout << "p3 deleted.\n";

return 0;
}

总结:

  • 指向对象的指针是管理动态创建对象的常用方式。
  • new ClassName(args) 分配内存并调用构造函数。
  • delete ptr 调用析构函数并释放内存。
  • new ClassName[size] 分配数组并调用默认构造函数。
  • delete [] ptr 调用数组元素的析构函数并释放内存。
  • 使用 -> 运算符通过指针访问对象成员。
  • 定位 new (new (address) ClassName(args)) 在指定地址构造对象,不分配内存。
  • 定位 new 创建的对象必须通过显式调用析构函数 (ptr->~ClassName()) 来销毁,内存需另外管理。

12.6 复习各种技术

本章我们深入探讨了类如何与动态内存分配交互,以及为了正确管理资源和避免错误所必须采用的技术。本节将简要回顾其中几个关键技术点。

12.6.1 重载 << 运算符

为了方便地输出对象的状态,我们经常为自定义类重载输出运算符 <<

  • 实现方式: 通常实现为非成员友元函数
    • 非成员: 因为左操作数是 std::ostream 对象(如 cout),而不是我们自定义类的对象。调用形式是 operator<<(cout, myObject)
    • 友元: 因为它通常需要访问类的 private 数据成员来获取要输出的信息。
  • 函数签名: friend std::ostream & operator<<(std::ostream & os, const ClassName & obj);
    • 第一个参数是 ostream 对象的引用。
    • 第二个参数通常是待输出对象的 const 引用(因为输出操作不应修改对象)。
    • 返回 ostream 对象的引用,以支持链式输出(cout << obj1 << obj2;)。
  • 实现: 函数内部访问对象的成员,并将它们格式化输出到传入的 ostream 对象 os 中。

示例 (回顾 String 类):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// string1.h excerpt
class String {
// ... private: char * str; int len; ...
friend std::ostream & operator<<(std::ostream & os, const String & st);
// ...
};

// string1.cpp excerpt
std::ostream & operator<<(std::ostream & os, const String & st) {
os << st.str; // 友元函数访问私有成员 str
return os;
}

// 使用
String greeting("Hello");
std::cout << "Message: " << greeting << std::endl; // 调用 operator<<(cout, greeting)

12.6.2 转换函数

转换函数允许类对象被隐式或显式地转换为其他类型。

  • 语法: operator typeName() const;
    • 必须是成员函数
    • 没有声明返回类型。
    • 通常没有参数。
    • 通常声明为 const,因为转换操作不应修改对象状态。
  • 功能: 定义了从当前类类型到 typeName 类型的转换规则。
  • explicit (C++11): 可以使用 explicit 关键字阻止隐式转换,只允许显式转换(如 static_cast)。这有助于避免意外转换和二义性。

示例 (回顾 Stones 类):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// stones.h excerpt
class Stones {
// ... private: double pounds; ...
public:
// ...
explicit operator double() const; // 显式转换为 double
explicit operator int() const; // 显式转换为 int
};

// stones.cpp excerpt
Stones::operator double() const {
return pounds;
}
Stones::operator int() const {
return int(pounds + 0.5); // 四舍五入
}

// 使用
Stones stone_obj(100.7);
// double weight = stone_obj; // 错误!因为是 explicit
double weight = static_cast<double>(stone_obj); // OK: 显式转换
int int_weight = static_cast<int>(stone_obj); // OK: 显式转换
std::cout << "Weight: " << weight << std::endl;

注意事项:

  • 谨慎使用隐式转换函数,它们可能导致代码行为难以预测或产生二义性。优先使用 explicit
  • 避免提供相互冲突或模糊的转换路径。

12.6.3 其构造函数使用 new 的类

这是本章的核心内容。当类的构造函数使用 new 来分配动态内存,并且类负责管理这块内存(即在析构函数中使用 delete)时,必须特别注意对象的复制和赋值行为。

问题: 编译器生成的默认复制构造函数和默认赋值运算符执行浅复制(只复制指针地址),导致:

  • 多个对象指向同一块内存。
  • 析构时发生**重复删除 (double deletion)**。
  • 赋值时可能发生内存泄漏(旧内存未释放)。

解决方案 (三/五法则):
必须提供自定义的特殊成员函数来执行深复制并正确管理内存:

  1. 析构函数 (~ClassName()):

    • 必须定义。
    • 负责使用 deletedelete[] 释放构造函数中通过 new 分配的所有内存。
  2. 复制构造函数 (ClassName(const ClassName &)):

    • 必须定义。
    • 为新对象分配自己的内存。
    • 将源对象的数据复制到新分配的内存中。
  3. 复制赋值运算符 (ClassName & operator=(const ClassName &)):

    • 必须定义。
    • 检查自我赋值 (if (this == &other) return *this;)。
    • 释放当前对象 (*this) 的旧内存
    • 为当前对象分配新的内存。
    • 将源对象的数据复制到新分配的内存中。
    • 返回对当前对象 (*this) 的引用。

示例 (回顾 String 类关键部分):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// string1.h excerpt
class String {
private:
char * str;
int len;
// ...
public:
String(const char * s); // Constructor using new
String(const String & st); // Copy constructor (deep copy)
~String(); // Destructor using delete[]
String & operator=(const String & st); // Assignment operator (deep copy)
String & operator=(const char * s); // Another assignment overload
// ...
};

// string1.cpp excerpt (实现深复制)
String::String(const String & st) { // Copy Constructor
len = st.len;
str = new char [len + 1];
std::strcpy(str, st.str);
// ... (update static count etc.)
}

String::~String() { // Destructor
delete [] str;
// ... (update static count etc.)
}

String & String::operator=(const String & st) { // Assignment Operator
if (this == &st)
return *this;
delete [] str; // Free old string
len = st.len;
str = new char [len + 1];
std::strcpy(str, st.str);
return *this;
}

现代 C++ (零法则):
如果可能,尽量避免手动管理原始指针和 new/delete。使用标准库提供的容器(如 std::string, std::vector)和智能指针(如 std::unique_ptr, std::shared_ptr)。这些类已经内置了正确的资源管理和复制/移动语义,遵循 RAII 原则。如果你的类只包含这类成员,通常就不需要编写自定义的析构函数、复制/移动构造函数或赋值运算符,编译器生成的默认版本就能正确工作(零法则)。

掌握这些技术对于编写安全、健壮且能正确处理动态内存的 C++ 类至关重要。

12.7 队列模拟

本章我们学习了类和动态内存分配,现在我们将这些知识应用于一个实际问题:模拟银行 ATM 的客户队列。通过模拟,我们可以分析不同条件下(如不同的客户到达率、不同的队列最大长度)客户的平均等待时间,帮助银行做出决策。

模拟场景:

  • 有一个 ATM 机。
  • 客户随机到达,平均每小时到达一定数量的客户。
  • 客户到达后,如果 ATM 空闲且队列为空,则直接使用 ATM。
  • 如果 ATM 繁忙或队列非空,客户进入队列等待。
  • 队列有最大长度限制,如果客户到达时队列已满,则客户离开(被拒绝服务)。
  • 每个客户的交易时间是随机的(在某个范围内)。
  • 我们想模拟一段时间(例如 100 小时),然后统计:服务的总客户数、被拒绝的总客户数、平均队列长度、平均客户等待时间。

实现思路:

为了实现这个模拟,我们需要两个主要的类:

  1. Customer 类: 用于表示一个客户。它需要存储客户的关键信息:
    • 到达时间 (Arrival Time): 客户何时加入队列。
    • 交易所需时间 (Processing Time): 客户在 ATM 上需要花费多长时间。
  2. Queue 类: 用于表示等待队列。这是一个典型的先进先出 (FIFO - First-In, First-Out) 数据结构。我们需要能够:
    • 将客户添加到队尾 (enqueue)。
    • 从队首移除客户 (dequeue)。
    • 检查队列是否为空 (is_empty)。
    • 检查队列是否已满 (is_full)。
    • 获取当前队列中的客户数量 (queue_count)。

由于队列的长度可能在运行时变化(客户加入和离开),并且我们可能需要处理潜在的大量客户,使用动态内存分配来实现 Queue 类是合适的。我们将使用**链式队列 (Linked Queue)**,其中每个节点包含一个 Customer 对象和一个指向下一个节点的指针。

12.7.1 队列类 (Queue Class)

设计要点:

  • 节点结构 (Node):Queue 类内部定义一个私有的 Node 结构(或类),包含一个 Customer 对象(项目)和一个指向下一个 Node 的指针。
  • 数据成员:
    • front: 指向队首节点的指针。
    • rear: 指向队尾节点的指针。
    • items: 当前队列中的项目数(客户数)。
    • qsize: 队列的最大容量(构造时指定)。
  • 特殊成员函数 (处理动态内存):
    • 构造函数: 初始化队列为空,设置最大容量。
    • 析构函数: 释放所有节点占用的内存,防止内存泄漏。
    • 复制构造函数: 实现深复制,创建一个完全独立的队列副本(如果需要复制队列)。
    • 赋值运算符: 实现深复制赋值,处理自我赋值和内存管理。
    • (注意:对于这个模拟,我们可能不需要复制或赋值队列,可以考虑禁用它们或使用默认行为,但完整的类设计应考虑这些)
  • 公有成员函数 (队列操作):
    • bool isempty() const;
    • bool isfull() const;
    • int queuecount() const;
    • bool enqueue(const Customer &item); // 添加客户到队尾
    • bool dequeue(Customer &item); // 从队首移除客户,并通过引用参数返回

queue.h (Queue 类定义)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
#ifndef QUEUE_H_
#define QUEUE_H_

// 前向声明 Customer 类
class Customer;

// 为了简化,我们直接使用 Customer 类型,而不是模板
typedef Customer Item;

class Queue {
private:
// 内部节点结构
struct Node {
Item item; // 存储的数据 (Customer 对象)
Node * next; // 指向下一个节点的指针
};
enum { Q_SIZE = 10 }; // 默认队列大小 (可以修改)

Node * front; // 指向队首的指针
Node * rear; // 指向队尾的指针
int items; // 当前队列中的项目数
const int qsize; // 队列最大容量 (const 成员)

// 复制构造函数和赋值运算符设为私有,以禁止复制 (简单处理方式)
// 如果需要复制,则需要实现深复制
Queue(const Queue & q) : qsize(0) { }
Queue & operator=(const Queue & q) { return *this; }

public:
// 构造函数,可以指定最大容量
Queue(int qs = Q_SIZE);
// 析构函数
~Queue();

bool isempty() const { return items == 0; }
bool isfull() const { return items == qsize; }
int queuecount() const { return items; }

// 添加项目到队尾
bool enqueue(const Item &item);
// 从队首移除项目
bool dequeue(Item &item); // item 用于接收出队的数据
};
#endif // QUEUE_H_

queue.cpp (Queue 类实现)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
#include "queue.h"
#include <cstdlib> // for std::rand() - 仅用于 Customer 示例,Queue 本身不需要

// --- Customer 类定义 (简单版本,放在这里仅为编译 Queue) ---
// 实际应用中应放在单独的 customer.h/cpp
class Customer {
private:
long arrive; // arrival time for customer
int processtime; // processing time for customer
public:
Customer() : arrive(0), processtime(0) {} // default constructor
void set(long when) {
processtime = std::rand() % 3 + 1; // 随机处理时间 1-3 分钟
arrive = when;
}
long when() const { return arrive; }
int ptime() const { return processtime; }
};
// --- Customer 类定义结束 ---


// Queue 方法实现
Queue::Queue(int qs) : qsize(qs) { // 初始化 const 成员 qsize
front = rear = nullptr; // 初始化为空队列
items = 0;
}

Queue::~Queue() {
Node * temp;
while (front != nullptr) { // 循环直到队列为空
temp = front; // 保存当前队首节点地址
front = front->next; // 移动 front 指向下一个节点
delete temp; // 删除旧的队首节点
}
}

// 添加项目到队尾
bool Queue::enqueue(const Item &item) {
if (isfull())
return false; // 队列已满,无法添加

Node * add = new Node; // 创建新节点
// 如果 new 失败,会抛出异常 (这里简化处理,未捕获)
add->item = item; // 设置节点数据
add->next = nullptr; // 新节点是队尾,next 为空
items++; // 项目数增加

if (front == nullptr) // 如果队列原本为空
front = add; // 新节点同时是队首和队尾
else
rear->next = add; // 将原队尾节点的 next 指向新节点
rear = add; // 更新 rear 指向新的队尾节点
return true;
}

// 从队首移除项目
bool Queue::dequeue(Item &item) {
if (isempty())
return false; // 队列为空,无法移除

item = front->item; // 获取队首节点的数据 (通过引用参数返回)
items--; // 项目数减少
Node * temp = front; // 保存队首节点地址
front = front->next; // 移动 front 指向下一个节点
delete temp; // 删除旧的队首节点

if (items == 0) // 如果删除后队列为空
rear = nullptr; // rear 也设为空
return true;
}

12.7.2 Customer 类

这个类相对简单,只需要存储客户的到达时间和所需的交易时间。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// customer.h (理想情况下)
#ifndef CUSTOMER_H_
#define CUSTOMER_H_

class Customer {
private:
long arrive; // arrival time for customer
int processtime; // processing time for customer
public:
Customer() : arrive(0), processtime(0) {}
void set(long when); // 设置到达时间,并随机生成处理时间
long when() const { return arrive; }
int ptime() const { return processtime; }
};
#endif // CUSTOMER_H_

// customer.cpp (理想情况下)
#include "customer.h"
#include <cstdlib> // for std::rand()

void Customer::set(long when) {
processtime = std::rand() % 3 + 1; // 假设处理时间为 1, 2, 或 3 分钟
arrive = when;
}

(在上面的 queue.cpp 中,我们为了方便编译,将 Customer 的简单定义直接放在了那里。在实际项目中,应将其分为 .h.cpp 文件。)

12.7.3 ATM 模拟 (atm.cpp)

现在我们可以编写主程序来执行模拟了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
#include <iostream>
#include <cstdlib> // for rand() and srand()
#include <ctime> // for time()
#include "queue.h" // 包含 Queue 类 (它内部包含了 Customer 的简单定义)

const int MIN_PER_HR = 60; // 每小时分钟数

// 函数:判断是否有新客户到来
// 参数 x: 平均多少分钟来一位客户
// 返回值: true 如果这一分钟有客户来, false 如果没有
bool newcustomer(double x);

int main() {
using std::cin;
using std::cout;
using std::endl;
using std::ios_base;

// 设置随机数种子
std::srand(std::time(0));

cout << "Case Study: Bank of Heather Automatic Teller\n";
cout << "Enter maximum size of queue: ";
int qs; // 队列最大长度
cin >> qs;
Queue line(qs); // 创建队列对象

cout << "Enter the number of simulation hours: ";
int hours; // 模拟总小时数
cin >> hours;
// 模拟以分钟为单位进行
long cyclelimit = MIN_PER_HR * hours; // 总模拟分钟数

cout << "Enter the average number of customers per hour: ";
double perhour; // 平均每小时客户数
cin >> perhour;
double min_per_cust; // 平均多少分钟来一位客户
min_per_cust = MIN_PER_HR / perhour;

Item temp; // 用于存储新客户
long turnaways = 0; // 因队列满而被拒绝的客户数
long customers = 0; // 加入队列的总客户数
long served = 0; // 完成服务的总客户数
long sum_line = 0; // 累计的队列长度 (用于计算平均长度)
int wait_time = 0; // 当前客户在 ATM 处还需等待的时间
long line_wait = 0; // 所有客户累计的总等待时间 (队列中 + ATM处)

// --- 模拟主循环 ---
for (long cycle = 0; cycle < cyclelimit; cycle++) {
// 1. 是否有新客户到来?
if (newcustomer(min_per_cust)) {
if (line.isfull()) {
turnaways++; // 队列满,拒绝
} else {
customers++; // 客户加入
temp.set(cycle); // 设置客户到达时间 (当前分钟数) 和随机处理时间
line.enqueue(temp); // 加入队列
}
}

// 2. ATM 是否空闲?如果空闲且队列有人,则服务下一位
if (wait_time <= 0 && !line.isempty()) {
line.dequeue(temp); // 从队列取出客户
wait_time = temp.ptime(); // 设置 ATM 需等待时间 = 客户处理时间
line_wait += cycle - temp.when(); // 累加客户等待时间 (当前时间 - 到达时间)
served++; // 服务客户数增加
}

// 3. 如果 ATM 正在服务,则减少剩余等待时间
if (wait_time > 0)
wait_time--;

// 4. 累加当前队列长度
sum_line += line.queuecount();
}
// --- 模拟循环结束 ---

// --- 输出模拟结果 ---
if (customers > 0) {
cout << "\nCustomers accepted: " << customers << endl;
cout << " Customers served: " << served << endl;
cout << " Turnaways: " << turnaways << endl;
cout << "Average queue size: ";
cout.precision(2);
cout.setf(ios_base::fixed, ios_base::floatfield);
cout << (double) sum_line / cyclelimit << endl; // 平均队列长度
cout << " Average wait time: "
<< (double) line_wait / served << " minutes\n"; // 平均等待时间
} else {
cout << "No customers!\n";
}

cout << "Done!\n";

return 0;
}

// 函数定义:判断是否有新客户到来
// 这是一个简单的概率模型:在一分钟内,客户到来的概率是 1/x
bool newcustomer(double x) {
// rand() * x / RAND_MAX < 1 等价于 rand() / RAND_MAX < 1/x
return (std::rand() * x / RAND_MAX < 1);
}

编译和运行:

你需要将 queue.cppatm.cpp 一起编译链接。

1
2
g++ atm.cpp queue.cpp -o atm
./atm

程序会提示你输入队列最大长度、模拟小时数和平均每小时客户数,然后运行模拟并输出结果。你可以尝试不同的输入值,观察它们对平均等待时间和队列长度的影响。这个模拟虽然简单,但它展示了如何使用类(特别是涉及动态内存的类)来解决实际问题。

12.8 总结

本章深入探讨了当 C++ 类需要直接管理动态分配的内存(使用 newdelete)时所面临的挑战和必需的技术。核心问题在于编译器自动生成的默认成员函数(特别是复制构造函数和赋值运算符)执行的浅复制行为,这对于包含原始指针成员的类来说是危险的。

主要内容回顾:

  1. 动态内存和类的问题:

    • 如果类使用 new 分配内存并存储在指针成员中,默认的复制构造函数和赋值运算符只会复制指针的地址(浅复制),而不是指针指向的数据。
    • 浅复制导致多个对象共享同一块动态内存,当其中一个对象被销毁并调用析构函数 delete 内存时,其他对象的指针就变成了悬挂指针
    • 后续对悬挂指针的访问或在其他对象析构时再次 delete 同一块内存(重复删除)会导致未定义行为和程序崩溃。
    • 默认赋值运算符还可能导致内存泄漏,因为它覆盖了旧指针而没有释放其指向的内存。
  2. 特殊成员函数和复制控制 (Rule of Three/Five):

    • 为了解决浅复制问题,管理动态内存的类通常需要提供自定义的特殊成员函数:
      • 析构函数 (~ClassName()): 必须定义,负责使用 deletedelete[] 释放由构造函数分配的所有动态内存。
      • 复制构造函数 (ClassName(const ClassName &)): 必须定义,执行深复制——为新对象分配独立的内存,并将源对象的数据复制到新内存中。
      • 复制赋值运算符 (ClassName & operator=(const ClassName &)): 必须定义,执行深复制,同时需要处理自我赋值obj = obj;)并释放旧资源,最后返回 *this
    • 三法则 (Rule of Three, C++11 前): 如果你需要自定义析构函数、复制构造函数或复制赋值运算符中的任何一个,你几乎肯定需要全部三个。
    • 五法则 (Rule of Five, C++11 及以后): 随着移动语义的引入,如果需要自定义上述三个或移动构造函数/移动赋值运算符中的任何一个,通常需要考虑所有五个。
  3. 改进的 String 类: 通过实现自定义的析构函数、复制构造函数和赋值运算符(执行深复制),我们创建了一个能够安全管理动态内存的 String 类。

  4. 构造函数中使用 new 的注意事项:

    • 必须在析构函数中配对使用 deletedelete[]
    • new 可能失败并抛出 std::bad_alloc 异常。如果构造函数在 new 失败前已分配其他资源,需要注意资源泄漏问题(析构函数不会被调用)。使用 RAII(如智能指针)是更安全的做法。
    • 如果类的成员是其他类的对象,默认的复制/赋值操作会调用成员对象的相应复制/赋值操作。如果成员对象能正确处理自己的资源,这通常是安全的。
  5. 返回对象:

    • 按引用返回 (&const &): 高效,用于返回已存在的对象,但不能返回局部变量的引用。
    • 按值返回 (ClassName): 安全,返回副本或移动后的对象。现代 C++ 通过 RVO/NRVO 和移动语义使其通常足够高效。
  6. 指向对象的指针:

    • 使用 new 动态创建对象,返回对象指针。
    • 使用 delete 销毁对象(调用析构函数并释放内存)。
    • 使用 new[]delete[] 处理动态对象数组。
    • 使用箭头运算符 -> 访问指针指向对象的成员。
    • 定位 new (placement new): 在预先分配好的内存地址上构造对象,需要显式调用析构函数 (ptr->~ClassName()) 来销毁对象,内存需另外管理。
  7. 静态类成员:

    • 静态数据成员: 被类的所有对象共享,独立于任何对象存在,通常在类外初始化。
    • 静态成员函数: 不与特定对象关联(无 this 指针),只能访问静态成员,可通过类名调用 (ClassName::static_func())。
  8. 队列模拟: 演示了如何应用类和动态内存管理(链表实现的队列)来解决一个实际的模拟问题。

  9. 现代 C++ 建议 (Rule of Zero): 尽可能使用标准库提供的资源管理类(如 std::string, std::vector, std::unique_ptr, std::shared_ptr),它们遵循 RAII 原则并正确实现了复制/移动语义。如果你的类只使用这些工具来管理资源,通常就不需要编写任何自定义的特殊成员函数(零法则),从而使代码更简单、更安全。

评论