C++基础:Stanford CS106L学习笔记 5 内存与指针

目录

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,其对应的二进制数值为106x上下的内存情况未知,可能是任何值。

我们可以使用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 指针类型

对于任何类型TT* 是指向类型为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_tnullptr_t唯一的实例是nullptr,并且它会自动转换为任何指针类型的实例。

5.2.2 指向堆的指针

在现代 C++ 中,不再建议使用原始指针(例如T*)来引用堆分配,因为如果忘记释放它们,可能会导致内存泄漏。考虑改用​智能指针 ​,例如unique_ptrshared_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肯定是用1063.14初始化的,而*ptr3是进行了值初始化的,其成员都被零初始化了。

delete

一旦你有了一个指向堆的T*,你就可以解引用它、将该指针传递给其他函数、把它存储在另一个数据结构中等等。但是,一旦你用完这个内存区域,必须记得要进行​释放 ​。这可以通过将new返回的同一个指针传递给operator delete来实现:

delete ptr;

这会释放由ptr指向的内存。对之前未由new返回的指针调用delete是无效的,试图对同一个指针进行两次delete操作也是如此。这条规则的一个例外是nullptr:对nullptr执行delete操作始终是有效的,并且不会产生任何效果。未能deletenew分配的指针不会导致程序崩溃,但会导致​内存泄漏​,使程序使用的内存超过其所需。

new[]delete[]

通常情况下,我们不想为单个对象分配空间,而是希望一次为多个对象分配空间。C++ 通过数组分配operator new[]来支持这一点:

T* ptr = new T[n];

上面的代码片段分配了一块连续的 内存区域,其大小足以容纳nT实例。重要的是,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[]返回)在内存中是连续的。

第一个事实使编译器能够精确知道为一个包含nT类型元素的数组分配多少字节 ------ 实际上,我们可以调用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 = &pi;
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指针比较
相关推荐
学习路上_write44 分钟前
FREERTOS_定时器——创建和基本使用
c语言·开发语言·c++·stm32·嵌入式硬件
秋深枫叶红44 分钟前
嵌入式第二十六篇——数据结构双向链表
c语言·数据结构·学习·链表
学技术的大胜嗷1 小时前
如何在 VSCode 中高效开发和调试 C++ 程序:面向用过 Visual Studio 的小白
c++·vscode·visual studio
匠心网络科技1 小时前
前端框架-框架为何应运而生?
前端·javascript·vue.js·学习
锦锦锦aaa1 小时前
【版图面试之60问】
经验分享·笔记
liu****1 小时前
10.指针详解(六)
c语言·开发语言·数据结构·c++·算法
报错小能手1 小时前
C++流类库 标准输入流的安全性与成员函数 ostream 成员函数与自定义类型的IO
开发语言·c++·cocoa
影林握雪1 小时前
M|窃听风暴 Das Leben der Anderen (2006)
经验分享·笔记·其他·生活
进击的荆棘1 小时前
C++起始之路——基础知识
开发语言·c++