ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C/C++ const指针:const int *p与int *const p

C/C++ const指针:const int *p与int *const p

有一次代码评审,朋友指着一行函数签名问我:“这里到底是常量指针还是指针常量?”我当场愣了一下,因为这两个词在不同资料里的用法完全相反。有人说const int *p是常量指针,也有人把它叫指针常量;回到int *const p,又有人换了一套说法。中文术语的不统一,让本来就容易搞混的声明雪上加霜。

所以这篇文章不看中文名词打架,直接回到代码本身。我会把const int *p、int *const p、const int *const p这三种声明掰开揉碎讲清楚,覆盖声明读法、类型系统原理、工程场景、编译报错和面试题。无论是刚接触C/C++的初学者,还是被“右左法则”折磨过的面试准备者,看完都能在10秒内判断一个声明到底在限制谁。

1. 先把定义拧清楚:常量指针和指针常量到底谁是谁

1.1 中文术语混乱的根源,用英文直接锁定

这个问题之所以恶心,是因为“常量指针”和“指针常量”在中文教材里没有统一口径。我见过把const int *p叫“常量指针”的书,因为它指向的内容是常量;也见过把int *const p叫“指针常量”的书,因为指针本身是常量。

谁对谁错先放一边,工程中真正靠谱的锚点是英文和类型写法:

  • “常量指针”对应const pointer,类型写成int *const p,意思是“这个指针是常量,指针本身不能改,但指向的值可以改”。
  • “指针常量”对应pointer to constant,类型写成const int *p,意思是“这个指针指向一个常量,通过它不能改指向的值,但指针自己可以改”。

我建议你以后跟人讨论时直接报类型语法,或者用英文说,不要单说中文简称。尤其在看开源代码、写技术文档的时候,const int *p和int *const p才是一锤定音的东西。中文称呼只是习惯,不是标准。记住“常量指针是 const pointer,指针常量是 pointer to constant”这个大方向,后面读代码就不会被教材带偏。

1.2 右左法则:一行声明秒变人话

读复杂声明有个经典方法叫“右左法则”,英文叫 Clockwise/Spiral Rule 的一个简化版。具体读法是:从标识符(变量名)开始,先向右看,再向左看;右边有东西先读右边,右边读完再读左边。这对指针声明特别好用。

拿const int *p举例:

const int *p;

从p开始,右边没有东西,向左看,先碰到*,说明p是一个指针;继续向左,碰到const int,说明这个指针指向一个const int。合起来就是:p是一个指向“常量整型”的指针。因此p本身可以改,*p不能改。

再看int *const p:

int *const p;

从p开始,向右看没有内容;向左看,第一个先碰到const,再碰到*,再碰到int。按“先说明指针,再说明指针的限定”这个顺序,读作:p是一个带 const 的指针,指向int。所以p本身不能改,*p可以改。

第三种形态const int *const p也一样:向右没东西,向左先看到*,说明是指针;再看到const,说明指针本身受限;再往左是int和const。读作:p是一个指向const int的 const 指针。指针不能改,指向的值也不能改。

有没有更便于记忆的说法?有。我在工程里经常用一句话:“const 在星的左边,管的是值;const 在星的右边,管的是指针本身。”这里的“左边”和“右边”指的是相对于*号的位置。const int *p中 const 在星左边,所以*p受限;int *const p中 const 在星右边,所以p受限。这个口诀不严谨,但判断速度极快。

1.3 三种声明形态对照表

把三种形态放一起看,谁受限、谁自由就一目了然:

声明写法英文叫法指针本身能否修改指向的值能否修改典型用途
const int *ppointer to const int可以不可以只读遍历、入参只读声明
int *const pconst pointer to int不可以可以固定缓冲区地址、寄存器映射
const int *const pconst pointer to const int不可以不可以全局只读表、双限制场景

还有一个容易踩的等价写法:int const *p和const int *p在语法上是完全等价的。C和C++标准都允许 const 写在类型前面或后面。很多老工程师习惯写成int const *p,这时候你只要按右左法则读,照样能判断出来。建议自己写代码时统一用const int *p,让别人少烧点脑细胞。

