C++内存管理——对象、指针与引用

在开始讨论 C++ 中的内存管理之前,我们先确保相互理解,并统一一些基础术语。如果你是资深的 C++ 程序员,关于指针、对象和引用,你很可能已经有了自己的理解,这些理解来源于丰富的经验。如果你是从其他语言转过来,可能也对这些术语在 C++ 中的含义以及它们与内存和内存管理的关系有自己的看法。

本章将确保我们对一些基础(但意义深远)的概念达成共识,方便后续内容建立在这一共同理解之上。具体来说,我们将探讨以下问题:

  • C++ 中内存是如何表现的?至少在 C++ 语言环境下,我们所说的"内存"究竟是什么?
  • 什么是对象、指针和引用?这些术语在 C++ 中具体指什么?对象的生命周期规则是什么?它们如何与内存相关联?
  • C++ 中的数组是什么?在这门语言中,数组是一个底层但高效的构造,它的表现方式直接影响内存管理。

技术要求

本书假设读者具备一定的 C++ 基础,或熟悉语法类似的语言,如 C、Java、C# 或 JavaScript。因此,我们不会讲解变量声明、循环、条件语句或函数的基础知识。

不过,本章会使用 C++ 的一些特性,部分读者可能不太熟悉。请在阅读本书前参考附录:你应该知道的事项。

部分示例代码使用了 C++20 或 C++23 标准,请确保你的编译器支持相应版本,以充分利用示例。

本章的代码示例可在以下地址找到:
github.com/PacktPublis...

C++ 中的内存表示

这是一本关于内存管理的书。你们作为读者正在努力理解内存的含义,而我作为作者,则试图传达它的意义。

C++ 标准对内存的描述可以在 [wg21.link/basic.memobj] 中查看。基本上,C++ 中的内存被表达为一个或多个连续字节序列。这也就意味着内存可以由若干不连续的连续内存块组成,因为历史上 C++ 支持由多个不同段组成的内存。C++ 程序中的每个字节都有唯一的地址。

C++ 程序中的内存包含各种实体,如对象、函数、引用等。高效管理内存需要理解这些实体的含义,以及程序如何使用它们。

"字节"(byte)这一术语在 C++ 中非常重要。正如 [wg21.link/intro.memory] 详细说明,字节是 C++ 中的基本存储单位。字节中包含的位数是由具体实现决定的。标准明确规定,字节必须足够宽,以包含基础字面字符集中的普通字面编码以及 UTF-8 编码形式的八位代码单元。标准还指出,字节由连续的位序列构成。

让人惊讶的是,在 C++ 中,字节不一定是八位(octet):字节至少包含八位,但可能包含更多位(这在某些特殊硬件上很有用)。虽然未来标准委员会可能会限制这一点,但截至本书出版时,情况就是如此。关键点是:字节是程序中最小可寻址的内存单位。

对象、指针和引用

我们往往非正式地使用"对象"、"指针"和"引用"等词,而不太考虑它们具体的含义。在 C++ 这类语言中,这些词有精确定义,规定了我们实际能做什么。

在动手实践之前,先看看这些术语在 C++ 中的正式定义。

对象

如果我们询问不同语言的程序员如何定义"对象",他们可能会说"将变量和相关函数组合在一起的东西",或者"类的实例",这些是面向对象编程中对"对象"一词的传统理解。

作为一门语言,C++ 试图对用户自定义类型(如结构体或类)提供统一支持,同时也支持基本类型,如 int 或 float。因此,对 C++ 来说,定义对象时更多从其属性角度出发,而非词义本身,这一定义也涵盖了最基本的类型。C++ 中对象的定义可参见 [wg21.link/intro.object],其考虑以下因素:

  • 对象是如何被显式创建的,比如定义对象时,或者通过多种版本的 new 操作符构造对象。对象也可能被隐式创建,比如表达式计算结果产生的临时对象,或修改联合体的活动成员时。
  • 对象存在于某处(有地址),并占用非零大小的存储区域,从构造开始到销毁结束。
  • 对象的其他属性,包括其名称(若有)、类型和存储周期(自动、静态、线程局部等)。

C++ 标准明确指出,函数不是对象,即便函数有地址并占用存储空间。

由此可以推断,即使是普通的 int 也是对象,但函数不是。亲爱的读者,你已经能看出本书会涉及许多基础话题,因为对象的生命周期和占用存储是我们每天编程时使用的实体的基本属性。生命周期和存储显然是内存管理的核心内容。你可以通过下面这个简单程序验证:

c 复制代码
#include <type_traits>
int main() {
   static_assert(std::is_object_v<int>);
   static_assert(!std::is_object_v<decltype(main)>);
}

什么是对象?它是有生命周期且占用存储的东西。控制这些特性正是本书存在的部分原因。

指针

在 C++ 标准文本中,"指针"一词出现了大约2000次,但如果你打开该文档的电子版仔细查找,会发现正式的定义竟然很难找到。这或许令人惊讶,因为人们往往会将"指针"这个概念与 C 语言(进而是 C++)联系在一起。

