C++ const 入门:只读对象、引用与指针访问路径
08 const:把"这里是否允许修改"写进类型里
上一章我们提到,引用使得函数能够直接访问外部调用者传入的对象。随之而来的是,函数的接口设计必须澄清一个关键问题:它仅仅是需要读取这个对象的数据,还是打算在内部修改它。
const 关键字的核心价值,就在于它能把这种修改权限的声明直接嵌入到类型系统中。因此,每当在代码里看到 const 时,我们首先要确认的是:通过当前的这条访问路径,代码是否被允许去修改底层的对象。
这里强调“访问路径”非常重要。在 C++ 中,同一个内存对象往往存在多个访问入口,比如对象原本的名字、引用、指针以及函数参数等。const 的作用通常是限制某一条特定入口的修改权限,而不是一刀切地把对象的所有访问途径全部锁死。只要在脑海里牢牢抓住这个核心概念,后续在面对 const int&、const int* 乃至 int* const 等看似复杂的声明时,思路就不会轻易发生混淆。
我们可以先看看实际工程中最常见的一种参数写法:
void PrintName(const std::string& name) {
std::cout << name << "\n";
}
在这个函数里,name 是一个绑定到了外部传入字符串上的引用。加上 const 修饰后,意味着函数内部只能对它进行读取,而无法做任何修改。这样的接口设计等同于在向调用者明确承诺:我会使用你传入的数据进行操作,但我绝对不会破坏它原有的状态。
const 对象创建后只读
const 最直接、最基础的用法,就是用来定义一个完全只读的对象:
const int max_score = 100;
对于这类对象,在创建的那一刻就必须赋予一个初始值。如果在后续的代码中试图通过这个名字去修改它的数据,编译器就会直接报错拦截:
const int max_score = 100;
// max_score = 90; // 编译错误
当 const 成为类型的一部分后,我们可以将 max_score 的完整类型理解为 const int,它在语义上明确代表着这是一个只读的整数对象。
这种写法非常适合用来声明那些在程序运行期间不应该发生变化的值,比如各种阈值和配置参数:
const int kMaxScore = 100;
const int kMinScore = 0;
具体的变量命名风格通常由各个项目的编码规范来决定。我们在这里更关注它的语义逻辑:一旦这类变量被创建,它的状态就会被永久冻结。阅读代码的人只要看到 const,就能笃定后续的业务逻辑绝不可能对它进行重新赋值。
除了防止误操作,使用 const 对象还能显著降低阅读代码时的心智负担。对于一个普通的变量,由于它在后续随时可能被修改,阅读者就不得不时刻留心它在每一处可能发生状态改变的位置。而对于 const 对象,由于其只读的特性,我们只需要关注它的初始值和最终被使用的地方。代码规模越大,这种“减少状态追踪”带来的工程价值就越发明显。
在实际开发中,很多难以排查的逻辑问题往往源于状态的变化过于频繁。试想一下,如果一个变量在函数开头被定义,随后在几百行的逻辑中经历了多次反复的修改,最后再参与核心计算,任何人在阅读时都必须在脑海中小心翼翼地维护这个变量的当前状态。因此,一个良好的编码习惯是:只要一个值满足被定义为 const 的条件,就坚决给它加上 const。这无异于在向后来的维护者宣告,这个变量的值是恒定可靠的。通过这种语法约束,我们有效控制了程序中处于变化状态的数据规模。
至于常量的具体命名方式,通常取决于团队的规范。在我们的示例代码中,大多参考了常见的 C++工业界规范(如 Google C++ Style Guide),对于常量习惯使用 k 开头的帕斯卡命名法(PascalCase),例如 kMaxScore。不过,命名只是一种表层的约定,真正发挥效力的是类型底层的语义保障。像 const int kMaxScore = 100; 这样的声明,是在同时向编译器和开发者传达一个确切的事实:它是一个不可逾越的整数上限,并且从诞生起就不容更改。
const 引用:只读访问外部对象
在上一章我们了解到,引用参数能够让函数直接绑定到调用者所提供的对象上。如果在引用的基础上再施加 const 约束,函数在内部就只拥有对该对象的只读访问权:
#include <iostream>
#include <string>
void PrintName(const std::string& name) {
std::cout << name << "\n";
}
int main() {
std::string student_name = "Ada";
PrintName(student_name);
return 0;
}
在上面的代码中,PrintName 函数并没有产生 student_name 的数据拷贝,而是通过引用的方式直接读取了外部的字符串资源。同时,const 关键字又严格封锁了函数内部试图修改该数据的可能性。
特别是在处理大型对象时,这种参数写法几乎成为了标准范式:
相比之下,对于诸如 int、double 这样轻量级的小对象,直接按值传递往往更加直观且高效:
void PrintScore(int score) {
std::cout << score << "\n";
}
但当面对像 std::string、std::vector 这样内部包含较多数据结构的对象时,只要函数在逻辑上不需要修改它们,使用 const T& 就是最顺理成章的选择。
我们需要将 const T& 视为一种专门用于接口语义表达的写法。它实际上同时向外传递了两个信息:第一,这个参数是对外部对象的一种绑定;第二,函数承诺只会对其进行读取。虽然避免了对象的复制,但这仅仅是它带来的工程收益之一,更重要的是它所建立的信任关系。当调用者看到类似 PrintName(const std::string& name) 的签名时,就可以毫无顾虑地将自己的核心数据传递进去,因为函数在类型层面就已经宣誓了对 name 这条访问路径的只读约束。
在基础编码阶段,我们通常遵循一个实用的原则:小对象按值传递,大对象如果不需要修改则按 const T& 传递。当然,在复杂的工程实践中,往往还需要综合考量对象的底层类型、移动语义的成本、函数的调用频率等因素。但在本系列的现阶段,我们首要建立的认知依然是:const T& 的第一属性是“只读语义”,它带来的性能优化是这种语义之下的自然结果。
同时,我们也要避免矫枉过正,不假思索地把所有参数都强行改成 const T&。像 void PrintScore(const int& score) 虽然在语法上完全合法也能正常运行,但对于 int 这种极小的数据类型而言,直接按值传递不仅代码更清爽,在底层执行时往往也更高效。const T& 发挥作用的最佳场景,是当函数需要只读访问像字符串、大型容器或者自定义的复杂结构体时。参数的类型设计应当服务于接口的清晰表达,而不是机械地套用某一种固定的格式。
此外,使用 const 引用还有一个非常现实的编译期收益:它的兼容性更强,既能够绑定到普通的可变对象上,也能够完美兼容那些原本就被声明为 const 的只读对象。逻辑上很容易理解,既然函数只需要读取数据,那它就不应该强制要求调用方提供一个具备修改权限的对象。如果我们将参数声明为 const std::string&,无论是普通的字符串还是被限制为只读的字符串,都可以顺利传入;但如果仅仅声明为 std::string&,编译器就会拒绝接收那些只读对象,因为这个非 const 的引用在类型上保留了修改的潜力。
这种类型上的差异会直接影响函数的适用范围。如果一个纯粹用于输出的打印函数被写成了 void PrintName(std::string& name),不仅会让阅读者怀疑它是否会暗中篡改数据,同时还会导致那些被定义为 const std::string 的常量变量无法被传入。仅仅为了打印,却向调用方索要了对象的修改权限,这种接口设计显然过于沉重了。只有修正为 void PrintName(const std::string& name),函数实际的能力与其在接口上宣称的意图才算彻底对齐,调用者使用起来才足够踏实。
归根结底,const 引用是打磨基础接口的一种利器。只要函数对外部对象的需求仅限于读取,就理直气壮地把这种只读属性写进参数类型里;只有在函数确实需要原地更改外部状态时,才去使用非 const 的普通引用。只要在设计时把这条分界线划清楚,其他人在阅读你的函数声明时,就能立刻评估出调用该接口可能带来的数据风险。
const 保护的是访问路径
为了更准确地理解 const 的运作机制,我们需要仔细推敲下面这段代码:
int value = 10;
const int& ref = value;
在这里,ref 是一个绑定到了 value 上的只读引用。如果我们试图通过 ref 来修改底层的数据,编译器绝不会放行:
// ref = 20; // 编译错误
然而,值得注意的是,原始对象 value 本身依旧是一个普通的 int 变量。这意味着,只要我们使用 value 这个名字,修改操作仍然是合法的:
value = 20;
std::cout << ref << "\n";
执行完毕后,如果我们去打印 ref,会发现它反映出了最新的数值 20。这个现象帮助我们建立起一个极为重要的直觉认知:像 const int& ref 这样的声明,它仅仅封锁了通过 ref 这条具体访问路径进行修改的能力,但它并没有、也无法将底层的原始对象强行变成一个全局意义上的不可变实体。
我们可以将这种关系用一张图来梳理:
当然,如果对象在最初定义时就被声明成了常量:
const int value = 10;
那么在整个程序的生命周期内,通过任何常规方式都不可能再去修改它的值。相比之下,函数参数中的 const T& 要常见得多。它本质上是接口提供方做出的一种承诺:本函数只会在其内部通过这条参数路径去只读地访问对象,绝不越权。
很多人对 const 感到困惑,往往也是源于这两种用法的交织。当 const 直接作用于对象的定义时,它宣告的是对象自身状态的不可篡改;而当 const 依附于引用或指针这样的访问媒介时,它规范的仅仅是这条特定路径的权限。虽然它们在代码里的模样很相似,但语义的重心其实并不重合。因此,在阅读代码时,养成先确认“到底是对象本身被声明为了常量,还是仅仅访问路径被限制为只读”的习惯,远比死记硬背语法规则要可靠。
借助这个“访问路径”的模型,我们就能很自然地解释函数参数传递中一些看似绕脑的现象了:
void Print(const int& value) {
std::cout << value << "\n";
}
站在 Print 函数内部来看,它绝对无法通过内部的局部名字 value 去修改调用方传入的数据。但站在调用方的角度看,只要被传入的对象原本是个普通的变量,调用方在调用 Print 之前或之后,依然拥有随意修改它的自由。函数所承诺的仅仅是“属于我的这条访问路径是安全的”,它从未奢求也无权干涉“这个外部对象从此必须永远保持静止”。把这层边界梳理清楚之后,我们就不会误将 const 参数当成是一种能冻结一切的全局锁了。
指针里的 const 怎么读
虽然我们还没有正式步入探讨指针的章节,但由于 const 与指针的组合在 C++ 中极其普遍,我们有必要提前掌握一种清晰的阅读方法。
我们来看第一种常见的组合形式:
int x = 10;
const int* p = &x;
在这里,p 是一个指针变量。当我们试图通过 p 去访问它所指向的目标对象时,编译器只允许读取操作,严禁写入:
std::cout << *p << "\n";
// *p = 20; // 编译错误
不过,指针 p 变量本身并不受限,我们随时可以让它转而指向其他的 int 变量:
int y = 30;
p = &y;
由此可见,const int* 表达的核心重点是:只能以只读的方式去访问指针所指向的目标对象。
这种形式的指针频繁地出现在函数的参数定义中:函数接收了一个内存地址,但被明确要求只能读取该地址上的内容。同时,指针本身仍可灵活改变指向。从语法的角度拆解,这里的 const 实际上修饰的是底层的数据类型 int,因此它的限制作用精准地落在了目标对象的访问权限上。
再来看第二种相对不同的组合:
int x = 10;
int* const q = &x;
在这种写法中,指针变量 q 自身被死死固定住了。一旦它在初始化时指向了 x,后续就再也无法让它改变目标,指向其他任何地方:
int y = 30;
// q = &y; // 编译错误
然而,这并不妨碍我们通过 q 这个渠道去修改它所指向的目标对象的数据:
*q = 20;
因此,int* const 想要强调的核心是:指针变量自身的指向是恒定不变的。
这种声明方式偶尔会出现在一些复杂的局部逻辑中,用来向维护者传达一个明确的信息:“这个指针自诞生之日起,就坚定不移地指向同一个对象,绝不游移”。在这个语法结构里,const 紧挨着指针变量名 q,所以它的修饰效力直接作用于指针本身。至于通过这根指针去修改目标数据,则依然畅通无阻,因为这条访问路径并没有被 const 所封锁。
如果我们把两边的限制都加上:
const int* const r = &x;
那么结果也很直观:r 既不能改变自己的指向方向,程序也无法通过 r 去修改它所指向的目标对象的数据。
为了方便记忆,我们可以整理出这样一张简表:
const int* p → 只能通过 p 只读访问目标数据,但 p 本身的指向可以改变
int* const q → q 的指向被彻底锁死,但可以通过 q 去修改目标数据
const int* const r → 上述两种限制叠加,既不能换目标,也不能改数据
在这里,我们只需要先熟悉这些声明的阅读方式即可。关于指针如何保存内存地址、如何取址和解引用,以及空指针的详细机制,我们在下一章会有非常系统的梳理。
其实,在解析指针中 const 的修饰范围时,有一个颇为稳妥的视觉技巧:首先定位到指针的变量名,然后向两侧观察。如果 const 更靠近前面的目标类型说明符(比如 int),那么它限制的就是对目标对象的访问权;如果 const 紧紧贴在指针变量名自身旁边,那它限制的就是指针改变指向的能力。对于初学者来说,这种基于空间位置的直观解读法,远比去生啃诸如“顶层 const”和“底层 const”这样晦涩的学术名词要受用得多。
如果我们将这种分析回归到“究竟能不能修改”这个最朴素的问题上,逻辑就会变得异常清晰:
const int* p
p = &other; // 重新绑定指向是合法的
*p = 20; // 试图修改底层数据是非法的
int* const q
q = &other; // 试图重新绑定指向是非法的
*q = 20; // 修改底层数据是合法的
在遇到复杂的指针声明时,不妨先把问题拆解开来:第一步问问这个变量本身能不能改变指向方向,第二步再问问通过它能不能修改远端的目标对象。只要把这两个维度拆开看,那些看似纠缠不清的 const 也就迎刃而解了。在日后我们自己设计指针相关的接口时,同样也是依据这两个具体的问题来进行严密的权限校验。
const 是接口承诺
我们要认识到,在函数参数中加上 const,绝不仅仅是为了应付编译器的语法检查,它本质上是一份接口向外部调用者签署的严肃承诺。
我们来对比一下这两个函数的差异:
当 Normalize 函数选择接收普通的 std::string& 时,调用者在使用前就必须做好心理准备:这个传入的字符串大概率是会被内部逻辑改写的。而 Print 函数则大方地亮出了 const std::string&,这就等同于在安抚调用者:放心地传数据过来吧,我只负责阅读,绝不越界。
可以说,接口的语义定义得越清晰,调用者在使用时的顾虑就越少。在大型协同开发中,有相当一部分隐蔽的 bug 都是因为接口的意图传达得含混不清所导致的。巧妙利用 const,就是将一部分原本需要靠口头沟通和文档维系的意图,转化为一套强硬的、由编译器把关的类型规则。
在现阶段设计函数参数时,我们可以遵循一套比较通用的判断逻辑:
🏂
轻量级小对象,函数仅需数值参与计算 → 直接按值传递,如 int score
结构较重的大对象,且函数内部无需修改 → 使用常量引用 const T&
逻辑上确实需要原地修改调用方提供的数据 → 使用普通引用 T&
业务语义允许缺失,有可能连对象都不存在 → 使用指针(留待下一章详细探讨)
虽然这套经验法则无法一网打尽那些极其高阶的复杂场景,但它足以帮助我们在编写日常代码时,写出意图明确、边界清晰的高质量接口了。
上述逻辑的核心精神在于,我们要致力于把“到底允不允许修改”这样的关键业务意图,直接刻录到函数的类型签名中,让代码的结构本身去说话。这样一来,后续维护的同事甚至不需要点开冗长的函数体,单凭浏览参数的类型结构,就能精确推断出函数与外部对象之间的交互底线。类型系统承担的表达越丰富,那些脆弱的注释和不靠谱的口头约定就可以相应地减少。
除了向外传达意图,const 还会在内部严格监督函数体保持诚实。如果一个开发者信誓旦旦地声明了 void Print(const std::string& name),却在几百行后的某个分支里偷偷写下了 name += "!",编译器会毫不留情地直接阻断这次构建。这种机器级别的防御,比那些写在注释里的“保证不会修改传入参数”要坚固无数倍。毕竟注释依赖于人的自觉,而类型规则是由冷酷的编译器来执行的。整个 C++ 语言中有很多精妙的接口设计,都是建立在这个哲学之上的:凡是能够固化进类型系统的约束,就绝对不要让它游离在外。
当然,const 并非包治百病的灵丹妙药,它同样有自己的能力边界。它仅仅是在声明一条特定的访问路径不具备修改权限,这并不等同于它能够解决多线程下的并发安全问题,也无法保证极其复杂的对象内部结构永远静如止水。在更深层的应用中,某些类为了性能可能会使用内置的缓存机制,甚至需要动用 mutable 这样的特殊语法开辟后门,但那些都是我们在深入面对对象编程时才会触及的高阶话题。在本章中,我们只需要夯实最基础的核心认知:const 的最大价值,就是将“只读”的意图升格为类型定义的一部分。
在日常编码中,还有一个非常实用且能显著提升代码质量的技巧:在动手写具体的逻辑时,不妨养成一种条件反射,先尝试把那些看起来不需要发生变化的关键局部变量统统声明为 const,然后再根据编译器的报错提示来做局部的妥协。比如我们刚刚计算出的一个平均值仅仅是为了留作最终展示,那就直接写成 const double average = ...;。如果后续写着写着不小心又对它进行了赋值,报错信息就会像一个尽职的审查员一样提醒你停下来反思:这段逻辑真的有必要去覆盖之前计算好的值吗?这种不经意间的提示,往往能拦截下大量因为粗心大意引发的状态混乱。
总而言之,把 const 用好,代码的层次感会瞬间得到提升。在一堆错综复杂的代码块中,那些没有加限制的可变变量往往暗示着程序的某种业务状态正在被积极推进;而那些被打上了只读烙印的变量,则像是沿途设立的里程碑,代表着某个阶段稳固可靠的计算成果。当阅读者面对一大串局部变量时,借助这些修饰符就能迅速分清哪些数据还在奔波变化,哪些数据已经尘埃落定。在写基础代码时就把这个好习惯培养起来,等以后面对庞大的类体系和浩如烟海的标准库接口时,你的思路就会顺畅许多。
一段完整演示
为了巩固前面的概念,我们可以通过下面这段完整的示例代码,将 const 对象、常量引用以及指针里的修饰符组合放在一起进行梳理:
#include <iostream>
#include <string>
void PrintName(const std::string& name) {
std::cout << name << "\n";
}
void AddSuffix(std::string& name) {
name += "_cpp";
}
int main() {
const int max_score = 100;
std::cout << max_score << "\n";
std::string name = "Ada";
PrintName(name);
AddSuffix(name);
PrintName(name);
int value = 10;
const int& read_only_ref = value;
value = 20;
std::cout << read_only_ref << "\n";
const int* p = &value;
std::cout << *p << "\n";
int* const q = &value;
*q = 30;
std::cout << value << "\n";
return 0;
}
在阅读这段代码的逻辑走向时,一定要紧紧抓住“访问路径”这个核心概念。你会发现,虽然 read_only_ref 这条路径禁止我们对底层数据进行修改,但原生名字 value 这条路径却拥有完整的读写自由。同样地,p 这个指针仅仅保留了对目标数据的只读权限,而 q 这个看似严格固定的指针,牺牲的只是自身转向的能力,依旧能够对远端的数据进行大刀阔斧的改写。
可以说,const 成功地把抽象的修改权限,变成了一种肉眼可见的语法结构。等我们在下一章正式进入指针的世界,去探究内存地址究竟是什么、指针变量里到底保存着什么、以及 &x 和 *p 背后各自深藏怎样的玄机时,这种权限的边界感将会显得尤为重要。
今后在遇到任何包含 const 的声明时,你可以尝试遵循这样的阅读顺序:
如果把这个思维推导过程代入到日常常见的几种代码结构中,结论就会非常清晰:
🏂
const int max_score → max_score 作为一个独立对象,从出生就被宣告为只读
const int& score → score 作为一条引用的通道,仅供读取数据之用
const int* p → 通过对 p 进行解引用 (*p) 获得的访问路径,被设为只读
int* const p → p 这个指针变量自身的朝向被焊死了,再也无法转向
这种通过逻辑倒推的阅读方式,远比靠直觉去死记硬背符号的左右位置要稳固得多。我们始终要关注的是“到底是谁被剥夺了修改的权利”,而不是像机器扫描代码一样去机械地数 const 到底排在第几个位置。
读完本章的内容,其实只需要在脑海里沉淀出一个非常朴素的技术观念:我们之所以要在代码中频繁引入 const,目的就是为了尽量压减那些不可控的状态变化,为了在函数之间清晰地传递彼此的协作底线,更是为了让不知疲倦的编译器来代劳,帮我们死死守住那条名为“只读”的安全防线。一旦在编写基础代码的阶段把 const 给用活了,将来再一头扎进 STL 源码、复杂的类继承体系或是庞大的企业级工程中时,你就能少做很多提心吊胆的猜测。
所以在面对成堆的 const 时,切忌不要急躁。先深吸一口气问自己三个问题:究竟是谁被声明成了只读?程序正试图通过哪一条路径去触碰数据?函数接口到底在向外部世界做出怎样的承诺?针对对象定义的 const 意在凝固状态,针对引用的 const 旨在表明只读意图,而针对指针的 const 则需要细致拆分出指针躯壳与底层目标的权限差异。当你能将这三种应用场景剥离开来看待时,const 就不再是一个让人头疼的语法绊脚石,而是你用来精心构建稳定软件架构的一把利器。
阅读导航




