ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C/C++ const关键字全解析:从指针只读到constexpr与工程避坑指南

C/C++ const关键字全解析:从指针只读到constexpr与工程避坑指南 最近又有朋友问我 const 的用法。其实 const 这个关键字从C语言入行第一天就会遇到但直到今天在面试和代码评审里它依然是翻车大户。有人说它是只读修饰符有人说它是常量定义器还有人面对const int *p和int *const p时永远分不清。我见过太多人在 const 上栽跟头甚至包括一些写了五六年 C 的开发者。所以今天这篇我想把 const 关键字的前前后后、C 和 C 里的各个使用场景、以及它在真实工程里的坑一次说清楚。文章适合正在学习 C/C 的初学者也适合写过几年代码但想回头把基础补扎实的人。1. const到底是什么从变量只读说起1.1 从变量到只读const的最基础用法const 修饰普通变量的写法很简单const int a 10; a 20; // 编译错误assignment of read-only variable a这段代码几乎是所有C语言教材里都会出现的例子。它告诉我们一件事一旦用 const 初始化了一个变量后续就不能再通过这个变量名去修改它。注意我说的是“不能通过这个变量名修改”并不是“这个变量真的永远不会变”。如果 a 指向的内存被其它指针或别名偷偷改掉a 的值照样会变化只是编译器不允许你通过 a 这个左值去赋值而已。这一点在嵌入式开发里尤其重要很多人把 const 变量当成“绝对不会变”的常量然后发现程序跑着跑着值变了反而一脸懵。为什么要有 const 这种东西本质上是程序员之间互相承诺的契约。你写一个函数给同事用如果函数的参数不会改动就在参数类型前加 const你写一个全局配置不希望被其它模块乱改就定义成 const。编译器拿到这个契约之后会在编译阶段检查有没有违约行为相当于在代码里画了一条“只读黄线”。但这个契约的严格程度在 C 和 C 里并不完全一样。C 语言中const 修饰的变量本质上还是变量只是带有只读属性它不一定是编译期常量。举个例子// C 语言中 const int n 10; switch (x) { case n: // 错误case 标签要求整数常量表达式const n 不是常量表达式 break; }C 里同样一段代码却能通过编译因为 C 对 const 整型变量有“常量表达式”的特殊加成只要它用常量表达式初始化就可以在 switch 的 case 标签、数组长度、模板等场景里当常量用。这个差异经常被新手忽略也在 C 和 C 混合项目中埋过不少雷。所以别急着把 const 变量的值直接塞进需要常量表达式的地方先确认你所在的编译环境是不是 C 再说。1.2 const 修饰谁位置里有讲究很多书说const int a和int const a等价这没错。但一牵扯到指针位置就开始迷惑人了。理解的关键是掌握这样一句话const 修饰的是它左边紧挨着的类型如果左边没有类型就修饰右边的类型。int const a; // const 左边是 int所以修饰 inta 是 const int const int a; // const 左边没有类型只有关键字修饰右边的 inta 还是 const int所以普通变量不用纠结const 写在类型前还是类型后含义一样。真正容易翻车的是 typedef 场景。看这个typedef int *P; const P p; // 这个 const 到底修饰谁有人习惯性地把const P p理解成const int *p以为是“指向常量的指针”但事实完全不是这样。P 是一个指针类型const 修饰的是 P 这个整体p 本身是一个常量指针也就是int *const p。判断方法是先做类型替换const P相当于const (int *)这里的 const 修饰整个指针而不是 int。这种 typedef 加上 const 的组合在封装容器、回调函数、第三方库接口里非常常见稍不留神就会把只读语义搞反。这种“修饰左边左边没有就修饰右边”的规则配合下一节讲到的“从右往左读法”几乎可以解决所有 const 指针声明的理解问题。先记住这句话下面我们来拆指针。2. 指针与const的组合最容易翻车的几种写法2.1 四种组合对照表先放一张表我建议你直接存下来声明含义指针本身可否修改p指向的值可否修改*p 1const int *pp 是指向 const int 的指针可以不可以int const *p同上const 修饰 int可以不可以int *const pp 是 const 指针指向 int不可以可以const int *const pp 是 const 指针指向 const int不可以不可以看完表格说第一个坑。const 是向左结合的所以有人直接记“const 在 * 左边是数据类型只读const 在 * 右边是指针本身只读”。这个口诀在多数场景下是对的但碰上多级指针就得谨慎了。口诀能用但更靠谱的理解还是要回到声明结构本身。看一下多级指针int *const *p; // p 是指针指向 int *const 元素 int const **pp; // pp 是指针指向 const int * 元素这里用从右往左读法更清晰。声明int *const *p把 p 提出来右边是*说明 p 是一个指针指针指向的类型是左边的int *const也就是说 p 指向一个“不可变的 int 指针”。这种多级指针一般在复杂接口中出现平时写代码用到的不多但如果要吃透 const就必须直面它。2.2 从右往左读法从右往左读法其实很简单先找到变量名然后从右边最近的符号开始一层一层往左读把声明翻译成一句话。const int *pp 的右边是*所以先读成“p 是一个指针”再往左读const int就是“指向 const int”。组合起来p 是指向 const int 的指针。int *const pp 的右边是const所以先读成“p 是一个 const”再往左读* int就是“是一个 int 指针”。组合起来p 是一个 const 指针指向 int。const int *const pp 右边是 const左边是 * const int读成“p 是 const 指针指向 const int”。这个方法几乎可以当作无脑工具使用唯一需要适应的是“const 修饰右边”的问题。实际上从右往左读就是看哪个词最靠近变量名。最靠近变量名的修饰符决定了变量本身的属性隔一个星号才轮到指向对象的属性。把这件事想清楚以后看到再长的声明也不慌。2.3 赋值转换能加const不能随便去constconst 指针在赋值转换中有一条重要原则只允许逐层“增加 const”不允许“去掉 const”除非显式使用 const_cast。意思是int value 10; const int *cp value; // 合法给 int* 增加底层const int *p cp; // 非法把 const int* 当 int* 用为什么去掉 const 是非法的因为如果允许int *p cp那你就能通过 p 去修改原先被声明为 const 的数据绕过所有只读保证。编译器从语言层面堵死这条路径是很合理的设计。不过这里有个容易混淆的点const int *cp value只是 cp 本人认为 value 是只读的value 本身还是一个非 const 变量你完全可以通过value 20;修改它。const 指针描述的是“通过这条指针路径的访问权限”并不是数据本身不可变。更复杂的是二级指针的赋值const int **cpp; int **pp; cpp (const int **)pp; // 直接赋值会编译错误必须强制转换这里的风险在于如果允许const int **cpp pp那么通过 cpp 去指向一个 const int 是合法的但它实际上指向的是 pp 里存的 int*最终可能通过这个路径修改一个本来应该只读的对象。这种绕圈子的类型安全细节平时写代码未必遇得到但面试官非常喜欢拿它来试探基础牢不牢。3. 函数参数里的const从scanf的const char*说起3.1 为什么scanf的format参数是const char *打开 C 标准库scanf 的函数原型是这样的int scanf(const char *format, ...);如果你写过自定义格式扫描器第一反应可能是这个 format 指针本来就是要被读取的函数内部绝不会去修改格式串里的字符所以用const char *再合适不过。它带来的直接好处有三个第一函数体内如果手滑写了format[0] X编译器直接报错不给你留一点犯错的空间第二调用者可以放心地传入字符串字面量因为字符串字面量在 C 里是 const char 数组如果形参写成char *字面量就不能直接传了第三它把“只读”这个语义直接写进接口使用者看一眼原型就知道这个参数不会被修改省去翻函数实现的麻烦。这里也顺带纠正一个常见误解很多教材说“scanf 的格式串就是一段字符串常量”所以必须用 const char *。准确讲不仅仅是为了配合字符串字面量更是为了接口的 const 正确性。一个原则是只要函数不修改入参指向的数据就应当加 const这和使用者传什么没有关系。字符串字面量只是其中一个触发场景。3.2 参数设计值传递、const指针、const引用如何选在 C 语言里函数参数只有值传递和指针传递两种。如果参数只是“读不改”通常会写成const T *如果参数本身就是个标量比如 int、double直接传值就行不需要指针更不涉及 const。C 里多了一个引用传递所以const T 成为大对象只读传参的首选。传递方式是否复制是否可修改实参可接受临时对象T value是否改的是副本是T *p否可以修改 *p通常否需要取地址const T *p否不可以修改 *p通常否T r否可以修改 r否const T r否不可以修改 r是为什么 const 引用能接临时对象非 const 引用不能这是 C 的规则临时对象生命周期通常到语句结束如果绑定到非 const 左值引用函数就能修改一个马上要销毁的东西语义上很可疑绑定到 const 引用则会把临时对象的生命周期延长到引用存活期同时又保证不会去改它所以允许。理解这一点后写函数时就知道void f(const std::string s)为什么比void f(std::string s)更优既避免了一次拷贝又能无缝接受f(hello)。3.3 const 参与函数重载与 const_cast 的边界C 中 const 可以参与重载。看一个典型例子void print(int x) { cout non-const endl; } void print(const int x) { cout const endl; }如果用 int 变量调用编译器会优先匹配非 const 版本如果用 const int 变量调用就只能匹配 const 版本。如果你只定义了 const 版本非 const 变量也能调用它因为非 const 对象可以被绑定到 const 引用。这种重载机制在类成员函数中尤其常见比如标准库里 vector 的operator[]就有 const 和非 const 两个版本对应只读容器和可写容器两种使用场景。那么 const_cast 是干什么的它专门用来“剥掉”const。看起来方便用起来要小心而且必须遵守一条红线如果原始对象本身是 const通过 const_cast 去修改它是未定义行为只有当原始对象不是 const、只是某个指针/引用把它当成 const 使用时const_cast 去掉 const 才是安全的。举个安全的例子你有一个非 const 变量 a但某个函数只接受const int r你在函数里确定 a 不会被逻辑上禁止修改于是const_castint(r)并赋值。这是允许的。但不安全的例子是const int a 10; const_castint(a) 20;a 可能存储在只读段或编译器内联优化后的位置程序可能崩溃行为也没法预测。所以 const_cast 是“最后的钥匙”不是常规手段。4. C里const的高级用法成员函数、引用和constexpr4.1 const成员函数、mutable与this指针C 类中的成员函数如果声明成 const就在函数体后面加一个 const 关键字class Counter { public: int value() const { return count_; } void inc() { count_; } private: int count_ 0; };这个 const 修饰的是 this 指针本身。普通成员函数的 this 类型是Counter *而 const 成员函数的 this 类型是const Counter *所以函数体内对任何非 mutable 成员变量的赋值都会编译失败。这相当于给成员函数加了一个“只读操作”的标签任何试图修改对象状态的代码都过不了编译。mutable 是这里唯一的后门。它允许在 const 成员函数中修改被修饰的成员变量常用于统计调用次数、缓存计算结果、线程锁等“内部状态”。比如class Logger { public: void log(const std::string msg) const { write_count_; // mutable 成员可以改 // ... } private: mutable int write_count_ 0; };这种设计看起来矛盾但想想也合理一个日志函数本身不该改变日志内容但统计写了多少条日志属于“使用侧信息”不应该影响对象的常量性。工程上用 mutable 要克制别把所有的成员都标成 mutable 拿来逃避 const 设计。4.2 const 引用的生命周期和性能考量在 C 里const T 是性能与语义的平衡点。假设你有一个大结构体struct BigData { std::vectorint table; std::string name; }; void show(const BigData data);这里如果不加引用调用会复制整个 vector 和 string成本很高如果只写BigData data调用者无法传入临时对象和 const 对象接口适用范围就窄了只有const BigData data既避免了复制又能接受show(makeData())这样的临时结果。除了传参const 引用还能延长临时对象的生命周期。注意只对 const 左值引用成立int getValue() { return 42; } const int r getValue(); // 临时对象生命周期延长这个特性可以让你安全地把函数返回值绑到 const 引用上。不过别因此养成滥用习惯如果返回的是局部对象本身最好还是按值返回现代 C 的移动语义和返回值优化都处理得很好了。const 引用主要用于参数而不是到处用它来“省拷贝”。4.3 constexpr 和 const 的真正区别C11 引入 constexpr 后const 的含义就有了一个平行的对照系。const 只表示“这个对象在当前作用域不可修改”不保证一定能在编译期算出来constexpr 才是真正意义上的编译期常量。看例子int x 10; const int y x; // 合法y 只是运行期只读 constexpr int z x; // 编译错误x 不是常量表达式 constexpr int w 10; // 合法编译期就能得到 10所以用 const int 声明数组长度在 C 里要区分情况如果初始值是字面量const int 也可以当常量表达式用如果初始值来自运行时输入就不行。而 constexpr 则把要求提升到了编译期求值因此更适合用在模板参数、数组维度、switch 的 case 标签等场景。C14 以后 constexpr 函数也可以在编译期求值C20 又引入了 consteval 强制编译期求值constexpr 体系越来越丰富。但不管怎么发展那条基线不会变const 解决的是“权限可读性”constexpr 解决的是“计算时机与常量表达式”。二者可以结合使用比如constexpr const int limit 10;既要求编译期常量又强调不可修改这种写法在工程里非常常见。4.4 顶层const和底层const重载里谁说了算C 中把指针本身的 const 称为“顶层 consttop-level const”把指针指向对象的 const 称为“底层 constlow-level const”。例如int *const p1; // 顶层 const指针自身不可变 const int *p2; // 底层 const指向的对象不可变 const int *const p3; // 既顶层又底层函数重载时顶层 const 形参不被当作可重载条件因为传值调用时函数获得的是实参的一份副本拷贝后这个副本是不是 const 对调用者没有影响。所以下面两个声明会冲突void func(int *p); void func(int *const p); // 重复声明无法构成重载而底层 const 会影响重载void func(int *p); void func(const int *p); // 合法这是两个不同函数这个知识点在阅读 STL 源码和设计库接口时极其有用。比如自定义一个迭代器类可能会同时提供iterator和const_iterator底层 const 决定了两个类对应的访问权限。把这个概念吃透很多重载匹配的困惑都能解开。5. 关键字冲突的坑从MySQL字段到C语言标识符5.1 MySQL中字段名撞上关键字反引号救场搜索词里提到了“MySQL表中字段为关键字”这是和 const 相关的另一类工程坑不是 C/C 的编译错误而是在 SQL 语法层面撞车。MySQL 有一张保留字列表select、order、group、table、between、usage等都不能直接当作表名或字段名。很多人第一次写CREATE TABLE user (group VARCHAR(10));直接报语法错误就是因为用了保留字。解决办法是用反引号键盘上数字1左侧那个键把标识符包起来CREATE TABLE user ( group VARCHAR(10) ); SELECT group FROM user;反引号是 MySQL 的标识符引用符做成这样数据库就会把你写的字符串当成普通标识符而不是关键字。其它数据库的标准写法不同标准 SQL 一般用双引号SQL Server 用方括号。无论哪种都不如“起名时避开关键字”来得干净。5.2 C/C 的关键字列表别拿 const 当变量名C/C 里也一样const 本身是关键字直接做变量名会编译失败int const 5; // 编译错误但工程中经常会遇到这样的情况你读的配置项是“constant”代码里想叫它 const或者数据库字段叫 const_value一不小心就撞上。C 语言关键字表里除了 const还有 if、enum、extern、sizeof、typedef、volatile 等C 还要额外避开 class、template、namespace、new、delete、operator 等。真的撞上了怎么办最稳妥的命名法不是转义而是加后缀或前缀int const_value; const char *current_type;避免把语言关键字和业务词混在一起。对于需要和数据库字段一一映射的代码如果数据库字段用了orderC 里的成员变量也不要写order可以写成order_no或order_field。命名规范要把这种“敏感词”在设计阶段提前过滤掉等到编译报错再改往往已经牵扯几十个文件了。5.3 工程上建立自己的“避关键字”清单讲了 C/C、MySQL其实其它语言也都有类似问题。一个比较实用的习惯是在项目的 wiki 或 README 里维护一张“保留字与保留词清单”把当前技术栈里所有不能随便用的词列出来。比如 C/C 的关键字、MySQL 的保留字、Python 的关键字、甚至公司内部封装的元数据字段名。写表结构、接口、变量名之前先查这张清单能省下大量返工时间。我的个人经验是最危险的不是那些一眼就能看出是关键字的词反而是那些看起来人畜无害的短词比如desc、rank、count、source、right。它们在一些数据库里不是保留字换一个版本却成了保留字或者放在某个上下文里合法但放到框架的反射/元编程机制里就出问题。遇到这种短词宁可多敲几个字母改名也不要赌它一定合法。6. 常见问题与排查技巧实录6.1 const相关编译错误速查表下面把这些年最容易碰到的 const 报错整理成一张表方便直接对照典型报错大概率原因处理建议assignment of read-only variable x给 const 变量直接赋值去掉赋值或重新设计变量属性assignment of read-only location *p通过 const 指针或引用修改指向值检查指针声明是const int *还是int *constpassing const A as this argument discards qualifiers在 const 成员函数中调用了非 const 成员函数给被调函数加 const或者去掉外层 constinvalid conversion from const int* to int*将 const 指针转成非 const 指针不要做这种转换或者使用 const_cast 并写清楚理由read-only variable is not assignableC/C 里试图修改 const 对象确认对象是否真的应为 const这张表只是抛砖引玉真正排查时报错信息可能因为编译器不同而略有差异但思路是一致的先锁定 const 修饰的是谁。6.2 两个高频翻车场景复盘场景一遍历时犯错。const char str[] hello; const char *p str; while (*p ! \0) { (*p); // 错误p 指向 const char不能修改 p; // 正确p 本身不是 const }问题是把“指向 const 字符的指针”和“const 指针”搞混了。很多人在遍历字符串时因为看到 const 就以为 p 不能动于是越写越别扭实际上 const 在星号左边限制的只是*p不能修改p本身随便移动。反过来int *const p这种情况p 不能移动但*p可以改。这两条规则是 const 面试最常考的入门题。场景二接口设计时过度使用 const_cast。我曾经在老代码里见过不少const_castchar*(str.c_str())因为某些 C 接口要求char *却又不会真正修改字符串。这种代码虽然能跑但本质上是在拆除编译器给你的安全护栏。推荐做法是先把 C 接口的形参改成const char *如果改不了也要在 const_cast 旁边写清楚“已知安全的原因”并加注释。跨模块的 const 约定一旦被用 const_cast 打破后续维护的人根本不知道谁是安全的。6.3 高效排查 const 问题的思路框架遇到 const 报错不要慌按这个顺序走读声明用“从右往左读法”先确定 const 修饰的是变量本身还是变量指向的数据。看调用链确认你拿到的是一个 const 对象还是只是一个 const 指针/引用。对象本身非 const 时你还可以通过其它路径修改它对象本身是 const 时任何修改路径都是危险的。看函数签名检查你调用的函数形参是不是const T*或const T以及 this 是否 const。如果函数确实不需要修改数据就给形参加 const如果函数需要修改而且数据本身非 const就不要把形参声明成 const。处理 const_cast把它当成最后手段使用前确认源对象的真正身份并写注释说明理由。整个排查过程大约十分钟大多数问题其实都卡在第一步。我曾经带过一个新人他把一个函数形参从std::string s改成const std::string s然后发现里面有几十处s 的操作无法编译。这不是 const 错了而是他需要重新审视哪些地方是真正需要修改的哪些可以拿局部变量替代。遇到这种大规模编译错误建议逐个击破不要全局强行 const_cast。6.4 团队协作里的const规范最后聊聊团队里怎么保证 const 正确性长期不塌方。约定一定要在代码规范里写清楚函数入参默认使用 const 或 const *出参才用非 const 指针/引用成员函数能加 const 就加 const禁止使用 const_cast 绕过接口限制必须使用时要写代码评审说明。编译器优化并不是建立 const 规范的理由真正的理由是让接口语义清晰减少调用方的认知负担。代码评审中我会特别关注两种情况一是函数参数明明是只读却漏了 const此时建议补上二是从 const 数据到非 const 数据的隐式或显式转换如果没有充分理由要打回去重写。再一个实用技巧是开启编译器警告-Wall -Wextra它能把很多 const 相关的隐式转换问题暴露出来C 里再加-Wcast-qual甚至-Wold-style-cast能帮你发现不安全的旧式强制转换。静态分析工具比如 clang-tidy、Coverity也很值得引入它们在 CI 里跑一跑很多 const 隐患会被自动拦截。我个人在实际项目中的体会是const 写多了不会拖累代码反而会让代码更好读。一个函数签名里如果所有不会修改的参数都加上了 const你一眼就能看出哪些参数是只读输入哪些是最终输出。我自己的编码习惯是凡是不需要改的入参一律 const宁可多敲几个字符也不给后人留猜谜空间。这个习惯救过我很多次也推荐你试试。
返回列表