12.1 动态内存和类
当类需要使用 new
在自由存储区(堆)上分配内存时,情况会变得比之前我们看到的类(如 Stock
, Time
, Vector
)更复杂。这些类的数据成员本身(如 int
, double
, std::string
)要么大小固定,要么像 std::string
那样自己管理内存。
但是,如果你的类直接使用指针来管理通过 new
分配的内存(例如,自定义一个字符串类来管理 char*
指针指向的内存),那么 C++ 编译器自动生成的某些默认行为(特别是对象复制和赋值)可能会导致严重的问题,如内存泄漏和程序崩溃。本章将探讨这些问题以及如何通过定义特殊的成员函数来解决它们。
12.1.1 复习示例和静态类成员
让我们从一个简单的、故意设计得有问题的字符串类 StringBad
开始,它使用 char*
指针来指向动态分配的内存。
StringBad 类定义 (stringbad.h)
1 |
|
StringBad 类实现 (stringbad.cpp)
1 |
|
这个类看起来似乎能工作:它在构造时分配内存,在析构时释放内存。但是,它缺少一些关键的东西,我们稍后会看到。
静态类成员 (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 | // 示例:添加一个静态成员函数到 StringBad |
12.1.2 特殊成员函数
C++ 类有一些特殊的成员函数,如果程序员没有显式定义它们,编译器可能会自动生成默认版本。这些函数对于控制对象的创建、销毁、复制和移动至关重要:
- 默认构造函数 (Default Constructor): 如果你没有定义任何构造函数,编译器会生成一个。它通常什么也不做(对于内置类型成员)或调用成员对象的默认构造函数。
- 析构函数 (Destructor): 如果你没有定义析构函数,编译器会生成一个。它通常什么也不做或调用成员对象的析构函数。对于需要释放资源的类(如
StringBad
),必须定义自己的析构函数。 - 复制构造函数 (Copy Constructor): 如果你没有定义,编译器会生成一个。它的参数通常是同类对象的
const
引用(例如StringBad(const StringBad&)
)。默认的复制构造函数执行成员逐一复制 (memberwise copy) 或称**浅复制 (shallow copy)**。 - 复制赋值运算符 (Copy Assignment Operator): 如果你没有定义,编译器会生成一个。它的参数通常是同类对象的
const
引用,并返回一个指向调用对象的引用(例如StringBad& operator=(const StringBad&)
)。默认的赋值运算符也执行**成员逐一复制 (浅复制)**。 - 移动构造函数 (Move Constructor) (C++11): 如果你没有定义任何复制/移动操作或析构函数,编译器可能会生成一个。参数是同类对象的右值引用(
StringBad(StringBad&&)
)。默认执行成员逐一移动。 - 移动赋值运算符 (Move Assignment Operator) (C++11): 如果你没有定义任何复制/移动操作或析构函数,编译器可能会生成一个。参数是同类对象的右值引用,返回对象引用(
StringBad& operator=(StringBad&&)
)。默认执行成员逐一移动。
关键问题:成员逐一复制 (浅复制)
对于像 int
或 double
这样的简单数据成员,成员逐一复制工作得很好。但是,对于指针成员(如 StringBad
中的 char * str
),成员逐一复制仅仅是复制指针的值(地址),而不是指针所指向的数据。这就是所谓的浅复制。
12.1.3 回到 StringBad:复制构造函数的哪里出了问题
考虑以下代码,它会隐式地调用复制构造函数:
1 | // main_problem.cpp |
当你运行这段代码时(假设编译器生成了默认的复制构造函数和赋值运算符),你会遇到严重的问题,很可能程序会崩溃。原因如下:
-
callme1(headline1)
: 当headline1
按值传递给callme1
时,会调用复制构造函数来创建参数sb
。默认的复制构造函数执行浅复制:sb.str
被设置为与headline1.str
相同的内存地址。 -
callme1
结束: 当callme1
函数返回时,其局部变量sb
被销毁,调用sb
的析构函数~StringBad()
。这个析构函数执行delete [] sb.str;
,释放了headline1.str
指向的内存! -
headline1
的后续使用: 回到main
函数后,headline1.str
现在是一个悬挂指针 (dangling pointer)**,它指向已经被释放的内存。任何试图访问headline1
内容的操作(如cout << headline1
或knot = headline1
)都将导致未定义行为**(通常是崩溃)。 -
StringBad sailor = sports;
: 同样,默认复制构造函数使sailor.str
和sports.str
指向同一块内存。 - 作用域结束: 当
main
函数的内部作用域{}
结束时,所有局部对象(headline1
,headline2
,sports
,sailor
,knot
)的析构函数被调用。-
~StringBad()
forknot
可能尝试删除已经被callme1
中sb
的析构函数删除的内存(如果knot = headline1
发生在callme1
之后)。 -
~StringBad()
forsailor
会删除sports.str
指向的内存。 -
~StringBad()
forsports
会再次尝试删除同一块内存。 -
~StringBad()
forheadline2
正常工作。 -
~StringBad()
forheadline1
尝试删除已经被callme1
中sb
的析构函数删除的内存。
-
这种重复删除 (double deletion) 同一块内存的行为是导致程序崩溃的常见原因。
12.1.4 StringBad 的其他问题:赋值运算符
默认的赋值运算符 operator=
同样执行浅复制,也会导致问题:
1 | StringBad knot; // knot.str 指向 "C++" 的内存 |
执行 knot = headline1;
后:
-
knot.len
被设置为headline1.len
。 -
knot.str
被设置为headline1.str
的值(内存地址)。
结果:
-
knot.str
和headline1.str
指向同一块内存 (“Celery…”)。 -
knot
原来指向的内存 (“C++”) 的地址丢失了,这块内存没有被delete
,造成了**内存泄漏 (memory leak)**。 - 当
knot
和headline1
的析构函数被调用时,会发生重复删除。
结论:
对于管理动态内存的类(即类内部有指针成员,并且类负责 new
和 delete
这些指针指向的内存),编译器生成的默认复制构造函数和默认赋值运算符是不安全的,会导致浅复制、内存泄漏和重复删除等问题。
为了解决这些问题,我们需要为这样的类提供我们自己的、正确实现的:
- 析构函数: 确保释放所有通过
new
分配的资源。 - 复制构造函数: 执行**深复制 (deep copy)**,即为新对象分配自己的内存,并将原始对象的数据复制到新内存中。
- 复制赋值运算符: 执行深复制,并正确处理自我赋值(
obj = obj;
)和释放旧资源。
这通常被称为复制控制 (Copy Control) 或遵循**三/五/零法则 (Rule of Three/Five/Zero)**。如果一个类需要自定义析构函数、复制构造函数或复制赋值运算符中的任何一个,那么它很可能需要自定义所有这三个(C++11 前的三法则)。在 C++11 中,如果需要自定义这三个中的任何一个或移动构造函数/移动赋值运算符,则可能需要考虑所有五个(五法则)。或者,通过使用 RAII(资源获取即初始化)原则和智能指针等现代 C++ 技术,可能可以避免手动管理内存,从而不需要自定义这些特殊成员函数(零法则)。
12.2 改进后的新 String 类
上一节我们看到了 StringBad
类的问题:由于它管理动态内存,编译器自动生成的默认复制构造函数和赋值运算符执行的浅复制(只复制指针地址)导致了内存泄漏和重复删除等严重错误。
为了解决这些问题,我们需要遵循复制控制原则(或称三/五法则),为管理动态内存的类提供自定义的特殊成员函数。本节我们创建一个改进的 String
类(放在 string1.h
和 string1.cpp
中),它能正确处理动态内存。
核心改进:深复制 (Deep Copy)
关键在于实现深复制,而不是浅复制。深复制意味着当复制对象时,不仅复制普通成员的值,还要为指针成员指向的数据分配新的内存,并将原始数据复制到这块新内存中。
1. 复制构造函数 (Copy Constructor)
当一个对象需要通过同类型的另一个对象来初始化时(例如 String s2 = s1;
或按值传递参数),复制构造函数会被调用。我们的自定义版本必须执行深复制:
1 | // string1.cpp excerpt |
现在,当 String s2 = s1;
执行时,s2
会拥有自己独立的一块内存,其中包含与 s1
相同内容的字符串副本。s1
和 s2
的 str
指针将指向不同的内存地址。
2. 赋值运算符 (Assignment Operator)
当将一个已存在的对象赋值给另一个已存在的对象时(例如 s2 = s1;
),赋值运算符会被调用。它需要做更多工作:
1 | // string1.cpp excerpt |
这个实现确保了:
- 自我赋值安全: 如果尝试
s1 = s1;
,操作会直接返回,不会错误地释放内存。 - 内存管理: 在复制新数据之前,正确释放了对象原来占用的内存,防止内存泄漏。
- 深复制: 为对象分配了新的内存并复制了数据。
12.2.1 修订后的默认构造函数
默认构造函数现在创建一个空字符串,而不是像 StringBad
那样创建一个 “C++” 字符串。这通常更有用。
1 | // string1.cpp excerpt |
12.2.2 比较成员函数
为了能够比较 String
对象,我们重载了关系运算符 ==
, <
, >
。这些通常实现为友元函数,因为我们希望能够比较 string1 == string2
,并且它们需要访问私有成员 str
。我们利用 C 库函数 strcmp()
来进行比较。
1 | // string1.cpp excerpt (友元函数定义) |
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 | // string1.cpp excerpt |
实际应用中通常需要添加边界检查(检查 i
是否在 0
到 len-1
的有效范围内)。
12.2.4 静态类成员函数
我们保留了静态成员 num_strings
和静态成员函数 HowMany()
来跟踪已创建的 String
对象数量。
1 | // string1.h excerpt |
12.2.5 进一步重载赋值运算符
除了从另一个 String
对象赋值,我们通常还希望能够直接从 C 风格字符串 (const char*
) 赋值,例如 myString = "Hello";
。为此,我们重载了另一个版本的赋值运算符。
1 | // string1.h excerpt |
使用改进后的 String 类
现在,使用这个改进后的 String
类(定义在 string1.h
和 string1.cpp
中),之前导致问题的代码可以安全运行了。下面的示例程序 (sayings1.cpp
) 展示了如何使用这个类:
1 | // sayings1.cpp -- 使用改进的 String 类 |
这个程序现在可以正确地创建、复制、赋值和销毁 String
对象,而不会出现内存泄漏或崩溃,因为我们提供了正确的析构函数、复制构造函数和赋值运算符来处理动态内存。
12.3 在构造函数中使用 new 时应注意的事项
当类的构造函数使用 new
来分配动态内存时,需要特别注意内存管理和潜在的错误情况,以确保程序的健壮性和避免资源泄漏。
12.3.1 应该和不应该
应该做的事:
在析构函数中使用
delete
: 如果构造函数使用new
分配了内存,那么必须在析构函数中使用delete
(或delete[]
如果分配的是数组)来释放这些内存。这是防止内存泄漏的基本要求。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17class 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) ***
// ...
};使用
delete[]
释放数组: 如果构造函数使用new T[size]
分配了数组,析构函数必须使用delete[]
来释放。如果使用new T
分配单个对象,则使用delete
。混用会导致未定义行为。实现深复制: 如果类管理动态内存,必须提供自定义的复制构造函数和复制赋值运算符来执行深复制,以避免浅复制带来的问题(如重复删除、悬挂指针)。
不应该做的事 (或需要注意):
忘记
delete
: 不在析构函数中释放构造函数分配的内存会导致内存泄漏。每次对象销毁时,它占用的动态内存都不会被回收。构造函数中
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
38class 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
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 | // gadget.h |
发生了什么?
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
内容的独立副本。
- 编译器生成的
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 | class Product { |
12.4.2 返回指向非 const 对象的引用
语法: ClassName & functionName(parameters);
何时使用:
当函数需要返回一个已经存在的对象,并且允许调用者通过返回的引用修改这个对象时。最常见的例子是重载某些运算符,如 []
(下标) 或 +=
(复合赋值)。
优点:
- 高效: 同样避免了创建对象的副本。
- 允许修改: 可以用于实现链式调用或允许直接修改返回的对象。
缺点/注意事项:
- 不能返回局部对象的引用: 与返回
const
引用一样,绝对不能返回函数内部局部变量的引用。 - 破坏封装 (可能): 如果返回的是类内部私有成员的非
const
引用,可能会破坏类的封装性,允许外部代码直接修改内部状态,应谨慎使用。
示例:
重载 String
类的下标运算符 []
,允许修改字符。
1 | // 在 String 类中 (来自 string1.h) |
另一个例子是 operator+=
,它修改对象自身并返回自身的引用以支持链式操作(虽然链式赋值不常见)。
1 | // 在 Time 类中 |
12.4.3 返回对象 (按值返回)
语法: ClassName functionName(parameters);
何时使用:
当函数需要返回一个新创建的对象(在函数内部计算或构造得到),或者需要返回一个现有对象的副本而不是其本身时。这是最常见和最安全的返回对象的方式,特别是对于局部对象。
优点:
- 安全: 不会返回悬挂引用。返回的是一个独立的副本(或移动后的对象)。
- 简单: 易于理解和实现。
缺点/注意事项:
- 可能有效率开销: 传统上,按值返回会调用复制构造函数来创建返回值的副本,这可能涉及大量数据的复制和内存分配/释放,效率较低。
- 返回值优化 (RVO/NRVO): 现代 C++ 编译器通常会应用返回值优化 (Return Value Optimization, RVO) 或命名返回值优化 (Named Return Value Optimization, NRVO)**。这些优化可以完全避免**复制构造函数的调用,直接在调用者指定的内存位置上构造返回的对象,从而大大提高按值返回的效率。
- 移动语义 (C++11): 如果 RVO/NRVO 不适用,但类有移动构造函数,编译器可能会选择调用移动构造函数而不是复制构造函数来转移资源所有权,这也比深复制高效得多。
示例:
重载 Vector
类的 +
运算符。
1 | // 在 Vector 类中 |
由于 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 | const String getName() { |
总的来说,除非有非常特殊的原因需要阻止对返回的临时对象调用非 const
方法,否则通常不推荐按值返回 const
对象。直接按值返回非 const
对象通常更灵活,并且更能受益于 RVO 和移动语义。
总结:
- 返回引用 (
&
或const &
): 高效,无复制。用于返回已存在的、生命周期足够长的对象。绝不能返回局部变量的引用。const &
更安全,&
允许修改。 - 按值返回 (
ClassName
): 安全,返回副本或移动后的对象。适用于返回函数内创建的新对象。效率通常由 RVO/NRVO 和移动语义保证。 - 按值返回
const
对象 (const ClassName
): 不常用,可能阻止对临时对象的修改,但通常意义不大,甚至可能影响优化。
12.5 使用指向对象的指针
就像可以使用指向内置类型(如 int*
)或结构(如 MyStruct*
)的指针一样,也可以声明和使用指向类对象的指针。指针本身存储的是对象的内存地址。
声明指向对象的指针:
1 | ClassName * pointerName; |
例如,要声明一个指向 String
对象的指针:
1 |
|
12.5.1 再读 new 和 delete
使用 new
运算符可以在自由存储区(堆)上动态地创建对象,并返回该对象的地址。这个地址可以存储在相应类型的指针中。
使用 new
创建对象:
1 | pointerName = new ClassName; // 调用默认构造函数 |
当使用 new ClassName(...)
时,会发生两件事:
- 在自由存储区分配足够容纳
ClassName
对象的内存。 - 调用相应的构造函数来初始化这块内存中的对象。
示例:
1 |
|
使用 delete
销毁对象:
通过 new
创建的对象必须使用 delete
来销毁,以释放内存并执行清理工作。
1 | delete pointerName; |
当使用 delete pointerName
时,会发生两件事:
- 调用
pointerName
指向的对象的析构函数 (~ClassName()
)。 - 释放该对象占用的内存。
示例 (续):
1 | // main.cpp (续) |
忘记 delete
通过 new
创建的对象会导致内存泄漏。
new[]
和 delete[]
用于对象数组:
同样可以动态分配对象数组。
1 | int size = 5; |
-
new ClassName[size]
会为数组中的每个元素调用默认构造函数。如果类没有默认构造函数,这种分配方式将失败。 - 必须使用
delete []
来释放通过new[]
分配的数组。使用delete
(不带[]
) 会导致未定义行为(通常只调用第一个元素的析构函数,并可能导致内存损坏)。
12.5.2 指针和对象小结
- 声明:
ClassName * ptr;
- 动态创建:
ptr = new ClassName(args);
(调用构造函数) - 动态销毁:
delete ptr;
(调用析构函数) - 访问成员:
- 解引用和点号:
(*ptr).memberName
或(*ptr).methodName(args)
- 箭头运算符 (常用):
ptr->memberName
或ptr->methodName(args)
。箭头运算符->
是专门为指向对象的指针访问其成员而设计的,它等价于先解引用再用点号访问。
- 解引用和点号:
示例 (使用箭头运算符):
1 |
|
12.5.3 再读定位 new 运算符
标准 new
在自由存储区(堆)上查找内存块。C++ 还允许通过另一种形式的 new
——**定位 new
(placement new)**——来指定分配对象的位置。
前提: 你需要提供一个指向已分配好的内存块的地址,并确保该内存块足够大以容纳要创建的对象。
语法:
1 |
|
-
new (buffer)
: 这部分是定位new
运算符。它不分配新内存,而是告诉编译器在buffer
指向的地址处构造对象。 -
ClassName(arguments)
: 调用相应的构造函数来初始化buffer
指向的内存区域。
何时使用定位 new
?
- 内存池管理: 当你需要自己管理一块大的内存区域(内存池),并在其中反复创建和销毁对象时,可以避免频繁调用标准
new
和delete
带来的开销和内存碎片。 - 特定硬件地址: 在某些嵌入式系统或底层编程中,可能需要在特定的硬件地址上创建对象。
- 优化: 在性能要求极高的场景下,如果能预先分配好内存,定位
new
可以省去标准new
的内存查找开销。
销毁定位 new
创建的对象:
定位 new
只负责调用构造函数,它不负责内存管理。因此,你不能对通过定位 new
获取的指针使用标准的 delete
。标准的 delete
会尝试释放内存,但这块内存不是由标准 new
分配的(或者你打算重用它)。
要销毁通过定位 new
创建的对象,你需要显式地调用该对象的析构函数:
1 | ptr->~ClassName(); // 显式调用析构函数 |
-
ptr->~ClassName()
: 这是调用析构函数的语法。它执行对象的清理工作,但不释放内存。 - 内存的释放由管理
buffer
的代码负责(例如,如果buffer
是一个大的char
数组,它会在数组生命周期结束时自动释放;如果是通过标准new
分配的,则需要对应的delete []
)。
示例:
1 |
|
总结:
- 指向对象的指针是管理动态创建对象的常用方式。
-
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 | // string1.h excerpt |
12.6.2 转换函数
转换函数允许类对象被隐式或显式地转换为其他类型。
- 语法:
operator typeName() const;
- 必须是成员函数。
- 没有声明返回类型。
- 通常没有参数。
- 通常声明为
const
,因为转换操作不应修改对象状态。
- 功能: 定义了从当前类类型到
typeName
类型的转换规则。 -
explicit
(C++11): 可以使用explicit
关键字阻止隐式转换,只允许显式转换(如static_cast
)。这有助于避免意外转换和二义性。
示例 (回顾 Stones
类):
1 | // stones.h excerpt |
注意事项:
- 谨慎使用隐式转换函数,它们可能导致代码行为难以预测或产生二义性。优先使用
explicit
。 - 避免提供相互冲突或模糊的转换路径。
12.6.3 其构造函数使用 new
的类
这是本章的核心内容。当类的构造函数使用 new
来分配动态内存,并且类负责管理这块内存(即在析构函数中使用 delete
)时,必须特别注意对象的复制和赋值行为。
问题: 编译器生成的默认复制构造函数和默认赋值运算符执行浅复制(只复制指针地址),导致:
- 多个对象指向同一块内存。
- 析构时发生**重复删除 (double deletion)**。
- 赋值时可能发生内存泄漏(旧内存未释放)。
解决方案 (三/五法则):
必须提供自定义的特殊成员函数来执行深复制并正确管理内存:
析构函数 (
~ClassName()
):- 必须定义。
- 负责使用
delete
或delete[]
释放构造函数中通过new
分配的所有内存。
复制构造函数 (
ClassName(const ClassName &)
):- 必须定义。
- 为新对象分配自己的内存。
- 将源对象的数据复制到新分配的内存中。
复制赋值运算符 (
ClassName & operator=(const ClassName &)
):- 必须定义。
- 检查自我赋值 (
if (this == &other) return *this;
)。 - 释放当前对象 (
*this
) 的旧内存。 - 为当前对象分配新的内存。
- 将源对象的数据复制到新分配的内存中。
- 返回对当前对象 (
*this
) 的引用。
示例 (回顾 String
类关键部分):
1 | // string1.h excerpt |
现代 C++ (零法则):
如果可能,尽量避免手动管理原始指针和 new
/delete
。使用标准库提供的容器(如 std::string
, std::vector
)和智能指针(如 std::unique_ptr
, std::shared_ptr
)。这些类已经内置了正确的资源管理和复制/移动语义,遵循 RAII 原则。如果你的类只包含这类成员,通常就不需要编写自定义的析构函数、复制/移动构造函数或赋值运算符,编译器生成的默认版本就能正确工作(零法则)。
掌握这些技术对于编写安全、健壮且能正确处理动态内存的 C++ 类至关重要。
12.7 队列模拟
本章我们学习了类和动态内存分配,现在我们将这些知识应用于一个实际问题:模拟银行 ATM 的客户队列。通过模拟,我们可以分析不同条件下(如不同的客户到达率、不同的队列最大长度)客户的平均等待时间,帮助银行做出决策。
模拟场景:
- 有一个 ATM 机。
- 客户随机到达,平均每小时到达一定数量的客户。
- 客户到达后,如果 ATM 空闲且队列为空,则直接使用 ATM。
- 如果 ATM 繁忙或队列非空,客户进入队列等待。
- 队列有最大长度限制,如果客户到达时队列已满,则客户离开(被拒绝服务)。
- 每个客户的交易时间是随机的(在某个范围内)。
- 我们想模拟一段时间(例如 100 小时),然后统计:服务的总客户数、被拒绝的总客户数、平均队列长度、平均客户等待时间。
实现思路:
为了实现这个模拟,我们需要两个主要的类:
Customer
类: 用于表示一个客户。它需要存储客户的关键信息:- 到达时间 (Arrival Time): 客户何时加入队列。
- 交易所需时间 (Processing Time): 客户在 ATM 上需要花费多长时间。
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 |
|
queue.cpp (Queue 类实现)
1 |
|
12.7.2 Customer 类
这个类相对简单,只需要存储客户的到达时间和所需的交易时间。
1 | // customer.h (理想情况下) |
(在上面的 queue.cpp
中,我们为了方便编译,将 Customer
的简单定义直接放在了那里。在实际项目中,应将其分为 .h
和 .cpp
文件。)
12.7.3 ATM 模拟 (atm.cpp)
现在我们可以编写主程序来执行模拟了。
1 |
|
编译和运行:
你需要将 queue.cpp
和 atm.cpp
一起编译链接。
1 | g++ atm.cpp queue.cpp -o atm |
程序会提示你输入队列最大长度、模拟小时数和平均每小时客户数,然后运行模拟并输出结果。你可以尝试不同的输入值,观察它们对平均等待时间和队列长度的影响。这个模拟虽然简单,但它展示了如何使用类(特别是涉及动态内存的类)来解决实际问题。
12.8 总结
本章深入探讨了当 C++ 类需要直接管理动态分配的内存(使用 new
和 delete
)时所面临的挑战和必需的技术。核心问题在于编译器自动生成的默认成员函数(特别是复制构造函数和赋值运算符)执行的浅复制行为,这对于包含原始指针成员的类来说是危险的。
主要内容回顾:
动态内存和类的问题:
- 如果类使用
new
分配内存并存储在指针成员中,默认的复制构造函数和赋值运算符只会复制指针的地址(浅复制),而不是指针指向的数据。 - 浅复制导致多个对象共享同一块动态内存,当其中一个对象被销毁并调用析构函数
delete
内存时,其他对象的指针就变成了悬挂指针。 - 后续对悬挂指针的访问或在其他对象析构时再次
delete
同一块内存(重复删除)会导致未定义行为和程序崩溃。 - 默认赋值运算符还可能导致内存泄漏,因为它覆盖了旧指针而没有释放其指向的内存。
- 如果类使用
特殊成员函数和复制控制 (Rule of Three/Five):
- 为了解决浅复制问题,管理动态内存的类通常需要提供自定义的特殊成员函数:
- 析构函数 (
~ClassName()
): 必须定义,负责使用delete
或delete[]
释放由构造函数分配的所有动态内存。 - 复制构造函数 (
ClassName(const ClassName &)
): 必须定义,执行深复制——为新对象分配独立的内存,并将源对象的数据复制到新内存中。 - 复制赋值运算符 (
ClassName & operator=(const ClassName &)
): 必须定义,执行深复制,同时需要处理自我赋值(obj = obj;
)并释放旧资源,最后返回*this
。
- 析构函数 (
- 三法则 (Rule of Three, C++11 前): 如果你需要自定义析构函数、复制构造函数或复制赋值运算符中的任何一个,你几乎肯定需要全部三个。
- 五法则 (Rule of Five, C++11 及以后): 随着移动语义的引入,如果需要自定义上述三个或移动构造函数/移动赋值运算符中的任何一个,通常需要考虑所有五个。
- 为了解决浅复制问题,管理动态内存的类通常需要提供自定义的特殊成员函数:
改进的
String
类: 通过实现自定义的析构函数、复制构造函数和赋值运算符(执行深复制),我们创建了一个能够安全管理动态内存的String
类。构造函数中使用
new
的注意事项:- 必须在析构函数中配对使用
delete
或delete[]
。 -
new
可能失败并抛出std::bad_alloc
异常。如果构造函数在new
失败前已分配其他资源,需要注意资源泄漏问题(析构函数不会被调用)。使用 RAII(如智能指针)是更安全的做法。 - 如果类的成员是其他类的对象,默认的复制/赋值操作会调用成员对象的相应复制/赋值操作。如果成员对象能正确处理自己的资源,这通常是安全的。
- 必须在析构函数中配对使用
返回对象:
- 按引用返回 (
&
或const &
): 高效,用于返回已存在的对象,但不能返回局部变量的引用。 - 按值返回 (
ClassName
): 安全,返回副本或移动后的对象。现代 C++ 通过 RVO/NRVO 和移动语义使其通常足够高效。
- 按引用返回 (
指向对象的指针:
- 使用
new
动态创建对象,返回对象指针。 - 使用
delete
销毁对象(调用析构函数并释放内存)。 - 使用
new[]
和delete[]
处理动态对象数组。 - 使用箭头运算符
->
访问指针指向对象的成员。 - 定位
new
(placement new
): 在预先分配好的内存地址上构造对象,需要显式调用析构函数 (ptr->~ClassName()
) 来销毁对象,内存需另外管理。
- 使用
静态类成员:
- 静态数据成员: 被类的所有对象共享,独立于任何对象存在,通常在类外初始化。
- 静态成员函数: 不与特定对象关联(无
this
指针),只能访问静态成员,可通过类名调用 (ClassName::static_func()
)。
队列模拟: 演示了如何应用类和动态内存管理(链表实现的队列)来解决一个实际的模拟问题。
现代 C++ 建议 (Rule of Zero): 尽可能使用标准库提供的资源管理类(如
std::string
,std::vector
,std::unique_ptr
,std::shared_ptr
),它们遵循 RAII 原则并正确实现了复制/移动语义。如果你的类只使用这些工具来管理资源,通常就不需要编写任何自定义的特殊成员函数(零法则),从而使代码更简单、更安全。