博主介绍:程序喵大人

好文推荐:

【AIAgent项目】从零构建一个代码PRAgent
【C++地基】C++必备知识点 - 01 一个最小 C++ 程序是怎么跑起来的
【C++地基】C++必备知识点 - 02 类型和变量:给一块数据定规则、起名字
【C++地基】C++必备知识点 - 03 表达式和运算符:一行代码怎样算出结果
【C++地基】C++必备知识点 - 04 控制流:程序为什么走这条路
【C++地基】C++必备知识点 - 05 函数:把一段逻辑装进一个可调用的盒子
【C++地基】C++必备知识点 - 06 作用域和生命周期:名字在哪能用,对象活到什么时候
【C++地基】C++必备知识点 - 07 引用:给同一个对象再起一个名字https://blog.csdn.net/qq_29426201/article/details/164094785?spm=1001.2014.3001.5501)
【C++地基】C++必备知识点 - 08 const:把“这里是否允许修改“写进类型里

程序中的变量不仅有一个方便人类阅读的名字,在底层也有一个对应的地址。名字是我们编写代码时的标识,而地址则是对象真正在内存中所处的位置。简单来说,指针就是一种专门用来保存这种地址的变量。

我们可以先来看看这最基础的三行代码:

int score = 90;
int* p = &score;
std::cout << *p << "\n";

在这里,score 是一个普通的 int 对象;&score 这个操作的作用是取得该对象所在的内存地址;而 p 则是一个指针变量,它负责把 score 的这个地址保存起来。到了最后一行,*p 所表达的动作是:顺着 p 里面保存的这个地址找过去,最终访问到那个真正的目标对象。

初学者在接触指针时最容易感到混乱的,往往是因为同一个符号在不同的位置代表着截然不同的含义。比如 &score 里的 & 代表的是“取地址”动作;而 int* p 里的 * 是在声明 p 这个变量的类型是指针;等到写成 *p 时,这里的 * 动作又变成了“解引用”。只要在脑海中把这三种不同的使用场景严格区分开,指针的入门就会顺畅很多。

实际上,这章我们要建立的核心模型非常简单:对象安安静静地躺在内存里,地址是用来描述它具体位置的一串数据,而指针变量仅仅是一个用来保存这串位置数据的容器。大家觉得指针难,通常是被复杂的符号组合或是潜在的安全风险给绕晕了,其底层的物理模型其实极其直白。只要在阅读代码时,始终能在脑海中把“指针变量 p 它自己”和“p 所指向的那个目标对象”剥离开来看待,绝大多数的基础指针逻辑就都能迎刃而解。

对象有地址

当程序真正运行起来之后,每一个被创建出来的对象都会在内存中占据一个实实在在的存储位置。我们可以通过取地址运算符 & 来获取一个对象的具体地址:

#include <iostream>

int main() {
  int score = 90;
  std::cout << &score << "\n";
  return 0;
}

执行这段代码,控制台上会输出一串代表着内存地址的数值(通常是十六进制),它具体长什么样完全由当前的操作系统平台和运行环境来决定。作为开发者,我们完全不需要去死记硬背这个具体的数字,只需要明白 &score 这个表达式,代表的就是 score 这个对象在此时此刻所处的确切位置。

我们可以用这样一层简单的关系来描述它:

这里有一个关键的认知转换:地址这串数值,它本身也是一种数据。既然它是数据,自然就可以被存放在某个变量里面——而指针变量,就是专门为了存储这类地址数据而设计的。

当然,地址值并不适合作为具体的业务数据去参与运算。它存在的唯一意义,就是充当一个路标,让程序能够间接地顺藤摸瓜找到那个真正的对象。在不同的机器或不同次的运行中,即使是同一段程序打印出的同一个对象的地址,往往也会因为操作系统的内存分配机制而有所不同。因此在基础阶段,我们只需要确立一个概念:&score 能给出对象的位置,而指针则负责把这个位置妥善保管起来。