我们尝试给出一个有用但非正式的定义:指针是一个带类型的地址。它将某种类型与内存中某个位置的数据关联起来。因此,在下面的代码中,n 是一个 int 对象,而 p 指向的是一个 int 对象,恰好是 n 对象的地址:

ini 复制代码
int n = 3; // n 是一个 int 对象
char c;
// int *p = &c; // 不行,非法
int *p = &n;

这里需要理解的是,除非 p 未初始化、p 指向 nullptr,或者程序员故意利用类型系统让 p 指向其他东西,否则 p 确实指向一个 int。当然,指针 p 本身是一个对象,它符合相关规则。

关于指针的许多语法困惑,可能源自 *& 符号的上下文含义。诀窍是记住它们在引入名字时和用于已有对象时的不同作用:

ini 复制代码
int m = 4, n = 3;
int *p; // 声明(定义)一个指向 int 的指针 p(当前未初始化),引入名称 p
p = 0; // p 是空指针(不一定指向地址0;这里的0是约定俗成)
p = nullptr; // 同上,更清晰。尽量用 nullptr 替代字面量 0 来表示空指针
p = &m; // p 指向 m(p 存储 m 的地址)
assert(*p == 4); // p 已存在,*p 访问 p 指向的内容
p = &n; // p 现在指向 n(p 存储 n 的地址)
int *q = &n; // q 声明并定义为指向 int 的指针,&n 是 n 的地址,q 指向 int
assert(*q == 3); // 此时 n 的值为 3,q 指向 n,故 *q == 3
assert(*p == 3); // p 也同理
assert(p == q); // p 和 q 指向同一个 int 对象
*q = 4; // q 已存在,*q 表示"q 指向的内容"
assert(n == 4); // 确实,n 的值被通过 q 间接修改为 4
auto qq = &q; // qq 是 q 的地址,类型是"指向指针的指针",即 int **,但这种用法很少用
int &r = n; // 声明 r 为 int 类型的引用,引用后面详细介绍。注意这里的 & 是声明中的符号

如你所见,引入名字时,* 表示"指向";对已有对象,* 表示"指针指向的内容"(被指对象)。类似地,引入名字时,& 表示"引用";对已有对象,& 表示"取地址",得到指针。

指针允许我们进行算术运算,但这被认为是危险的操作,因为它可能让我们访问程序中任意位置,导致严重错误。指针算术取决于指针指向的数据类型:

scss 复制代码
int *f();
char *g();
int danger() {
   auto p = f(); // p 指向 f() 返回的内容
   int *q = p + 3; // q 指向 p 指向地址加上3个 int 大小的位置。不知道那里是什么,极其危险......
   auto pc = g(); // pc 指向 g() 返回的内容
   char * qc = pc + 3; // qc 指向 pc 指向地址加3个 char 大小的位置。请不要让指针指向你不了解的地址
}

当然,访问任意地址的内容就是自找麻烦。这会导致未定义行为(详见第2章),一旦发生,你就得自己承担后果。请不要在实际代码中这样做,因为可能破坏程序,甚至造成更严重的后果。C++ 功能强大且灵活,但如果你使用它,就应负责任且专业地编程。

C++中用于指针操作的四种特殊类型:

  • void* 表示"无具体类型含义的地址"。void* 是没有关联类型的地址。所有指针(忽略 constvolatile 限定符)都可以隐式转换为 void*;非正式说法是"所有指针,不论类型,都是地址"。反之则不成立,比如并非所有地址都可以隐式转换成 int*
  • char* 表示"指向字节的指针"。由于 C++ 来源于 C 语言,char* 可以别名程序内存中的任意地址(C 语言中 char 实际代表"字节"而非"字符")。C++ 正在努力让 char 恢复"字符"的含义,但目前 char* 几乎可以别名程序中任何内存区域,这限制了某些编译器优化(因为难以对"可能代表任何内存"的指针做约束和推理)。
  • std::byte* 是 C++17 以来新的"指向字节的指针"。长期目标是让 byte* 替代 char* 用于逐字节操作,但由于大量现有代码依赖 char*,这一过程需要时间。

下面是一个从 int* 转换到 void*,再转换回的例子:

ini 复制代码
int n = 3;
int *p = &n; // 到目前为止没问题
void *pv = p; // 可以,指针就是地址
// p = pv; // 不可以,void* 不一定指向 int(C 语言允许,C++ 不允许)
p = static_cast<int *>(pv); // 可以,你要求转换,但如果错了后果自负

下面的例子稍复杂,使用了 const char*(也可以用 const byte* 替代),展示如何在某些情况下逐字节比较两个对象是否相等:

arduino 复制代码
#include <iostream>
#include <type_traits>
using namespace std;

bool same_bytes(const char *p0, const char *p1, std::size_t n) {
    for(std::size_t i = 0; i != n; ++i)
        if(*(p0 + i) != *(p1 + i))
            return false;
    return true;
}

template <class T, class U>
bool same_bytes(const T &a, const U &b) {
    using namespace std;
    static_assert(sizeof a == sizeof b);
    static_assert(has_unique_object_representations_v<T>);
    static_assert(has_unique_object_representations_v<U>);
    return same_bytes(reinterpret_cast<const char*>(&a),
                     reinterpret_cast<const char*>(&b),
                     sizeof a);
}

