☰
C++ const与指针组合规则详解:从const int *p到int *const的彻底理解
2026/9/26 21:23:05 网站建设 项目流程

刚学C++的时候,身边好几个朋友都跟我说过同一句话:“别的我都还能忍,一碰到const 限定符和指针组合在一起,脑子就嗡的一声。” 这话我特别理解,因为const int *p、int *const p、const int *const p这三副面孔,长得像三胞胎,脾气却完全不同,新手在这个地方栽跟头几乎是必然的。

这篇东西就是写给“被指针和 const 绕晕的人”看的,无论你是在校学生、转行自学,还是正在准备面试想把这部分彻底讲清楚,都可以放心往下读。我尽量不用教科书说话的方式,而是把C++ 里 const 与指针的组合规则,拆成一个个能看得见摸得着的小例子,看完你再去写代码,至少不会再对着编译器的红色报错发呆。

1. const 到底在“限定”什么:它不是锁住数据,而是锁住通道

很多人刚开始学 const 时,会以为它像冰箱里冷冻层一样,把某个变量的值“冻住”,以后谁也动不了。这个理解不精确,会直接导致后面学指针时混乱。

1.1 const 修饰的是“变量的名义权限”

先看最简单的场景:

const int x = 10; x = 20; // 编译错误:x 是只读的

这里的x确实不能被修改。但麻烦的是,同样一份数据,完全可以通过另一个非 const 的变量名去操作:

int y = 10; const int& ref = y; // ref 只是“名义上只读” int* p = &y; // p 可以直接改 y *p = 99; std::cout << y << std::endl; // 输出 99,ref 也跟着变成 99

看出问题了吗?ref是 const 的,但y不是。const 并没有把内存里的数据“冻住”,它只是给了你一个承诺:从 ref 这条路径访问时,不允许写。数据本身该怎样还怎样,别的非 const 路径照样可以改它。

这个区别特别重要。const 限定的从来不是“物理内存”,而是“当前这个变量名/这条路径的访问权限”。用大白话说:const 相当于在变量名外面包了一层“只读壳”,而不是把内存焊死。

1.2 为什么 C++ 要给你这个“形式上的锁”

初学者最容易问的问题是:既然 const 可以被绕过,那它还有什么用?实际上 const 的价值不在于防黑客、防恶意代码,而在于自我约束和接口承诺。

举个生活例子:你去食堂打菜,阿姨给了你一个餐盘。餐盘上贴了纸条“今日特供,只许看菜名不许夹菜”,这是对你这个访问者的约束;但食堂大厨自己可以随时往锅里加盐。当你在项目里把一个函数参数声明成const std::string&时,你是在对所有调用者说:“我这里只读,不修改你的字符串,你放心传。” 更重要的是,编译器会帮你盯住,如果有人在函数体里偷偷s.push_back('!'),编译直接报错,不让坏习惯悄悄溜进代码里。

这也是为什么后来学类、学函数重载、学移动语义,都会反复强调 const 的重要性——它不是语法装饰品,而是 C++ 设计者给你的一套“契约工具”。

1.3 const 变量必须初始化,这不是故意为难你

还有个小细节:const int x;不初始化,直接编译错误。因为一个只有只读权限的名字,如果连初始值都没给,那它里面的值就永远无法被设置了,这毫无意义。类比来说:不许别人改的保险箱,你总要先把东西放进去再锁门。而普通变量int a;没初始化只会得到一个不确定的垃圾值,但语法上还能通过。

到这里,const 的基础含义已经说清楚:const 限定的是"通过这个变量名修改内容的资格"。下面就可以上主菜了——加上指针之后,问题会多出一层维度。

2. 指针的两层身份:纸条和房间,const 到底贴在哪儿

为什么const一碰到指针就容易乱?因为指针这个东西是“两层”的。我们必须先把这两层剥开,所有规则都会变得顺理成章。

2.1 指针变量本身,和它指向的对象,是两码事

把指针想成一张写着房间号的纸条。房间是数据,纸条是装着地址的指针变量。

  • 纸条本身可以不换,但它写的房间号可以变——对应“指针变量可以指向不同的地址”;
  • 房间里的东西可以换,也可以不换——对应“目标对象可以被修改,也可以是只读”。