指针变量存的是地址

当我们去定义一个指针变量时,代码看起来是这样的:

int score = 90;
int* p = &score;

在这里,p 的类型被声明为 int*,我们可以将它连贯地读作:这是一个“指向 int 类型对象的指针”。在这个指针变量 p 里面,真真切切保存着的,就是 score 所在的那串地址数值。

它们之间的关系可以梳理成如下结构:
在这里插入图片描述

一定要时刻提醒自己,指针变量它自身也是一个实打实的变量。它同样拥有自己的名字 p,拥有属于自己的数据类型 int*,并且也有着自己独立的内存空间来存放具体的值。只不过,它存放的这个值,恰好是另一个对象的内存地址而已。

这一点非常关键。pscore 完全是两个物理上独立的变量。你可以直接输出 p 来查看那串晦涩的地址数值,也可以通过写下 *p 这样的语法去访问位于那个地址上的目标对象。只有在思维层面彻底把“指针变量的躯壳”和“远端的目标对象”剥离开来,后续再去理解什么是空指针、指针如何改变指向,以及 const 指针的修饰规则时,思路才不会绞在一起。

这也是指针与我们在前几章学过的引用最核心的差异。引用在日常使用中几乎完全隐身,就仿佛是对象的一个透明别名;而指针则非常硬核,它强制要求你在代码里显式地去处理地址的获取(&)以及目标对象的解引用(*)。

我们可以把这两者放在一起对比一下:

int score = 90;
int& ref = score;   // ref 纯粹就是 score 的另一个名字
int* p = &score;    // p 是一个独立的容器,里面保存了 score 的地址

正因如此,当我们需要通过引用去访问对象时,直接写 ref 即可;而当我们需要通过指针去访问目标数据时,就必须带上解引用符号写成 *p

解引用才是访问目标对象

正如前面所说,p 内部仅仅是存放着一串地址。真正想要触碰到那个地址背后所代表的对象,就必须执行“解引用”这个动作:

int score = 90;
int* p = &score;

std::cout << *p << "\n";

代码里的 *p 表达的是这样一个指令:“请沿着 p 里面保存的这串地址顺藤摸瓜,把那个 int 对象给我揪出来”。因此,这段代码最终打印出来的,自然就是 score 的数值。

当然,一旦我们通过解引用锁定了那个目标对象,就可以对它进行修改:

*p = 95;
std::cout << score << "\n";

执行后输出的结果将是 95。这说明对 *p 进行赋值,本质上就是在直接改写 p 所指向的那个底层对象的数据。

我们可以把这些操作整合进一个完整的示例程序中看看:

#include <iostream>

int main() {
  int score = 90;
  int* p = &score;

  std::cout << "score = " << score << "\n";
  std::cout << "*p = " << *p << "\n";

  *p = 95;
  std::cout << "score = " << score << "\n";
  return 0;
}

在阅读这类代码时,必须在大脑里迅速地翻译出这些符号的准确语义:

在这里插入图片描述

在编写和阅读指针相关的代码时,有一条保命的习惯需要尽早养成:每一次在敲下或看到 *p 这个解引用动作时,都要下意识地问一句:“p 在此时此刻,确实是指向一个安全、有效的对象吗?”

这是因为,解引用本质上是一种盲目的信任,你默认这个地址必定能引导程序找到一个合法的数据实体。如果这种信任被辜负了,程序就会瞬间跌入不可预知的危险深渊。业界常说的空指针、野指针、悬空指针等各类内存恶梦,其共同的罪魁祸首都是指针变量里保存了一个根本无法安全访问的无效地址。在夯实基础的阶段,最起码的一点要求就是:从严格检查 nullptr 开始,把“解引用前必须确认有效性”的意识刻进骨子里。