struct X {
    int x {2}, y{3};
};
struct Y {
    int x {2}, y{3};
};

#include <cassert>
int main() {
    constexpr X x;
    constexpr Y y;
    assert(same_bytes(x, y));
}

has_unique_object_representations 这一类型特征在类型的值唯一定义时为真,即无填充位的类型。这个特征很重要,因为 C++ 并未明确规定对象中填充位的状态,对两个对象逐位比较时可能得到意想不到的结果。值得注意的是,浮点类型对象不被认为是由其值唯一定义,因为存在多种不同的 NaN(非数)值。

引用(References)

C++ 语言支持两类相关的间接引用:指针(pointers)和引用(references)。像它们的"表亲"指针一样,引用在 C++ 标准中也被频繁提及(超过1800次),但正式定义却很难找到。

我们将再次尝试提供一个非正式但可操作的定义:引用可以被视为对现有实体的别名。我们刻意没有使用"对象"一词,因为引用可以指向函数,而我们已知函数不是对象。

指针是对象,因此占用存储空间。引用则不是对象,不占用自身存储,尽管实现中可能用指针模拟其存在。比较 std::is_object_v<int*>std::is_object_v<int&>:前者为真,后者为假。

对引用使用 sizeof 运算符,会返回它所引用对象的大小。因此,对引用取地址,得到的是它所引用对象的地址。

在 C++ 中,引用总是绑定到一个对象,并且在引用的生命周期内始终绑定该对象。而指针在其生命周期内可以指向多个不同的对象,正如之前所见:

ini 复制代码
// int &nope; // 无法编译(nope会引用什么?)
int n = 3;
int &r = n; // r 引用 n
++r; // n 变为 4
assert(&r == &n); // 取 r 的地址即取 n 的地址

指针和引用的另一个区别是,与指针不同,引用没有算术运算。这使得引用相比指针更安全一些。程序中两种间接引用都有用武之地(本书也会用到两者),但日常编程中,通常建议能用引用就用引用,必须用指针时才用指针。

现在,我们已经了解了内存的表示方式,并初步了解了 C++ 对字节、对象、指针和引用等基本概念的定义,接下来可以更深入探讨对象的一些重要性质。

理解对象的基本属性

我们之前看到,在 C++ 中,对象有类型和地址。对象从构造开始到销毁结束,占据一段存储空间。接下来,我们将更详细地探讨这些基本属性,了解它们如何影响我们的编程方式。


对象的生命周期

C++ 的一个优势(也是相对复杂的原因之一)是程序员可以控制对象的生命周期。通常,自动对象在其作用域结束时按照确定顺序析构。静态(全局)对象在程序终止时析构,顺序在同一文件内是确定的,但跨文件静态对象的顺序则更复杂。动态分配的对象则"在程序指示的时候"析构(此处有许多细节)。

下面是一个简单示例程序,用来观察对象生命周期:

c 复制代码
#include <string>
#include <iostream>
#include <format>

struct X {
   std::string s;
   X(std::string_view s) : s{ s } {
      std::cout << std::format("X::X({})\n", s);
   }
   ~X(){
      std::cout << std::format("~X::X() for {}\n", s);
   }
};

X glob { "glob" };

void g() {
   X xg{ "g()" };
}

int main() {
   X *p0 = new X{ "p0" };
   [[maybe_unused]] X *p1 = new X{ "p1" }; // 漏了 delete,会内存泄漏
   X xmain{ "main()" };
   g();
   delete p0;
   // 忘记 delete p1 了
}

执行时打印:

css 复制代码
X::X(glob)
X::X(p0)
X::X(p1)
X::X(main())
X::X(g())
~X::X() for g()
~X::X() for p0
~X::X() for main()
~X::X() for glob

构造函数和析构函数调用次数不匹配说明我们有问题。具体来说,本例中我们用 new 手动创建了 p1 指向的对象,但未手动销毁它。

许多不熟悉 C++ 的程序员常混淆指针和被指对象。本例中,p0p1 本身是在 main() 函数作用域结束时析构的,与 xmain 一样,但由于它们指向的是动态分配的对象,必须显式调用 delete 来销毁对象。我们对 p0 做了这一步,却故意(示例目的)忘了对 p1 做。

那么 p1 指向的对象会怎样?它被手动构造了,但未被销毁。它悬浮在内存中,程序无法访问,这就是所谓的内存泄漏:程序分配了内存但未释放。

更严重的是,p1 指向对象的析构函数永远不会被调用,可能导致各种资源泄漏(文件未关闭,数据库连接未关闭,系统句柄未释放等)。第4章"使用析构函数"将探讨如何避免这种情况,编写干净简单的代码。

对象大小、对齐和填充

由于每个对象占用存储空间,对象关联的空间大小是 C++ 类型的重要(虽是底层)属性。例如:

arduino 复制代码
class B; // 前置声明,未来会有类 B
void f(B*); // 没问题,虽然不知 B 具体细节,所有对象地址大小相同
// class D : B {}; // 错误!要知道 D 是什么,需要知道 B 的大小和内容

上例中,尝试定义 D 类会失败。因为编译器需要知道 D 的大小以分配内存,而 D 继承自 B,不知道 B 大小就无法计算 D 大小。

对象或类型的大小可用 sizeof 运算符获得,返回编译时的非零无符号整数,表示存储对象所需字节数:

c 复制代码
char c;
// 根据标准,char 占用 1 字节
static_assert(sizeof c == 1); // 对象,括号可省略
static_assert(sizeof(c) == 1); // 括号可用
static_assert(sizeof(char) == 1); // 类型,括号必需

struct Tiny {};
// C++ 所有类型占用非零字节,即使"空"类 Tiny 也不例外
static_assert(sizeof(Tiny) > 0);

上例中,Tiny 是空类(无数据成员)。类可以有成员函数仍然是空类,空类带成员函数在 C++ 中很常用。

C++ 对象至少占用 1 字节,即使是空类对象。因为若大小为 0,则对象可与相邻对象重叠,难以推理。

C++ 与其他语言不同,不规范所有基本类型大小。例如,sizeof(int) 在不同编译器和平台可能不同。规则有:

  • sizeof(signed char), sizeof(unsigned char), sizeof(char), 以及 sizeof(std::byte) 都是 1,因它们可表示单字节。
  • 表达式 sizeof(short) >= sizeof(char)sizeof(int) >= sizeof(short) 在所有平台均成立。可能存在 sizeof(char) == sizeof(int) == 1 的情况。
  • C++ 标准规定了基本类型的最小位宽,详见 [wg21.link/tab:basic.fundamental.width]。
  • 对任意类型 T,sizeof(T) > 0 恒成立,C++ 不存在零大小对象,即使是空类对象也不例外。
  • 结构体或类对象的大小不小于其所有数据成员大小之和(但存在例外)。

下面的例子说明了最后一点:

c 复制代码
class X {};
class Y {
   X x;
};
int main() {
   static_assert(sizeof(X) > 0);
   static_assert(sizeof(Y) == sizeof(X)); // <--- 这里
}

sizeof(Y) 等于 sizeof(X),这是为什么?因为虽然 X 是空类,但对象必须占用至少一字节。Y 的数据成员 x 就占了这字节,所以 Y 无需额外增加空间。

再看:

c 复制代码
class X {
   char c;
};
class Y {
   X x;
};
int main() {
   static_assert(sizeof(X) == sizeof(char)); // <--- 这里
   static_assert(sizeof(Y) == sizeof(X));    // <--- 这里也一样
}

同理,X 占用其唯一成员 char 的大小,Y 占用其唯一成员 X 的大小。

继续:

c 复制代码
class X { };
class Y {
   X x;
   char c;
};
int main() {
   static_assert(sizeof(Y) >= sizeof(char) + sizeof(X));
}

这正是我们之前说的规则的正式表达。sizeof(Y) 大概率等于 sizeof(char) + sizeof(X)

最后看继承例子:

c 复制代码
class X { };
class Y : X { // <--- 私有继承
   char c;
};
int main() {
   static_assert(sizeof(Y) == sizeof(char)); // <--- 这里
}

Y 的数据成员不再是 X 对象,而是继承自 X。空基类 X 可以被"合并"进派生类 Y,称为空基类优化(empty base optimization)。编译器通常会自动应用此优化,尤其在单继承时。

注意:本例使用私有继承以强调 XY 的实现细节,不参与 Y 的接口。空基类优化同样适用于公有或保护继承,但私有继承保留了 XY 内部实现的事实。

从 C++20 起,如果你认为合成比继承更合适描述两个类(如 XY)的关系,可以使用 [[no_unique_address]] 标记成员,告诉编译器如果该成员是空类对象,则无需在包含对象中占用存储。编译器可以选择是否支持该属性,使用前请确认编译器兼容性。

c 复制代码
class X { };
class Y {
   char c;
   [[no_unique_address]] X x;
};
int main() {
   static_assert(sizeof(X) > 0);
   static_assert(sizeof(Y) == sizeof(char)); // <--- 这里
}

前面的示例都很简单,使用了零、一个或两个小数据成员的类。实际代码远不如此简单,来看下面的程序:

c 复制代码
class X {
   char c; // sizeof(char) == 1(定义所限)
   short s;
   int n;
};
int main() {
   static_assert(sizeof(short) == 2); // 假设如此......
   static_assert(sizeof(int) == 4);   // ......也是如此
   static_assert(
      sizeof(X) >= sizeof(char) + sizeof(short) + sizeof(int)
   );
}

假设上面两个静态断言成立(大概率如此,但不保证),我们知道 sizeof(X) 至少为 7(成员大小之和)。然而实际中,sizeof(X) 很可能是 8。看起来有点奇怪,但这是对齐(alignment)规则的逻辑结果。

对象(或其类型)的对齐指定了对象在内存中的放置位置。char 的对齐为1,意味着可以放在任何地址(只要能访问该内存)。而对齐为2(如 short)的类型,必须放在地址是2的倍数的位置。更一般地,类型的对齐为 n,则对象必须放在地址是 n 倍数的位置。

注意,对齐必须是严格正的2的幂,不符合会导致未定义行为。虽然编译器会帮你避免,但如果不小心,结合本书介绍的技巧,还是可能出错。能力越大,责任越大。

C++ 提供两个与对齐相关的运算符:

  • alignof:返回类型 T 或该类型对象的自然对齐方式。
  • alignas:让程序员强制指定对象的对齐方式,常用于内存操作或与特殊硬件接口时("特殊"可广义理解)。alignas 只能合理地增加自然对齐,不能减少。

对于基本类型 T,通常 sizeof(T)alignof(T) 相等,但此规律不适用于复合类型。例如:

c 复制代码
class X {
   char c;
   short s;
   int n;
};
int main() {
   static_assert(sizeof(short) == alignof(short));
   static_assert(sizeof(int) == alignof(int));
   static_assert(sizeof(X) == 8); // 很可能是这样
   static_assert(alignof(X) == alignof(int)); // 同理
}

一般来说,复合类型的对齐是其所有数据成员中最大对齐的那个。这里,"最大"指数值最大。对于类 X,最大对齐成员是 int n,所以 X 对象按 int 的对齐边界对齐。

你可能想知道,为什么如果 sizeof(short) == 2sizeof(int) == 4,那么 sizeof(X) == 8 是合理的?我们接下来会看看 X 类型对象的可能内存布局。

图中每个方框代表内存中的一个字节。可以看到,cs 的第一个字节之间有一个 ?,这是因为对齐产生的。如果 alignof(short) == 2alignof(int) == 4,那么唯一正确的 X 对象布局会将其成员 n 放置在一个4字节对齐的边界上。这意味着在 cs 之间会有一个填充字节(该字节不参与 X 的值表示),用来让 s 对齐到2字节边界,n 对齐到4字节边界。

更令人惊讶的是,数据成员在类中的排列顺序会影响该类对象的大小。例如,考虑下面的代码:

c 复制代码
class X {
   short s;
   int n;
   char c;
};
int main() {
   static_assert(sizeof(short) == alignof(short));
   static_assert(sizeof(int) == alignof(int));
   static_assert(alignof(X) == alignof(int));
   static_assert(sizeof(X) == 12); // 很可能是这个结果
}

这常常让人感到意外,但确实如此,值得深思。以此例,X 对象的可能内存布局如下:

图中每个方框代表内存中的一个字节。我们看到 sn 之间有两个 ?,这应该清楚了,但 n 之后的三个 ? 可能让人感到意外。毕竟,为什么要在对象末尾添加填充?

答案是数组。稍后我们会讨论,数组的元素在内存中是连续的,因此每个元素必须正确对齐。像这种情况,类 X 对象末尾的填充字节确保了如果数组中某个元素对齐正确,那么下一个元素也会对齐正确。

了解了对齐后,注意仅仅调整 X 类中成员的顺序,就可能导致每个对象的内存消耗增加50%。这不仅增加了程序的内存占用,还会影响性能。C++ 编译器不会帮你重新排列数据成员,因为代码中会直接使用对象地址。改变数据成员的相对位置可能破坏用户代码,因此程序员应谨慎设计布局。需要注意的是,保持对象尺寸小不是选择布局的唯一因素,特别是在多线程代码中(有时保持两个对象间隔一定距离有助于缓存效率)。布局很重要,但不能盲目对待。

复制与移动

现在我们需要简单介绍复制与移动,这两个概念在 C++ 这样有真正对象的语言中至关重要。

C++ 语言认为有六个特殊成员函数。除非程序员显式阻止,否则这些函数会自动为你的类型生成。它们分别是:

  • 默认构造函数:六个函数中最不特殊的,只有当你没写任何构造函数时才隐式生成。
  • 析构函数:对象生命周期结束时调用。
  • 拷贝构造函数:用同类型的单个对象构造新对象时调用。
  • 拷贝赋值运算符:用另一个对象内容替换已有对象内容时调用。
  • 移动构造函数 :用一个可"移动"的对象(如匿名表达式结果、函数返回值)引用构造新对象时调用。程序也可以用 std::move() 显式将对象标记为可移动。
  • 移动赋值运算符:与拷贝赋值类似,但当赋值操作的参数是可移动对象时调用。

当类型不自己管理资源时,通常不需要写这些特殊函数,编译器自动生成的版本已经足够。举例:

arduino 复制代码
struct Point2D {
   float x{}, y{};
};

Point2D 代表二维坐标,没有不变量(x 和 y 的所有值都有效)。这里对 x 和 y 使用了默认初始化为 0,所以默认构造的 Point2D 对象代表坐标 (0,0),六个特殊成员函数表现正常。拷贝构造调用成员拷贝构造,拷贝赋值调用成员拷贝赋值,析构函数平凡,移动操作等同于拷贝操作(因为成员是基本类型)。

如果你想添加带参数的构造函数,让用户代码可以用非默认值初始化 x 和 y,可以这样做。但这会导致隐式默认构造函数消失:

csharp 复制代码
struct Point2D {
   float x{}, y{};
   Point2D(float x, float y) : x{ x }, y{ y } {}
};

