目录
-
-
- [5.1 内存](#5.1 内存)
- [5.2 指针](#5.2 指针)
-
5.1 内存
在任何编程语言中,你创建的每个对象都存在于程序内存的某个位置。当操作系统启动时,它会给每个程序(更具体地说,每个进程)分配一定量的内存供其使用,这被称为该程序的地址空间。从概念上讲,地址空间可以被视为一大块二进制数据:由 0 和 1 组成。然而,在实际中,这块数据被划分为不同的部分:
| 地区 | 描述 |
|---|---|
| 共享内存 | 操作系统预留的、与程序共享的内存,它允许当前进程与操作系统和/或其他进程之间进行通信 |
| 栈 | 存储函数调用 、局部变量 和控制流信息,会随着函数的执行自动增大和缩小(如下所述) |
| 堆 | 存储动态分配的对象,这些对象的存在时间超过函数调用,并且需要程序员进行显式管理(如下所述) |
| 全局变量 | 在函数外部声明的变量存放在这里 |
| 指令段 | 也称为文本段。这里存放的是当前正在执行的进程代码:编译器生成的原始机器码就位于此处 |

内存通常是按字节寻址的,每个字节从 0 开始编号。

每个区域都有重要的用途,但在我们关于 C++ 的讨论中,我们将重点关注栈 和堆,因为这两个地方是程序变量的存储位置。
5.1.1 栈
栈 (也称为调用栈 或程序栈 )是存储函数调用及其局部变量的地方。每次调用函数时,都会创建一个栈帧 (也称为活动记录)来存储该函数的局部变量。
栈先进后出。
栈的存储
由于main是任何 C++ 程序调用的第一个函数,因此在程序运行期间,总会有一个main函数位于栈的底部(只有当程序退出时才会被弹出)。当调用其他函数时,它们的栈帧会被压入到前一个栈帧的顶部。可以使用栈图 来描绘程序生命周期中任意时刻的这一过程。由于栈在内存中实际上是向下增长的(后面会详细介绍这一点),所以在绘制栈图时,通常会将main函数放在顶部,其他函数放在其下方。例如:
cpp
int main() {
foo(106);
int x = 107;
return 0;
}
void foo(int z) {
int y = z;
}
行1:此时,存在一个单独的空栈帧,用于main函数。请注意,其中没有变量x的条目,因为在程序的这个阶段,该变量尚未声明。
行2,行7-9:main调用foo,将一个新的栈帧压入栈中。foo声明了y,如其栈帧中y的条目所示。
行3:foo执行完毕,因此它的栈帧从栈中弹出。main声明了x。

栈溢出
栈并非取之不尽的资源,在大多数系统上只有1-8MB,在Unix系统上,可以输入ulimit -s 来查询栈空间(用KB表示)。
因此必须注意避免过多嵌套函数调用而导致栈空间耗尽。例如,考虑下面这个有问题的程序,它本意是计算一个数的阶乘:
cpp
int factorial(int n) {
return n * factorial(n - 1);
}

缺少基准情况时,factorial会无限地推入栈帧。由于操作系统只为栈分配了这么多内存,程序最终会耗尽空间,引发段错误并导致程序被强制终止。这种特定情况被称为栈溢出,也就是stack overflow。
因此,递归要有终止条件:
cpp
int factorial(int n) {
if (n == 0) return 1;
return n * factorial(n - 1);
}
栈的局限
- 局部变量的大小在编译时必须是严格已知的。例如,创建一个包含
n个整数的数组(其中n是从std::cin读取的)在 C++ 中是不被允许的。 - 如前所述,栈的空间是有限的,因此在这里存储非常大的对象(如大型数组、查找表等)是不可行的。
- 局部变量的生命周期与其所在函数的生命周期绑定。你不能在栈帧中创建一个比其所在函数调用更长寿的变量,因为当函数执行结束时,栈帧中的所有内容都会被释放。
5.1.2 堆
堆 (也称为自由存储区)存储着大小可变的内存块,这些内存块的存在时间可以超过函数调用的生命周期。它克服了前面提到的栈的局限性,但代价是性能稍差,并且需要程序员付出额外的精力来进行管理。
当你从堆中请求内存时,分配器 必须在内部搜索一个未被使用的区域,该区域的大小至少要与你请求的内存量相当 ------ 不过你可能会得到更大的区域!如果没有足够大的连续块可用,分配器会发出无法满足请求的信号。如果请求得到处理,它会返回一个指向新分配内存区域的指针,这样你就可以操作该区域的内容,并在将来再次引用该区域。当你使用完这块内存后,可以(通过指向该区域的指针)将其归还给分配器,以便为将来的请求释放空间。
拥有一个独立的堆解决了栈的所有局限性:
- 由于分配器是一种运行时结构,你可以分配任何大小的内存块 ------ 包括那些直到运行时才会知道大小的内存块。
- 堆通常比栈大得多得多,这使得分配非常大的内存块成为可能。
- 在堆上创建的分配独立于栈帧存在。一个内存块可以在一个栈帧中分配,却在另一个栈帧中释放。
使用堆上的内存通常比使用栈上的内存速度慢。一方面,这是因为在堆上分配内存需要分配器寻找一个空闲位置,而栈只需要将数据压入当前栈顶所在的位置。另一方面,由于堆内存的分配方式,它可能会变得碎片化,并且比栈内存分布得更分散。如果处理器访问的内存位置往往更集中(例如栈),而不是分布更分散(堆),那么它的工作速度会更快。这被称为局部性原理。
5.2 指针
在许多常见的编程语言中,例如 Python,地址空间的使用是自动为你管理的。在 C++ 中,大部分情况也是如此,然而,作为一种系统编程语言,C++ 还通过一种被称为指针的语言特性,让你能够直接访问这种内存。
C++ 中的指针既是内存中对象的地址,也是我们在代码中表示该地址的方式。
在字节可寻址内存(大多数计算机都使用这种内存)中,程序地址空间中的每个字节都由一个从 0 开始并逐个递增的数字来标识 ------ 这就是每个字节的地址 。一个对象在内存中可能占用多个字节,在这种情况下,整个对象的地址就是其第一个字节的地址(即地址值最小的那个字节的地址)。例如,在大多数系统上,一个int类型占用 4 个字节(即 32 位)的空间。如果我们去查看一个整数的内存,可能会看到类似下面这样的情况:
cpp
int main() {
int x = 106;
return 0;
}

在该程序的某次特定运行中,x的内存起始地址为0x7fff4a5ff71c。需要注意的是,为简洁起见,地址通常以十六进制(基数 16)表示,而非十进制(基数 10)。x的第一个字节是01101010,其对应的二进制数值为106。x上下的内存情况未知,可能是任何值。
我们可以使用operator&(被称为取地址运算符 )来获取指向x的指针,该运算符接收一个变量并返回指向该变量的指针(即该变量的地址)。考虑对上述代码片段稍作修改的版本:
cpp
int main() {
int x = 106;
int* px = &x;
std::cout<< x <<std::endl; // 106
std::cout<< *px <<std::endl; // 106
std::cout<< px <<std::endl; // 0x50527c
return 0;
}
x_ptr是指向x的指针。从概念上讲,我们可以认为x_ptr正指向 x在内存中的位置。

实际上,x_ptr存储着x的地址。更具体地说,x_ptr本身是一个数字,其值为x的第一个字节的地址。注意,x_ptr占用 8 个字节的空间(事实上,在 64 位系统上,所有指针都占用 8 个字节的空间)。

如果我们要在上面的代码中打印出x_ptr,我们只会看到它所包含的地址(0x7fff4a5ff71c)。不过,对于一个指针,我们可以对其进行解引用以获取它所指向的实际值:
cpp
std::cout << x << "\n"; // Prints 106
std::cout << x_ptr << "\n"; // Prints 0x7fff4a5ff71c
std::cout << *x_ptr << "\n"; // Prints 106
所以,一个指针就是一个数!

现在我们将讨论指针的语法,包括它们如何被解引用。
5.2.1 指针类型
对于任何类型T,T* 是指向类型为T 的对象的指针类型。在底层,每个指针都表示为对象起始字节的地址 ------ 那么为什么要包含有关类型的信息呢?请记住,C++ 是一种类型安全的语言(见第1节),因此我们所指向的对象是int还是std::string至关重要,因为这会改变指向对象所支持的操作。
在 64 位系统上,指针的大小始终为 64 位(8 字节)。在 32 位系统上,指针会占用 32 位(4 字节)。实际上,这为程序可利用的内存量设定了一个上限 ------32 位机器上的程序最多可寻址 2³²B≈4GB。事实上,这是转向 64 位机器的主要动机之一:它们能够寻址(从而利用)多得多的内存!
*解引用
给定一个T* 指针,我们可以通过T 类型的间接运算符 ,即operator* 来获取它所指向的值。这被称为对指针进行解引用 。准确地说,operator* 会返回一个T&,也就是对T 的引用。为什么呢?这一技术细节意味着,解引用指针不会对所指向的对象进行任何复制 ------ 它仅仅是访问该对象已存在的内存。此外,这使得我们能够使用间接寻址运算符来修改底层数据,例如:
cpp
int x = 106;
int* x_ptr = &x;
*x_ptr = 107;
行2:
x_ptr指向x的地址。
行3:
由于间接引用返回一个int&,我们可以通过x_ptr修改x,将其从106改为107。

->解引用访问结构体成员
如果T恰好是一个结构体,例如std::pair<double, double>,我们可以通过成员访问运算符operator-> 直接访问其成员。这和先解引用指针再访问成员是一样的。例如:
cpp
std::pair<double, double> my_pair { 10, 20 };
auto* ptr = &my_pair;
double second = ptr->second; // Same as (*ptr).second
行2:
ptr指向my_pair。注意使用auto来让编译器推断类型。
行3:
->运算符会对所指向对象中的特定成员进行解引用 。

const与指针
每个T 也有一种指向常量的指针 类型,即const T*(也可写成T const*),它表示一个指向const T 的指针。我们不能修改这种指针所指向的对象的内容。不过,我们可以修改指针本身所指向的对象。
cpp
int x = 106;
int y = 107;
const int* ptr = &x;
// *ptr = 107; // Not allowed, `ptr` points to `const int`
ptr = &y; // However, we can change where `ptr` points to
事实上,如果我们想防止指针本身被修改,可以使用一个常量指针 ,例如T* const。指针本身无法被修改(即不能指向另一个对象),但我们仍然可以修改其指向的底层对象。因此,每个T都有四种指针类型,如下表所示:

nullptr
有一种特殊的值,nullptr,它表示指向无对象的指针。它通常用于表示值的缺失。nullptr可以被强制转换为上述任何一种指针类型以及任何类型T,并且在底层,nullptr始终存储着特殊地址0。小心!你不能解引用 nullptr,因为它不指向任何对象。尝试这样做,无论是通过operator*还是operator->,都会导致段错误。
cpp
int* ptr = nullptr;
// Either of these commented lines would crash:
// int x = *ptr;
// *ptr = 106;

在 C++ 中,nullptr有一个特殊的类型,nullptr_t。nullptr_t唯一的实例是nullptr,并且它会自动转换为任何指针类型的实例。
5.2.2 指向堆的指针
在现代 C++ 中,不再建议使用原始指针(例如T*)来引用堆分配,因为如果忘记释放它们,可能会导致内存泄漏。考虑改用智能指针 ,例如unique_ptr和shared_ptr,它们会自动进行释放。这些内容将在后面的章节中讨论。
new
到目前为止,我们展示的示例中包含了指向栈区域的指针,例如x_ptr,它指向一个局部变量x。这在 C++ 中并不常见,因为引用(在前一章中讨论过,下面还会详细介绍)更常用于实现相同的功能。你更常看到指针被用于引用堆上的分配。如前所述,堆存储的是动态分配的内存,其生命周期可以超过函数调用。要在堆上分配一个T类型的对象,我们可以使用operator new向分配器请求:
T* ptr = new T;
请注意,此版本的new不会初始化T。它的内存将是分配的块中遗留的任何内容,通常是垃圾数据。要实际初始化对象,你可以使用统一初始化,例如:
T* ptr = new T { /* Args to initialize T */ };
或者值初始化:
T* ptr = new T();
为了看出差异,考虑以下情况,T = std::pair<int, double>:
cpp
auto ptr1 = new std::pair<int, double>;
auto ptr2 = new std::pair<int, double> { 106, 3.14 };
auto ptr3 = new std::pair<int, double>();
我们无法确切知道*ptr1包含什么,因为它没有被初始化。不过,*ptr2肯定是用106和3.14初始化的,而*ptr3是进行了值初始化的,其成员都被零初始化了。

delete
一旦你有了一个指向堆的T*,你就可以解引用它、将该指针传递给其他函数、把它存储在另一个数据结构中等等。但是,一旦你用完这个内存区域,必须记得要进行释放 。这可以通过将new返回的同一个指针传递给operator delete来实现:
delete ptr;
这会释放由ptr指向的内存。对之前未由new返回的指针调用delete是无效的,试图对同一个指针进行两次delete操作也是如此。这条规则的一个例外是nullptr:对nullptr执行delete操作始终是有效的,并且不会产生任何效果。未能delete用new分配的指针不会导致程序崩溃,但会导致内存泄漏,使程序使用的内存超过其所需。
new[] 和 delete[]
通常情况下,我们不想为单个对象分配空间,而是希望一次为多个对象分配空间。C++ 通过数组分配 和operator new[]来支持这一点:
T* ptr = new T[n];
上面的代码片段分配了一块连续的 内存区域,其大小足以容纳n个T实例。重要的是,n可以是一个动态确定的值 ------ 它不需要在编译时已知,这使我们能够克服栈内存 / 局部变量所受的静态大小限制。这种语法也不会初始化该区域中的任何元素。如果我们想要初始化这些元素,可以使用:
cpp
T* ptr1 = new T[n]();
T* ptr2 = new T[n] { t1, t2, /* ... */, tn };
例如,考虑这段代码所产生的内存内容:
cpp
double* ptr0 = new double[5];
double* ptr1 = new double[5]();
double* ptr3 = new double[5]{ 1, 2, 3, 4, 5 };

当使用new[]分配数组时,在使用完该数组后,必须使用相应的delete[]。 尝试使用delete(不带[])来释放这样的指针是无效的。
cpp
T* ptr = new T[n];
delete[] ptr;
注意:指向对象的指针和指向数组的指针在语法上没有区别。程序员有责任了解两者之间的差异并使用适当的操作,例如对其调用正确版本的delete。
5.2.3 指针运算
给定一个数组指针T*,我们如何访问第 i 个元素?一种方法是使用指针算术。回想一下:
- 每种类型
T在编译时都有固定的大小。 - 数组分配(由
new[]返回)在内存中是连续的。
第一个事实使编译器能够精确知道为一个包含n个T类型元素的数组分配多少字节 ------ 实际上,我们可以调用sizeof(T)来获取T在编译时的大小,因此n * sizeof(T)是调用new T[n]必须分配的最小字节数。
第二个事实与第一个事实相结合,让我们能够知道数组中各个元素的地址。如下例所示,将一个整数加到指针上,会使该指针的地址以sizeof(T)的倍数 递增:
cpp
int* arr = new int[4]();
int* ptr_to_2nd = arr + 1;
int* ptr_to_3rd = arr + 2;
arr指向已分配数组的起始位置。给arr加上1会得到指向内存中arr后面第1个元素的指针,加上2会得到指向arr后面第2个元素的指针,以此类推。

在底层,给一个数字加上一个整数,会从其底层地址增加或减少sizeof(T)字节的倍数。在这种情况下,sizeof(int) = 4,所以例如arr + 1,就是给arr的底层地址增加 4 个字节。

我们通常希望访问数组中不同位置的元素。使用指针,我们可以通过解引用来获取,例如*(arr + 1),以得到索引为1的元素。这是一种相当常见的操作,为此存在一种专门的语法:operator[]。对于指针类型,arr[i] 与 *(arr + i) 完全相同。
cpp
int& elem1 = *(arr + 2);
int& elem2 = arr[2];
// The above two lines are exactly the same.
// elem1 and elem2 refer to the same element!
5.2.4 与引用的关系
了解指针后,人们经常会问指针是否与引用有关。答案是肯定的!在底层,编译器对待引用的方式和对待指针一样,不过,它们的语义有所不同。指针使用->来访问底层对象,而引用则使用与值类型相同的.。作为值类型的表示法:
cpp
std::pair<double, double> p { 2.72, 3.14 };
std::pair<double, double>* ptr = &p;
std::pair<double, double>& ref = p;
std::cout << ptr->first << "\n";
std::cout << ref.first << "\n";
然而,一个重要的区别是引用不能被重新绑定。一旦引用被绑定(指向)一个对象,它就必须始终指向该对象。
cpp
double pi = 3.14;
double e = 2.72;
// We can rebind pointers, changing what data they point to
double* ptr = π
ptr = &e;
// However, we cannot do the same with references
double& ref = pi;
ref = e; // This line changed *pi*, not ref!
最后,引用不允许是nullptr------ 它们必须始终指向一个对象。存在一些不规范的方法可以让引用存储nullptr,但这类程序即便能够编译,在 C++ 中也是无效的,且可能会导致未定义行为。
从技术上讲,我们可以编写这样的代码来获取空引用:
cpp
int& null_ref = to_ref(nullptr);
std::cout << null_ref << "\n"; // This line will crash!
然而,任何访问null_ref的尝试(这会导致对底层指针进行解引用)都会使程序崩溃!
5.2.5 综合应用
cpp
std::vector<int> v {1,2,3,4,5};
int* arr = &v[0]; // Copy construction拷贝构造
arr+= 1; // Random access随机访问
++arr; // Move pointer forward指针前移
arr+= 2; // Random access随机访问
if (arr == &v[4]) // Pointer comparison指针比较