更进一步说,这里的“有效”其实包含了双重维度的考量。第一层很好理解,就是指针本身不能是代表空状态的 nullptr;而第二层往往更为致命,那就是它所指向的那个对象,在当前执行流下必须依然存活着。检查 nullptr 只需要一个简单的 if 语句,但追踪对象的生命周期却需要极高的代码素养。举个例子,如果一个指针记录着某个局部变量的地址,而那个局部变量已经随着函数调用的结束被系统回收了,那么哪怕这个指针里还残留着之前的那串地址数字,对其解引用也是一场灾难。所以,我们在第 06 章探讨的生命周期概念,以及第 07 章划下的那道关于返回局部引用的红线,到了指针这里不仅没有失效,反而变得愈加凶险。

nullptr 表示空指向状态

与引用不同,指针是完全可以不指向任何实质性对象的。在现代 C++ 的标准中,我们统一使用 nullptr 这个关键字来显式地表达这种空状态:

int* p = nullptr;

对于一个空指针,绝对不能进行解引用操作。如果强行写下 *p,程序就会尝试去读取一块不存在或未授权的内存,这通常会导致程序当场崩溃。因此,在任何不确定的场景下,解引用之前都必须有一道安全屏障:

int* p = nullptr;

if (p != nullptr) {
  std::cout << *p << "\n";
}

能够合情合理地保持为空,这正是指针在机制上区别于引用的一个核心特质。引用在定义的那一刻起就必须死死绑定在一个已有对象上;而指针则从容得多,它完全可以先安安静静地作为一个空壳存在,直到逻辑需要时,再去接管某个具体的对象:

int score = 90;
int* p = nullptr;

p = &score;
std::cout << *p << "\n";

正因为拥有这种特质,指针在表达“某项事物可能根本不存在”的业务逻辑时,显得极为贴切。最典型的场景莫过于各种数据结构中的查找函数:如果找到了目标数据,就返回指向它的有效指针;如果没找到,就干干净净地抛回一个 nullptr。对于这种模式,初学者要做的第一件事就是把检查逻辑变为肌肉记忆:

在这里插入图片描述

这也折射出了指针与引用在接口设计哲学上的根本差异。当我们使用引用作为参数或返回值时,是在传递一种强硬的态度:“想要交互,就必须拿出一个实实在在的对象来”;而当我们在接口中使用指针时,态度则变得宽容而充满不确定性:“这里有可能会交给你一个对象,但也有可能什么都没有”。一个有经验的开发者在阅读到指针接口时,自然而然就会在心底升起防备:它会不会是个空壳?这种警惕感,正是由指针的类型属性所天然赋予的。

指针参数什么时候有价值

理清了基础机制,我们就可以把指针运用到函数的参数传递中了:

void AddOne(int* p) {
  if (p == nullptr) {
    return;
  }
  ++(*p);
}

当我们需要调用这个函数时:

int score = 90;
AddOne(&score);

在这个过程中,&score 负责提取出变量 score 的物理地址,并将这串地址数据交接给函数。函数内部的形参 p 妥善接管了这个地址,随后在经历了安全检查后,通过 ++(*p) 顺着地址找到了外部的原生对象并进行修改。最终,外部的 score 就会如期变成 91

我们来看一个结合了空指针处理的完整示例:

#include <iostream>

void AddOne(int* p) {
  if (p == nullptr) {
    return;
  }
  ++(*p);
}

int main() {
  int score = 90;
  AddOne(&score);
  std::cout << score << "\n";

  int* empty = nullptr;
  AddOne(empty);
  return 0;
}

在这个例子中,指针作为参数其实默默地向外传达了两层含义:第一,我有能力顺着地址去修改你传入的原始对象;第二,我也非常大度地允许你传入一个空指针,借此来表示“当前这个环节压根没有对象需要被处理”。

在这里插入图片描述

但如果在你的业务逻辑里,这个函数执行的前提是“必须拿出一个切实存在的对象交给我修改”,那么使用引用作为参数往往是更加纯粹和直接的选择:

void AddOne(int& value) {
  ++value;
}

只有当你的业务逻辑中,确实存在“可能没有实体数据传入”这种合理的分支状态时,指针参数才会展现出它无可替代的契合感:

void AddOne(int* value);

可见,接口的形态本身就是一种强有力的约束宣言——看到引用,开发者的大脑会自动将其等价为一个确凿存在的对象;而一看到指针,防范空指针的雷达就应该立刻竖起来。

因此,指针参数提供的是另一套维度的接口语义。虽然它们都有能力直接对外部数据动刀,但向外展示的包容度完全不同。void AddOne(int& value) 是一种必须履约的强制要求;而 void AddOne(int* value) 则是一种留有余地的灵活选项。至于在实际开发中到底该翻哪张牌,唯一的评判标准就是:你的这项业务,到底需不需要去兼容“没有对象”这种边缘情况。

这种语义上的差异,在函数调用的现场也会一览无余。在使用引用传递时,调用方写的只是极其自然的 AddOne(score);而当使用指针时,调用方必须要煞有介事地写下 AddOne(&score)。这个显眼的 & 符号就像是一张醒目的便签,明白无误地告诉所有读代码的人:我已经把底层地址的钥匙交给这个函数了。也正是因为多出了这层直接操纵内存地址的权力,使用指针时自然也就必须肩负起更为沉重的空值检查责任。

指针可以改指向

既然我们已经明确,指针变量这个躯壳里装的只是一段地址数据,那么理所当然地,它完全可以把原有的地址丢弃,换上一段全新对象的地址:

#include <iostream>

int main() {
  int a = 10;
  int b = 20;

  int* p = &a;
  std::cout << *p << "\n";

  p = &b;
  std::cout << *p << "\n";
  return 0;
}

在执行这段代码时,第一次输出将是 10,而第二次则变成了 20。这是因为指针 p 在运行中途叛变了:它最初老老实实地指向着 a,随后便被重新赋值,转而指向了 b

在这里插入图片描述

这种能够中途倒戈的特性,是引用绝对无法染指的。引用一旦完成了最初的绑定仪式,在余下的生命周期里,它就只会忠诚地充当那个初始对象的别名。而指针骨子里就是一个用来存储地址的独立变量,里面装的数据当然可以随时替换。

在这里插入图片描述

改变指向的能力赋予了指针无与伦比的灵活性,尤其是在编写复杂的数据结构(如链表、树)时。但这不可避免地大幅抬高了代码的阅读成本。在追踪包含指针流转的代码时,你绝不能仅仅看一眼它最初诞生时指向上帝的哪个角落就觉得万事大吉,你必须要全程盯紧它,提防它在某个不为人知的角落被塞入了另一个毫不相干的地址。因为指针在不同时间段指向不同的对象,那么对于同一个 *p 的操作,其后果可能天差地别。这是我们在使用指针时,相较于引用必须要付出更多心智去防范的地方。

为了理清思绪,我们可以做一个直观的对照总结:

在这里插入图片描述

指针和 const 的关系回顾

在上一章中,我们曾提前预热过 const int*int* const 的读法。现在既然我们已经建立起了完整的指针模型,不妨将它们结合起来再进行一次梳理。

我们先看只读目标的情况:

int value = 10;
const int* p = &value;

在这里,p 老老实实地保存着 value 的地址。当我们尝试通过 p 进行解引用并介入底层的对象时,由于 const 的阻拦,我们只能进行读取操作:

std::cout << *p << "\n";
// *p = 20;  // 编译错误

但是,指针变量 p 自身并没有受到任何枷锁的限制,我们依然可以大手一挥,让它重新保存另一个对象的地址:

int other = 30;
p = &other;

接着,我们再看指针本身被锁死的情况:

int value = 10;
int* const q = &value;