void oops() {
   Point2D pt; // 编译错误,没有默认构造函数
}

当然可以修复。方法之一是显式写默认构造函数:

csharp 复制代码
struct Point2D {
   float x, y; // 不用默认初始化
   Point2D(float x, float y) : x{ x }, y{ y } {}
   Point2D() : x{}, y{} {} // <--- 这里
};

void oops() {
   Point2D pt; // 正常
}

另一个方法是委托默认构造函数调用带参数构造函数:

csharp 复制代码
struct Point2D {
   float x, y;
   Point2D(float x, float y) : x{ x }, y{ y } {}
   Point2D() : Point2D{ 0, 0 } {} // <--- 这里
};

void oops() {
   Point2D pt; // 正常
}

还有更好办法,用 = default 明确告诉编译器保留默认行为:

csharp 复制代码
struct Point2D {
   float x{}, y{};
   Point2D(float x, float y) : x{ x }, y{ y } {}
   Point2D() = default; // <--- 这里
};

void oops() {
   Point2D pt; // 正常
}

最后这种方式通常产生最优代码,因为编译器能根据程序员意图最大化优化。= default 明确表达了"请保持默认行为"。

关于这些构造函数的说明

本例中添加带参数构造函数只是为了示范,Point2D 实际上是聚合类型。聚合类型有特殊的初始化支持,但这不是本示例重点。聚合类型满足一系列限制(无用户声明或继承构造函数,无私有非静态数据成员,无虚基类等),通常无不变量,且编译器能高效初始化。

当类显式管理资源时,编译器自动生成的特殊函数往往不满足需求,无法表达程序员意图。假设我们写一个简单的字符串类:

c 复制代码
#include <cstring> // std::strlen()
#include <algorithm> // std::copy()

class naive_string { // 简单到没用
   char *p{}; // 指向字符元素(初始为 nullptr)
   std::size_t nelems{}; // 元素数量(初始为 0)
public:
   std::size_t size() const { return nelems; }
   bool empty() const { return size() == 0; }
   naive_string() = default; // 空字符串
   naive_string(const char *s)
      : nelems{ std::strlen(s) } {
      p = new char[size() + 1]; // 留出空间放方便的结尾 '\0'
      std::copy(s, s + size(), p);
      p[size()] = '\0';
   }
   char operator[](std::size_t n) const { return p[n]; }
   char& operator[](std::size_t n) { return p[n]; }
   // ... 其他代码省略
};

虽然简单,但该类显式分配资源,分配了 size() + 1 字节来存储字符序列副本。因此,编译器自动生成的特殊函数不适用。例如,默认拷贝构造函数会浅拷贝指针 p,导致两个指针共享同一个指向对象,可能导致错误。默认析构函数只会释放指针本身,但不会释放指针指向的内存,会引发内存泄漏等问题。

在这种情况下,我们需要实现"三法则":写析构函数、拷贝构造函数和拷贝赋值操作符。在 C++11 引入移动语义前,这已足够实现资源管理。现在加上移动构造和移动赋值操作符,被称为"五法则",更全面高效。

析构

由于我们的 naive_string 类型通过指针 p 管理动态分配的数组资源,其析构函数非常简单,职责就是释放 p 指向的内存:

javascript 复制代码
// ...
~naive_string() {
   delete [] p;
}
// ...

注意不需要检查 p 是否为 nullptrdelete nullptr; 在 C++ 中什么也不做且安全)。另外,我们用的是 delete[] 而非 delete,因为内存是通过 new[] 分配的,具体区别将在第7章讲解。

复制操作

拷贝构造函数是在用同类型对象初始化新对象时调用的。例如:

scss 复制代码
// ...
void f(naive_string); // 按值传递

void copy_construction_examples() {
   naive_string s0{ "What a fine day" };
   naive_string s1 = s0; // 调用拷贝构造函数
   naive_string s2(s0); // 同上
   naive_string s3{ s0 }; // 同上
   f(s0); // 按值传递,也调用拷贝构造
   s1 = s0; // 这里不是拷贝构造,是拷贝赋值
}

对于我们的 naive_string 类,正确的拷贝构造函数示例如下:

scss 复制代码
// ...
naive_string(const naive_string &other)
   : p{ new char[other.size() + 1] },
     nelems{ other.size() } {
   std::copy(other.p, other.p + other.size(), p);
   p[size()] = '\0';
}
// ...

拷贝赋值可以有多种写法,但很多都复杂或危险。比如下面的示例------但千万别这样写赋值操作符!:

arduino 复制代码
// ...
// 错误的拷贝赋值操作符
naive_string& operator=(const naive_string &other) {
   // 先释放原有内存
   delete [] p;
   // 再申请新内存 ------ 注意这里
   p = new char[other.size() + 1];
   // 复制内容
   std::copy(other.p, other.p + other.size(), p);
   // 更新大小,添加结尾零
   nelems = other.size();
   p[size()] = '\0';
   return *this;
}
// ...

这看起来合理(虽然有点啰嗦),但如果内存申请失败会怎样?比如内存不足时,new 会抛异常,这时函数会中断,导致对象处于不正确状态:p 非空,nelems 非零,但 p 指向的内存无效(野指针),程序行为未定义。

