C语言const详解:从指针类型到函数参数,彻底吃透只读修饰符的坑与用
很多人学了指针再学 const觉得自己懂 const 了——不就是告诉编译器“这个变量别想改嘛”。但真放到项目里const 的威力往往是在编译器的报错里体现出来的。前两天帮人调一个字符串处理函数函数签名是char *find_word(char *buf, const char *word)调用方传过来的是一块只读字符串字面量find_word(str, hello)。结果编译直接报 warning说参数类型不匹配。对方很困惑“不就加了个 const 吗怎么就不让传了”这个问题恰恰是理解 C 语言关键字 const 的关键分水岭。const 不是一个简单的“只读修饰符”它本质上是类型系统的一部分它把“我不会修改这个对象”的约定从注释层面的口头承诺变成了编译器可以检查的硬约束。这篇文章我就想把自己实际梳理过的 const 用法、坑点和设计思路完整写一遍覆盖变量声明、指针、函数参数、返回值、优化和常见面试题帮你把 const 吃完吃透。1. 先从一次编译报错谈起const 为什么不是一个“可选项”先说上面那个案例。调用方传的是字符串字面量在 C 语言里字符串字面量的类型是char[]没错不是const char[]但标准明确要求修改字符串字面量的行为是未定义行为。所以几乎所有编译器在处理字符串字面量时底层都会把它放进只读数据段。那么问题来了函数参数声明为char *word实际上就是把一个“本不该被修改”的内存区域的地址交给了一个可以随意写入的指针。为了让这种危险在编译期就暴露出来你必须把参数类型写成const char *word。这里就引出了 const 的第一个核心作用它可以存在于类型里参与函数签名的匹配。char *和const char *是不同的类型。当你把函数入参声明成const char *时你其实是在对调用者说我这个函数只读你传进来的这段内存不会去写它。同时你也在对编译器说如果调用者试图把一个非 const 指针传进来没问题因为从char *到const char *是安全的隐式转换但如果我内部写了这个指针指向的内容就给我报错。很多初学者把 const 当成一个“可有可无的修饰”这是最大的误区。在 C 语言里类型是一种契约而 const 是这个契约里关于“可变性”的那部分条款。如果你在一个函数里明明不修改参数却不写 const那么你等于主动放弃了编译器的保护。更麻烦的是一旦别人把一个const char *类型的变量传给你的非 const 参数编译直接失败而且报错信息往往非常隐晦类似“discards qualifiers”或者“passing argument 1 of foo discards const qualifier”。这不是编译器在找茬恰恰是它在努力救人。我见过太多线上事故本质上就是有人拿了一个const char *的字符串强行塞给某个会修改缓冲区的函数然后在某种极端输入下把只读段写坏了程序崩溃或者内存被破坏。如果一开始就把函数签名设计成 const 正确的形态这类问题在编译期就能被 100% 拦截。这也是我写这篇文章最想强调的一点const 不是锦上添花它是 C 语言类型安全体系里不可或缺的一环。你越早把它当成类型的一部分来用后面踩的坑就越少。2. const 最关键的分水岭变量本身 const还是它指向的数据 const把 const 用于声明普通变量是最简单的用法但即便在这最简单的一层也藏着理解所有复杂用法的钥匙。比如const int a 10; a 20; // 编译错误这一句谁都看得懂。但真正重要的是理解这里的 const 是修饰a这个标识符本身让a变成一个不可修改的左值。而它背后更深的含义是你向编译器承诺这个对象在我的程序生命周期内不会变除非你通过特殊手段去破坏这个承诺。这种“变量本身不可变”和“变量指向的数据不可变”的区分是 const 在指针场景中最容易让人混乱的根源。C 语言里描述一个指向只读整数的指针和描述一个指向整数的只读指针是两种完全不同的东西const int *p; // p 是一个指针指向 const int不能通过 p 修改它指向的 int int * const p; // p 是一个 const 指针p 本身不能被改写但它指向的 int 可以改这两行代码是面试高频题也是实际项目里最容易写错的点。怎么一眼分辨我的方法很简单看 const 离谁近。const int *p中const 紧挨着 int所以它修饰的是 int也就是“指向的对象只读”int * const p中const 紧挨着 p所以它修饰的是指针本身。如果写const int * const p那就是指针本身和指向的对象都只读。下面这个表格把四种组合列清楚了建议直接背下来以后声明变量的时候对着选声明指针本身可改指向的数据可改适用场景int *p可改可改普通指针const int *p可改不可改只读观察指向外部传入的只读数据int * const p不可改可改固定指向某个缓冲区但要修改内容const int * const p不可改不可改固定指向一块只读数据这里有一个必须注意的细节const int *p表示不能通过p去修改它所指向的 int但如果这个 int 本来就不是 const 的你完全可以用另一个非 const 指针去修改它。const 是“通过这个指针不能改”不是“那个对象绝对不能改”。这一点在函数参数里尤为重要函数拿到const int *p它自己不动这个数据但调用方如果自己持有非 const 指针随时可以改。函数只能管住自己管不住调用方。从右往左读是一个好习惯。看到const int *p从右读到左就是“p 是指向 int 的指针且 int 是 const”看到int * const p从右读到左就是“p 是 const 的指针指向 int”。这个习惯养成之后再复杂的指针声明都能拆明白。3. 指针、数组与函数参数const 最常见的三个“变式”3.1 函数入参保护缓冲区不被破坏在实际项目里const 用得最多的地方就是函数参数。一个函数如果不需要修改某个指针指向的数据就应该声明为const。标准库就是最好的范例strlen(const char *s)只读字符串计算长度。strcpy(char *dest, const char *src)目标缓冲区可写源字符串只读。memcpy(void *dest, const void *src, size_t n)目标可写源只读。这套设计的逻辑很清楚输出参数由函数写入的通常不加 const输入参数函数只读取的应该加 const。这样函数内部不小心写了输入缓冲区编译器会立刻报错调用方看到签名也能一眼知道哪些参数会被函数修改哪些只是被读取。写代码的人不用再去翻文档、查实现类型本身就说了算。有个容易被忽略的点如果函数内部需要把参数转发给另一个函数const 的约束会“传染”。比如你写了一个void process(const char *s)然后在里面想调char *dup(const char *s)没问题因为const char *转const char *类型一致但如果你想调void lowercase(char *s)编译就过不去因为你的s是 const 的不能把它当成可写的传入。这就是类型系统在强制你思考你到底能不能承受“这个数据被修改”如果数据确实来自只读区你就得调整设计而不是强行去掉 const。3.2 数组参数const 会退化成指针当你把数组作为函数参数时C 语言不会真的把整个数组拷贝一份传进去而是退化成指向首元素的指针。所以下面这三个声明在参数层面上是等价的void f(const int arr[]); void f(const int arr[10]); void f(const int *arr);三者都等价于const int *arr也就是说形参是一个指向 const int 的指针。这里 const 保护的是数组的每一个元素函数内部不能通过arr[i]去修改元素。但要注意数组本身的大小信息在这里已经丢失了const 保护不了“数组越界”的问题那是边界检查的领域不是 const 的职责。这带来一个实用的习惯如果你希望一个函数接收数组并只读处理就直接写成const int a[]或者const int *a别纠结。但如果在函数内部需要修改数组比如写一个排序函数那就不加 const。排序函数的标准签名通常是void sort(int *a, size_t n)因为这个函数必然要改数组元素。3.3 返回值告诉调用者“你拿到的东西别乱改”const 也可以用在函数返回值上但它体现的价值分两种情况一种有意义一种基本没用。有意义的场景是返回指针。比如const char *get_version(void) { static const char ver[] 1.2.3; return ver; }这个返回值声明成const char *就是在告诉调用者这个指针指向的数据是只读的你只能看不能写。而且这往往不是“约定”而是必须——因为ver本身定义成了const如果你用char *作为返回类型编译器直接报错。反过来如果你返回的是一段静态存储区的字符串字面量类型其实可当作const char[]那返回const char *就是最正确的选择。没什么用的场景是返回值是普通值的情况比如const int get_count(void);在 C 语言里这种写法对普通返回值几乎不产生任何运行时影响因为函数返回的 int 本身就是个临时值你没法给一个临时值赋值get_count() 5;本来就不合法。不过它有那么一点文档价值而且编译器通常也不警告。我的建议是普通值类型的返回值不必加 const返回指针时一定要根据实际情况决定加不加。这是代码规范层面性价比极高的习惯。4. const 与编译器的约定优化、只读段和未定义行为const 不只是写给人看的它也是写给编译器看的。一个对象如果被 const 修饰编译器在某些情况下可以放心做优化比如常量传播、公共子表达式消除等。但这里有个非常大的误区const 不代表对象一定被放进只读段更不代表“物理上不能改”。先看存储位置。局部变量加 constvoid test(void) { const int a 10; // a 在栈上分配写代码时不能改但物理上只是栈内存 }这里的a编译器一般不会放进只读段它就活在栈里。const 在这里只是编译期的约束源码层面不允许写a 20你在运行时强转掉 const 去写它是未定义行为但物理上经常“能写成功”。再看全局变量const int g_val 42;这种对象主流编译器一般会放进.rodata段也就是只读数据段。如果你强行去掉 const 去写它在支持内存保护的平台比如 Linux、Windows上通常会在运行时直接触发段错误。这就有意思了同样是 const 对象局部变量和全局变量被强改的后果可能完全不同。C 标准对这件事的表述是试图通过非 const 限定的左值去修改一个 const 限定的对象行为未定义。未定义行为的意思是编译器做什么都是合理的。它可能真的让修改生效可能在加了-O2优化后完全不生效可能直接崩溃也可能在某个版本编译器下运行得好好的换个版本问题就出现。所以千万不要在代码里写那种“我知道这里虽然 const 了但我偷偷强转改一下没事”的逻辑这属于给自己埋雷。编译器在优化时对 const 的信任程度才是真正值得理解的机制。看一段例子const int g_val 42; void print(void) { printf(%d\n, g_val); printf(%d\n, g_val); }如果编译器知道g_val是 const 且从未被修改并且确认整个翻译单元内没有人绕过 const 去修改它它可能直接在编译期把g_val替换成常量 42甚至把两次printf合并。这时候如果你在某个奇葩的第三方库代码里通过强转修改了g_val的值内存里确实变成了 43但程序打印出来的还是 42。你排查半天以为是并发问题其实是 const 优化把内存读取给省了。再来说说volatile和const搭配的场景。有些对象确实是“只读”的平凡代码不能改它但它的值又不能被编译器当成常量传播因为外部硬件、另一个线程或者底层代码会修改它。这类对象的标准写法就是volatile const组合volatile const uint32_t *reg (volatile const uint32_t *)0x40000000;比如嵌入式开发里读一个只读寄存器。const表示你的程序不应该写这个寄存器volatile表示每次读都必须真实访问内存不能拿缓存或优化后的旧值。两个关键字完全是并行约束不冲突反而常在一起用。如果你把 volatile 丢了编译器可能以为地址内容恒定不变把多次读取优化掉那读到的永远是第一次的值硬件状态更新了你也不知道。这类 bug 在嵌入式开发和底层驱动里特别经典不夸张地说排错能排好几天。5. 实际项目里的 const 决策和 #define、static、强制转换怎么配合5.1 const 与 #define很多场景不是替代关系在 C 语言里定义“常量”有两个选择#define MAX_LEN 64和const int MAX_LEN 64。很多人问哪个更好我的结论是能用 const 就用 const但编译器必须支持把 const 当成编译期常量用的场景除外。const int MAX_LEN 64;是一个类型安全的只读变量它遵守作用域规则能出现在调试器里能取地址能参与重载C 里。它唯一的“短处”是在 C 语言里const 限定的对象不是整数常量表达式。也就是说下面的代码在 C 语言里是不合法的或者至少不能保证合法const int MAX_LEN 64; int arr[MAX_LEN]; // 在 C99 之后会被当成变长数组 VLA而不是常量大小C 语言里数组大小必须是一个整数常量表达式而MAX_LEN虽然声明为 const但它不是一个常量表达式。GCC 在默认模式下可能网开一面但严格模式或换一个编译器行为就不同。所以当你需要定义“数组长度”“case 标签”“位域宽度”这类编译期常量时应该用#define或者枚举。比如#define MAX_LEN 64 int arr[MAX_LEN]; enum { BUFSIZE 128 }; char buf[BUFSIZE];反过来如果你只是需要一个“程序员不该修改的全局只读变量”const 就比宏安全得多因为它有类型也有作用域。宏是简单的文本替换既没有类型检查又容易误伤同名标识符还没有调试信息一旦出错排查很费劲。实际项目里最好的做法是编译期常量用宏或 enum运行期只读变量用 const。5.2 static const 和普通 const谁来管作用域全局变量的 const 有一个容易忽略的链接属性问题。在 C 语言里文件作用域的const变量默认是外部链接的也就是说// file1.c const int g_val 10;这个g_val对其它文件可见另一个文件可以用extern const int g_val;引用它。如果你希望这个只读对象只在本文件内可见就加上static// file1.c static const int g_internal 10;写成static const之后外部文件就链接不到这个符号了。这是一种“文件私有常量”的常见写法。函数内部的static const又是另一种语义它具备静态存储期函数第一次执行时初始化之后一直存活每次调用不会重新初始化而且它通常是只读的。比如const char *get_msg(void) { static const char msg[] hello; return msg; }这个msg放在函数内部全局可见性为零但生命周期贯穿整个程序存储位置一般在只读段。返回它的指针非常安全这也是我前面例子用到的模式。用static const好处是既避免全局命名空间被污染又保证只读数据不会被反复构造。5.3 强制转换与 const能不用就不用用了要出大事C 语言允许你强制去掉指针的 const 限定语法上就是强转const int a 42; int *p (int *)a; *p 43; // 未定义行为这段代码编译完全没有警告但运行结果是未定义行为。前面说过这可能导致直接崩溃也可能“看起来正常”还可能产生离奇的缓存不一致问题。在实际项目中除了调用一些过时的、没写 const 的第三方 C 接口时偶尔需要做一次转换而且要确保该接口实际上不会修改数据我不建议在任何业务逻辑里做这种去 const 的强转。一旦做了你就绕过了编译器的检查把自己交给了未定义行为的深渊。反过来把int *转成const int *是安全且自动的不需要强转。这也是 const 设计上最优雅的地方它允许信息从可变流向只读不允许反向流动。这种单向性保证了一个函数只要声明了 const 入参就不会意外地破坏来自调用方的只读数据。6. const 的边界什么时候不要用、什么时候用了等于白用const 不是万能药它也有自己的边界和限制。理解这些边界你才算真正“吃掉”了这个关键字。第一类边界普通值返回类型加 const 意义不大。我在前面提过const int foo(void)对返回值不能提供什么有效约束因为返回值本身就是右值临时值你本来就不能给它赋值。这种写法只会增加阅读负担不如不写。第二类边界函数的局部变量加 const有一定价值但别期待太大。局部 const 变量确实能防止你在这个函数后面的代码里不小心改动它对维护来说有好处但它不会显著影响优化效果因为编译器在知道局部变量地址不泄漏的情况下本来就能推导出它是否被修改。所以我的习惯是局部变量是否加 const 主要看“防止自己手滑”还是“代码自文档化”的需求主观性较强。第三类边界const 管不住“间接修改”。比如void foo(const int *p) { int *q (int *)p; *q 100; // 你没绕过编译器吗绕过了但还是未定义行为 }再比如一个 const 指针指向的结构体里如果包含一个非 const 的指针成员struct Node { int data; struct Node *next; }; void foo(const struct Node *node) { node-data 1; // 错误data 是 const 的 node-next-data 2; // 合法next 指针本身是 const但它指向的 Node 不是 const }这是 const 里最容易踩坑的“浅层只读”问题。const struct Node *只能保证你不能修改node直接指向的那个结构体不能保证通过next间接访问到的其它节点也是只读的。如果你要设计一个“整个链表都只读”的接口C 语言里没有一个简单声明能做到只能靠约定和文档。理解了这一点你就知道为什么很多大项目里的高级接口宁可定义不同的结构体类型read-only view 和 mutable view也不愿意在复杂链式结构上硬加 const。第四类边界const 和线程/多核读写是两码事。在无锁场景下一个全局const整数被一个线程读、另一个线程改通过强转本质上是数据竞争即使有 const 也救不了你。如果你的设计里允许这种跨线程访问必须用同步原语或者更干脆点对象不要定义成 const。理解了这些边界之后再去看网上那些“const 面试八股文”你会发现它们大多数只是话术真正重要的是你能不能在实际代码中做出决策这个参数该不该加 const返回值是 const 指针还是可变指针常量用宏还是 const需要防编译器优化时要不要再叠一个 volatile这些决策没有标准答案但判断依据都藏在这篇文章的分析逻辑里。我个人在多年写 C 代码的过程中最大的转变是把 const 当作接口设计的一部分来思考而不是当作编译器的某种限制。每次写函数签名前先问自己“这个函数会修改哪个参数、只读哪个参数”然后把答案直接写进类型里。这样写出来的代码别人接手时不需要翻实现光看函数声明就能明白调用边界在哪里。而这才是 const 这个关键字真正的价值所在。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →