const 和指针的组合,是每个 C 学习者都要过的「读声明」关:const int *p、int *const p、const int *const p,三胞胎考倒一片。本章的目标有三个:把声明读法练成肌肉记忆(从右往左)、把「const 是视角不是属性」这个本质说透(违它的约就是 UB)、顺带把另外两个限定符 restrict 和 volatile 一起结清,它们是 C 优化世界的三张契约。原文小节编号错成 7.x,一并修正。
const int *p; // p 是 指针 → 指向 int → int 是 const:内容不可改,指针可改
int *const p; // p 是 const → 常量指针 → 指向 int:指针不可改,内容可改
const int *const p; // 双 const:都不许改
口诀:「const 在 * 左边,约束内容;const 在 * 右边,约束指针本身」。三行的可改矩阵:
| 声明 | *p = x 改内容 | p = &y 改指向 |
|---|---|---|
const int *p | ✗ | ✓ |
int *const p | ✓ | ✗ |
const int *const p | ✗ | ✗ |
(int const *p 和 const int *p 完全等价,const 贴左贴右随缘,但贴 * 的位置决定约束对象,这个不能乱。)
最容易误解的一点:const int *p = &x; 不代表 x 真的是常量。x 本身可能是个普通 int,只是「通过 p 这条路」不许写:
int x = 10; // x 完全可写
const int *p = &x; // p 是只读视角
// *p = 20; // 编译错误:这条路禁行
x = 20; // 直接走 x 这条路,完全合法
const int y = 10; // 这次 y 真是 const
int *q = (int *)&y; // 强转丢掉 const
*q = 99; // UB:y 在只读段可能崩;编译器按 y≡10 优化,行为不可测
前半段是 const 的正确用途(一个变量,多种访问权限);后半段是违约(强转丢 const 再写),后果见计组课 UB 的经典描述:编译器可能早就把所有读 y 的地方替换成 10,你写进去的 99 根本没人看见。「通过某种途径写 const 对象」是未定义行为,第 14 章的清单会再见到它。
只读参数一律加 const,这是 C 工程的门槛级习惯:
size_t my_strlen(const char *s); // 承诺:不改 s
char *my_strcpy(char *dst, const char *src); // 只改 dst,src 只读
三个收益:调用者放心传字符串字面量和其他只读数据;编译器帮你拦截函数体内的手滑;接口自文档(看到 const 就知道谁是入参谁是出参)。glibc 的原型全是这个风格,你的代码没理由不跟。
restrict(C99)是对编译器的性能承诺:这块内存只会通过这个指针访问,没有别名。有了承诺,编译器才敢做重排和向量化:
void add_arrays(int n, int *restrict a, const int *restrict b) {
for (int i = 0; i < n; i++)
a[i] += b[i]; // 有 restrict:编译器知道写 a 不会影响读 b,放心流水
}
没有 restrict 时,编译器必须防着 a 和 b 指向同一块内存(写 a[i] 可能改变 b[i] 的值),每次都要保守;有了 restrict, SIMD 批量处理解锁,数值代码常见百分之几十的提速。违约的代价照例是 UB:glibc 的 memcpy 原型就带 restrict,所以重叠拷贝必须用 memmove(它内部的启发式会先判断方向再拷),这是这个契约最著名的应用场景。
volatile 和 restrict 方向相反,它是负优化契约:告诉编译器「这个变量会被你看不见的东西改变」,于是不许缓存进寄存器、不许合并访问、不许假设两次读值相同:
volatile int *status = (volatile int *)0x4000; // 假设是设备状态寄存器
while (*status == 0) { } // 没有 volatile:编译器只读一次,死循环;有 volatile:每轮真读
三大用武之地:MMIO 硬件寄存器(计组课第 9 章:设备状态自己会变)、信号处理函数共享的 sig_atomic_t 变量、setjmp/longjmp 跨越的局部变量。必须钉死的一条:volatile 不是线程同步工具,它不保证原子性、不建立内存屏障,多线程标志位请用 C11 的 _Atomic 或锁。把 volatile 用在普通多线程代码上,是我见过最流行的「感觉在防,其实在裸奔」的写法。
int *(*p)[3]、int (*(*p)[3])()(别慌,第 13 章的方法论,这里只需第一个)分别读作什么;写出「指向「含 5 个 const char* 的数组」的指针」的声明。void swap_if_larger(int *restrict a, int *restrict b),测试重叠调用(swap_if_larger(&x, &x))是否真的违约(用 -O2 看汇编里是否出现激进优化);再对比 memcpy 与 memmove 在 memcpy(buf, buf+1, 7) 下的输出差异。const 关键字在 C 里的执行者是编译器(编译期拦截),在内存硬件层几乎没有痕迹(局部变量照样可写)。对比 Java 的 final、硬件的写保护页(操作系统课第 6 章),说说「不可变」在三个层次上分别由谁执行、各能拦住谁。