我们可以尝试修补,但仍建议不要写成这样:

arduino 复制代码
// ...
// 另一个错误的拷贝赋值操作符
naive_string& operator=(const naive_string &other) {
   // 先申请新内存
   char *q = new char[other.size() + 1];
   // 释放旧内存,指针指向新内存 ------ 注意这里
   delete [] p;
   p = q;
   // 复制内容
   std::copy(other.p, other.p + other.size(), p);
   // 更新大小,添加结尾零
   nelems = other.size();
   p[size()] = '\0';
   return *this;
}
// ...

表面上看更安全,因为只有在确认分配成功后才释放旧内存。它甚至可能通过大多数测试,直到遇到下面的测试:

csharp 复制代码
void test_self_assignment() {
   naive_string s0 { "This is not going to end well..." };
   s0 = s0; // 糟糕!
}

此时拷贝赋值会表现极差。申请新内存后释放旧内存,而旧内存是源对象的数据,释放后再复制就从无效内存读取,程序崩溃。

自我赋值问题修复(不完美)

ini 复制代码
// ...
// 解决自我赋值,但代码变复杂,暗示设计有问题
naive_string& operator=(const naive_string &other) {
   if(this == &other) return *this; // 防止自我赋值
   char *q = new char[other.size() + 1];
   delete [] p; // 注意这里
   p = q;
   std::copy(other.p, other.p + other.size(), p);
   nelems = other.size();
   p[size()] = '\0';
   return *this;
}
// ...

这是一个"悲观"方案,所有赋值操作都会执行额外的判断,虽然这个分支很少被触发。暴力解决问题导致代码复杂且难以维护。面对"悲观化"时,最好退一步重新思考问题。

复制-交换惯用法(copy-and-swap)

C++ 有一种著名惯用法,称为安全赋值惯用法,俗称复制-交换。思想是:赋值操作分两步------销毁目标对象已有状态(左值),复制源对象状态(右值)到目标。销毁相当于析构函数,复制相当于拷贝构造函数。

通常通过结合拷贝构造函数、析构函数和 swap() 函数实现:

arduino 复制代码
// ...
void swap(naive_string &other) noexcept {
   using std::swap; // 让标准 swap 可用
   swap(p, other.p); // 交换数据成员
   swap(nelems, other.nelems);
}

// 赋值操作符惯用写法
naive_string& operator=(const naive_string &other) {
   naive_string { other }.swap(*this); // <--- 这里
   return *this; // 就这么简单!
}
// ...

这招非常实用,异常安全、简单且适用于几乎所有类型。赋值语句做了三件事:

  1. 用拷贝构造函数构造源对象的匿名副本(可能抛异常,异常时不改变 *this,保证不破坏状态)。
  2. 交换匿名临时对象和当前对象内容(把新数据放到 *this,旧数据放临时对象)。
  3. 临时对象表达式结束时销毁,释放旧数据。

复制-交换还能安全处理自我赋值。它会额外做一次拷贝,但换来了避免分支判断的简单和安全。

swap() 函数前的 noexcept 表示此函数不会抛异常,这有助于后续优化。

移动操作

naive_string 经过析构函数、拷贝构造和拷贝赋值实现了基本资源管理,但可以更快且更安全。

考虑下面的非成员字符串拼接运算符:

arduino 复制代码
// 返回 s0 和 s1 的拼接结果
naive_string operator+(naive_string s0, naive_string s1);

可用于用户代码:

javascript 复制代码
naive_string make_message(naive_string name) {
   naive_string s0{ "Hello " }, s1{ "!" };
   return s0 + name + s1; // <--- 注意这一行
}

return 表达式先调用 operator+() 创建匿名对象(s0 + name),再用它和 s1 拼接生成另一个匿名对象。每个匿名对象都要分配内存、拷贝数据、销毁,开销大,还可能抛异常。

但它确实能工作。

自 C++11 起,可以利用移动语义显著提高效率。除了"三法则"函数,还可添加移动构造和移动赋值。编译器会在确定对象不再使用时自动调用它们。

示例如下:

kotlin 复制代码
// ...
return s0 + name + s1;
// ...

相当于:

kotlin 复制代码
// ...
return (s0 + name) + s1;
//         ^^^^^^^^^^^ <-- 匿名对象,无法引用
// ...

再展开:

scss 复制代码
// ...
((s0 + name) + s1);
// ^^^^^^^^^^^^^^^ <-- 匿名对象,同上
// ...

复制操作的目的是保持源对象不变以备后续使用。匿名临时对象无须保持,因此可以用移动操作而非复制。标准要求移动后对象处于"有效但未定义状态",即可安全销毁或赋值,且保持不变式。通常是移动后对象相当于默认状态。


naive_string 的移动构造函数示例如下:

arduino 复制代码
// ...
naive_string(naive_string &&other) noexcept
   : p{ std::move(other.p) },
     nelems{ std::move(other.nelems) } {
   other.p = nullptr;
   other.nelems = 0;
}
// ...