注意:const int *p和int const *p等价,但int *const p完全是另一个意思。判断时先确定*的位置,再看 const 在哪一侧。别被“const 越来越靠近指针”这种模糊说法带偏。

2. const 到底约束了什么:类型系统和内存视角

2.1 从类型系统的角度看:const 是类型的一部分

很多人把 const 理解成“内存只读锁”,其实它首先是一个类型修饰符。对一个变量来说,const int和int是两个不同的类型:一个是“常量整型”,一个是“普通整型”。编译器根据类型来决定哪些操作合法,哪些操作非法。

对指针来说,要把“指针本身的类型”和“指针所指向对象的类型”拆开看:

  • const int *p中,p的类型是“指向 const int 的指针”,*p的类型是const int。所以p++合法,因为 p 本身不带 const;*p = 5非法,因为*p的类型带 const。
  • int *const p中,p的类型是“指向 int 的 const 指针”,*p的类型是int。所以p++非法,因为 p 的类型带 const;*p = 5合法,因为*p的类型不带 const。

这种区别在 C++ 术语里有专门的叫法:指针本身的 const 叫 top-level const(顶层 const),所指对象的 const 叫 low-level const(底层 const)。函数重载区分时,顶层 const 不参与重载区分,底层 const 参与。也就是说int *const p和int *p在重载角度会被视为同一类形参,而const int *p和int *p可以重载。

理解到这一层,你就不会再去背“是什么指针常量、什么常量指针”了,直接看类型结构就行。编程中真正的护城河不是术语,而是对类型的拆解能力。

2.2 内存视角:const 不是硬件写保护

有些同学验证 const 时做过“大胆实验”:把 const 指针强转成非 const 指针,然后修改指向的值,发现程序居然能跑。于是得出“const 没用”的结论。这是典型的误读。

const 的约束本质上是编译期的契约,运行时不生成任何边界检查指令。你写*p = 5,编译器发现*p是const int,直接报错阻止编译;但如果通过强转绕过编译器,比如*(int *)p = 5,程序能不能跑取决于那块内存到底在哪、是否真的只读。

真正的内存只读保护通常来自操作系统或硬件,比如全局 const 变量可能被放在只读数据段,字符串字面量通常保存在只读区。这时候强转去写才会出现段错误。如果 const 指向的是一块普通栈上变量,只是“通过这个指针的视角不允许写”,那强转后可能真的能改成功,只不过这是未定义行为,改的是什么结果都合理,你没有资格抱怨程序崩溃。

我在实际项目里很少做这种强转实验,因为 const 的最大价值不是“防黑客”,而是“防手滑”。它把“这里不许改”的意图直接写进类型系统,让编译器和代码审查者都能一眼看到边界在哪。你非要把边界凿穿,C/C++ 确实拦不住,但这不是 const 的缺陷,是 C 系语言相信程序员的传统。

2.3 为什么不建议通过强转绕过 const

工程上有一条不成文的规矩:看到const修饰的参数,函数内部要么只读,要么在必要的时候先把指针复制给同等或更宽松的形态。这里有个反直觉的细节。

如果指针指向的对象本身就不是 const,你通过const int *去看它,只是“换了一个受限视角”,完全合法。这是正常的权限收窄。但如果对象本身是const int,你再通过强转去写它,就属于未定义行为,谁也不知道会发生什么。

回到接口设计:const是接口的“只读契约”。你调用一个函数,如果它声明void read(const char *s),那你尽可以放心传数据给它,它承诺不改。如果它声明void write(char *s),你就得心里有数,它可能改。这个契约一旦被强转破坏,后续所有基于类型推导的安全检查都会失效。我在接手老项目的过程中见过不少 “去掉 const 后一切正常” 的代码,它们也许侥幸跑了很多年,但每次迁移平台、升级编译器都可能成为定时炸弹。

3. 工程实战:三种形态分别用在哪里

3.1 函数形参怎么选:const int * 是默认,int * const 先别急着用