const 到底谁锁?锁纸条,还是锁房间?这就是所有混乱的来源。来看两个最简单的对比:

int a = 10, b = 20; const int* p1 = &a; // 房间是只读的:P1 不能通过它改 a 的值,但纸条可以改指向 p1 = &b; // 合法:换个房间 // *p1 = 30; // 非法:这个纸条上写着“只许看不许改” int* const p2 = &a; // 纸条本身是固定的:p2 永远指向 a,但房间值可以改 *p2 = 30; // 合法:a 变成 30 // p2 = &b; // 非法:这个纸条被胶水粘死了,不能换

对照一下,const放在*的前面和后面,效果完全不同。const int *p里的*p是“房间里的东西”,const 修饰它,就是房间只读;int *const p里的p是“纸条本身”,const 修饰它,就是纸条不能换。

2.2 记忆诀窍:从右往左读,看 const 离谁更近

多年以后你可以把各种复杂声明背下来,但刚开始最实用的招式是从右往左读:

  • const int *p:p是指针,指向const int。哈,这里的“指向 const int”本质上就是说,目标类型带有只读属性。
  • int *const p:p本身是 const 的,指针值不可变。
  • const int *const p:p是 const 的,它指向的int也是 const 的。

想更省事一点,还有个口诀:const 修饰的是离它最近的“实质性变量”。在const int *p中,const 跟着int,两者搭配成“const int 类型的对象”,所以被锁的是目标;在int *const p中,const 跟在*后面,它黏着的是指针变量p自己,所以被锁的是指针本身。

2.3 为什么文本类资料里叫法还不统一

你上网搜索会发现,同样的东西有不同叫法:const int *p有人叫“常量指针”,也有人叫“指向常量的指针”;int *const p有人叫“指针常量”,还有人叫“常量指针”。这纯粹是翻译和教材习惯造成的坑,非常讨厌。

所以我建议你不要死记中文名词,而是记住一个原始英文描述:

  • pointer to const int:指向(const int)的指针 → 目标只读;
  • const pointer(to int):本身是 const 的指针 → 指针值只读。

面试时你用英文短语描述都不容易出错。日常交流里如果只说中文,先跟对方确认指的是哪一种,免得吵架。

3. 三副面孔逐一拆解:const int *、int *const、const int *const

光看懂原理不够,咱们把三种组合放到真实场景里,一个一个过,顺便给出可运行代码和典型用途。

3.1 第一种:const int *p—— 看得见,摸不得

这种写法最常见于“只读遍历”和“字符串常量”。比如你要写一个函数,打印一个 int 数组的内容:

void printArray(const int* arr, int size) { for (int i = 0; i < size; i++) { std::cout << arr[i] << " "; } std::cout << std::endl; }

这里的const int* arr保证了在函数内部不会意外修改数组元素。如果你手贱写了arr[0] = 100,编译器立刻报错,避免你在排查诡异 bug 时浪费两个小时。

这种指针还可以随意指向另一个 int 变量:

const int* p = &a; p = &b; // 没问题

注意一种常见场景:把字符串字面量赋给指针。在 C++ 中,字符串字面量"hello"的类型是const char[6],所以在较新的 C++ 标准里,你应该用const char* str = "hello";来接收。用char* str = "hello";会得到编译警告甚至错误,因为通过这个指针去修改字符串字面量是未定义行为。记住:凡是可能指向“只读区域”的指针,声明时就要带上 const。

3.2 第二种:int *const p—— 铁了心不换地址

这种“指针本身只读”的写法,适合你要把一个地址固定在某个变量上,确保整个生命周期里它不乱指。比如嵌入式开发里,你可能会定义指向某个寄存器地址的常量指针:

int* const REG_STATUS = reinterpret_cast<int*>(0x40000000); // REG_STATUS = &otherReg; // 编都不让编:地址不能换 *REG_STATUS = 1; // 但寄存器内容可以改

在普通业务代码里,int* const出现的频率不如第一种高,因为局部变量如果不想换地址,直接用引用int& ref = a;更自然。但了解它仍然是必要的,特别是面试时喜欢拿它和引用作对比。

