
前阵子给团队做代码评审又看到有人在类里写了这样的函数int getValue(const std::string key)逻辑本身没毛病但明明这个函数不会修改任何成员却漏掉了尾部那个const。旁边一个新来的同事问了一句“函数后面加 const 到底有什么用不加好像也能跑。”这一问把我拉回到自己刚学 C 时的困惑。今天就把这个语法彻底拆开讲透从编译器视角、重载决议、继承规则到工程落地一步不落。函数尾部的const只对成员函数有效它修饰的是隐藏的this指针本质上是给调用者一份“我保证不改对象内部状态”的承诺同时也是给编译器一把尺子谁在 const 成员函数里动了成员变量编译阶段就该被拦下来。这个特性看起来小但牵扯出 mutable、const_cast、重载、虚函数覆盖、模板约束、接口设计乃至并发安全一系列话题。这篇文章适合所有写 C 的人无论是刚入门想搞懂语法语义还是写了几年想厘清边界应该都能拿到点东西。1. 加 const 那一刻this 指针发生了什么1.1 函数签名的隐藏维度this 指针的顶层与底层先回到最基础的问题一个普通的成员函数调用obj.func(42)编译器在背后做了什么它会把这个调用翻译成类似func(obj, 42)的形式多出来的第一个参数就是this指针类型是T*。而你写的所有对成员变量的访问本质上都是通过这个指针完成的。当你在函数尾部加上const比如void func(int x) const变化就在于this指针的类型原来的T*变成了const T*。也就是说this 指针本身指向的东西被限定了只读——你手里拿的钥匙从“能开门能改屋里东西”变成了“只能看看”。这里有个不少人没想通的点const加在函数尾部是修饰this指向的对象而不是修饰this指针本身。因为this指针的值即对象的地址你本来也没法改所以这属于底层 const指向常量的指针而不是顶层 const指针本身不可变。可以这么理解对象是房间this 是钥匙尾部 const 把“打开房间能改家具”的权限收回了但钥匙还是能进门参观。也因此这个const是函数签名的一部分。int get() const和int get()是两个不同的函数它们可以同时存在于一个类中依靠 const 限定参与重载决议。这跟int get()和double get()靠返回类型区分是两回事——返回类型永远不参与重载但函数尾部的 const 参与。这一点在后面讲重载时还会再展开。1.2 一个最小案例看编译器如何拦住误修改口说无凭我们看一段最小示例。定义这样的类class Point { public: double getX() const { return x_; } void setX(double x) { x_ x; } void badSet() const { x_ 100.0; // 编译错误不能在 const 成员函数中修改成员变量 } private: double x_; };badSet()一旦写了赋值语句编译直接报错。很多编译器的提示长这样error: assignment of member ‘Point::x_’ in read-only object有意思的是报错信息里出现了 read-only object。我们明明是在修改x_但编译器看到的却是“你在修改一个只读对象”。这正是因为this指针是const Point*通过它访问的任何成员都会被当作只读数据。这个编译期约束的价值怎么强调都不为过。C 有一种说法叫“能早查的错不晚查”把对状态修改的限制放在编译期意味着你不需要等到运行到那行代码、踩到那个坑、再花两小时调试才意识到问题。团队协作时const成员函数就像是在源码层面写了张“本函数无副作用”的契约后来者维护时如果想在函数里加个缓存、打个日志计数器之类的操作编译器会第一时间提醒他这违反了约定。1.3 为什么只有成员函数能加这个 const这个问题经常被初学者问到“为什么全局函数int add(int a, int b) const不能编译”答案其实在前面已经讲了尾部 const 修饰的是this而全局函数根本没有this指针。同样地静态成员函数也不能加 const因为静态成员函数不绑定到具体对象它没有隐藏的this参数。class Demo { public: int get() const { return 1; } // 正确非静态成员函数 static int getStatic() { return 2; } // 正确静态成员函数 static int getStatic2() const { return 3; } // 编译错误静态成员函数无 this }; int test() const; // 编译错误普通函数没有 this这个规则背后透露出的语义是const必须依附于一个具体的实例对象才有意义。对静态数据、全局数据谈“成员函数不修改实例状态”是空谈因为你压根没有实例。理解这一点以后看到报错就不会一头雾水了。2. 一不留神就翻车的三个 const 细节2.1 mutable逻辑 const 对物理 const 的例外const 成员函数里不能修改成员变量这个规则很严格但现实世界总有合理的例外。比如有一个网络请求类GetStatus() const调用了底层系统接口系统接口本身不是线程安全的你想在 const 函数里加个互斥锁来保护它再比如某个计算耗时较长你想在 const 函数里把结果缓存起来避免下次重复计算。这两种场景都不改变对象的“逻辑状态”——锁的加锁解锁不影响对外可见的数据缓存的结果也和直接计算得到的结果等价。但它们确确实实在物理上修改了成员变量。这时候mutable就派上用场了class CachedPrimeChecker { public: bool isPrime(int n) const { auto it cache_.find(n); if (it ! cache_.end()) { return it-second; } bool result computePrime(n); cache_[n] result; // 编译通过cache_ 是 mutable 的 return result; } private: bool computePrime(int n) const { /* 耗时计算 */ } mutable std::mapint, bool cache_; };mutable的意思是“即便在 const 成员函数里也可以修改”它允许你打破物理上的 const 约束实现逻辑 const对外表现不变内部可以做些不妨碍语义的调整。用它的经典场景包括缓存计算结果、统计调用次数、保护线程安全的锁、以及引用计数。但要提醒一句mutable 是给“有充分理由的例外”准备的不是用来偷懒绕开 const 的后门。如果一个成员变量是对象的核心数据你却给它加上 mutable然后在 const 函数里大改特改那等于把 const 的契约撕了。我在代码审查里见过有人为了让一段编译错误消失把一堆成员变量全部标成 mutable最后 const 成员函数形同虚设这种做法比不加 const 更糟糕因为它给人虚假的安全感。2.2 指针成员const 挡不住指向内容的修改这是 const 成员函数最大的认知陷阱。类里有个char*或者int*成员你在 const 成员函数里确实不能给这个指针本身重新赋值但指针指向的内容你改起来毫无障碍。class MyBuffer { public: const char* c_str() const { return data_; } // 返回 const char*是安全的 void unsafePrint() const { data_[0] X; // 编译通过因为 data_ 是指针const 限制的是指针本身不是它指向的字符数组 data_ other; // 编译错误指针本身的赋值被禁止 } private: char* data_; };第一次踩到这个坑的人往往会觉得不可思议“我明明写的 const 成员函数为什么还能改内容”原因就在于 const 的粒度。T* const p表示 p 本身不可变但*p依然可写。函数尾部的 const 把 this 变成const MyBuffer*成员data_因此变成char* const即“char 指针常量”而不是const char*。所以你能通过它修改字符只是不能再让它指向别处。由此会引出一个接口设计问题如果 const 成员函数返回的是非 const 指针或非 const 引用调用者就能绕过 const 语义修改原本认为只读的对象。所以好的习惯是 const 版本返回 const 指针或 const 引用。这段位对应到标准库就是const std::string::c_str()返回const char*绝不会让你拿到能改底层内存的裸指针。深层语义是const 成员函数必须传递只读性而不是把它截断。2.3 const_cast能用但先搞清对象到底是不是 const有时候你确实需要在 const 成员函数里调用一个非 const 的旧接口或者需要临时去掉 const 做点什么事。C 提供const_cast这把锤子但它有个极其重要的前提如果对象本身定义时就是 const强行通过const_cast修改它是未定义行为如果对象本身不是 const只是通过 const 引用或 const 指针临时访问那么去掉 const 再修改才是安全的。// 情况一对象本身是 const —— 修改是 UB const int kValue 42; const_castint(kValue) 100; // 可怕标准说这是未定义行为 // 情况二对象本身不是 const只是经过了 const 引用 —— 安全 int realValue 10; const int ref realValue; const_castint(ref) 20; // OK真实对象不是 const实际工程里第二种情况常见于有个老 C 接口接受char*而你手里只有const char*且你能确认底层字符串本来就是可写的缓冲区。这时用const_cast是不得已而为之。但如果是第一种情况——某个函数拿到const std::string内部居然const_cast掉 const 并尝试修改而调用方传进去的或许就是真正的常量对象那就是在雷区散步了。我的建议是把const_cast当作“最后手段”并集中封装在小函数里不要让它在业务代码里满天飞。看到它的时候读代码的人至少应该能快速判断“这里为什么要去 const”“代价是什么”。没有任何解释的const_cast出现在代码审查里我基本都会打回去要求重写。3. 重载、虚函数与 staticconst 参与规则定责3.1 const 版本重载的决议规则与 Getter 双版本写法前面说过const参与了函数签名所以void foo() const和void foo()可以共存。那么问题来了一个对象调用foo()时编译器怎么知道选哪个版本规则非常简单又非常容易被忽略const 对象只能调用 const 版本非 const 对象优先调用非 const 版本但如果只有 const 版本非 const 对象也能调用 const 版本。反过来const 对象绝对调不了非 const 版本因为调用非 const 成员函数需要把this从const T*提升为T*C 不允许去掉底层 const 的隐式转换。最经典的实践是 Getter 的双版本写法。比如一个字符串包装类的下标操作class StringLike { public: char operator[](size_t idx) { return buffer_[idx]; } const char operator[](size_t idx) const { return buffer_[idx]; } };常量的StringLike对象拿去下标访问得到的是const char只能读不能写非 const 对象则拿到可读可写的引用。这背后是 const 正确性最基本的思想同一种操作根据调用方是否 const提供不同访问权限。如果只提供非 const 版本那么一个被你声明为 const 的容器就完全没法用了如果只提供 const 版本那想通过下标修改元素也不行了。双版本是最常见的解决方案。还有个实用技巧两个版本如果逻辑几乎相同可以让非 const 版本直接调用 const 版本并用const_cast去掉返回值的 const这样可以避免重复实现。但前提仍然是“返回的对象本身不是 const”标准库很多地方也是这么做的char operator[](size_t idx) { return const_castchar(static_castconst StringLike(*this)[idx]); }这段代码的本质是先把自己看成 const 对象调用 const 版本拿到 const 引用再用 const_cast 去掉 const。写起来有点绕但确实能省掉一版重复逻辑。3.2 virtual 函数的 const 限定必须严格匹配多态场景下const 的匹配规则异常严格基类虚函数如果加了 const派生类重写它时也必须加 const否则编译器不认为你在覆盖它。C11 引入override关键字之后这种不匹配会直接报编译错误是好事把很多潜在 bug 提前掐死了。class Shape { public: virtual double area() const { return 0.0; } }; // 错误例子漏掉 const class Circle : public Shape { public: double area() override { return 3.14159 * r_ * r_; } // 编译错误不匹配基类 }; // 正确例子 class Circle : public Shape { public: double area() const override { return 3.14159 * r_ * r_; } };为什么这么严格因为多态调用依靠的是虚表虚表里的函数签名必须完全一致才能正确跳转。一个 const 一个非 const 就是两个不同的函数签名虽然名字一样但在虚表里占据的位置不同。如果派生类漏掉了 const它实际上是在基类旁边新增了一个新的虚函数而不是覆盖原有虚函数。调用方如果持有基类引用调用的依旧是基类的那个 const 版本派生类自己的版本根本不会被触发。这里有个特别容易让人迷糊的场景设计一个抽象接口的时候成员函数到底该不该加 const我的经验是如果这个函数逻辑上不改变对象的外部可观测状态就应该加 const。比如area()这种计算函数、size()这种查询函数都有理由标记为 const。反过来如果接口设计时就把查询函数做成非 const那调用方持有 const 引用时很多事情都做不了被迫到处const_cast接口设计就是失败的。3.3 static 成员函数为什么不能加 const上一节已经提到静态成员函数没有 this自然不能加 const。这里我想再往前推一步静态成员函数最接近普通函数它不依赖对象状态所以 const 这个修饰符对它是无意义的。有人会从“const 表示不修改状态”的角度争辩说静态函数不修改对象状态为什么不加呢关键在于 const 的语义锚定点是对象而不是函数本身。一个静态函数工作在多份对象之上的情况很常见比如static bool compare(const Widget a, const Widget b)它当然不会修改任何对象但这个“不会修改”已经通过参数上的 const 完整表达出来了不需要函数尾部的 const 再平添一层含义。非静态成员函数的尾部 const 是唯一无法用参数表达 const 的地方——因为this是隐式参数不能在参数列表里写出来加 const。所以 C 专门提供尾部 const 来填补这个空缺。理解了这个设计初衷静态函数为什么不允许加 const 也就豁然开朗了。4. const 正确性在真实项目里的落地经验4.1 接口设计的黄金习惯能 const 就 const进到工程层面函数尾部 const 不是可有可无的装饰而是一个接口设计原则不修改对象状态的成员函数一律声明为 const。这带来几个直接好处调用方持有 const 对象或 const 引用时依然可以调用这些查询接口代码组合灵活度大幅提升编译期拦截误修改防止后续维护者在查询函数里偷偷塞入状态改动读代码的人从函数声明就能推断函数行为无需深入函数体并发环境下const 函数对外承诺了只读语义减少了加锁范围的可能性我在团队里推行过一个很简单但有效的代码规范类内所有成员函数先问一句“这个函数会不会改变成员变量”不会就加 const。这个问题的难度不大但能做到的人不多因为很多人在赶工时不会停下来做这种“额外动作”。可从长期维护成本看这个动作的收益远超那几秒钟的功夫。以容器为例size()、empty()、find()这些查询函数都应该是 const 的。标准库就是这么设计的所以const std::vectorint可以安全地调用size()但如果你自己写的类把查询函数漏了 const那持有 const 引用的代码就会陷入“能看不能摸”的窘境——不修改状态却也不能调用查询。这种恶心人的接口我在实际项目里见过太多基本都是设计者对 const 认识不够导致的。4.2 const 与性能语义收益大于微优化有种说法是“加了 const编译器就能优化得更好”这句话要分两层看。一方面const 成员函数允许编译器假设对象在调用期间不被修改理论上可以做某些优化但实际上由于存在 mutable 成员和 const_cast 这类“破坏约定”的手段编译器一般不会做特别激进的假设。所以把 const 当作一种性能优化手段通常是不靠谱的。另一方面const 在接口语义层面带来的收益是实实在在的。你写了一个const std::string getToken() const调用方知道这个函数不会改内部状态这比“每个 getter 返回 const 引用但不加尾部 const”的代码更安全也比“每次传对象都拷贝一份再调用”的代码更高效。不拷贝依赖的就是 const 引用绑定没有 const 边界整个传递链路就失去了安全性保障。更实际的性能优化场景是返回值非 const 对象调用非 const 版本的 Getter返回T可以直接对内部成员赋值const 版本返回const T避免拷贝。如果你只提供传值返回的 Getter那每次读取都要复制一次性能白白流失。所以双版本 Getter 在性能敏感代码里不是锦上添花而是必要设计。4.3 从函数尾 const 延伸出去的三个相近话题这个语法和 C 里几个重要概念共享同一套思想值得一起学习。第一个是引用限定符ref-qualifier。C11 之后成员函数还能在尾部加或限定它只能被左值或右值调用。比如void add() 只能在左值对象上调用void add() 只能在临时对象上调用。这和 const 共同作用时能精确表达调用情境。在实现移动语义和资源管理类时非常有用。第二个是const 对象的线程安全语义。一个对象如果被声明为 const多个线程同时访问其 const 成员函数应当是安全的——前提是这些函数内部没有对 mutable 成员做非原子修改。如果你的类里有 mutable 计数器、缓存、锁之类的成员const 成员函数的线程安全就要自己仔细维护好最好把相关的同步对象也标成 mutable并确保锁的粒度覆盖得住。第三个是模板与 const 的交互。泛型代码里常遇到“不知道传入类型是不是 const”“容器元素类型是否带 const”等问题这时std::is_constT、std::remove_constT这些类型萃取工具就登场了。理解函数尾部 const 之后再去读标准库的 type traits 文档会顺畅很多。我个人还有一个很小的习惯写 const 成员函数时如果函数体稍长会刻意在测试里给对象加一个 const 引用强制走一遍 const 版本的调用路径。这一招帮我抓到过好几个“const 版本漏了 const_cast 或者 mutable 后行为不一致”的隐性问题。测试里多一行const auto ref obj; ref.query();成本极低收益却很直接。