刚学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 的“只读权限”和某个非只读操作冲突了,按本文的方法逐层追下去,很快能找到出口。