1.2. C++的内存表示

这是一本关于内存管理的书籍。作为读者的您正在试图理解其含义,而作为作者的我则致力于传达这些概念。

标准中对内存的描述方式可参见[wg21.link/basic.memobj],C++中的内存可表示为一个或多个连续的字节序列。所以内存可以表示为不连续的连续内存块集合,从历史上看,C++支持由不同内存段组成的存储系统。C++程序中的每个字节都可以有唯一地址。

C++程序的内存中存储着各种实体,如对象、函数、引用等。要高效管理内存,就需要理解这些实体的含义,以及程序如何利用它们。“字节”一词在C++中具有重要意义。如[intro.memory]中详细说明的:

不过在C++中:

1.2.1 对象、指针和引用

我们常会随意地使用“对象”、“指针”和“引用”这些术语,而很少深入思考其在C++中的确切含义。然而,在C++语言中,这些术语都有着精确定义,这些定义会制约我们的编程实践。

开始动手实践之前,先来厘清这些术语在C++中的定义:

对象

如果询问不同语言的开发者如何定义“对象”,可能会得到诸如“将变量和相关函数组合的实体”或“类的实例”这样的回答 —— 这些确实反映了面向对象编程领域对这个术语的传统理解。但在C++中,“对象”有着更为基础而精确的定义…

C++作为一门编程语言,致力于为用户自定义类型(如结构体和类)与基本类型(如int和float)提供同等的支持。因此,C++对“对象”的定义是基于其属性特征,而非字面含义,而且这个定义涵盖了最基本的类型。

C++中对象的定义详见[wg21.link/intro.object],主要有以下几点:

C++标准明确指出:函数不属于对象范畴,即便函数具有地址并占用存储空间。

由此可以得出两个重要结论:

  1. 最基础的int类型也是对象
  2. 函数不是对象

细心的读者已经发现,本书将触及编程最基础的核心概念 —— 日常使用的这些实体(对象),其生命周期和存储空间正是内存管理的关键组成部分。通过下面这个简单程序,能更直观的进行了解:

#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的地址。

  1. 除非p未初始化、指向nullptr或开发者刻意操纵类型系统,否则p始终指向int类型对象
  2. 指针p本身也是一个对象(符合对象的所有定义规则)

关于指针的(语法)混淆,主要源于*和&符号的上下文多义性。需要区分它们在变量声明和对象操作时的不同作用:

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* 转换的示例,请看以下代码:

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++ 如何定义一些基本概念,如字节、对象、指针或引用,接下来将更深入地研究对象的一些重要定义属性。