这一次,被 const 定住的是 q 这个指针变量自身,它再也无法调转方向去指向其他的内存区域了。但有趣的是,顺着 q 摸向目标对象的这条路径却是全副武装、畅通无阻的,我们完全可以通过解引用去修改底层的数据:

*q = 20;

再次印证了那个核心结论:出现在指针定义里的 const,本质上依然是在精细地雕琢和限制不同的访问路径。当你面对一长串让人眼花缭乱的指针声明时,最稳妥的策略就是毫不留情地把它劈成两层来看待:第一层剥开看指针变量这具躯壳本身能不能被重新赋值,第二层深入去看当解引用动作发生后,目标对象能不能顺着这条数据通道被修改。

在这里插入图片描述

这种拆解式的阅读法,远比去死记硬背符号的排列顺序要可靠得多。每一次判断,都是在独立地拷问两个截然不同的对象:先去问 p 自己能不能接纳新地址,再去问 *p 能不能强硬改写目标值。只要把这两个维度的限制给剥离开来,无论是 const int*,还是让人抓狂的 const int* const,都会在你面前变得透明且毫无秘密可言。

推而广之,这种拆解思维其实也是我们在工业界阅读任何指针代码的通用心法。首先去凝视指针变量本身:它此刻囊括的是谁的地址?它在接下来的流程中会不会突然变节换上新的地址?在操作之前,它有没有可能是一个虚无缥缈的空壳?当这些都确认无误后,再把目光聚焦在目标对象身上:一旦解引用发生,究竟是哪块内存的数据在遭受访问?这条特定的通路究竟有没有赋予我们篡改数据的特权?那个可怜的对象此时此刻是否还安全地存活着?如果把这两层逻辑搅成一团浆糊,指针自然就成了令人恐惧的洪水猛兽;但一旦你将它们清晰地剥离,你会发现,指针说到底也只不过就是“一个装载地址的容器”再搭配上一套“对目标对象有条件的访问机制”罢了。

一段完整演示

在结束这一章之前,让我们用一段包含了地址提取、指针变量绑定、解引用操作、空指针安全检查以及指针作为函数参数传递的综合程序,来重温一下刚才建立的模型:

#include <iostream>

void AddOne(int* p) {
  if (p == nullptr) {
    return;
  }
  ++(*p);
}

int main() {
  int score = 90;
  std::cout << "address = " << &score << "\n";

  int* p = &score;
  std::cout << "*p = " << *p << "\n";

  *p = 95;
  std::cout << "score = " << score << "\n";

  AddOne(&score);
  std::cout << "score = " << score << "\n";

  int* empty = nullptr;
  if (empty != nullptr) {
    std::cout << *empty << "\n";
  }

  return 0;
}

在日常开发中,每次遭遇指针相关的代码逻辑,你可以尝试强迫自己在脑海里过一遍这份检查清单:

暂时无法在飞书文档外展示此内容

熟练驾驭指针,是每一位 C++开发者必须要跨越的基础关卡。在本章中,我们仅仅是描摹出了它最为核心、纯粹的物理模型。至于像涉及操作系统层面的动态内存分配、颇具历史包袱的数组退化问题,以及现代 C++ 中为解决资源泄露而发明的智能指针体系,甚至那些令人闻风丧胆的野指针和悬空指针灾难案例,我们都会在后续探讨资源管理和标准模板库(STL)的专门章节中为大家逐一揭晓。

当你踏踏实实地理清了引用、const 约束以及底层指针的工作原理之后,再去面对现代 C++ 中广泛使用的 auto 类型推导机制时,一切都会显得水到渠成。在接下来的章节中,我们将重点剖析 autoauto& 以及 const auto& 的细微差别,看看当贴心的编译器替你省去敲击冗长类型名的麻烦时,那些关乎生命周期和修改权限的底层规则,又是如何在无形之中被精准保留下来的。

码字不易,欢迎大家点赞,关注,评论,谢谢!

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