有一次代码评审朋友指着一行函数签名问我“这里到底是常量指针还是指针常量”我当场愣了一下因为这两个词在不同资料里的用法完全相反。有人说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 pint *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关系最近所以锁 pconst int *p中 const 落在int *p的前半部分本质上修饰的是*p的数据类型。想改数据就找 const 在星左的声明想改指针就找 const 在星右的声明。5.3 口头禅式的自查方法最后分享一个我平时写代码会用到的自查问题“我到底想让谁不能变是指针本身还是指针指向的内容”如果想要指针本身不变比如固定缓冲区地址就写char *const buffer。如果想要指向的内容不变比如只读入参就写const char *data。两个都不能变就写const char *const data。这个“以意图驱动声明”的思路比死记硬背术语实用得多。我在实际项目中几乎不用去查资料就是先想清楚“哪个对象需要不可变”再把它写到类型里。编译器随后会确认你是否真的做到了该报错报错该放行放行所有规则都透明可见。我自己的习惯还有一条在代码审查时凡是看到函数参数带有奇奇怪怪的 const 修饰都会先问一句“这个 const 是想表达什么约束”。如果对方说不清楚多半这行代码就是抄来的或者加了并没有实际作用的装饰性 const。好的 const 用法应该能让意图更明显而不是让读代码的人更困惑。