3.3 第三种:const int *const p—— 两头都锁死,最安全

这个组合表示:指针变量不能更换指向,指向的对象也不能通过这个指针修改。

int value = 7; const int* const p = &value; // p = &other; // 不行:指针只读 // *p = 10; // 不行:目标只读

哪个场景会用到?比如全局配置表:

const int* const kConfig = &globalConfigValue;

让别人拿到的是一把“只读且不能换门牌”的钥匙,彻底封死误操作的可能。写底层库、操作系统模块时,这种“双重 const”非常常见,因为它把设计者的意图表达得无比清晰。

3.4 三种组合一张表看清

声明写法指针本身(纸条)指向对象(房间)典型用途
const int *p可改只读只读遍历、字符串字面量接收
int *const p固定可改绑定寄存器地址、固定入口指针
const int *const p固定只读全局常量引用、只读配置入口

3.5 别忽略“多个 const 变量”的声明陷阱

一个小测验经常在面试中出现:

const int *p1, p2;

请问p2是什么类型?很多人以为是const int *,错了。p2只是const int,不是指针。因为*只作用于紧跟它的p1,并不会波及p2。想让两个变量都是 const 指针,得写成:

int *const p1, *const p2;

这个细节很反直觉,实际工作中最好一行只声明一个指针变量,既清晰也不会踩这个坑。

4. 函数参数里的 const:为什么老手都爱写 const 引用和 const 指针

指针和 const 最大的战场不在变量声明,而在函数参数。你和别人协作写代码时,函数的“接口长相”直接影响到整个项目好不好维护。

4.1 值传递的代价,可能比你想象的大

假设你要写一个函数,读取一份大文件的内容,然后统计里面的单词数。文件内容存在std::string里,这个对象可能几 MB 甚至更大。

int countWords(std::string s) { ... }

这么写,每次调用都会做一次完整的字符串拷贝,拷贝一个几 MB 的字符串又慢又费内存。如果你改成传指针:

int countWords(std::string* s) { ... }

拷贝倒是省了,但函数内部既可以读也可以写。万一函数里不小心改了字符串,调用者完全不知道,排查起来很麻烦,而且调用者还要担心空指针问题。

所以真正优雅的做法是传 const 引用:

int countWords(const std::string& s) { ... }

这里const std::string&到底做了什么?它相当于语法糖版的“const std::string* 指针”,没有拷贝,不能修改,也不会出现空指针。老手看到函数签名写const std::string&,瞬间就知道这个函数不会动数据;看到std::string&,就知道这个函数大概率要原地修改内容。光靠阅读签名就能读懂接口语义,这是 C++ 的项目级素养。

4.2 指针参数也一样:能 const 就 const

如果这个参数本质上需要传“一组数据”的起始地址,比如数组,那函数签名里通常写成指针:

void processData(const int* data, size_t count);

const int* data对调用者来说是一个承诺:“我只读你的数据,不会改坏。” 万一内部有 bug 尝试写入,编译器会当场拦住。

我自己见过不少新人写库函数时,参数一律不带 const,理由是“先跑通再说”。结果到了后期,合约问题接二连三:A 模块删了 B 模块的数据,C 模块想调用却发现接口不匹配,只能被迫拷贝一份再传。等你在一个中型项目里吃过两次亏,就会明白“参数能加 const 就加 const”基本是 C++ 工程界的准则,不是强迫症。

4.3 const 引用和 const 指针在类型上的一个小门槛

再提醒一个容易被编译器报错搞懵的点:普通变量的地址&a可以赋给const int* p,这没问题;但反过来,一个 const 变量的地址&ca不能赋给int* p,因为那样别人就可以通过这个指针去修改一个本不该修改的 const 对象。

const int ca = 3; const int* pc = &ca; // 可以 int* p = &ca; // 编译错误:无法将 const int* 转为 int*

这条规则的本质就是权限不能随便放大:你手里是一张“只读通行证”,不能交给想要“读写通行证”的人去干危险的事。反过来,读写通行证降级为只读通行证,完全合法。理解这个“权限只许缩小,不许放大”的道理,你以后看编译错误会顺很多。