绝大多数你见过的“const 指针参数”,用的都是const char *、const int *这类指向常量的指针。比如这个常见的只读函数:

size_t count_chars(const char *str) { size_t cnt = 0; while (*str++) { cnt++; } return cnt; }

str本身是const char *,函数内部可以对str做自增,但不能通过它修改字符串内容。这对调用方来说是一个很清晰的承诺:我传字符串给你,你只读不改。与此同时,这种声明也允许直接传入字符串字面量,比如count_chars("hello"),因为字符串字面量在 C++ 里就是const char[]类型。

如果参数不加 const,比如void count_chars(char *str),在 C++ 里传字符串字面量会直接报错或警告,因为const char[]不能隐式转换成char *。这就是为什么老项目里大量 C 函数声明成const char *,不只是为了性能,更是为了类型兼容。

int *const p做形参的情况则少得多。很多人喜欢把参数写成void sort_array(int *const arr, size_t n),想表达“我不会让 arr 指向别处”。但形参本质上是实参的一份拷贝,函数内部对arr赋值并不会影响调用方的指针变量。int *const只会约束函数内部的arr,而函数内部本来就没人打算改它,所以这个 const 加不加几乎没区别,还容易给读者造成“这里有什么特殊约束”的错觉。

真正需要区分的是输出型参数和输入输出参数。如果函数要修改调用方传入的变量,应该用int *out;如果函数明确不修改,就用const int *in。把声明意图写在类型里,比在注释里写一万句“我不会改”都管用。

3.2 数组遍历和固定边界:const int * 负责走动,int * const 负责守门

数组遍历是const int *最自然的应用场景。比如:

void print_all(const int *arr, size_t n) { const int *p = arr; while (p != arr + n) { printf("%d\n", *p); p++; // 没问题,p 本身可以改 } }

这里p是const int *,它指向的内容不能通过p修改,但p可以自增,所以遍历能推进。如果你不小心在循环体里写了(*p)++,编译器会立刻报错,防止你把只读的调用方数据改掉。

int *const用在局部场景里,最常见的是“固定边界”的语义。比如一个函数内部要遍历一个区间,但你不希望起始指针和结束指针被意外改掉:

void process_range(int *const start, int *const end) { int *cur = start; while (cur != end) { *cur = computed_value(cur); cur++; } }

在这里,start和end被固定为区间两端的边界,循环指针用独立的cur来移动。如果中途有人写了start++,编译错误立刻警告你“start 是只读变量”。这种做法并不炫技,但它把边界保护从“人肉注意”提升到了“编译器检查”的层面。

另外还有一个非常经典的细节:int *const p的p本身不能改,不代表*p不能改。所以*p = 5完全合法。有些初学者刚学会const就把它当成万能只读修饰符,看到int *const就以为里面的数据也不能动,然后用*(p + 1)去读相邻元素,发现能读出来,又开始怀疑人生。记住:int *const锁的是“指针变量”,不是“指向的数据”。

3.3 结构体和类成员:固定地址的指针成员怎么处理

在类或结构体里,int *const也有明确用途。最典型的场景是“视图”或“缓冲区指针”设计:一个对象持有外部传入的缓冲区的地址,且这个地址在对象生命周期内不允许改变。

class RingBuffer { char *const buffer; // 指针成员不可改,但 buffer 指向的数据可改 size_t capacity; size_t head; size_t tail; public: RingBuffer(char *buf, size_t cap) : buffer(buf), capacity(cap), head(0), tail(0) {} };

这样写的好处是,buffer一旦在构造函数里初始化,之后就不可能被重新赋值为别的地址。如果有人试图写this->buffer = other_buffer;,编译器直接拒绝。缓冲区地址的安全性由类型系统保障,不需要靠注释提醒自己“千万别改 buffer”。

但要注意一个衍生问题:C++ 类里如果成员带 const,默认的拷贝赋值运算符会被禁止。因为赋值操作需要给buffer成员赋值,而buffer是 const,无法赋新值。这类对象只能通过拷贝构造初始化,不能整体赋值。如果你确实需要可赋值的结构体,就不要在成员里放int *const,改成普通指针或使用std::unique_ptr之类的智能指针来管理所有权。

嵌入式开发里还有一种常见的用法是寄存器地址指针。硬件寄存器地址在运行时不可变,但寄存器值本身就允许读写,所以常写成:

volatile uint32_t *const reg_base = (volatile uint32_t *)0x40000000U;

reg_base是 const 指针,地址不可改;volatile uint32_t告诉编译器不要优化对它的读写。这种写法在驱动代码里非常普遍,也顺便解释了为什么 const 指针和 volatile 经常同时出现:一个是权限约束,一个是访问语义约束,两者负责的事情完全不同。

4. 编译错误与排查笔记:这些报错再也不用怕

4.1 最典型的两个 read-only 错误

弄混这两类指针,编译器给的报错信息其实非常明显,只是很多新手被英文吓住了。这里总结最常见的两条:

error: assignment of read-only variable 'q'

这条是针对int *const q的。你写了类似q = &another;,试图修改指针本身,编译器就报“只读变量 q 不能赋值”。看到 “read-only variable”,第一反应就是某个变量自身带了 const。

error: assignment of read-only location '*p'

这条是针对const int *p的。你写了类似*p = 10;,编译器报“只读位置 *p 不能赋值”。注意它说的是 read-only location,不是 read-only variable,说明受限制的是*p这块位置,而不是指针变量p本身。英文里 location 和 variable 两个词,直接告诉你到底是谁被锁了。

Clang 和 MSVC 的报错文本略有差异,但关键词read-only、discards qualifier、const通常都会出现。排查思路很简单:先找到报错的这行,再判断这行改动的是指针变量本身还是它所指向的内容。改动指针变量报 read-only variable,改动内容报 read-only location。

4.2 综合判断题:面试题里最常见的几种写法

面试题里最经典的考法就是给你四五个声明,问哪一行编译报错。这里我整理一份典型的,你可以直接当成练习做:

int a = 10, b = 20; const int *p1 = &a; // p1 可改,*p1 不可改 p1 = &b; // 正确 // *p1 = 30; // 报错:read-only location int *const p2 = &a; // p2 不可改,*p2 可改 *p2 = 30; // 正确 // p2 = &b; // 报错:read-only variable const int *const p3 = &a; // p3 和 *p3 都不可改 // p3 = &b; // 报错 // *p3 = 30; // 报错 int const *p4 = &a; // 等价于 const int *p4 // *p4 = 30; // 报错 p4 = &b; // 正确

做题时我的建议顺序是:先找到*号,再看const在*的左边还是右边;左边管内容,右边管指针。这个顺序比背代码还快。

还有一类题会考类型转换。比如:

const int x = 100; const int *p = &x; // 正确 int *q = &x; // C++ 编译错误:discards qualifiers

因为&x的类型是const int *,不能隐式转换成int *。在 C 语言里这条一般只是警告(具体取决于编译器选项),在 C++ 里是错误。这种题考的其实是“const 不能随便去除”的规则。

4.3 踩坑实录:const 的传染性比想象中更强

我工作中遇到的最大的坑,不是分不清语法,而是接口设计初期没把 const 定好,后期重构引爆一片。

有一个模拟项目 X 的函数原本声明为:

void parse_line(char *line, size_t len);

后来代码审查发现函数内部全程没有修改line,于是想把它改成:

void parse_line(const char *line, size_t len);

以为只是加一个 const,影响面很小。结果编译直接炸掉几十处,因为函数内部调用了另一个工具函数,而那个工具函数的形参是char *。一旦line变成const char *,你就没法安全地传给期望char *的接口了。这就像你把房间门锁上之后,突然发现钥匙留在房间里了。

const 是传染性的。你给一个变量戴上 const 帽子后,整个调用链上所有只用不写的接口都得同步戴上 const 帽子,否则就会一层层报错。这也解释了为什么很多老项目里的 const 用得少,因为早期没规划好,后期想加就牵一发而动全身。

处理这类问题的正确方式不是强转去掉 const,而是先梳理调用链,把所有只读接口的形参一并调整。如果实在有某个第三方库接口不遵循 const 规范,也要在理由充分的前提下做一个显式的安全转换,并在代码审查里说明为什么这个地方突破了只读契约。C 语言编译器对 const 的检查没有 C++ 那么严格,经常只给警告不报错,这时候更要靠团队规范兜底,别把 warning 当摆设。

4.4 字符串字面量的经典坑:const char* 到底怎么来的

很多初学者写char *p = "hello";,编译器给一个 deprecated conversion 警告,运行的时候却没事。然后在 C++ 里直接换成char *p = "hello";,编译就报错了。为什么?因为字符串字面量在 C++ 标准里的类型是const char[],它保存在只读的静态存储区,你试图修改它是未定义行为。正确写法一定是:

const char *str = "hello";

在 C语言里,标准并没有把字符串字面量的类型定义为const char[],但明确说了尝试修改字符串字面量是未定义行为。所以 C 编译器允许隐式转换成char *,最多给警告;C++ 直接把这条路堵死,要求你写成const char *。老项目里常见的警告 “deprecated conversion from string constant to 'char*'”,就是在提醒你这里丢掉了 const 限定,长期来看是隐患。

接手这类代码时,最稳的迁移方式是把所有只读字符串参数的形参逐步改成const char *,同时把内部可能修改字符串的逻辑单独切分出来。这个过程和前面说的 const 传染现象是一体两面,提前规划好就不会手忙脚乱。

5. 速查表与记忆锚点:5分钟彻底记住

5.1 一张表秒判 const 归属

我把最常见的几种形态汇总到一张表里,你复制到笔记里,或者贴在手边都行:

声明指针本身(p)指向的值(*p)记忆口诀
const int *p可改不可改const 在星左,管值
int const *p可改不可改等价于上一行
int *const p不可改可改const 在星右,管指针
const int *const p不可改不可改两层都锁
int *p可改可改普通指针

这张表不需要背,理解“星左管值、星右管针”之后,你想忘都难。考试或者写代码犹豫的时候,心里默念一遍,再对着声明画一下星号的位置,基本不会错。

5.2 我的三个记忆锚点

第一个锚点是“右左法则”。从变量名开始,先右后左读,碰见*就意识到是指针,碰见const就意识到这里加了限制。不管声明多复杂,这个读法都不会骗人。

第二个锚点是“星左管值、星右管针”。这是判断速度最快的口诀,适合笔试、面试、以及快速看别人代码。注意这句话只对“指针声明中 const 和星号的位置关系”有效,别拿去分析引用或函数指针,场景不对。

第三个锚点是“不能改谁,const 就修饰谁”。int *const p中 const 跟p关系最近,所以锁 p;const int *p中 const 落在int *p的前半部分,本质上修饰的是*p的数据类型。想改数据就找 const 在星左的声明,想改指针就找 const 在星右的声明。

5.3 口头禅式的自查方法

最后分享一个我平时写代码会用到的自查问题:“我到底想让谁不能变?是指针本身,还是指针指向的内容?”

如果想要指针本身不变,比如固定缓冲区地址,就写char *const buffer。如果想要指向的内容不变,比如只读入参,就写const char *data。两个都不能变,就写const char *const data。

这个“以意图驱动声明”的思路,比死记硬背术语实用得多。我在实际项目中几乎不用去查资料,就是先想清楚“哪个对象需要不可变”,再把它写到类型里。编译器随后会确认你是否真的做到了,该报错报错,该放行放行,所有规则都透明可见。

我自己的习惯还有一条:在代码审查时,凡是看到函数参数带有奇奇怪怪的 const 修饰,都会先问一句“这个 const 是想表达什么约束”。如果对方说不清楚,多半这行代码就是抄来的,或者加了并没有实际作用的装饰性 const。好的 const 用法应该能让意图更明显,而不是让读代码的人更困惑。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进