这里调用 std::move() 可省略(基本类型移动相当于复制),但显式写明意图更好。std::move() 实际不移动任何东西,只是告诉编译器该对象可被移动,类似强制类型转换。

移动构造函数要点:

  • 参数类型是 naive_string&&,即右值引用。
  • 标记为 noexcept,表示不会抛异常。
  • 将状态从 other 转移给当前构造对象,并将 other 置于有效但未定义状态。

利用 <utility> 头文件中的 std::exchange() 可简化移动构造:

css 复制代码
a = std::exchange(b, c);

表示"将 b 赋给 a,同时将 c 赋给 b"。移动构造用法:

arduino 复制代码
// ...
naive_string(naive_string &&other) noexcept
   : p{ std::exchange(other.p, nullptr) },
     nelems{ std::exchange(other.nelems, 0) } {
}
// ...

这是惯用 C++ 写法,在某些情况下能带来优化。


移动赋值操作符也类似复制赋值惯用法:

arduino 复制代码
// 移动赋值操作符惯用写法
naive_string& operator=(naive_string &&other) noexcept {
   naive_string { std::move(other) }.swap(*this);
   return *this;
}

移动赋值与复制赋值的底层逻辑相同。

数组

我们在前面的示例中使用过数组,但并未正式定义这个有用但较底层的结构。注意,本节中的"数组"指的是原生内置数组,而非其他更高级但同样有用的结构,如 std::vector<T>std::array<T, N>

简单来说,在 C++ 中,数组是同类型元素的连续序列。因此,在下面的代码中,a0 对象占用内存大小为 10 * sizeof(int) 字节,而 a1 对象占用 20 * sizeof(std::string) 字节:

c 复制代码
int a0[10];
std::string a1[20];

数组中索引 ii+1 处元素之间的字节数正好是 sizeof(T),其中 T 是数组元素类型。

考虑下面这个表达式,C++ 中和 C 中对数组 arr 的访问方式:

css 复制代码
arr[i]

它等价于:

css 复制代码
*(arr + i)

因为指针算术是有类型的,表达式中的 + i 表示"向后移动 i 个元素",即"向后移动 i 个元素大小的字节数"。


数组大小必须为正整数,不能为 0,除非是动态分配的数组:

arduino 复制代码
int a0[5]; // 合法
static_assert(sizeof a0 == 5 * sizeof(int));
enum { N = sizeof a0 / sizeof a0[0] }; // N == 5

// int a1[0]; // 不允许:数组大小为0时,内存地址会与后续对象重合!

int *p0 = new int[5]; // 合法,但需要管理指针指向的内存
int *p1 = new int[0]; // 合法,动态分配,但仍需管理指针指向的内存

// ...
delete [] p1; // 正确释放
delete [] p0; // 正确释放,负责任地管理资源

每次调用 operator new[] 都必须返回不同的地址,即使数组大小为 0。每次调用技术上都返回不同对象的地址。

总结

本章我们回顾了 C++ 语言中的一些基础概念,例如:什么是对象?什么是指针和引用?当我们谈论对象或类型的大小与对齐时指的是什么?为什么 C++ 中不存在零大小对象?类的特殊成员有哪些,什么时候需要显式编写它们?这些主题虽不全面,但为我们接下来的内容建立了共同的词汇基础。

有了这些准备,我们可以"动手实践"了。我们掌握了一套底层的工具和思路,可以基于此构建更高层次的抽象,但同时也需要保持一定的编程纪律。

下一章将讨论我们需要避免的事项,包括未定义行为(undefined behavior)、实现定义行为(implementation-defined behavior,较少涉及)、格式错误但不要求诊断的代码、缓冲区溢出以及其他不推荐的行为。

随后一章将介绍 C++ 的类型转换(casts),讲述即使在想绕开语言类型系统规则时,它们如何帮助我们表达清晰的意图。

之后,我们将开始构建优雅且强大的抽象,这将有助于实现我们的目标------安全且高效地管理资源,特别是内存管理。

相关推荐
oioihoii44 分钟前
C++和C#界面开发方式的全面对比
开发语言·c++·c#
Dovis(誓平步青云)1 小时前
C++ Vector算法精讲与底层探秘:从经典例题到性能优化全解析
开发语言·c++·经验分享·笔记·算法
whoarethenext4 小时前
c/c++的opencv图像金字塔缩放
c语言·c++·opencv·图像金字塔
鑫鑫向栄6 小时前
[蓝桥杯]剪格子
数据结构·c++·算法·职场和发展·蓝桥杯
白总Server6 小时前
C++语法架构解说
java·网络·c++·网络协议·架构·golang·scala
掘金-我是哪吒7 小时前
分布式微服务系统架构第142集:全栈开发
分布式·微服务·云原生·架构
羊儿~7 小时前
P12592题解
数据结构·c++·算法
.Vcoistnt7 小时前
Codeforces Round 1028 (Div. 2)(A-D)
数据结构·c++·算法·贪心算法·动态规划
PXM的算法星球8 小时前
paoxiaomo的XCPC算法竞赛训练经验
c++·算法
孤独得猿8 小时前
高阶数据结构——并查集
数据结构·c++·经验分享·算法