这是一本关于内存管理的书籍。作为读者的您正在试图理解其含义,而作为作者的我则致力于传达这些概念。
标准中对内存的描述方式可参见[wg21.link/basic.memobj],C++中的内存可表示为一个或多个连续的字节序列。所以内存可以表示为不连续的连续内存块集合,从历史上看,C++支持由不同内存段组成的存储系统。C++程序中的每个字节都可以有唯一地址。
C++程序的内存中存储着各种实体,如对象、函数、引用等。要高效管理内存,就需要理解这些实体的含义,以及程序如何利用它们。“字节”一词在C++中具有重要意义。如[intro.memory]中详细说明的:
字节是C++中的基本存储单元
一个字节的具体位数由具体实现定义
但标准规定,一个字节必须足够宽以同时包含:
基本字符集中普通字面量的编码
UTF-8编码形式的8位代码单元
一个字节由连续的位序列构成
不过在C++中:
字节(byte)不一定是8位组(octet)
一个字节至少包含8位,但可能更多(在某些特殊硬件上)
字节是程序中内存的最小可寻址单元
我们常会随意地使用“对象”、“指针”和“引用”这些术语,而很少深入思考其在C++中的确切含义。然而,在C++语言中,这些术语都有着精确定义,这些定义会制约我们的编程实践。
开始动手实践之前,先来厘清这些术语在C++中的定义:
如果询问不同语言的开发者如何定义“对象”,可能会得到诸如“将变量和相关函数组合的实体”或“类的实例”这样的回答 —— 这些确实反映了面向对象编程领域对这个术语的传统理解。但在C++中,“对象”有着更为基础而精确的定义…
C++作为一门编程语言,致力于为用户自定义类型(如结构体和类)与基本类型(如int和float)提供同等的支持。因此,C++对“对象”的定义是基于其属性特征,而非字面含义,而且这个定义涵盖了最基本的类型。
C++中对象的定义详见[wg21.link/intro.object],主要有以下几点:
显式创建:通过定义对象或使用operator new的各种变体构造对象时创建。也可能隐式创建(例如,表达式结果产生的临时对象),或更改union当前活跃成员时。
存储特性:
对象始终具有存储地址
占据非零大小的存储区域
生命周期从构造开始到析构结束
其他属性:
名称(若有的话)
类型
存储周期(automatic, static, thread_local等)
C++标准明确指出:函数不属于对象范畴,即便函数具有地址并占用存储空间。
由此可以得出两个重要结论:
细心的读者已经发现,本书将触及编程最基础的核心概念 —— 日常使用的这些实体(对象),其生命周期和存储空间正是内存管理的关键组成部分。通过下面这个简单程序,能更直观的进行了解:
#include <type_traits>
int main() {
static_assert(std::is_object_v<int>);
static_assert(!std::is_object_v<decltype(main)>);
}
何为对象?在C++中,对象是指具有生命周期并占据存储空间的实体。如何控制这些特性,正是本书存在的意义。
尽管C++标准文本中约有2000处提及“指针”,但若仔细查阅标准文档,会惊讶地发现 —— 这与C/C++语言紧密关联的核心概念,竟然没有正式的定义条款。这一现象或许会令许多将指针视为C/C++标志性特性的开发者感到意外。
int n = 3; // n 是一个 int 对象
char c;
// int *p = &c; // 这样做是非法的
int *p = &n;
可以理解为:n是一个int类型的对象,而p作为指针指向一个int对象 —— 这个对象恰好是n的地址。
关于指针的(语法)混淆,主要源于*和&符号的上下文多义性。需要区分它们在变量声明和对象操作时的不同作用:
int m = 4, n = 3;
int *p; // p 声明(并定义)了一个指向 int 的指针(当前未初始化),引入了一个名字
p = 0; // p 是一个空指针(它不一定指向地址零;这里的 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 的地址,也就是一个 int 的地址: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); // 确实如此,因为通过 q 我们间接修改了 n 的值
auto qq = &q; // qq 是 q 的地址,其类型是“指向指针的指针”,即 int **... 但我们几乎不会(或者根本不会)用到这个
int &r = n; // 将 r 声明为整数 n 的引用(见下文)。请注意在这种情况下, & 是在声明中使用的
在声明对象时,*表示“指向...的指针”;而在操作现有对象时,表示“指针所指向的内容”(即被指向的对象)。类似地,在声明名称时,&表示“...的引用”;而在操作现有对象时,它表示“取地址”并返回一个指针。
指针可以进行算术运算,但这(理所当然地)是非常危险的操作。其可能将我们带到程序中的任意位置,从而导致严重破坏。指针的算术运算取决于其类型:
int *f();
char *g();
int danger() {
auto p = f(); // p 指向 f() 返回的内容
int *q = p + 3; // q 指向 p 所指位置加上三个 int 大小的位置。
// 这个位置具体在哪里毫无头绪,但这是个非常糟糕的主意……
auto pc = g(); // pc 指向 g() 返回的内容
char * qc = pc + 3; // qc 指向 pc 所指位置加上三个 char 的大小。
// 请不要让你的指针就这样指向你完全不了解的地方
}
当然,随意访问地址的内容无异于自找麻烦,这可能触发未定义行为(将在第2章详述),后果将完全由您自己承担。请勿在实际代码中进行此类操作 —— 可能会伤害程序,甚至更糟。C++强大而灵活,但如果使用C++编程,其开发者应当以负责任和专业的态度行事。
C++为指针操作提供了四种特殊类型:
void* 表示“无特定(类型相关)语义的地址”。void* 是一个没有关联类型的地址。所有指针(如果不考虑 const 和 volatile 限定符)都可以隐式转换为 void*;非正式的理解方式是“所有指针,无论其类型如何,本质上都是地址”;反之,则不成立。例如,并非所有地址都能隐式转换为 int 指针。
char* 表示“指向字节的指针”。由于 C++ 源自 C 语言,char* 可以别名化内存中的任何地址(char 类型,尽管名称暗示“字符”,但在 C 和延伸的 C++ 中实际上表示"字节")。C++ 正在努力让 char 回归“字符”的本义,但截至本书写作时,char* 仍然可以别名化程序中的所有内容。这限制了一些编译器优化的机会(很难对可能导致内存中变化的内容进行约束或推断)。
std::byte* 是新的“指向字节的指针”,从 C++17 开始支持。byte* 的(长期)目的是,在那些进行逐字节操作或寻址的函数中,取代 char*。但由于有大量代码使用 char* 实现这些功能,这一转变需要一些时间。
关于 void* 转换的示例,请看以下代码:
int n = 3;
int *p = &n; // 目前没问题
void *pv = p; // 可以,指针就是一个地址
// p = pv; // 不行,void* 不一定指向 int(在 C 中可以,C++ 中不行)
p = static_cast<int *>(pv); // 可以,你明确要求这么做了,但如果错了,
// 那就只能自己负责了
面这个稍复杂的示例使用了 const char*(也可以用 const byte* 替代),有时可以通过逐字节比较两个对象的表示形式,来判断它们是否相等:
#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 特性对于由值唯一确定的类型(即不包含填充位的类型)返回 true,这很重要。C++ 并未规定对象中填充位的处理方式,对两个对象进行逐位比较可能会产生意料之外的结果。浮点类型的对象不可视为由值唯一确定,所以存在多个不同的值都符合 NaN(“非数字”)的定义。
C++ 语言支持两种相关的间接访问方式:指针和引用。与它们的表亲指针类似,引用在 C++ 标准中被频繁提及(超过 1800 次),但很难找到其正式定义。
我们再次尝试提供一个非正式但实用的定义:引用可以看作是现有实体的别名。这里特意没有使用"对象",因为可以引用函数,但函数不可为对象。
指针是对象,因此它们占用存储空间。而引用不是对象,也不占用自己的存储空间,实现可能用指针来进行模拟。比较 std::is_object_v<int*> 和 std::is_object_v<int&>:前者为 true,后者为 false。
对引用应用 sizeof 操作符将返回其所指对象的大小,获取引用的地址将得到其所指对象的地址。
C++引用总是绑定到一个对象,并在其生命周期内保持绑定。而指针在其生命周期内,可以指向多个不同的对象:
// int &nope; // 这样无法通过编译(nope 应该引用谁呢?)
int n = 3;
int &r = n; // r 引用 n
++r; // n 的值变为 4
assert(&r == &n); // 取 r 的地址实际上取的是 n 的地址
指针和引用的另一个区别是,与指针不同,不存在引用算术,所以引用比指针更安全。程序中可以同时使用这两种间接访问方式(本书中都会用到!),但日常编程的一个经验法则是:尽可能使用引用,必要时才使用指针。
现在,已经探讨了内存的表示方式,并了解了 C++ 如何定义一些基本概念,如字节、对象、指针或引用,接下来将更深入地研究对象的一些重要定义属性。