ARTICLE DETAIL

资讯详情

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

C++ const 全解析:指针、参数、返回值与成员函数

C++ const 全解析:指针、参数、返回值与成员函数 1. 为什么 const 值得单独拿出来讲写 C 的人几乎每天都要和const打交道但真正把它用明白的人并不多。我见过太多项目里const的分布完全是随机的——有人喜欢在参数上到处加有人觉得返回值加了更安全还有人一遇到编译报错就无脑把const删掉。结果就是接口契约模糊调用方不知道哪些东西能被改维护时谁都不敢动重构一次提心吊胆一次。const关键字、指针、参数、返回值、类成员函数这几个关键词凑在一起基本覆盖了 C 里const最容易出问题、也最能体现功力的一块区域。这篇内容想做的事情很明确把const在指针、参数、返回值、类成员函数这四个场景里的行为规则讲透配合可直接编译验证的代码说清楚每种写法的语义、代价和适用边界。它解决的是我知道该加 const但加在哪、为什么加、加了之后为什么编译不过这一连串实际困扰。适合已经能写 C 类、但对const的传播规则只有模糊印象的同学也适合想把自己接口设计规范重新梳理一遍的老手。我不会只给你结论每个规则背后编译器为什么这么设计我都会尽量讲清楚因为只有理解了原因下次遇到没见过的组合你才能自己推出来。关于版本本文代码基于 C17 验证C11 之后const相关的核心规则没有大改C20 引入的consteval、constinit是另一条线这里不展开避免把主线搅乱。下面所有结论我都实际编译跑过包括那些看起来应该能过却报错的情况。2. 指针与 const 的组合顶层与底层到底谁说了算指针和const放在一起是劝退重灾区根源在于const相对指针的位置决定了它修饰的到底是指针本身还是指针指向的对象。这两个东西是完全独立的只是语法把它们挤在了一起。2.1 四种写法的逐一对照先把四种情况摆出来从左到右读看const离谁近int a 1, b 2; const int* p1 a; // 指向常量的指针不能通过 p1 改 a int const* p2 a; // 和 p1 完全等价写法不同而已 int* const p3 a; // 常量指针p3 本身不能改指向 const int* const p4 a; // 两者都不能改判断口诀很简单const 在*左边修饰的是指向的对象const 在*右边修饰的是指针本身。int const* p2这种写法容易让人晕但只要记住const和int谁前谁后不影响语义int const就是const int它整体是被指向的类型那就落在*左边的情形里。用生活类比p1像是一把可以换锁芯但钥匙被焊死的钥匙——你能让它指到别的地方但不能用它去改那个地方的内容p3像是一把锁芯焊死、但可以换门开的钥匙——它必须一直指着同一个对象但那个对象的内容你可以改。验证一下编译行为*p1 10; // 错误p1 指向的是 const int p1 b; // 正确p1 自己的指向可以改 *p3 10; // 正确a 本身不是 const p3 b; // 错误p3 是常量指针 *p4 10; // 错误 p4 b; // 错误2.2 顶层 const 与底层 const 的赋值规则这是热词里被反复问到的点顶层指针和底层指针可以相互赋值吗。答案是不能随便赋规则非常明确。标准里的说法是const出现在对象自身叫顶层 consttop-level const出现在所指对象上叫底层 constlow-level const。int* const p3是顶层 constconst int* p1是底层 const。赋值规则一句话总结顶层 const 在拷贝时可以被忽略底层 const 必须严格匹配或者只能从更宽松转向更严格。具体表现int* p a; const int* cp p; // 正确int* 转 const int*权限收紧安全 p cp; // 错误const int* 转 int*权限放宽危险 int* const pc a; p pc; // 正确顶层 const 被忽略p 拿到的是 int*为什么p cp不允许因为如果允许你就可以通过p去修改一个声明为const的对象这直接违反了const的承诺。编译器宁可让你多写一次const_cast并且自己承担后果也不愿意默认放行这种降级。这个设计逻辑和只读接口可以接受可写对象反过来不行是一个道理后面讲参数传递时会再次用到。2.3 实测常见的编译报错与解读我整理了一份实际项目里最常撞见的报错以及它们对应的真实原因报错信息GCC 简化版典型代码真实原因invalid conversion from const int* to int*int* p cp;底层 const 被丢弃assignment of read-only variablep3 b;给顶层 const 指针重新赋值passing const X as this argument discards qualifiers在 const 对象上调用非 const 成员函数缺少 const 成员函数binding reference of type X to const X discards qualifiersX r constObj;非 const 引用不能绑定 const 对象注意int const * const p这种双重 const 的写法很多人第一眼会读错。稳妥的读法是从右往左遇到 const 就记一次修饰。* const p说明指针本身是 const前面的int const说明指向的对象也是 const两者叠加。有一个容易踩的坑函数声明里的顶层 const 会被忽略。下面两个声明是同一个函数不能重载void f(int* p); void f(int* const p); // 重声明不是重载而底层 const 会参与重载void g(int* p); void g(const int* p); // 合法重载这个差异的原因在于函数签名里描述的是调用方看到什么顶层 const 只是形参自己的属性调用方根本不关心形参指针本身会不会被改所以标准直接规定它在签名里被略去。3. 参数上的 const接口契约的第一道防线参数是const出现频率最高的地方也是最容易加得过或加得不够的地方。核心判断依据只有一个这个参数在函数内部需不需要修改以及调用方是否需要知道这一点。3.1 值传递参数上要不要加 const很多人写void f(const int x)这其实是几乎没意义的。原因很直接值传递的形参是调用方实参的一份拷贝你在函数里怎么改它调用方都感知不到。加const只是约束了你自己在函数体内不能改这个局部变量对接口使用者零影响而且和顶层 const 一样会被签名忽略。那我为什么还偶尔会加两个场景。一是函数体很长我想让编译器帮我拦住手滑改错变量的情况相当于给自己加一道护栏二是在函数声明和定义分离时保持写法一致避免有人看到声明没有 const 就去纠结。但这两点都只是个人习惯不是设计需求。如果是为了表达接口契约值传递参数上的 const 是无效的。3.2 引用与指针参数这里才是 const 的主战场真正有价值的是在引用和指针参数上做区分。以引用为例void readOnly(const std::string s); // 承诺不改 s void willModify(std::string s); // 明确表示会改 sconst T的价值有三层第一层是语义表达。调用方看到const T立刻知道这个参数进去是只读的不需要担心函数结束后对象被改成什么样。这比写一行注释可靠得多因为编译器会强制检查。第二层是可用性扩展。非 const 引用不能绑定临时对象和 const 对象readOnly(hello); // 正确临时 string 绑定到 const willModify(hello); // 错误临时对象不能绑定到非 const 引用 const std::string cs hi; readOnly(cs); // 正确 willModify(cs); // 错误这一点在使用上差别巨大。如果你把参数写成std::string调用方就不能传字面量、不能传临时计算结果每次都得先建一个具名变量接口会变得难用。第三层是避免拷贝。这个大家熟。但要注意const T不是万能的对于int、double、指针这类小到能塞进寄存器的类型传值反而可能更快因为解引用有间接成本而且const T会阻止某些常量传播优化。我的一般经验是内置标量类型传值类类型传const除非你有明确的性能测量数据支持另一种选择。3.3 参数上的 const 指针别写成顶层 const参数是指针时别写成顶层 constvoid f(int* const p); // 无意义和 void f(int*) 是同一个函数 void f(const int* p); // 有意义不能通过 p 修改指向内容 void f(int* p); // 引用指针可以改指针本身的指向 void f(const int* p); // 常见于遍历场景指向常量的指针引用指针和引用的选择也值得说一句。参数用T*通常暗示这个参数可以为空用T则暗示必须传一个有效对象。配合const使用后const T*表达可能为空的只读对象const T表达必定存在的只读对象。把这两种语义分开接口的可读性能提升一个档次。我现在的习惯是需要表达可能为空时用指针保证非空时用引用只读与否一律用 const 标清楚。4. 返回值上的 const一个被误用最多的位置返回值上加const争议最大因为它同时存在有意义的场景和纯粹添乱的情况而且两者语法看起来一模一样。4.1 返回 const 值的真实作用边界先看几个例子const int getValue(); // 基本无意义 const std::string getName(); // 基本无意义 const std::vectorint getData() const; // 有意义返回内部数据的只读引用 const std::string getNameRef() const; // 有意义对于按值返回的情况返回类型加顶层 const标准会把它剥掉和直接返回非 const 值几乎等价。唯一的实际影响是它会阻止调用方对返回值做移动const std::string getName(); std::string s getName(); // 拷贝不是移动因为getName()返回的是const std::string无法绑定到std::string移动构造被压制只能走拷贝。在 C11 之前有人用这个技巧阻止a b c这种对临时对象赋值的无意义写法但有了移动语义之后这个写法反而成了性能陷阱。现代 C 里按值返回不要加 const这是明确的结论。4.2 返回引用时必须谨慎返回const T是真正有价值的场景它让你能把内部成员以只读视图的方式暴露出去既避免拷贝又防止调用方改坏内部状态class Config { public: const std::string name() const { return name_; } private: std::string name_; };调用方拿到const std::string可以读、可以比较但改不了。如果这里返回std::string调用方就能直接改掉name_封装形同虚设。但这里有一个必须强调的坑注意绝对不要返回指向局部变量的引用或指针加不加 const 都一样。const T不负责延长被引用对象的生命周期除了临时对象绑定到 const 引用的那几种特殊情形返回局部变量引用是典型的悬空引用。反过来返回值是不是应该加 const还要考虑调用方想不想改。如果返回值本身就是一个独立的新对象比如工厂函数返回非 const 值让调用方能移动、能改是最合理的如果返回值是内部数据的引用那必须加 const。判断标准就是这个返回的东西调用方有没有正当理由去修改它。5. 类成员函数上的 constthis 指针在背后干活成员函数后面的const是最容易被忽略、也最容易在项目里制造连锁编译错误的语法。它的本质其实很朴素修改 this 指针的类型。5.1 const 成员函数的本质在普通成员函数里this的类型是X* const——指针本身是常量你不能让 this 指向别处但指向的对象可改。当你在函数后面加上constthis的类型变成const X* const指向的对象也变成只读于是你不能再修改任何非 mutable 的成员变量。class Counter { public: int get() const { return count_; } // 只读可以调用 void inc() { count_; } // 会改状态 private: int count_ 0; };于是产生了两条重要后果一const 对象只能调用 const 成员函数。因为 const 对象的this必须是const X*非 const 成员函数需要X*权限不匹配。二const 成员函数可以被非 const 对象调用。权限从宽松到严格是允许的。所以给成员函数加 const 只会扩大它能被调用的范围不会缩小。这也是为什么我提倡能加 const 的成员函数都加上——它没有副作用只有好处。5.2 基于 const 的重载与 mutable 的合理使用一个实用的模式是同一个成员函数提供 const 和非 const 两个版本返回类型也相应变化。典型场景是容器的operator[]或者自定义的访问器class Buffer { public: char at(size_t i) { return data_[i]; } const char at(size_t i) const { return data_[i]; } private: std::vectorchar data_; };const 对象调用时拿到const char非 const 对象调用时拿到char调用方拿到什么权限由自己对象是否是 const 决定。编译器会根据调用对象的 const 属性自动选择匹配的版本不用手写两套逻辑。mutable是给 const 成员函数开的一个合法后门用于那些逻辑上不改变对象可见状态但物理上需要修改的成员典型是缓存和互斥量class Parser { public: int result() const { if (!cached_) { result_ compute(); cached_ true; } return result_; } private: mutable int result_ 0; mutable bool cached_ false; };result()从使用者角度看是只读的——调用它不会让对象表现出任何不同。内部缓存属于实现细节改它不影响这个承诺所以用mutable是合理的。但mutable滥用是灾难。我见过有人为了让所有代码都能在 const 函数里编译把整个状态都标成mutable等于把 const 保护直接拆了。判断标准是这个修改对观察者是否可见。如果修改后对象的行为、取值会变那它就不该是 mutable而应该把函数改成非 const让调用方明确知道这里会改状态。5.3 智能指针、迭代器与 const 的配合const和智能指针组合时先把指针本身 const和指向对象 const分开看规则和裸指针一样const std::shared_ptrWidget p1; // p1 本身 const可以改 *p1 std::shared_ptrconst Widget p2; // 公开 const Widget可以改 p2 const std::shared_ptrconst Widget p3; // 都不可改有一个常见误区shared_ptrconst T只是让你不能通过这个指针改对象但对象本身可能被其他非 const 指针改。它提供的不是线程安全的只读仅仅是编译期的访问限制。多线程场景下别指望 const 帮你解决数据竞争。迭代器上const_iterator和const iterator又是两回事std::vectorint::const_iterator it1; // it1 可移动指向的值不可改 const std::vectorint::iterator it2 v.begin(); // it2 不可移动指向的值可改C11 之后我更推荐用cbegin()/cend()直接拿const_iterator配合范围 for 时用const auto能省掉大量手写类型和 const 判断的麻烦。6. 常见问题排查与实战避坑规则讲完了真正落到项目里靠的是排查经验。下面这份清单是我这些年反复遇到的按症状分类方便对照。6.1 编译期问题快速对照表症状大概率原因解决方向在 const 对象上调成员函数报错成员函数没加 const给不修改状态的成员函数补 constconst 成员函数里改成员报错该成员不是 mutable判断是否真的只读是则加 mutable否则函数去掉 const传字面量给参数报错参数是非 const 引用改成const Tconst int*赋给int*报错丢弃底层 const检查是否真需要写权限需要则重新设计接口返回const T后性能变差返回值顶层 const 阻止移动按值返回时去掉 const函数模板实例化后报 const 相关错误模板参数推导出const T用std::remove_const_t或std::decay_t处理6.2 几个我踩过的具体坑坑一const 成员函数里调用另一个非 const 成员函数。这是新手最常见的连锁错误。get()是 const 的里面调了非 const 的refresh()编译器直接报错。解决办法不是把get()去掉 const而是先想清楚refresh()该不该改状态。如果它只是读取并计算结果那它本身就应该是 const。坑二std::exception::what()为什么是 const 的。这正是热词里提到的const std::exception的典型案例。what()被声明为const所以你可以放心地在 catch 块的 const 引用上调用它同时它也不用担心里面会改异常对象的状态。标准库在const使用上非常克制而规范值得借鉴——你看std::vector::size()、std::string::c_str()、std::map::find()的 const 版本几乎把所有只读操作都标了 const。坑三lambda 与 const。默认情况下 lambda 的operator()是 const 的捕获的变量按值捕获在 lambda 体内不可改。要改就得加mutableint x 0; auto f [x]() mutable { x; }; // 合法改的是 lambda 内部副本按引用捕获时则是另一回事改的是外部变量不受 lambda const 影响。这个差异在写回调时经常把人绕进去。坑四const_cast不是救命稻草。有人编译不过就const_cast强转这在两种情况下会直接导致未定义行为一是原始对象本身就声明为 const二是你通过const_cast拿到的非 const 引用去修改了一个真正存储在只读内存里的对象。const_cast唯一安全的用法是原始对象本身不是 const只是某个 const 引用恰好指向了它你通过其他路径知道这一点才把它转回去。6.3 我现在的 const 使用习惯最后分享一下我现在写代码时的几个固定习惯都是被项目磨出来的。第一类的所有不修改成员变量的成员函数一律加 const写完类先检查一遍。这个习惯能让你后续在任何 const 上下文里自由使用这些函数也让接口意图清晰。第二接口参数优先用const T除非是有明确修改语义的出参或者内置标量类型。出参如果没有修改语义一律视为设计问题。第三按值返回不加 const只在返回内部成员引用时加并且一定确认生命周期安全。第四不用const_cast绕过问题遇到 const 相关的编译错误先假设是自己的接口设计错了多数情况下这个假设是对的。第五读标准库头文件。string和vector里 const 的位置安排是很好的教材const成员函数、const参数、const返回值的组合方式看多了自然就有手感。这套规则用下来最直观的变化是接口变得不敢乱改了——调用方看到const就放心看到没有const就知道这里可能有副作用整个代码库的沟通成本明显下降。
返回列表