5. 最容易翻车的四个现场:踩坑记录、原因分析和排查方法

这部分我直接写我见过最多的翻车现场。每个从现象说到根因,帮你建立自己的“排错直觉”。

5.1 翻车现场一:试图通过 const 指针修改目标,编译器骂人

const int* p = &x; *p = 100; // error: assignment of read-only location

这是最直白的错误。新手第一反应经常是“那我不加 const 不就行了”,实际上应该想的是“我是不是真的需要在这里修改数据”。如果修改是有意的,说明这里本来就不该用const int*;如果只是手滑,改成只读版本才是正解。

排查思路很简单:看错误信息里read-only这个词。一旦出现,说明某条访问路径被 const 锁了。沿着代码看这条路径是从哪来的——是函数签名里的 const 参数,还是变量声明里自带的 const。

5.2 翻车现场二:const_cast 强行撬锁引发的运行时崩溃

情况也很经典:别人给了你一个const int*,你特别想改它指向的数据,于是用const_cast<int*>(p)强行去掉 const。如果原对象本来就不是 const,这通常能编译通过并且工作正常:

int a = 42; const int* cp = &a; int* p = const_cast<int*>(cp); *p = 0; // 还行,a 原本就是普通变量

但如果原对象真的是 const:

const int b = 42; const int* cp = &b; int* p = const_cast<int*>(cp); *p = 0; // 未定义行为,可能崩溃,可能悄悄有问题

用const_cast等于你强行撕掉只读标签,去改一个编译器认为“绝不会变”的数据。如果这块内存被放进了只读页,或者编译器做了各种优化假设,结果纯粹看运气。我见过一个同学在嵌入式的 flash 区域用 const_cast 写数据,程序秒崩。绝大多数情况下,const_cast都不该出现在代码里;如果真出现了,先问问设计上是不是出了偏差,而不是引以为傲。

5.3 翻车现场三:把“底层 const”和“顶层 const”混为一谈

这两句话听起来很学术,说穿了就是:

  • 顶层 const:指针本身只读,如int *const p;
  • 底层 const:指向的对象只读,如const int *p。

为什么必须区分?因为它们在赋值和类型匹配时规则完全不同。底层 const 可以“升格”:

int a = 5; int* p = &a; const int* cp = p; // 合法:可写指针能降级为只读指针

但反过来不行:

const int* cp = &a; int* p = cp; // 非法:权限放大被拒绝

顶层 const 的传递则无所谓,因为它只限制指针本身,不影响目标。理解了这两个词,你看到复杂声明就不会再慌。

5.4 翻车现场四:把 const 指针和“指针指向的对象被 const 修饰”混在一起导致接口不匹配

这是协作开发里最讨厌的坑。你的函数签名写的是const int* data,但某些调用方手里拿的是int*,明明可以传却总有人写const_cast来“适配类型”。其实完全没必要,int*传给const int*参数是隐式转换,编译器会自动通过。

反过来,如果你的函数签名写int* data,另一个模块只有const int*指针,那调用方就会被卡住。这时候与其用const_cast硬转,不如想想是不是函数本身根本不需要修改数据,改成const int* data才是正解。这种在接口层的 const 设计,往往比算法实现更能影响一个项目的代码质量。

写在最后的一点个人经验

我从学 C++ 到现在,带过很多新人,自己也写过不少把 const 位置搞反然后被编译器教育的代码。后来我把这套规则简化成一句话:先看*在谁的前面,再看 const 在谁的右边。const 贴着谁,谁就只有只读权限。

还有个小习惯,写接口和函数声明时,我会先想想“这个数据交出之后,是不是还要改”。如果不用改,立刻加 const 引用或 const 指针。这个习惯让我的代码少了很多无谓的拷贝和奇怪的 bug,也让后来接手的人少骂我好几句。以后你遇到报错信息里出现discards qualifiers这类字眼,第一反应不要慌,大概率就是 const 的“只读权限”和某个非只读操作冲突了,按本文的方法逐层追下去,很快能找到出口。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询