ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

C++ const完全指南:从const变量到constexpr与面试真题

2026/9/11 3:05:07 拓冰建站 浏览量
C++ const完全指南:从const变量到constexpr与面试真题 const这个关键字初学C的人都觉得太简单了——不就表示“不能改”嘛。可真到项目里写代码const的坑一点不比模板少。我见过有人把const int*和int* const搞混导致编译不过见过const_cast用顺手之后把一个只读字符串写崩掉也见过面试时被问到顶层const和底层const直接卡壳的。这些我都经历过。这篇东西想把C里const的用法从头到尾捋一遍从最简单的const变量到const指针、const引用、const成员函数、const_cast再到constexpr和面试真题全部过一遍。不管你是刚学C想弄明白还是工作几年想系统补一下看完应该都能收获点东西。1. 先搞明白const到底是锁住什么1.1 编译期的“契约”不是运行时的“锁”很多人把const理解成“运行时不允许修改”这个理解其实不太准确。const真正起作用的地方是编译期——编译器在静态检查时发现你对一个const对象做了赋值或修改操作直接报错根本不会让你生成可执行文件。它本质上是程序员和编译器之间的一份契约我承诺这个对象不会被修改你帮我在编译时盯着点。这也带来两个很实际的结果。第一const不会让程序变慢因为它不会生成任何额外的运行时检查代码纯粹是编译期的约束。第二const对象在内存里的位置不一定受保护它可能就是一个普通的栈变量。真正危险的是那些被放进只读区域的const数据比如字符串字面量、某些const全局对象编译器很可能会把它们放到只读数据段。这时候如果你绕过const去写那不是编译错误的问题而是未定义行为常见的表现就是段错误或者在微软VC的调试环境里弹出一句熟悉的提示write access to const memory has been detected, the output may be wrong!。我见过不少刚入行的同事一看到这类运行时错误就懵了我明明有const_cast啊我明明没有越界啊其实根本不是越界是“写了不该写的只读内存”。记住这句话const在运行时提供的唯一“保护”就是让那些真正放在只读区域的脏数据在你脸上炸开给你一个惨痛的教训。1.2 顶层const与底层const所有指针困惑的根源面试里问const十个里有八个会绕到顶层const和底层const上。这个概念不搞明白后面看别人代码只能靠猜。先说定义顶层consttop-level const表示指针本身是const也就是指针变量不能指向别的对象。写法是int* const p。这里的const修饰的是“p”这个指针变量。底层constlow-level const表示指针指向的对象是const也就是不能通过这个指针修改目标对象。写法是const int* p或int const* p。这里的const修饰的是“int”。判断技巧很简单从变量名开始往右读const在变量名右边多半是顶层constconst在类型名那一边多半是底层const。我自己的习惯是记住一个口诀const锁的是离它最近的那个名词。int* const p里const左边是int*右边是p它锁的是p本身const int* p里const左边是空白、右边是int它锁的是int。为什么要单独拎出来讲因为拷贝的时候顶层const和底层const的约束完全不同。顶层const拷贝不受影响我复制一个指针变量本身源对象是不是const无所谓但底层const的拷贝有严格限制——不能把一个const int*赋给一个int*否则你就能通过后者轻易修改一个本来声明为const的对象等于单方面撕毁契约。反过来int*赋给const int*是允许的这是权限的收窄编译器放心。2. 逐层拆解const在不同场景下的实战用法2.1 const修饰变量常量怎么用才不“白用”最基础的用法是const int maxLen 1024;。在C里如果初始化是编译期就能确定的常量表达式这个const变量本身也是一个编译期常量可以用来定义数组大小、作为模板参数、作为case标签等很多场景。但注意这不代表所有const变量都是编译期常量比如const int n getUserInput();这就是个运行时才确定的只读变量不能当数组长度用。想区分这两类得靠constexpr这个后面细讲。一个容易忽略的点是const变量的链接性。C里普通非const变量默认是外部链接而const变量默认是内部链接也就是每个翻译单元都有一份自己的拷贝。如果你在头文件里定义了一个const变量每个包含这个头文件的.cpp都会各自拥有一份这在多数场景下没问题甚至可以利用这一点省掉一些不必要的跨翻译单元访问。但如果你想在多个源文件间共享同一个const对象必须在定义时加extern并且在一个.cpp里定义、在头文件里用extern const声明。这也是面试里比较喜欢挂的一个小考点。还有一个从C语言转过来的误区别用const变量去“伪装”宏定义。const是正儿八经的变量有类型、有作用域、可以取地址。宏只是文本替换。能用const的地方优先用const这不是风格问题而是类型安全的问题。2.2 const与指针从右往左读永远不慌先做一组速记对比这几行代码是面试题重灾区写法指针是否可变指针指向的值是否可变const int* p可改可以指向其他int不可改int const* p可改不可改和上一行等价int* const p不可改可改const int* const p不可改不可改我在实际项目里最常遇到的是const char*和char* const的混用。比如某个函数签名是void parse(const char* str);它表达的意思是传入的字符串内容我不会修改。但如果你写成void parse(char* const str);语义就变成了“指针本身不能变但我可能在函数里改这个字符串的内容”这两个意思天差地别。特别注意字符串字面量。在C里字符串字面量比如hello的类型是const char[6]所以用const char* p hello;才是合法的。早期C为了兼容C允许char* p hello;这种写法但一旦你通过p去修改字符串内容就是写入只读内存的未定义行为。我曾经在一个旧项目的兼容层里见过一堆char*直接指向字符串字面量的代码平时不写不出事某个路径下代码偷懒复用了缓冲区去覆盖“字符串”内容结果整个内存被写坏排查了很久才发现是这种隐式转换埋的雷。现在C11之后这种写法已经被明确废弃/禁止新代码里再看到就可以直接改了。2.3 const引用传参界的“性价比之王”引用本身在某种程度上有点像自动解引用的指针而const引用则是传参时的首选。有个非常经典的规则如果函数不需要修改传入的参数优先写成const T。为什么不是按值传T因为大对象拷贝成本高。为什么不是非const引用T因为如果函数内部不修改参数把传入对象的修改权限开放出去反而限制了调用方——人家一个const对象根本传不进来。用const T就能同时兼容const和非const实参还能避免拷贝这是C里少有的“既要又要”的解决方案。const T还有一个隐藏能力绑定临时对象。比如const std::string s abc;是合法的因为编译器会构造一个临时string并延长它的生命周期到引用离开作用域为止。反过来std::string s abc;就是编译错误因为非const左值引用不能绑定右值。这个细节在写函数重载和转发时特别容易踩坑比如你把参数从std::string s改成std::string s想省一次拷贝结果发现所有传字面量的调用全部编译失败原因就在这。2.4 const修饰函数返回值别给自己找麻烦返回值加const的情况比参数少得多但有必要说清楚。返回一个const值比如const std::string get();在C11以后基本是负优化——它阻止了移动语义还会让一些调用方的代码编译不过。我有段时间喜欢给返回值加const觉得安全后来发现唯一的效果就是让写auto s obj.get();的人都来问我为什么代码报错。返回值加const真正有意义的场景是返回引用或指针的时候比如const T operator[]、const T* get()这时候你是在声明“返回的是内部数据的只读视图”防止调用方通过返回值修改对象内部状态。3. const在类与对象中的进阶玩法3.1 const成员函数到底改了什么类里面void print() const;这种写法很多初学者只背结论const成员函数里不能修改成员变量。但真要理解得看它的本质。所谓const成员函数实际上是给this指针加了底层const约束也就是说this的类型从Foo*变成了const Foo*。所以不是“函数不能修改成员变量”而是“这个函数拿到的this指向一个const对象你不能通过它去修改对象”。这带来一个连锁约束const成员函数里只能调用其他const成员函数。道理很简单你的this是const Foo*你没法把它转成Foo*去调用一个非const成员函数。反过来非const成员函数可以随便调用const成员函数因为权限收窄是允许的。构造函数和析构函数不能是const这一点也经常被问到。构造函数期间对象还没构造完const语义还没建立析构函数更是要修改对象的内部状态来释放资源把它标成const毫无意义。3.2 mutable打破const限制的唯一正规通道如果在const成员函数里确实需要修改某个成员变量怎么办标准答案是mutable。最常见的例子是安全计数器、统计字段、缓存这些“逻辑上不影响对象状态”的数据class Service { public: int getData() const { callCount_; // mutable允许在const成员函数里改 return data_; } private: int data_{0}; mutable long callCount_{0}; };callCount_被const成员函数修改了但没有改变对象的“逻辑状态”所以这种用法是被认可的。另一个极其经典的场景是线程同步一个线程安全的类如果get()方法是const的但里面需要加锁那互斥锁就必须声明为mutable否则根本没法治。这在后面第5部分多线程场景里会再展开。我用过的最阴间的mutable用法是懒加载。有的老代码为了在const接口里实现“首次访问时初始化缓存”把缓存指针声明为mutable在里面做new。这种代码可以用但一定要保证线程安全否则并发环境下两个线程同时触发懒加载直接双free我为此排查过一个线上崩溃教训很深刻。3.3 const重载同一个函数名两种语义const成员函数可以和非const成员函数构成重载编译器根据调用对象是否为const来选择。最典型的例子是容器的operator[]T operator[](size_t idx); const T operator[](size_t idx) const;对一个非const容器调用operator[]你拿到的可写引用可以修改元素对一个const容器调用你只能拿到const引用不能修改。这很符合直觉const容器就不应该允许你改里面的东西。如果你只提供非const版本const对象没法调用如果你只提供const版本非const对象也能调用但拿到的引用就永远是const同样很麻烦。在写自定义类时养成习惯如果接口语义是只读的就给const版本如果还要返回可写引用就把两个版本都写全。不要偷懒只想写一个。我踩过的一个坑是类内部有个缓存写了const T getCache() const但没有非const版本结果外部需要在某个深层逻辑里修改缓存内容被迫去const_cast绕了一圈最后全删了重写成两个重载干净利落。3.4 const与static结合时要注意什么static成员函数没有this指针所以不能声明为const。这一点面试里偶尔会出判断题原因就是const成员函数本质是约束this的没有this自然就谈不上const。static成员变量有没有const有通常用来定义类的编译期常量比如static const int kMax 100;。注意如果这个static const变量被取地址或者ODR-used需要在外部的.cpp里再定义一次C17里可以用inline static const省掉这个麻烦。我在C14时代写库代码被这个“undefined reference”折磨过一次之后全是inline static。4. 高频考点const_cast、const与define、constexpr4.1 const_cast能用但大部分时候不该用const_cast是C里专门用来去掉const或volatile限定的强制转换。为什么需要它一个典型场景是历史遗留代码里有很多C接口函数签名是char*但你的数据实际上是const的而且背后是个可写的缓冲区只是接口传进来时给标成了const。这种你知根知底的情况下用const_cast是合理的。真正危险的是“对象本身是const”的情况。比如const int a 100; const_castint(a) 200; // 未定义行为a本身声明为const编译器完全可以把它放到只读存储里你强转完去写轻则写入被忽略重则直接崩溃。我记得有次调试一个崩溃堆栈最后定位到就是有人对一个全局const配置项用了const_cast去修改“配置”编译器把那个对象放到了只读段一写就段错误。这已经不是用错工具的问题而是设计上根本没有资格修改它。另一个绝对禁区是字符串字面量。前面说了字符串字面量的类型是const char[N]你就算用const_cast去掉const再写同样是改写只读内存运行时崩溃没商量。给个判断标准如果原始对象真的是const别用const_cast如果原始对象不是const只是你手里只有const引用或指针可以谨慎使用但要先问问自己为什么不一开始就用非const接口。项目中大部分const_cast都属于设计异味能避免尽量避免。4.2 const和#define到底差在哪这个问题面试频率极高。两者表面上看都能定义“常量”实际差别非常大对比维度const#define处理阶段编译期预处理期文本替换类型检查有完整类型编译器会检查无类型替换成字面值再来检查作用域遵循C作用域规则从定义处到文件结束或undef调试是变量调试器可查看预处理后就没了看不到内存const变量有实体可取地址不分配实体纯字面替换能力只能定义常量可以定义函数宏、拼接等我最想强调的一点是作用域。define只要一不小心没有undef就会污染后面所有的代码和头文件。我之前在维护一个老项目时有个头文件里define了一个名字很通用的宏MAX导致后面包含它的所有文件里但凡有个成员函数想叫getMax()都会被预处理器活生生替换成get100()之类的灾难场面。排查这种问题极其痛苦因为你看到的代码和编译器看到的代码根本不一样。而const变量老老实实遵守作用域完全不会有这种问题。C实践里对“常量”的优先级是能用constexpr用constexpr能用const用const实在需要宏再考虑define。函数宏在现代C里多数能被inline函数和模板替代别再用宏写“函数”了。4.3 constexpr从“只读”到“编译期算完”constexpr是C11引入的关键字目的很明确让某些值和函数可以在编译期就求出来。它和const的最大区别是const表达的是“运行时只读”constexpr表达的是“编译期常量”。一个const变量可能在运行时才知道值但一个constexpr变量初始化必须能用常量表达式完成而且它本身就隐含const语义。看个例子int a 5; const int b a; // 合法b运行时才确定 constexpr int c a; // 错误a不是常量表达式 constexpr int d 5 3; // 合法编译期就算完了constexpr函数更有意思C14之后放宽了很多可以在里面写循环、分支。它的规则是用常量实参调用时编译期求值用运行期实参调用时退化成普通函数。这种“两栖”能力让它特别适合做一些配置表、工具计算又不牺牲普通运行时的灵活性。我在项目里最常用的组合是constexpr加上字符串解析、单位换算之类的纯计算逻辑。比如一个把角度转弧度的函数因为所有参数编译期都知道直接被编译器算成常量运行时零开销。面试问到constexpr一般会顺带问“constexpr和const能不能同时出现”答案是能但constexpr已经隐含const变量上重复写没问题但有点冗余。5. 典型代码场景与面试真题解析5.1 catch (const std::exception e) 为什么一定要const引用异常捕获里最标准的写法是catch (const std::exception e)这个const背后有好几层意思。第一你应该把异常对象当成只读信息它用来描述错误不是用来改的。第二引用可以避免对象切片——如果你写成catch (std::exception e)当实际抛出的是std::runtime_error时派生部分会被切掉丢失重要的错误信息而用引用捕获多态就能正常发挥作用。第三加const能让接口语义更清晰编译器帮你保证你不会无意中去修改异常对象。很多人写catch (std::exception e)也能工作但既然不打算改用const才是最佳实践。同样道理函数签名里const std::exception这种写法在异常相关的接口中也非常常见。我排查线上问题的时候经常要靠e.what()的字符串定位错误源如果曾经因为切片丢失了what()的信息调试成本直接翻倍。5.2 回调函数和接口设计const void* 与const std::function回调函数里const最常见的体现是const void* data这种C风格参数。为什么要加const因为回调是别人把数据交给你处理如果你不是要修改数据就应该声明为const这样调用方可以放心地把const数据传进去也避免你误改。比如一个音频采集回调、一个数据包解析回调绝大多数场景都是只读标记const是对调用方的承诺。在C现代代码里我更喜欢用std::function比如void onData(const std::functionvoid(const char* data, size_t len) callback);这里const引用包住std::function避免拷贝内部回调参数也是const指针表示只读。设计接口的人如果不注意const就会出现调用方手里是const字符串却传不进回调的尴尬局面。所以写库的人有一点很关键不修改的参数尽量全部加const宁可多写几行也别让使用方到处const_cast。5.3 多线程里的const陷阱const不代表原子也不代表线程安全const成员函数在多线程环境中有一个极易踩的坑你以为const方法不修改对象所以并发调用是安全的其实不一定。看这个例子class Counter { public: int get() const { return count_; } void inc() { count_; } private: int count_{0}; };get()是const的但如果一个线程在inc()另一个线程在get()这里依然有数据竞争。const只代表“不允许通过这个接口修改对象”不代表“对象不会被其他人修改”。反过来const成员函数内部如果只读取成员变量在别处不修改的情况下并发读是安全的。真正要做到线程安全得靠同步原语。于是mutex和mutable的经典组合就来了class ThreadSafeCounter { public: int get() const { std::lock_guardstd::mutex lock(mtx_); return count_; } private: mutable std::mutex mtx_; int count_{0}; };get()要加锁必须能“修改”mtx_所以mtx_必须mutable。很多第一次看到mutable的人就是在这种线程安全的类里相遇的。一次我在审查代码时看到有人直接在const成员函数里std::lock_guardstd::mutex lock(mtx_);编译不过然后他把整个函数去掉const了导致所有const调用点全部编译失败。其实只需要给mtx_加mutable几秒钟的事。这个组合要当成固定套路记下来。5.4 面试高频const真题速查面试里const相关问题翻来覆去就那几个整理一个速查表问题核心答案要点const int* p和int* const p区别前者不能改指向的值后者不能改指针本身顶层const和底层const顶层管指针本身底层管指向对象拷贝权限只收不放const T 能绑定临时对象吗能且会延长临时对象的生命周期const成员函数能修改成员变量吗不能除非成员变量声明为mutablestatic成员函数能是const吗不能static函数没有this指针const_cast什么时候安全原始对象本身非const只是接口传成了const引用/指针时可用对象本身是const时绝对不能用const和define的区别预处理vs编译期、有类型vs无类型、作用域、调试可见性constexpr和const的区别constexpr强调编译期求值隐含const语义const容器能用operator[]吗能用但返回的是const引用因为const版本operator[]返回const T为什么char* p hello;不行C中字符串字面量类型是const char[N]需要const char*来接收这些题如果都能不看资料答出来const的基础算是扎实了。但面试只是门槛真正的考验是项目里每一处接口签名是否设计得合理。5.5 再补两个实战细节第一STL的const_iterator和iterator。const容器只能拿到const_iterator它指向的元素是只读的。很多人会把const std::vectorint和std::vectorint::const_iterator搞混记住一点容器是const它给出来的迭代器就自动是const版本逻辑非常一致。第二const成员函数里如果想把某个容器成员返回出去返回const auto或者const T才能保持只读语义。有人图省事直接返回一个拷贝结果每次调用都多一次拷贝开销有人直接返回了非const引用结果外部通过这个引用把内部数据改了等于给类的封装开了个洞。6. 关于const我最想分享的经验我在实际项目中养成了一个习惯写任何接口前先问自己三个问题——这个参数我会在函数内修改吗这个成员函数允许被const对象调用吗这个返回值能不能暴露内部状态每一问都会导向一个const相关的决定。这套思考流程救了我很多次也让我给别人review代码时能一眼看出接口设计是否合理。另外一个小技巧遇到复杂的指针const类型不要靠记忆硬扛直接按“从右往左读变量声明”来拆。const char* const* pp是个指针指向一个const char* const也就是p指向的元素是一个指向const字符的const指针。一层层剥绝对不会乱。最后再分享一个排查经验如果程序莫名其妙报出写只读内存的错误先别急着崩溃看看代码里有没有出现const_cast特别是对字符串字面量和全局const对象的const_cast。把这行删掉或者改用其他设计大概率事情就结束了。const这套机制说到底是为了帮我们写出更可靠的代码而不是为了让我们在编译期和运行时之间来回折腾。真正理解了它你会发现自己的C代码从“能跑”到了“设计得清楚”那一步。