尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

C++八股勘误:const指针、shared_ptr线程安全与vector扩容真相

C++八股勘误:const指针、shared_ptr线程安全与vector扩容真相 1. 从背题到翻车为什么 C 八股需要勘误前几天帮一个朋友模拟面试他背得很顺const int *p是“常量指针”int *const p是“指针常量”vector扩容是两倍shared_ptr是线程安全的构造函数里调用虚函数不会走动态绑定。我追问了两个细节他卡住了int const *p和const int *p一样吗共享出shared_ptr指向的那个对象线程安全吗他说“大家八股都这么说我没细想。”这其实是 C 面试八股文最典型的问题结论背得滚瓜烂熟但结论本身可能是简化过的、过时的甚至从一开始就是错的。八股不是不能背而是不能把“别人转述的结论”当成“标准事实”。我见过太多候选人把“常见实现”说成“标准规定”把“某平台行为”说成“ C 必然如此”把“构造函数里调虚函数很危险”解释成“一定调用子类版本”。这些偏差轻则让面试官懒得追问重则直接暴露基础不扎实。所以这篇我想做的不是再给你一份面经而是挑几个流传最广的 C 八股说法逐个做技术勘误。勘误不是抬杠而是把话说准确。一个有经验的 C 工程师和背题之间最大的区别就是能说清楚“标准怎么规定、编译器怎么实现、实际代码怎么验证”。1.1 八股是怎么把“规定”变成“传说”的八股这类东西天然有失真。第一层失真来自面试答案本身为了方便记忆很多回答会被压缩成一句话。第二层失真来自口语传播一句话传几轮前提条件就丢了。比如“shared_ptr 线程安全”这句话标准完整说法是“控制块的引用计数操作是原子的多个 shared_ptr 实例之间的拷贝和析构不需要额外同步但同一个 shared_ptr 对象的并发写、以及指向对象的并发读写都不被这个保证覆盖”。这句话太长传到后来就变成“shared_ptr 安全”。还有一个问题很多人把“我用的编译器这样跑”当成“ C 标准这样规定”。最典型的就是std::vector扩容倍数。标准只规定了push_back的摊还复杂度是常数时间并没有规定扩容因子必须是 2。GCC 的 libstdc 常见两倍增长MSVC 的 STL 常见 1.5 倍增长你只在一种编译器上测过就不能得出结论说“标准规定两倍”。1.2 本文的勘误范围和验证方式我下面的勘误都以 C 标准以 C11 到 C20 的公开草案为主为底线同时给出一段能直接在 GCC、Clang 或 MSVC 上跑的例子。你拿到这篇内容不只是背正确答案而是知道用什么手段去验证。如果你在看的时候发现某个结论跟你记的不一样我建议你先把代码跑起来再回来看我的解释。很多时候一个疑问一旦能通过编译器和标准条款对上记忆就会变得非常牢固。2. 勘误一const 指针问题最常被一句话带偏面试题里关于 const 指针最经典的一题是const int *p、int const *p、int *const p有什么区别大多数人的答案是“const int *p是常量指针int const *p是 int 常量指针int *const p是指针常量。”这个答案表面上能得分但真被追问时很容易露馅因为第一句就已经不严谨了。2.1 const int * 和 int const * 到底等不等价在 C 类型语法里const的绑定规则很简单const修饰它前面的类型如果前面没有类型就修饰它后面的类型。这个规则在声明里体现得很清楚const int *p1; // const 左边没有类型所以修饰后面的 int int const *p2; // const 修饰前面的 int int *const p3; // const 修饰前面的 *p1和p2的类型完全一样都是“指向 const int 的指针”也就是你不能通过这个指针修改指向的 int 值但指针本身可以重新指向别处。p3不同它是“指向 int 的 const 指针”指针本身不能改但指向的 int 可以通过它修改。用一个static_assert就能验证#include type_traits static_assert(std::is_same_vconst int *, int const *); static_assert(!std::is_same_vconst int *, int *const);所以“const int *p是常量指针”这句八股错在哪它把“指向常量的指针”和“常量指针”混了。严谨的说法是const int *ppointer to const int指向常量的指针。int *const pconst pointer to int常量指针。int const *const pconst pointer to const int指针和指向的对象都不可变。2.2 顶层 const 与底层 const 的语义差异如果只记住上面三种写法面试还不够。很多面试官会继续问顶层 const 和底层 const。这是 C Primer 里的经典分类const int ci 42; const int *p ci; // 底层 const指针指向一个 const 对象 int i 0; int *const q i; // 顶层 const指针本身是 const顶层 const 表示“对象本身是 const”底层 const 表示“对象所指向/引用的东西是 const”。它们对类型推导的影响也不一样。比如模板参数推导或auto推导时顶层 const 通常会被忽略底层 const 会被保留const int ci 42; auto a ci; // a 是 int顶层 const 被丢弃 auto b ci; // b 是 const int*底层 const 保留这里对面试最有用的知识点是const放在类型名后面还是前面很多时候只是风格问题真正决定语义的是它到底修饰的是谁。不要用“const 在星号左边表示指针指向常量在右边表示指针本身是常量”这种粗略说法因为const int *p和int const *p违反了“左边/右边”的简单直觉。准确的说法是“看它绑定到哪个声明符层级”。2.3 面试官真正想考的能力这类题在面试里已经泛滥了面试官大概率不是想听你背定义而是想看你能不能把一段代码快速解释清楚。我建议你准备一个小模板先把写法拆开找到*再看const在*左边还是右边const在*左边修饰指针指向的对象。const在*右边修饰指针本身。两边都有指针和对象都不可变。这个思路能覆盖 95% 的 const 指针面试题。剩下 5% 是关于typedef的比如typedef int *PInt; const PInt p;这时const修饰的是整个PInt也就是指针本身而不是 int。这类陷阱别去钻太深面试里遇到直接用上面的规则推导看出题人想考什么。3. 勘误二初始化列表“按声明顺序”不是风格建议是 C 的强制语义另一个被八股严重简化的问题是构造函数初始化列表的执行顺序。很多人能背出“初始化列表按照成员声明顺序执行而不是按照初始化列表写的顺序”但很少有人会在现场把后果讲明白。3.1 不按声明顺序会发生什么假设我们有这样的代码#include cstdio struct A { int v; A(int x) : v(x) { std::printf(A(%d)\n, x); } }; struct B { int v; B(int x) : v(x) { std::printf(B(%d)\n, x); } }; struct Wrap { A a; B b; // 注意初始化列表里 b 写在前面a 写在后面 Wrap() : b(2), a(1) {} }; int main() { Wrap w; return 0; }输出一定是A(1) B(2)因为成员变量a先于b声明C 规定无论初始化列表怎么写都先初始化a再初始化b。这不是编译器好心帮你排序而是语言强制的规则成员初始化顺序是声明顺序与初始化列表的书写顺序无关。如果你以为“先执行初始化列表里 b(2)再执行 a(1)”那你写的代码里碰到依赖关系时就会踩坑。举个例子struct Data { int first; int second; // 看起来是先给 second 赋值再用 second 初始化 first // 实际执行顺序first(second); second(42); Data() : second(42), first(second) {} };声明顺序是first在前所以实际执行顺序是first(second)再second(42)。而first(second)发生时second还是一个未初始化的 int读取它是未定义行为。结果可能是 0也可能是残留的脏数据。这个 Bug 非常隐蔽因为代码看起来完全“有逻辑”。3.2 初始化顺序为何不能写成“初始化列表的顺序”如果你继续追问 “为什么编译器不能按初始化列表顺序执行”答案在对象生命周期模型里对象的成员是按声明顺序构造的析构时完全按相反的顺序析构。成员初始化列表只是给这个固定顺序提供构造参数而不是重新定义顺序。对于有继承关系的对象顺序还要更复杂一些先按继承声明顺序初始化基类子对象。再按成员声明顺序初始化成员变量。最后执行构造函数体。如果初始化顺序可以随初始化列表书写顺序改变那么基类和成员的构造顺序就会变得不可预测析构时也无法保持严格的逆序。标准最终选择把初始化顺序固定成“声明顺序”因为这是源码中最稳定、最不容易被重构改变的信息。3.3 实测验证和可执行的排查手段这类问题不能只靠记编译器其实会帮你识别。GCC 和 Clang 都有-Wreorder警告并且这个警告默认包含在-Wall里。把上面的Wrap用下面命令编译g -stdc17 -Wall -Wextra ordering.cpp你会看到类似warning: field a will be initialized after field b [-Wreorder] warning: reorder of member initializations我强烈建议项目里开启-Werror把这类警告变成编译错误。否则某次代码重构改动了成员声明顺序初始化列表没同步改线上就会出现难以定位的未定义行为。再分享一个小技巧如果面试官问“初始化列表顺序和成员声明顺序不一致会怎样”不要只说“会按声明顺序执行”。你可以补一句“编译器会警告-Wreorder但真正的问题不是警告而是成员初始化依赖别人的未初始化值会直接触发未定义行为。”这比背几十条八股都有说服力。4. 勘误三sizeof 类的“成员变量之和”只是幻觉“一个类的大小等于所有成员变量大小之和”这大概是 C 八股里存活时间最长的错误结论。也不是完全错遇到全是同一类型、自然对齐的成员时它碰巧是对的。但很多时候类的大小会被对齐、虚指针、空类优化这些东西改得面目全非。4.1 一个最朴素的例子对齐先看最简单的结构体#include cstdio #include cstddef struct Pod { char c; int i; }; int main() { std::printf(sizeof(Pod) %zu\n, sizeof(Pod)); std::printf(offsetof(i) %zu\n, offsetof(Pod, i)); return 0; }在主流 x86-64 平台上结果是sizeof(Pod) 8 offsetof(i) 4char占 1 字节int占 4 字节成员之和是 5但sizeof是 8。原因是对齐int需要按 4 字节对齐所以编译器会在char后面填充 3 个字节让i落在偏移 4 的位置。结构体整体大小还要对齐到成员最大对齐数的整数倍所以最后是 8 而不是 5。如果你觉得 8 和 5 差距不大那再看这个struct P { char a; double b; int c; };常见平台下a偏移 0b按 8 字节对齐偏移 8c偏移 16结构体整体大小是 24而不是 13。尾部还要补 4 个字节以满足结构体自身对齐到 8 的倍数。4.2 空类、虚指针和继承导致的额外空间对齐只是其中一个因素。空类的sizeof是另一个反直觉点struct Empty {}; static_assert(sizeof(Empty) 1);空类大小不是 0而是 1。因为 C 要求同一个类型的两个不同对象必须有不同地址如果空类大小是 0两个相邻对象就会地址重叠。所以编译器给空类分配 1 字节占位。再来看虚函数struct VirtualBase { virtual void f(); }; // x86-64 平台常见 sizeof(VirtualBase) 8有虚函数的类通常内部会有一个虚表指针vptr指向该类的虚函数表。这个指针本身也是成员一样的存在只是语法上没写成成员变量。注意vptr 是常见实现方式不是标准强制要求的但几乎不可能遇到不这么做的编译器。一个类如果既有虚函数又有普通成员它的内存布局除了对齐填充还会多出一个指针大小的 vptr。继承也会带来影响。空基类优化EBO经常被八股误传成“永远生效”实际上标准并没有强制要求每个空基类都被优化。主流编译器默认会做但这仍然是实现行为。下面这样的类很多编译器上sizeof是 4而不是 8struct Empty {}; struct Derived : Empty { int x; };如果你跟面试官说“sizeof(Derived)一定等于 8”那就错了。只能说“在主流编译器上通常等于 4但这依赖空基类优化”。4.3 怎么在面试题里既准确又不啰嗦回答sizeof相关题目最稳的框架是三步列出所有非静态成员。考虑每个成员的对齐要求算偏移。考虑是否有 vptr、空基类优化、#pragma pack等额外因素。如果面试官给的是一个包含char、int、double、虚函数的结构体你不需要背结果你可以现场用alignof和offsetof推。我一般会直接说“所以这道题没有唯一答案必须指定平台和对齐设置。最严谨的验证方式是写static_assert和offsetof把布局测出来。”这样回答既纠正了“成员变量之和”的错误前提又体现了你理解编译器行为而不是背答案。5. 勘误四构造函数和析构函数里调用虚函数走的是“当前类”而不是派生类这条八股流传得最广但也最容易被说秃噜嘴。常见说法是“构造函数里调用虚函数不会发生动态绑定所以不会调用派生类版本。”这个说法在特定场景下是对的但不够精确。5.1 常见错误版本先看一段代码#include iostream class Base { public: Base() { std::cout Base ctor: ; f(); } virtual void f() { std::cout Base::f\n; } }; class Derived : public Base { public: Derived() { std::cout Derived ctor: ; f(); } void f() override { std::cout Derived::f\n; } }; int main() { Derived d; return 0; }这段代码的输出是Base ctor: Base::f Derived ctor: Derived::f为什么不是两行都是Derived::f因为在构造Derived对象时程序先构造Base子对象。Base构造函数执行期间派生类部分还不存在派生类重写的f()有可能访问尚未构造的派生类成员所以语言规定此时调用f()只会调用Base自己的版本。等Derived构造函数体执行时派生类部分已经准备好从Derived构造函数体里调用f()自然就走到Derived::f。所以更准确的说法是在某个类的构造函数或析构函数里调用虚函数调用的是“当前正在构造或析构的这个类”及其基类里定义的版本而不会调用比它更衍生的类的重写版本。不要笼统说“构造时不会调用派生类版本”否则一旦遇到Derived构造函数体里调用f()就解释不清了。5.2 为什么不是动态绑定对象从里到外构造要理解这个行为关键是理解对象构造顺序从基类到派生类。基类构造时派生类成员还没创建如果允许虚函数派发到派生类重写版本重写函数里一旦访问派生类成员就是在访问尚未构造的对象这是灾难。析构时顺序反过来从派生类到基类。派生类析构函数先执行然后基类析构函数执行。因此在基类析构函数里调用虚函数也同样不会调用派生类版本因为派生类部分已经被销毁了。标准把这个行为描述为“构造或析构期间对象的动态类型被暂时冻结为当前正在构造或析构的类”。你可以把它理解成基类构造函数眼里的对象只是Base不是Derived。这是语言给出的安全边界。5.3 一个实际面试回答范本如果面试官问你“基类构造函数里调用虚函数会发生什么”建议回答结构先给结论调用的是基类自己的版本不是派生类重写版本。再给原因构造基类子对象时派生类部分还不存在不能让虚调用进入派生类逻辑。最后补一句扩展等到派生类构造函数体执行时动态类型已经变成当前类此时再调用的行为不同所以“构造期间不动态绑定”这句话要注意适用场景。我还遇到过面试官追问“那析构函数里调虚函数呢”答案是同理析构是反序基类析构时派生类部分已经销毁调用虚函数还是走当前类版本。这个对称关系记住了这类题就不会翻车。6. 勘误五智能指针“线程安全”的边界“shared_ptr 是线程安全的”也是典型的简化式八股。很多候选人被问到shared_ptr线程安全时直接回答“安全”面试官再追问“哪里安全、哪里不安全”就答不上来了。6.1 shared_ptr 的原子性指什么std::shared_ptr的控制块里通常保存引用计数。拷贝一个shared_ptr会让引用计数加一销毁一个副本会让引用计数减一。标准要求这个引用计数的加减操作是原子的所以多个线程各自持有同一个shared_ptr的副本分别拷贝或析构不需要额外加锁。比如这段代码是安全的#include thread #include memory #include vector std::shared_ptrint sp std::make_sharedint(42); void worker() { // 捕获时拷贝 sp多个线程同时拷贝/析构副本控制块引用计数操作是原子的 auto local sp; (void)local; } int main() { std::vectorstd::thread threads; for (int i 0; i 8; i) { threads.emplace_back(worker); } for (auto t : threads) { t.join(); } return 0; }这里的“安全”指的是不会因为多个线程同时拷贝/释放shared_ptr副本导致引用计数错乱也不会有多个线程重复 delete 同一块内存。6.2 常见问答中的错误示范先说第一个错误把“控制块安全”说成“shared_ptr 对象本身安全”。下面这个代码是典型的数据竞争std::shared_ptrint sp std::make_sharedint(1); // 线程 A sp std::make_sharedint(2); // 线程 B sp std::make_sharedint(3);两个线程同时写同一个sp对象这个对象内部不只是引用计数还有裸指针。并发读写同一个对象本身就是数据竞争。这种情况应该用std::atomicstd::shared_ptrT或者用互斥锁保护sp。第二个错误把“shared_ptr 线程安全”说成“指向的对象线程安全”。std::shared_ptrstd::vectorint data std::make_sharedstd::vectorint(); // 线程 A>#include iostream #include vector int main() { std::vectorint v; size_t last_cap 0; for (int i 0; i 100; i) { v.push_back(i); if (v.capacity() ! last_cap) { std::cout size v.size() capacity v.capacity() \n; last_cap v.capacity(); } } return 0; }在 GCC 的 libstdc 上常见输出是 1、2、4、8、16……在 MSVC 的 STL 上常见输出是 1、2、3、4、6、9、13、19……这并不代表谁对谁错只是不同实现的取舍不同。面试时如果你能主动说出“我在不同 STL 上实测过容量序列发现并不统一”效果会非常好。7.3 面试官为什么爱问这个问题这个问题真正想考察的是“扩容的副作用”。不管用 1.5 倍还是 2 倍当vector重新分配内存时原有的所有迭代器、指针、引用都会失效因为旧元素被搬到了新地址。很多线上 Bug 就是在这点上翻车的std::vectorint v {1, 2, 3}; int* p v[0]; v.push_back(4); // 如果触发扩容p 失效 std::cout *p; // 未定义行为所以回答vector扩容问题时最好包含三句话标准要求push_back摊还 O(1)实现通常用几何增长。扩容因子因标准库实现而异常见有 2 倍和 1.5 倍。扩容会让迭代器、指针、引用失效需要用索引或重新获取指针来避免悬空。8. 勘误之外把技术勘误变成面试加分项上面这些勘误挑的都是流传最广、影响最大的几个。但比记住正确答案更重要的是你要养成“背完八股后随手验证”的习惯。8.1 背答案前先跑代码我不反对背八股。面试本来就有时间压力能快速说出结论说明你有体系。但只背结论、没有验证过的人一旦被问“为什么”“怎么证明”就会露馅。C 的问题特别适合用代码验证。static_assert能验证类型和编译期常量offsetof能验证内存布局-Wall -Werror能捕捉初始化顺序和类型问题。编译器本身就是你最好的老师。我自己的习惯是每看到一个容易混淆的结论就写一个最小例子编译、运行、看汇编或调试输出。比如上面const int *p和int const *p的等价性一行static_assert就能钉死比你背十遍都稳。8.2 面对面试官的错误论点如何不得罪人地纠错面试中也可能遇到面试官说一个与你结论相反的话。这时候不要直接说“你错了”而是用一个更软的口径“我之前也这么记后来我在自己的编译器上测了一下发现int const *p和const int *p在标准里是同一个类型如果这里指的是int *const p那才是不同的语义。会不会我们说的是两个场景”这种表达既维护了对方的面子又把自己的观点摆出来了。技术讨论里对事不对人是基本素养你用“我实测过 标准怎么说”来回应比单纯“我记得”更有说服力。8.3 建议的 C 八股资料检查套路网上流传的 C 面试八股信息质量参差不齐。我的建议是不迷信“几百题速成”优先看 cppreference 和标准草案相关章节。涉及内存布局、编译器行为的问题以你在自己机器上的实测结果为主。区分“标准要求”和“实现行为”。标准要求是跨平台稳定的实现行为换一个编译器可能就变。面试前把容易混淆的几组概念写成一个表格比如 const 指针的三种写法、shared_ptr 的三层线程安全、sizeof 的三个影响因素。最后再分享一个私人习惯我电脑上有个scratch文件夹专门放这类最小验证代码。文件名就叫const_pointer.cpp、sizeof_layout.cpp、vector_capacity.cpp。每次面试前重新跑一遍比临时翻笔记管用得多。C 面试八股不是不能背但背完之后要让它能经得起编译器这一关。过了这一关你背出来的东西才有含金量。
返回列表