本文面向 C++ 游戏客户端 / 工具开发方向的校招面试,按「面试问法 → 一句话回答 → 机制 → 例子 → 边界」整理 22 个高频 C++ 基础知识点,分四章:初始化与对象模型、类型系统与代码组织、模板与编译期编程、标准库使用与异常安全。目标是每一题都能先口述一句话结论,再看懂一个能编译运行的例子,最后说清适用边界。
与已有资料的分工:指针与引用、new/malloc、RAII、智能指针、拷贝与移动语义、虚函数与虚表、并发与锁等先修内容,已在《百度后端 J100737 八股复习手册》(C++八股.md)和米哈游游戏客户端工具开发一面中完整讲解,本文不重复;每题只在需要时用一两句话衔接,并注明出处。重复出现的知识点仍遵循“只在学习资料里维护一次”,复盘时把没答上的题补进对应章节。
代码约定:标注“可编译运行”的示例都在本机 g++(MinGW-W64 15.2.0)下用 g++ -std=c++17 -Wall -Wextra -pedantic 编译并运行过,运行输出附在示例之后;标注“示意片段”的代码只用于说明问题,不要直接编译(例如演示未定义行为时)。凡是“本机常见为 X”的数字(对象字节数等)都是实现结果,不是标准保证;标准保证的部分会明确写出。
初始化与对象模型
直接初始化、拷贝初始化和列表初始化有什么区别?
一句话回答:三种写法决定“用哪个构造函数、允不允许隐式转换”:
{}列表初始化禁止窄化转换,并且会先在可行的initializer_list构造函数中选重载——所以vector<int> v(3, 7)和vector<int> v{3, 7}的含义完全不同。
直接初始化 T x(args) 直接用参数匹配构造函数;拷贝初始化 T x = expr 要求 expr 能隐式转换到 T(不选中 explicit 构造函数);列表初始化 T x{...} 统一了各种场景:聚合类型按成员声明顺序初始化;类类型先在 initializer_list 构造函数中做重载决议,没有可行的才考虑普通构造函数。若选中列表构造函数但元素窄化,程序报错,不会退回普通构造函数;列表初始化还禁止窄化转换(如 double 到 int、字面量越界)。
C++17 起,T x = T(args)、return T(args); 这类“用纯右值初始化对象”的写法不再要求拷贝或移动构造函数可见:对象直接在目标位置构造(保证的拷贝消除,见“函数返回对象会拷贝几次”一题)。
// q01:直接初始化 / 拷贝初始化 / 列表初始化的区别
// 验证:g++ -std=c++17 -Wall -Wextra -pedantic q01_init.cpp
#include <iostream>
#include <string>
#include <vector>
struct Frame {
int width;
int height;
};
int main() {
std::vector<int> direct(3, 7); // 直接初始化:3 个元素,值都是 7
std::vector<int> listed{3, 7}; // 列表初始化:2 个元素 3 和 7(initializer_list 优先)
Frame f{1920, 1080}; // 聚合初始化,成员按声明顺序赋值
std::cout << "direct.size=" << direct.size() << "\n";
std::cout << "listed.size=" << listed.size() << "\n";
std::cout << "frame=" << f.width << "x" << f.height << "\n";
std::string text = {"hello"}; // 拷贝初始化 + 列表初始化写法也合法
std::cout << "text=" << text << "\n";
return 0;
}direct.size=3
listed.size=2
frame=1920x1080
text=hello易错点
- 窄化转换在列表初始化里是编译错误而不是警告:
int pixels{2.5};实测报错narrowing conversion of '2.5e+0' from 'double' to 'int'。 T x();不是定义对象,而是声明一个返回 T 的函数(most vexing parse);要默认构造就写T x;或T x{};。initializer_list仅在参数匹配、可行时优先于普通构造函数;选中后如发生窄化是报错而非回退。vector<int>{3, 7}要的是两个元素,想调用计数/填充值构造函数应写()。- 聚合初始化的成员顺序由声明顺序决定,和初始化列表里的书写顺序无关(见“类成员的构造、析构顺序”一题)。
通用原则:默认用 {} 表达“我知道列表里放的是什么”,用 () 表达“按这些参数构造对象”;让编译器把窄化转换拦在编译期。
explicit 解决什么问题,什么时候必须写?
一句话回答:
explicit让构造函数或转换运算符不参与隐式转换:调用点想转换就必须把转换写出来(直接构造或static_cast),避免“一眼看不出创建了临时对象”的调用。
非 explicit 的单参数构造函数定义了一条隐式转换路径,函数调用、赋值、比较、算术运算里都可能悄悄构造临时对象;explicit 关掉这条路径。C++17 里 explicit 不能像 C++20 那样写条件(explicit(bool)),只能整条开关。
explicit operator bool 是常见特例:if、while、&&、|| 的上下文转换仍然允许,但 bool b = obj; 不允许——这正是“可检查但不易误用”的布尔接口写法。
// q02:explicit 阻止隐式转换
// 验证:g++ -std=c++17 -Wall -Wextra -pedantic q02_explicit.cpp
#include <iostream>
class FrameBudget {
public:
explicit FrameBudget(int ms) : ms_(ms) {}
int ms() const { return ms_; }
private:
int ms_;
};
void setBudget(FrameBudget budget) {
std::cout << "budget=" << budget.ms() << "ms\n";
}
class Handle {
public:
explicit Handle(bool valid) : valid_(valid) {}
explicit operator bool() const { return valid_; } // 显式布尔转换
private:
bool valid_;
};
int main() {
setBudget(FrameBudget(16)); // 显式构造:OK
// setBudget(16); // 错误:explicit 禁止 int -> FrameBudget 隐式转换
Handle handle(true);
if (handle) { // 上下文转换(if 条件):允许调用 explicit operator bool
std::cout << "handle valid\n";
}
// bool b = handle; // 错误:拷贝初始化不允许 explicit operator bool
return 0;
}budget=16ms
handle validsetBudget(16); // 编译错误
error: could not convert '16' from 'int' to 'FrameBudget'
(explicit 禁止隐式转换,必须写 setBudget(FrameBudget(16)))易错点
explicit不只用于单参数构造函数:多个参数也能经{a, b}作拷贝列表初始化;explicit会阻止T x = {a, b},但允许T x{a, b}。- 拷贝初始化
T x = expr不会调用explicit构造函数;同类型拷贝也需要可用的拷贝构造函数,若把拷贝构造本身声明为explicit,T b = a同样不成立(T b(a)仍可)。 - 自定义类型的
+、==、排序比较等很容易因为少写explicit而接受意外实参,出现“看起来能编译”的静默转换。
通用原则:单参数构造函数默认写 explicit;只有语义上确实允许隐式转换的类型(如 std::string 接受字符串字面量)才故意不加。
类成员的构造、析构顺序由什么决定?为什么不能照初始化列表的书写顺序理解?
一句话回答:构造顺序是:虚基类(按继承图深度优先、从左到右)→ 非虚直接基类(按基类声明顺序)→ 成员(按类内声明顺序)→ 构造函数体;正常析构逆序。初始化列表只回答“怎么初始化”,不决定“谁先初始化”。
编译器按成员声明顺序生成初始化代码;初始化列表的书写顺序与声明顺序不一致时,常见编译器会给出 -Wreorder 警告,但不会改变真实顺序。析构逆序(后构造的先析构)保证成员/基类析构时,它依赖的对象仍然活着,RAII 栈语义(如 lock_guard)就建立在这条规则上。
基类先于派生类构造、派生类的构造函数体最后执行,所以基类构造期间不能依赖派生类部分(这也是构造/析构期间虚调用不会表现完整多态的原因,米哈游一面已详细讲过)。
// q03:构造 / 析构顺序由声明顺序决定,而不是初始化列表的书写顺序
// 验证:g++ -std=c++17 -Wall -Wextra -pedantic q03_order.cpp
// 注意:本示例故意让初始化列表顺序与声明顺序相反,g++ -Wall 会给出 -Wreorder 警告,
// 警告本身就是这个知识点的现场证据。
#include <iostream>
#include <string>
struct Tracer {
std::string tag;
explicit Tracer(std::string t) : tag(std::move(t)) {
std::cout << "ctor " << tag << "\n";
}
~Tracer() {
std::cout << "dtor " << tag << "\n";
}
};
struct Widget {
Tracer a{"member a (declared first)"};
Tracer b{"member b (declared second)"};
Widget() : b("init-list: b written first"), a("init-list: a written second") {
std::cout << "ctor body of Widget\n";
}
};
int main() {
{
Widget w;
std::cout << "-- leaving scope --\n";
}
std::cout << "-- end of main --\n";
return 0;
}q03_order.cpp:20:12: warning: 'Widget::b' will be initialized after [-Wreorder]
q03_order.cpp:19:12: warning: 'Tracer Widget::a' [-Wreorder]
q03_order.cpp:22:5: warning: when initialized here [-Wreorder]
ctor init-list: a written second
ctor init-list: b written first
ctor body of Widget
-- leaving scope --
dtor init-list: b written first
dtor init-list: a written second
-- end of main --易错点
- 用一个成员去初始化另一个成员:如果前者的声明顺序在后,实际会先读到未初始化值(未定义行为);有依赖关系的成员把被依赖者声明在前。
- 引用成员、
const成员或没有默认构造函数的成员通常要显式初始化;但若已有可用的类内默认成员初始化器(T member{...};),构造函数不必重复写进初始化列表。 - 初始化列表没写的成员,若有类内默认成员初始化器就使用它;否则按默认初始化规则处理(无初始化器的内置类型成员可能是不确定值)。
通用原则:“成员按声明顺序初始化”是语言保证;书写初始化列表时保持与声明顺序一致,让代码顺序和真实执行顺序重合。
编译器会隐式生成哪些特殊成员函数?Rule of Zero / Five 怎么落地?
一句话回答:无用户声明的构造函数时可隐式声明默认构造;其余特殊成员各有生成/删除条件。用户声明析构或拷贝操作会阻止隐式移动;声明任一移动操作则隐式拷贝操作被定义为删除。优先 Rule of Zero;必须自行管理资源时按 Rule of Five 审视五种操作(可用
= delete明确禁止)。
隐式生成规则按“你声明了什么”分档(下表):无任何用户声明构造函数时才生成默认构造;用户声明析构、拷贝构造或拷贝赋值中的任意一个,移动构造/移动赋值都不再隐式生成;一旦用户声明了移动操作,拷贝构造/拷贝赋值被定义为 deleted。表中“deprecated”指标准仍允许生成、但明确不推荐依赖。
| 你在类里声明了 | 隐式拷贝构造 / 拷贝赋值 | 隐式移动构造 / 移动赋值 |
|---|---|---|
| 什么都不写 | 通常声明;成员不可拷贝则可能被删除 | 通常声明;成员不支持移动构造时也可能退回拷贝或被删除 |
| 析构函数 | 仍生成(deprecated) | 不生成 |
| 拷贝构造或拷贝赋值 | 另一个仍生成(deprecated) | 不生成 |
| 移动构造或移动赋值 | 定义为 deleted | 另一个不生成 |
// q04:特殊成员函数的隐式生成规则与 Rule of Zero
// 验证:g++ -std=c++17 -Wall -Wextra -pedantic q04_special_members.cpp
#include <cstddef>
#include <iostream>
#include <memory>
#include <string>
#include <type_traits>
#include <utility>
#include <vector>
// 用户声明了析构函数 -> 移动构造 / 移动赋值不再隐式生成;
// 成员 unique_ptr 又删除了拷贝构造 -> 这个类完全不可移动、不可拷贝。
struct Owned {
std::unique_ptr<int[]> data;
explicit Owned(std::size_t n) : data(new int[n]{}) {}
~Owned() {} // 只是多写了这一行
};
static_assert(!std::is_copy_constructible_v<Owned>, "拷贝构造因 unique_ptr 成员被删除");
static_assert(!std::is_move_constructible_v<Owned>, "移动构造因用户声明析构而不再生成");
// Rule of Zero:特殊成员全部交给成员自己管理(vector 会正确拷贝 / 移动)。
struct Managed {
std::vector<int> data;
};
static_assert(std::is_copy_constructible_v<Managed>);
static_assert(std::is_move_constructible_v<Managed>);
static_assert(std::is_nothrow_move_constructible_v<Managed>, "容器扩容时才会首选移动");
int main() {
Managed m1{{1, 2, 3}};
Managed m2 = std::move(m1); // 隐式移动构造:O(1) 接管缓冲区
std::cout << "m2.size=" << m2.data.size() << " m1.size=" << m1.data.size() << "\n";
return 0;
}m2.size=3 m1.size=0易错点
- 为了“顺手清理”写了一个空析构,结果移动能力被删除:有
unique_ptr成员的类会直接无法移动或拷贝(示例里的static_assert就是这种现场)。 - 移动后的源对象仍会被析构:标准库类型通常处于有效但未指定状态;自定义类型的移动构造/赋值必须维持自身不变量,可明确约定移动后的状态,不要假设所有类型都为空。
= default写在类内是内联的默认实现;= delete比“只声明不定义”更清楚地表达禁止。noexcept移动有利于容器扩容时保持异常保证,但不保证一定选择移动(米哈游一面已讲)。
与已有资料的关系:RAII、拷贝语义与移动语义的基础在《百度后端 J100737 八股复习手册》第 4、7 题和米哈游一面“移动构造和拷贝构造写法上有什么区别”里已完整,这里只补“隐式生成 / 删除规则”本身。
通用原则:先尝试 Rule of Zero:用标准容器和智能指针做成员,特殊成员交还给成员自己管理;必须自己管资源时,五个特殊成员成套写全。
sizeof 一个对象为什么可能比成员加起来大?对齐、填充和空基类优化是怎么回事?
一句话回答:每个成员必须放在满足自身对齐要求的地址上,编译器会在成员之间和对象尾部插入填充字节;含虚函数的类型常见实现会多一个虚表指针;空类大小至少 1 字节,但作为基类子对象时可以被优化掉(空基类优化,语言允许但不强制)。
alignof(T) 给出类型的对齐要求:通常由对齐最严格的成员决定,也可能因 alignas 或基类而更大;sizeof(T) 是其对齐值的整数倍,以保证数组中每个元素的对齐。成员声明顺序固定,但具体偏移和填充遵循布局规则与实现 ABI;例如本机 char 后接 int 时通常补 3 字节。
C++17 对同一访问控制区段内的非静态成员规定地址顺序;标准布局类型可合法使用 offsetof 查询成员偏移,但按字节复制并恢复对象值需要的是“可平凡复制”(std::is_trivially_copyable_v<T>),不是仅有标准布局。具体偏移和字节数由实现 ABI 决定;虚表指针是常见实现,空基类优化在满足条件时适用,不要把实验数字当 ABI 契约。
// q05:对象大小 = 成员 + 填充 + 对齐要求(外加常见实现里的虚表指针)
// 验证:g++ -std=c++17 -Wall -Wextra -pedantic q05_layout.cpp
#include <cstddef>
#include <iostream>
struct A { char c; int i; char c2; }; // 1 + 3(填充) + 4 + 1 + 3(尾部填充) = 12(本机常见)
struct B { int i; char c; char c2; }; // 4 + 1 + 1 + 2(尾部填充) = 8(本机常见)
struct Empty {}; // 标准保证:大小至少 1
struct EBO : Empty { int i; }; // 空基类优化(允许,不强制)
struct Virtual {
virtual ~Virtual() = default;
int i = 0; // 常见实现:多一个虚表指针
};
static_assert(sizeof(Empty) == 1, "空类必须有非零大小,保证不同对象地址不同");
int main() {
std::cout << "A=" << sizeof(A) << " B=" << sizeof(B)
<< " Empty=" << sizeof(Empty) << " EBO=" << sizeof(EBO)
<< " Virtual=" << sizeof(Virtual) << "\n";
std::cout << "alignof(A)=" << alignof(A)
<< " offsetof(A,i)=" << offsetof(A, i)
<< " offsetof(A,c2)=" << offsetof(A, c2) << "\n";
return 0;
}A=12 B=8 Empty=1 EBO=4 Virtual=16
alignof(A)=4 offsetof(A,i)=4 offsetof(A,c2)=8易错点
- 不要把结构体内存直接 dump 出去当二进制协议或文件格式:填充字节和字节序是实现相关的;序列化要显式写字段宽度与字节序。
#pragma pack是编译器扩展,可能改变布局并导致未对齐访问;alignas(1)不能降低类型本来的对齐要求,不可拿它代替序列化设计。- 把手写结构体跨模块/跨语言传(C ABI、插件接口)时,字段顺序和填充必须在接口文档里固定,或用固定宽度类型逐字段编解码。
通用原则:大小由对齐要求和实现 ABI 共同决定;凡是要跨边界传递的字节布局,显式定义,不依赖编译器默认。
函数返回对象会拷贝几次?RVO / NRVO 哪些是标准保证?
一句话回答:C++17 起,“用纯右值初始化对象”(如
return Traced("x");、T x = T(args);)保证不产生拷贝或移动;而return local;的 NRVO 只是允许的优化,标准不保证;不发生时通常尝试移动,也可能退回拷贝(取决于可用构造函数)。
保证的拷贝消除(C++17)把纯右值表达式当作“直接在目标对象上构造”,连拷贝/移动构造函数都不再要求可见;它适用于返回纯右值、用纯右值初始化对象、按值传递纯右值实参等场景。
NRVO(命名返回值优化)属于“允许的优化”:编译器可以把具名局部对象直接构造在调用方的返回槽里,也可以不优化(例如多个 return 分支返回不同局部对象时)。不优化时,返回局部对象会先按右值参与重载决议:若移动构造可用则移动,否则可能拷贝;若移动重载被显式删除,也可能直接编译失败。
// q06:返回值优化(RVO / NRVO)——哪些是标准保证,哪些只是常见实现
// 验证:
// g++ -std=c++17 -Wall -Wextra -pedantic q06_rvo.cpp (默认)
// g++ -std=c++17 -Wall -Wextra -pedantic -fno-elide-constructors q06_rvo.cpp
#include <iostream>
#include <string>
struct Traced {
std::string name;
explicit Traced(std::string n) : name(std::move(n)) {
std::cout << "ctor " << name << "\n";
}
Traced(const Traced& other) : name(other.name) {
std::cout << "copy-ctor " << name << "\n";
}
Traced(Traced&& other) noexcept : name(std::move(other.name)) {
std::cout << "move-ctor " << name << "\n";
}
~Traced() {}
};
Traced makeDirect() {
return Traced("prvalue"); // C++17 保证:直接在调用方目标对象上构造
}
Traced makeLocal() {
Traced local("local-name");
return local; // NRVO 是可选优化,标准不保证
}
int main() {
std::cout << "-- makeDirect --\n";
Traced a = makeDirect();
std::cout << "-- makeLocal --\n";
Traced b = makeLocal();
std::cout << "a=" << a.name << " b=" << b.name << "\n";
return 0;
}--- 默认编译 ---
-- makeDirect --
ctor prvalue
-- makeLocal --
ctor local-name
a=prvalue b=local-name
--- 加 -fno-elide-constructors ---
-- makeDirect --
ctor prvalue
-- makeLocal --
ctor local-name
move-ctor local-name
a=prvalue b=local-name易错点
return std::move(local);会阻止本来可能进行的 NRVO(不是阻止 C++17 保证的纯右值拷贝消除),通常迫使发生一次移动;返回局部对象直接return local;即可。回答“C++17 一定不拷贝”必须限定在纯右值场景;return local;是否零开销取决于实现(实验里-fno-elide-constructors后多了一次移动)。- 移动构造不是
noexcept时,容器为了异常安全可能退回拷贝;这一点米哈游一面已展开。
通用原则:返回局部对象就 return obj;;把“零拷贝”的确定性交给标准规则,而不是依赖编译器优化。
类型系统与代码组织
enum class 和普通 enum 有什么区别?为什么不建议再用普通 enum?
一句话回答:
enum class把枚举值圈在类型作用域内(要写Direction::North),并且不隐式转换成 int;普通 enum 的枚举值进入外围作用域,通常还能隐式转整数——两处都容易出静默错误。
普通 enum 的枚举值可隐式转换为整数参与运算,同一作用域内不同普通 enum 的同名值会冲突;不过重载仍可区分两个不同的枚举类型。enum class 是独立类型、参与重载决议,转整数必须显式 static_cast。
两者都可以指定底层类型(enum class Direction : std::uint8_t);固定底层类型后可以前向声明枚举、把大小写进接口契约。示例里 Direction 和 Result 有同名的 North,因为都在各自作用域内所以不冲突。
// q07:enum class(有作用域枚举)与普通 enum 的区别
// 验证:g++ -std=c++17 -Wall -Wextra -pedantic q07_enum_class.cpp
#include <cstdint>
#include <iostream>
enum class Direction : std::uint8_t { North, South, East, West };
enum class Result : std::uint8_t { North, South }; // 枚举值同名,不冲突
const char* toString(Direction d) {
switch (d) {
case Direction::North: return "N";
case Direction::South: return "S";
case Direction::East: return "E";
case Direction::West: return "W";
}
return "?";
}
int main() {
Direction d = Direction::North;
// int v = d; // 错误:enum class 不隐式转 int
int v = static_cast<int>(d); // 需要显式转换
std::cout << "direction=" << toString(d)
<< " value=" << v
<< " sizeof=" << sizeof(Direction) << "\n";
return 0;
}direction=N value=0 sizeof=1Direction d = Direction::North;
int v = d; // 编译错误
error: cannot convert 'Direction' to 'int' in initialization
(enum class 不隐式转 int,必须显式转换)易错点
enum class没有隐式转换,也不能直接做位运算;要当标志位就自己定义|、&运算符,或转到底层类型再运算。- 需要和 C 接口、旧代码交互的地方仍然会用普通 enum(甚至不指定底层类型),此时要确认它的实际宽度和取值约定。
- 打印枚举值要转底层类型或写
switch映射(示例的toString),不要假设枚举一定能直接输出。
通用原则:新代码默认 enum class;枚举一旦参与接口或序列化,同时固定底层类型,把大小和取值范围写清楚。
const 成员函数承诺了什么?为什么要写 const 重载和 mutable?
一句话回答:
const成员函数在类型层面禁止通过this修改非 mutable 成员(逻辑上应保持对象可观察状态),因此 const 对象或 const 引用只能调用 const 成员函数;同一接口通常写 const 与 non-const 两个版本,由编译器按对象的常量性自动选择。
const 成员函数里的 this 相当于“指向 const 的指针”,成员默认只读;但 const 是浅层的——指针或引用成员仍然可以修改它们指向的内容,所以“逻辑常量性”要靠设计保证,而不是靠成员变量上加 const。
mutable 允许 const 成员函数直接修改该成员,常见用途是缓存、访问计数、调试日志;它不使逻辑 const 自动成立。返回引用时,const 要传导到返回类型:const 版本返回 const T&,non-const 版本返回 T&。
常见实现里 non-const 版本会复用 const 版本:先用 static_cast<const T&>(*this) 调用 const 实现,再用 const_cast 去掉返回值的 const;这是少数被广泛接受的 const_cast 用法。
// q08:const 正确性——const 成员函数、const 重载、mutable
// 验证:g++ -std=c++17 -Wall -Wextra -pedantic q08_const.cpp
#include <cstddef>
#include <iostream>
#include <vector>
class Samples {
public:
void add(int v) { data_.push_back(v); }
int& at(std::size_t i) { return data_.at(i); } // 可写视图
const int& at(std::size_t i) const { return data_.at(i); } // 只读视图(按 const 重载选择)
int peek(std::size_t i) const { ++reads_; return data_.at(i); } // mutable:只读调用也允许记账
std::size_t reads() const { return reads_; }
private:
std::vector<int> data_;
mutable std::size_t reads_ = 0;
};
void audit(const Samples& s) {
std::cout << "peek=" << s.peek(0) << " reads=" << s.reads() << "\n";
// s.at(0) = 99; // 错误:const 对象只会调用 const 重载,返回 const int&,不可写
}
int main() {
Samples s;
s.add(41);
s.at(0) = 42; // 非 const 对象:拿到 int&,可写
std::cout << "value=" << s.at(0) << "\n";
audit(s); // 常量引用:只能读
return 0;
}value=42
peek=42 reads=1void audit(const Samples& s) { s.at(0) = 99; } // 编译错误
error: assignment of read-only location '(& s)->Samples::at(0)'
(const 对象只命中 const 重载,返回 const int&,不可写)易错点
- const 对象不能调用非 const 成员函数,这是编译期错误,不要用
const_cast绕过。 mutable不是免死金牌:多线程共享缓存时同样要同步;只有“逻辑上不可变”的接口才值得缓存。- 按值参数的顶层 const 会被忽略:
void f(const int)与void f(int)是同一个函数签名。
通用原则:接口默认 const:能只读就标 const,让编译器强制“读写权限”分离;用 const/non-const 重载把“视图”和“可写视图”分开。
重载决议是怎么选到具体版本的?为什么新增一个重载可能改坏老调用?
一句话回答:编译器按转换代价排序:精确匹配优于提升(char→int、float→double),提升优于普通转换(int→long)和用户定义转换;能选出唯一最优就调用,否则歧义报错。新增重载会改变转换排序,所以可能让老调用改选别的版本或直接歧义。
决议分步:先按名字查找收集候选(包括 ADL 从实参所在命名空间引入的函数),再按参数个数和可转换性筛掉不可行项,最后逐个比较每个实参到形参的转换等级。多个参数时,“一个参数更好、另一个更差”不能组合成更好——直接歧义。
转换序列同样好等条件下,非模板函数通常优先;0、NULL、nullptr 这类实参在指针/整数/布尔重载间的选择是最经典的踩坑点。示例里 unsigned 到 int、long、double 都是普通转换(级别相同),所以 f(1u) 实测直接歧义。
// q09:重载决议——编译器按转换代价选择唯一最优函数
// 验证:g++ -std=c++17 -Wall -Wextra -pedantic q09_overload.cpp
#include <iostream>
void f(int) { std::cout << "int\n"; }
void f(long) { std::cout << "long\n"; }
void f(double) { std::cout << "double\n"; }
int main() {
f(1); // 精确匹配:int
f(1L); // 精确匹配:long
f(1.0f); // float -> double 是“提升”,优于其他转换
f('a'); // char -> int 是“提升”
// f(1u); // unsigned -> int / long / double 都是普通转换,级别相同 -> 歧义错误
return 0;
}int
long
double
intf(1u); // 编译错误
error: call of overloaded 'f(unsigned int)' is ambiguous
(unsigned 到 int / long / double 都是普通转换,级别相同)易错点
- 隐式转换链会让“本该走 A 重载”的调用悄悄进 B 重载,尤其是用户定义转换和继承层次之间的转换。
- 默认参数会改变重载的可行性;重载数量一多,人脑很难预测决议结果,编译器的歧义信息才是准的。
- 想避免歧义:用不同的函数名或强类型表达不同语义(enum class、
count包装、字面量后缀)。
与已有资料的关系:网易互娱面经 1 已讲“重写和重载的区别”,这里补的是决议过程本身(谁被选中、为什么歧义)。
通用原则:同一个实参类型在重载集合里最好只有一条明确路径;接口稳定后新增重载,先确认不会抢走既有调用的精确匹配。
四种命名转换各自适用什么场景?C 风格转换为什么要避免?
一句话回答:
static_cast表达编译期已知的相关类型转换;dynamic_cast在运行期沿继承关系检查(需要多态类型,失败对指针返回空、对引用抛bad_cast);const_cast只增删 const/volatile;reinterpret_cast按位重新解释、几乎不做检查——C 风格转换会把几种混在一起、掩盖意图。
static_cast:上行转换(派生→基类)安全;下行转换(基类→派生)不检查真实类型,错了就是未定义行为;也用于数值转换、void* 与对象指针之间。
dynamic_cast:依赖运行期类型信息(RTTI),基类必须至少有一个虚函数;跨模块、游戏热路径通常改用虚函数/访问者避免运行时类型判断(这是常见做法而不是标准要求)。const_cast:只改变访问路径上的 const/volatile,去掉 const 后写一个本来就 const 的对象是未定义行为。reinterpret_cast:指针与整数互转、与硬件/协议边界打交道时使用,结果高度依赖平台约定。
// q10:四种命名转换的语义与风险
// 验证:g++ -std=c++17 -Wall -Wextra -pedantic q10_casts.cpp
#include <cstddef>
#include <cstdint>
#include <iostream>
#include <string>
struct Component {
virtual ~Component() = default;
};
struct Mesh : Component {
explicit Mesh(int id) : id(id) {}
void draw() const { std::cout << "draw mesh " << id << "\n"; }
int id = 0;
};
struct Light : Component {
void shine() const { std::cout << "shine\n"; }
};
// 假想的老接口:签名是 char*,其实只读
std::size_t legacyLength(char* text) {
std::size_t n = 0;
while (text[n] != '\0') ++n;
return n;
}
int main() {
Mesh mesh(7);
Light light;
// 1) static_cast:编译期已知的关系(上行转换、数值转换)
Component* up = static_cast<Component*>(&mesh); // 派生 -> 基类:安全
Mesh* down = static_cast<Mesh*>(up); // 基类 -> 派生:正确性由你保证
down->draw();
// 2) dynamic_cast:运行期检查的多态下行转换,失败返回 nullptr
Component* list[2] = {&mesh, &light};
for (Component* c : list) {
if (auto* m = dynamic_cast<Mesh*>(c)) {
m->draw();
} else {
std::cout << "not a mesh\n";
}
}
// 3) const_cast:只用于“接口声明缺少 const”的只读透传,写 const 对象是未定义行为
std::string tag = "v1.2.3";
std::cout << "legacy len=" << legacyLength(const_cast<char*>(tag.c_str())) << "\n";
// 4) reinterpret_cast:按位重新解释,仅在明确平台约定的边界使用
int value = 0x0102;
auto address = reinterpret_cast<std::uintptr_t>(&value);
int* back = reinterpret_cast<int*>(address); // 同一次运行内往返,前提是类型与对齐未变
std::cout << "round trip=" << *back << "\n";
return 0;
}draw mesh 7
draw mesh 7
not a mesh
legacy len=6
round trip=258易错点
static_cast下行转换“编译通过 ≠ 类型正确”;不能用它替代类型检查。dynamic_cast只能用于多态类型;转换失败对引用抛std::bad_cast(需要捕获),对指针返回nullptr。- 去掉 const 后修改原本就是 const 的对象才是未定义行为;若原对象并非 const、只是经 const 引用访问,
const_cast后写入在其他条件满足时可以合法。reinterpret_cast后按错误类型访问有对齐与别名风险。 - C 风格转换
(T)x会依次尝试 const_cast / static_cast / reinterpret_cast,阅读代码时看不出作者依据的是哪种假设。
通用原则:每次转换只表达一个假设,让代码自解释;能用虚函数或标准替代方案,就不写运行时类型判断链。
对象切片是什么?为什么多态对象不能按值传递或放进容器?
一句话回答:把派生类对象按值放进基类对象里(传参、赋值、存进
vector<Base>)只会复制基类子对象,派生数据和动态类型一起被“切掉”,之后虚调用走的都是基类版本。
按值传递会执行基类的拷贝构造(经由派生类到基类的转换),派生部分不被复制;副本的静态类型和动态类型都是基类,虚表指针指向基类。要保留动态类型必须用引用或指针;需要“按基类接口克隆对象”时写虚 clone(),因为语言里没有虚拷贝构造。
抽象基类不能被实例化,vector<AbstractBase>、按值传参都会直接编译失败——这反而能在编译期拦住切片。
// q11:对象切片——多态对象按值传递会丢掉派生部分
// 验证:g++ -std=c++17 -Wall -Wextra -pedantic q11_slicing.cpp
#include <iostream>
#include <string>
#include <vector>
struct Base {
virtual ~Base() = default;
virtual std::string name() const { return "Base"; }
};
struct Derived : Base {
std::string name() const override { return "Derived"; }
std::string extra = "extra-data"; // 切片后不会被复制
};
void printByValue(Base b) { // 参数按值:只复制 Base 子对象
std::cout << "by value: " << b.name() << "\n";
}
void printByRef(const Base& b) { // 参数按引用:动态类型保留
std::cout << "by reference: " << b.name() << "\n";
}
int main() {
Derived d;
printByValue(d); // 切片:打印 Base
printByRef(d); // 无切片:打印 Derived
std::vector<Base> items;
items.push_back(d); // 同样是切片
std::cout << "in container: " << items[0].name()
<< " sizeof(Base)=" << sizeof(Base)
<< " sizeof(Derived)=" << sizeof(Derived) << "\n";
return 0;
}by value: Base
by reference: Derived
in container: Base sizeof(Base)=8 sizeof(Derived)=40易错点
- 异常捕获
catch (Base e)同样切片(还会多一次拷贝),应写catch (const Base&)。 vector<Base>里 push 派生对象不会报错(基类可拷贝时),错误只会在运行时以“多态失效”的形式暴露。- 复制派生对象时如果基类拷贝构造可用,切片是静默发生的;设计多态类型时可考虑把基类拷贝构造设为 protected 或删除。
通用原则:多态对象只通过引用或指针传递与存储;“值语义”和“运行时多态”不要混在同一个容器里。
头文件里为什么不能直接定义普通函数或全局变量?inline 解决什么问题?
一句话回答:每个
#include都是一次文本复制;通常具有外部链接的非 inline 函数和变量在程序中只能有一个定义(ODR),直接写在被多个 .cpp 包含的头文件里就会重复定义。头文件里允许的定义是:类定义、inline函数、模板、inline变量(C++17)。
编译以翻译单元为单位、链接器负责合并。inline 的含义是“允许在多个翻译单元出现完全相同的定义,链接器合并成一个实体”;类内定义的成员函数隐含 inline。模板和类定义同样允许出现在多个翻译单元,但须满足 ODR 的相同 token 序列与名称查找等要求;定义不一致通常无需诊断。
C++17 之前,头文件里的 static 变量或匿名命名空间对象表达的是“每个翻译单元各一份副本”(内部链接);C++17 的 inline 变量给的是全程序唯一实体——示例用 inline constexpr int kMaxRetry 把常量暴露给所有翻译单元。
// q12:ODR 与头文件组织(多翻译单元示例,共 3 个文件)
// 验证:
// g++ -std=c++17 -Wall -Wextra -pedantic odr_main.cpp odr_utils.cpp -o odr.exe
#pragma once
#include <string>
std::string libraryVersion(); // 普通函数:头文件里只放声明
inline int scale(int value) { // inline 函数:多个 TU 各自包含也可被链接器合并
return value * 2;
}
inline constexpr int kMaxRetry = 3; // C++17 inline 变量:全程序唯一实体// q12:ODR 多翻译单元示例(实现文件)
// 验证:g++ -std=c++17 -Wall -Wextra -pedantic odr_main.cpp odr_utils.cpp -o odr.exe
#include "odr_utils.h"
std::string libraryVersion() {
return "1.0";
}// q12:ODR 多翻译单元示例(主文件)
// 验证:g++ -std=c++17 -Wall -Wextra -pedantic odr_main.cpp odr_utils.cpp -o odr.exe
#include "odr_utils.h"
#include <iostream>
int main() {
std::cout << "version=" << libraryVersion()
<< " scale=" << scale(kMaxRetry)
<< " maxRetry=" << kMaxRetry << "\n";
return 0;
}version=1.0 scale=6 maxRetry=3// 负例:头文件里定义非 inline 全局变量
// 该头文件被两个 .cpp 分别包含,链接时出现重复定义
#pragma once
int counter = 0; // 非 inline 变量:每个包含它的翻译单元各产生一个定义
int bump() {
return ++counter;
}ld.exe: odr_bad_b.cpp:(.text+0x0): multiple definition of `bump()'; odr_bad_a.cpp:(.text+0x0): first defined here
ld.exe: odr_bad_b.cpp:(.bss+0x0): multiple definition of `counter'; odr_bad_a.cpp:(.bss+0x0): first defined here易错点
#pragma once或 include guard 只处理同一个翻译单元内的重复包含,不解决跨翻译单元的重复定义。- 头文件里的
static/ 匿名命名空间变量是“每个 TU 各一份副本”:修改互不可见,多份副本还各自占内存。 - 模板或类在多个 TU 中定义不完全一致是未定义行为,链接器通常不报错——统一通过同一个头文件引用,不要手工复制类定义。
通用原则:头文件写接口和“允许合并的定义”(类、inline、模板),非 inline 实现放 .cpp;需要全程序唯一且头文件可用的常量用 inline 变量。
模板与编译期编程
为什么模板的定义通常要写在头文件里?显式实例化有什么用?
一句话回答:模板只是“生成代码的配方”,用具体类型使用时才实例化;编译器必须在使用点看到定义才能生成代码,所以定义放头文件,或者在一处显式实例化、其他地方用
extern template抑制重复生成。
隐式实例化:每个用到 Box<int> 且需要隐式实例化的翻译单元可以生成一份实例代码;ODR 允许合规的多翻译单元实例化,通常由链接器合并。代价是编译时间与目标文件膨胀都由每个 TU 分摊。
显式实例化 template class Box<int>; 在唯一的 .cpp 里生成代码;其他 TU 看到 extern template class Box<int>; 通常抑制该特化的隐式实例化,只保留引用。函数模板的显式实例化同理:template int max3<int>(int, int, int);。定义不可见时,错误在链接期才以 undefined reference 的形式出现。
// q13:模板实例化(多翻译单元示例,共 3 个文件)
// 验证:g++ -std=c++17 -Wall -Wextra -pedantic tpl_main.cpp tpl_box.cpp -o tpl.exe
#pragma once
#include <cstddef>
#include <string>
template <typename T>
class Box {
public:
void set(const T& value) { value_ = value; }
const T& get() const { return value_; }
private:
T value_{};
};
extern template class Box<int>; // 声明:Box<int> 会在别的翻译单元里实例化// q13:模板实例化(显式实例化文件)
// 验证:g++ -std=c++17 -Wall -Wextra -pedantic tpl_main.cpp tpl_box.cpp -o tpl.exe
#include "tpl_box.h"
template class Box<int>; // 显式实例化:全程序只在这里生成 Box<int> 的代码// q13:模板实例化(使用方)
// 验证:g++ -std=c++17 -Wall -Wextra -pedantic tpl_main.cpp tpl_box.cpp -o tpl.exe
#include "tpl_box.h"
#include <iostream>
int main() {
Box<int> count; // 链接到 tpl_box.cpp 里显式实例化的版本
count.set(7);
std::cout << "int box=" << count.get() << "\n";
Box<std::string> name; // 只有这里用到,本翻译单元隐式实例化
name.set("implicit");
std::cout << "string box=" << name.get() << "\n";
return 0;
}int box=7
string box=implicit// 负例:模板只有声明可见、定义在被调用的翻译单元之外
// 预期:链接失败(undefined reference)
#pragma once
template <typename T>
T quadruple(T value); // 只有声明// 负例:模板定义放在 .cpp 里(主文件看不到定义)
#include "tpl_bad.h"
template <typename T>
T quadruple(T value) {
return value * 4;
}ld.exe: tpl_bad_main.c:(.text+0x13): undefined reference to `int quadruple<int>(int)'
collect2.exe: error: ld returned 1 exit status易错点
- “编译通过、链接失败 undefined reference”最常见的根因就是模板定义不可见,而不是缺头文件或拼错名字。
- 显式实例化只覆盖你列出的类型;新增类型若在使用点能看到定义,可以隐式实例化;若定义只在 .cpp 中,则还要补相应显式实例化,否则可能链接失败。少写
extern template通常只是多份实例化开销。 - 模板报错通常带着实例化栈(
required from here),读它比读第一条错误更能定位真正的类型不匹配。 - 两阶段查找:非依赖名字在定义点查找;依赖的非限定调用还可通过实例化点的 ADL 查找关联命名空间中的候选,但并非后来声明的任意函数都能被找到。
通用原则:模板的正确性依赖“使用点可见”;想要编译防火墙和更小的目标文件,就把实例化集中在一处并用 extern template 分发声明。
auto、decltype、decltype(auto) 的推导规则有什么不同?
一句话回答:按值声明的
auto类似模板按值推导:丢掉顶层 const 和引用、数组与函数退化成指针;decltype(表达式)保留类型和值类别——标识符取声明类型、带括号的左值取T&、纯右值取T;decltype(auto)用 decltype 规则推导返回值,用于精确转发。
auto x = expr; 拿到的是“值”(拷贝/移动),auto&、const auto& 拿到“视图”。decltype 的三种常见结果:decltype(变量名) 是声明类型(含 const、引用);decltype((表达式)) 里若是左值则加 &;纯右值则是裸类型。decltype(samples[0]) 是 int&,因为下标表达式是左值。
decltype(auto)(C++14 起)让返回值按 decltype 规则推导,适合写“返回 c[i]、*it 这类引用或代理对象”的转发函数;如果接口本意是返回值,就不要用 decltype(auto),直接写 auto 更清楚。
// q14:auto、decltype、decltype(auto) 的推导区别
// 验证:g++ -std=c++17 -Wall -Wextra -pedantic q14_auto_decltype.cpp
#include <iostream>
#include <type_traits>
#include <vector>
namespace {
std::vector<int> samples{1, 2, 3};
int x = 0;
const int cx = 5;
int& rx = x;
auto a1 = cx; // auto:丢掉顶层 const -> int
auto& a2 = cx; // auto&:保留 const -> const int&
decltype(cx) d1 = 42; // decltype(标识符):声明类型 -> const int
decltype((cx)) d2 = x; // 多一层括号:左值表达式 -> const int&
decltype(rx) d3 = x; // decltype(引用标识符) -> int&
decltype(samples[0]) d4 = samples[0]; // 下标表达式是左值 -> int&
template <typename Container, typename Index>
decltype(auto) element(Container& c, Index i) { // 用 decltype 规则推导返回类型
return c[i]; // vector::operator[] 是 int&
}
} // namespace
static_assert(std::is_same_v<decltype(a1), int>);
static_assert(std::is_same_v<decltype(a2), const int&>);
static_assert(std::is_same_v<decltype(d1), const int>);
static_assert(std::is_same_v<decltype(d2), const int&>);
static_assert(std::is_same_v<decltype(d3), int&>);
static_assert(std::is_same_v<decltype(d4), int&>);
static_assert(std::is_same_v<decltype(element(samples, 0)), int&>);
int main() {
element(samples, 0) = 100; // decltype(auto) 保留了可写引用
d4 = 200; // d4 本身就是引用
std::cout << "samples[0]=" << samples[0]
<< " x=" << x << " rx=" << rx
<< " a2=" << a2 << " d1=" << d1 << "\n";
return 0;
}samples[0]=200 x=0 rx=0 a2=5 d1=42
(element() 返回 int&,samples[0] 被改成 100 后又被 d4 改成 200)易错点
for (auto v : list)会拷贝每个元素;只读遍历用const auto&,需要修改用auto&。- C++17 中
auto x = {1, 2};推导为initializer_list<int>,而auto x{1};推导为int;规则与普通初始化不同,跨标准版本可能变化,需明确项目使用的版本。 decltype(auto)返回引用时不能返回局部变量;同时要想清楚接口语义是“值”还是“视图”,参考 string_view 一题的生命周期讨论。
通用原则:要值用 auto,要视图用 auto& / const auto&,要精确复刻表达式类型才用 decltype / decltype(auto)。
lambda 的捕获方式有哪些?按引用捕获什么时候会悬空?
一句话回答:对普通局部变量,
[=]、[x]捕获创建时刻的副本(要修改副本加mutable),[&]、[&x]按引用捕获;只要 lambda 活过被捕获变量,或者异步回调在对象销毁后执行,就会访问已死亡的对象。
闭包对象为每个捕获生成一个数据成员:按值捕获在 lambda 创建时完成复制,之后原变量变化对闭包不可见;按引用捕获保存的是别名。成员函数里 [=]、[this] 捕获的是 this 指针,对象销毁后再调用就悬空;C++17 提供 [*this],按当前类类型复制所引用的对象进闭包;注意多态基类成员函数中只会复制基类部分。
初始化捕获 [extra = expr](C++14 起)可以自定义副本;示例里 sum 按引用改、extra 按值保存创建时的值。
// q15:lambda 的捕获方式与生命周期
// 验证:g++ -std=c++17 -Wall -Wextra -pedantic q15_lambda.cpp
#include <functional>
#include <iostream>
#include <string>
struct Widget {
std::string id = "hero";
std::function<std::string()> taskCopy() {
return [*this] { return id; }; // C++17:把整个对象拷贝进闭包
}
std::function<std::string()> taskRef() {
return [this] { return id; }; // 只捕获 this 指针:对象必须先于任务存活
}
};
int main() {
int counter = 0;
auto byRef = [&counter] { ++counter; };
auto byVal = [counter]() mutable { return ++counter; }; // 捕获创建时的副本
byRef();
std::cout << "outer counter=" << counter
<< " copy returned=" << byVal() << "\n"; // 副本从 0 自增 -> 1
Widget w;
auto task = w.taskCopy();
std::cout << "task=" << task() << "\n";
int sum = 0;
auto mixed = [&sum, extra = sum + 10] { sum += extra; }; // 初始化捕获:自定义副本
mixed();
std::cout << "sum=" << sum << "\n";
return 0;
}outer counter=1 copy returned=1
task=hero
sum=10
(按值捕获的副本在创建时是 0,自增后返回 1;外部 counter 已经被按引用捕获改成 1)易错点
- 把
[&]捕获的 lambda 放进异步任务、事件队列或容器,是最常见的悬空来源;任务型回调优先按值捕获所需数据(可以[x = std::move(x)]),需要复制对象且类型/复制代价合适时可用[*this]。 mutablelambda 的operator()不是 const;赋值给std::function没问题,但不能当 const 可调用对象复用。- 引用捕获加多线程共享就是数据竞争的入口(《百度后端 J100737 八股复习手册》数据竞争与 atomic 部分已展开)。
通用原则:lambda 的生命周期长于被捕获对象时按值(或拷贝对象/数据);只有生命周期嵌套关系明确的同步代码才按引用捕获。
if constexpr 解决什么问题?它和 if、SFINAE 有什么不同?
一句话回答:
if constexpr按编译期条件丢弃一个分支:没选中的分支不实例化,于是模板里“只对某些类型成立”的代码可以共存;它不改变函数签名,所以不参与重载决议——需要“按类型选择函数”时仍然用 SFINAE /enable_if或重载。
条件必须是常量表达式;丢弃的分支不实例化(依赖模板参数的错误不会触发),但语法和非依赖名字仍要正确。典型用途:按类型分发序列化、打印、单位换算;示例里对 int 走 std::to_string,对 std::string 走拷贝构造——如果没有 if constexpr,std::string(v) 这段对 int 会直接编译失败。
与 #if 的区别:#if 在预处理期,看不到类型信息;与 tag dispatch / 重载的区别:重载在候选集合里选函数,if constexpr 在函数体内部挑代码。
// q16:if constexpr —— C++17 的编译期分支
// 验证:g++ -std=c++17 -Wall -Wextra -pedantic q16_if_constexpr.cpp
#include <iostream>
#include <string>
#include <type_traits>
template <typename T>
std::string toText(const T& value) {
if constexpr (std::is_arithmetic_v<T>) {
return std::to_string(value);
} else {
return std::string(value); // 对 int 不会实例化,否则这段本来无法编译
}
}
int main() {
std::cout << "int=" << toText(42)
<< " double=" << toText(3.5)
<< " string=" << toText(std::string("net"))
<< "\n";
return 0;
}int=42 double=3.500000 string=net
(顺带观察:std::to_string 对浮点会按 %f 补零,不适合直接面向用户显示)易错点
if constexpr未选中分支的 return 不参与当前模板实例的auto返回类型推导;不同类型实例可返回不同类型。若声明了固定返回类型,则选中分支须能转换成该类型。- 丢弃分支不是“随便写”:语法错误、非依赖名字查找错误照样报编译错误。
- 写成运行期
if会真的实例化两个分支:对不支持相应成员/构造的类型直接编译失败,这正好是区分两者的现场测试。 if constexpr是 C++17 才有的特性;C++14 只能用 SFINAE / tag dispatch 写同等逻辑。
通用原则:有类型信息可用的分支用 if constexpr;接口层面的可替换实现用重载 / SFINAE;平台与编译开关用预处理。
标准库使用与异常安全
函数指针、lambda、std::function 该怎么选?
一句话回答:函数指针指向函数、不能携带捕获状态;lambda 是可带捕获状态的闭包对象,类型独一无二,便于在编译期优化调用;
std::function通过类型擦除存储可复制的可调用对象,通常有间接调用成本,大捕获还可能涉及动态分配(具体依实现)。
无捕获 lambda 可以隐式转换成函数指针(兼容 C 回调);模板参数 F&& 保留具体类型,调用点可以直接内联;std::function 保存可复制的闭包(构造时可拷贝或移动),空对象调用抛 std::bad_function_call。
本机 sizeof(std::function<int(int)>) 为 32 字节(常见实现);小对象优化是否命中取决于库实现和闭包大小,“小捕获不分配”不是标准保证。
// q17:函数指针、lambda、std::function 的表达能力与开销
// 验证:g++ -std=c++17 -Wall -Wextra -pedantic q17_callables.cpp
#include <functional>
#include <iostream>
int twice(int value) { return value * 2; }
using Callback = std::function<int(int)>;
template <typename F>
int apply(F&& f, int value) { // 模板:编译期绑定,无类型擦除
return f(value);
}
int main() {
Callback fromFn = twice; // 函数指针装进 std::function
Callback fromLambda = [k = 3](int value) { return k * value; }; // 带捕获的闭包
int (*fnPtr)(int) = [](int value) { return value + 1; }; // 无捕获 lambda 直接变函数指针
std::cout << "fn=" << fromFn(2)
<< " lambda=" << fromLambda(2)
<< " ptr=" << fnPtr(2)
<< " templated=" << apply(twice, 2) << "\n";
Callback empty; // 默认构造:不持有任何可调用对象
try {
empty(1); // 调用空 std::function
} catch (const std::bad_function_call&) {
std::cout << "bad_function_call caught, sizeof(std::function)="
<< sizeof(Callback) << "\n"; // 本机常见为 32 字节
}
return 0;
}fn=4 lambda=6 ptr=3 templated=4
bad_function_call caught, sizeof(std::function)=32易错点
- 有捕获的 lambda 不能转换成函数指针;C 回调要带上下文时一般靠
void*透传。 - 热路径中若发现
std::function成本显著,可优先采用模板直接调用并做性能测试;类型擦除更适合“注册表、延迟执行、接口解耦”等需要统一类型存储的场景。 std::function持有的是闭包副本:按引用捕获的大对象同样会悬空,生命周期问题与 lambda 捕获一题同源。
通用原则:热路径可优先考虑 lambda / 模板直接调用并测量;需要统一类型存储时可用 std::function;跨语言接口可在约定 ABI 后用函数指针配合上下文指针。
遍历中删除元素该怎么写?各容器的迭代器失效规则有什么不同?
一句话回答:节点式容器(list/map/set)删除只失效被删元素,用
it = c.erase(it)推进;vector/deque 修改时按具体操作判定失效范围,vector 批量删值可用 erase-remove 惯用法;不要在未核对失效规则前缓存迭代器跨越插入和删除。
失效范围取决于容器的存储方式:连续存储(vector)修改结构就得搬移元素;节点式容器(list/map/set/unordered_map)删除只影响被删节点,但 unordered_map 的 rehash 会让所有迭代器失效(指向元素的指针/引用仍有效,因为节点没动)。
| 容器 | 结构性修改后的失效范围 | 安全写法 |
|---|---|---|
| vector | 扩容:全部失效;中间插入/删除:位置及其之后失效 | erase-remove 惯用法;扩容前 reserve |
| deque | 首尾插入:所有迭代器失效,已有元素引用保持有效;删除首尾:失效规则依位置(end 也可能失效);中间插删通常使迭代器及引用失效 | 修改后重新取迭代器;不缓存 end() |
| list / map / set | 只有被删元素失效,其他迭代器与引用稳定 | it = c.erase(it) |
| unordered_map | rehash 使全部迭代器失效(指针/引用仍有效);删除只失效被删元素 | 长期引用用指针/引用,别存迭代器 |
// q18:遍历中安全删除元素 + 容器迭代器失效规则
// 验证:g++ -std=c++17 -Wall -Wextra -pedantic q18_iterator.cpp
#include <algorithm>
#include <iostream>
#include <map>
#include <string>
#include <vector>
int main() {
std::map<std::string, int> hp{{"a", 10}, {"b", 0}, {"c", 5}, {"d", 0}};
for (auto it = hp.begin(); it != hp.end(); ) {
if (it->second == 0) {
it = hp.erase(it); // C++11 起 erase 返回下一个有效迭代器
} else {
++it;
}
}
std::cout << "map left=" << hp.size() << "\n";
std::vector<int> values{1, -2, 3, -4, 5};
values.erase(std::remove_if(values.begin(), values.end(),
[](int v) { return v < 0; }),
values.end()); // 先移动元素再统一擦除尾部(erase-remove 惯用法)
std::cout << "vector left=" << values.size()
<< " front=" << values.front() << "\n";
return 0;
}map left=2
vector left=3 front=1易错点
- range-for 里修改容器大小(循环里 push_back/erase)是常见崩溃来源;改用显式迭代器循环。
- 例如 vector 尾部 erase/pop_back 会使旧 end() 失效,deque 的 end() 在结构修改后也不能想当然复用;map/list 的 end() 规则不同,需按容器核对。
- “失效后看着还能跑”是未定义行为,不代表安全;vector 的
reserve可避免容量内追加时重新分配,但改变不了插入/删除位置之后迭代器可能失效的规则。
与已有资料的关系:《百度后端 J100737 八股复习手册》第 14 题和米哈游一面追问已讲 vector 扩容的失效规则;这里补齐其他容器及“遍历中安全删除”的写法。
通用原则:迭代器、指针、引用的有效期绑定在容器的结构修改上:结构一变就重新获取视图,或换用结构稳定的容器。
std::string_view 为什么快?什么时候会变成悬空视图?
一句话回答:
std::string_view是非拥有的字符序列视图(常见实现为指针 + 长度),构造与拷贝视图通常不分配、不复制字符;它不拥有字符数据——只要底层字符串消亡或临时对象销毁,视图就悬空;它也不保证以'\0'结尾,不能直接交给 C API。
把参数从 const std::string& 换成 string_view,可以同时接受 std::string 和字符串字面量而不构造临时 std::string(const std::string& 接受字面量时会构造临时对象)。substr 是 O(1) 的视图,不产生新字符串;比较按字节进行。
视图的正确性完全取决于数据存活期:视图活着的时候,底层数据也必须在。
// q19:std::string_view 的非拥有语义与生命周期
// 验证:g++ -std=c++17 -Wall -Wextra -pedantic q19_string_view.cpp
#include <iostream>
#include <string>
#include <string_view>
void logName(std::string_view name) { // 不复制、不分配
std::cout << "prefix=" << name.substr(0, 3) << " len=" << name.size() << "\n";
}
int main() {
std::string owned = "renderer";
logName(owned); // string -> view:无拷贝
logName("shadowmap"); // 字面量 -> view:无 std::string 临时对象
std::string_view view = owned;
owned[0] = 'R'; // 修改原字符串
std::cout << "after=" << view << "\n"; // 视图看到的是同一块内存
// std::string_view dangling = std::string("temp"); // 悬空:临时对象在这行结束就被销毁
return 0;
}prefix=ren len=8
prefix=sha len=9
after=Renderer
(修改原字符串后,视图看到的是同一块内存)易错点
string_view sv = std::string("temp");立即悬空(临时对象在这行结束就销毁);此处不运行演示结果——那是未定义行为,只作禁止示例。- 把视图存进长期结构(缓存、成员变量)时要保证原数据也被一起持有;短视图不会延长原字符串的寿命,更不会替它保留内存;持有者销毁后视图会悬空。
- 没有
c_str():需要 C 接口时用std::string,或者显式传指针和长度。
通用原则:非拥有视图只用于函数参数和短期只读窗口;跨生命周期边界一律持有所有权(std::string)。
std::optional 与 std::variant 分别解决什么问题?
一句话回答:
optional<T>表达“有一个 T,或什么都没有”,把 −1、空指针这类哨兵值从接口里去掉;variant<A, B, ...>是类型安全的 union,表达“若干类型之一”,用std::visit或get_if分支处理。
optional 提供 has_value()、value()、value_or()、operator*;无值时 value() 抛 bad_optional_access,未检查就 *opt 是未定义行为。它需要表示“无值”状态,但具体对象大小、是否比 T 大由类型与实现决定,别依赖字节数。
variant 记录当前备选类型的下标:默认构造第一个备选类型(需要“空”状态时可把 std::monostate 放第一位);按错误类型访问抛 bad_variant_access;visit 按当前备选类型调用访问器;若有任一备选类型不支持调用该访问器则编译失败,泛型兜底分支可以覆盖其他类型。
// q20:std::optional 与 std::variant
// 验证:g++ -std=c++17 -Wall -Wextra -pedantic q20_optional_variant.cpp
#include <iostream>
#include <map>
#include <optional>
#include <string>
#include <variant>
std::optional<int> findHp(const std::map<std::string, int>& table, const std::string& id) {
auto it = table.find(id);
if (it == table.end()) {
return std::nullopt; // 没有这个键:明确的“无值”
}
return it->second;
}
using Attribute = std::variant<int, double, std::string>; // 类型安全的 union
int main() {
std::map<std::string, int> table{{"hero", 100}};
std::cout << "hero=" << findHp(table, "hero").value_or(-1)
<< " ghost=" << findHp(table, "ghost").value_or(-1) << "\n";
Attribute attr = std::string("fov=90");
std::visit([](const auto& value) { std::cout << "visit: " << value << "\n"; }, attr);
if (auto* asInt = std::get_if<int>(&attr)) {
std::cout << "int branch " << *asInt << "\n";
} else {
std::cout << "index=" << attr.index() << " (not int)\n";
}
return 0;
}hero=100 ghost=-1
visit: fov=90
index=2 (not int)易错点
optional区分的是“有没有值”,不是“值是不是 0/空”:要区分两层需要用optional<optional<T>>,但那通常说明接口该换设计。variant在赋值或emplace等构造新备选值抛异常时可能进入valueless_by_exception;按具体操作处理,不能假设永远有值。- C++17 没有
std::expected(C++23 才有):要表达“成功或错误”,C++17 可以用variant<Value, Error>或返回值配合错误码;明确哪种类型对应哪种状态。
通用原则:用类型表达“可能没有值”和“多种可能形态”,迫使调用方处理所有分支;哨兵值只留给语义确实唯一的场景。
排序比较器为什么必须满足严格弱序?违反会怎样?
一句话回答:比较器要回答“a 是否严格小于 b”:
comp(a, a)必须为 false,传递性必须成立;用<=或破坏传递性会让算法前提失效——标准里是未定义行为,常见表现是越界读、乱序甚至崩溃。
排序和有序容器把比较器当作全序(带等价类)使用:划分、合并都假设 comp 自洽。comp(a, b) 与 comp(b, a) 同时为 false 表示二者“等价”(不是相等),这个等价关系也必须传递。
多字段排序用 std::tie 或 tuple 比较一次表达,比手写 if 链更不容易写错。浮点 NaN 让 < 不满足严格弱序(与任何数比较都是 false)——含 NaN 的数据要先清理再排序。
// q21:比较器必须满足严格弱序
// 验证:g++ -std=c++17 -Wall -Wextra -pedantic q21_ordering.cpp
#include <algorithm>
#include <iostream>
#include <string>
#include <tuple>
#include <vector>
struct Task {
int priority; // 数字越小越优先
std::string name;
};
int main() {
std::vector<Task> tasks{{2, "b"}, {1, "z"}, {2, "a"}, {1, "a"}};
std::sort(tasks.begin(), tasks.end(), [](const Task& lhs, const Task& rhs) {
return std::tie(lhs.priority, lhs.name) < std::tie(rhs.priority, rhs.name);
});
for (const auto& t : tasks) {
std::cout << t.priority << ":" << t.name << " ";
}
std::cout << "\n";
// 错误示范(不要运行):用 <= 的比较器不满足严格弱序
// std::sort(values.begin(), values.end(), [](int a, int b) { return a <= b; });
return 0;
}1:a 1:z 2:a 2:b
(同优先级按名字排序;示例注释里的 <= 比较器是禁止示例,不要运行)易错点
map/set的键“等价性”同样由比较器决定(双向不小于即等价),比较器不自洽会导致元素丢失或查不到。stable_sort保留等价元素的原顺序,sort不保证;需要稳定语义时可把序号并入比较键。- 比较器应该是纯函数:比较过程中修改外部状态、依赖被排序容器本身都会破坏算法前提。
通用原则:比较器只表达“小于”,等价关系交给算法;多字段就组合成元组比较,不要手写多级 if。
基本 / 强 / 不抛出异常保证分别是什么?强保证怎么用 copy-and-swap 实现?
一句话回答:基本保证=失败后不泄漏、对象仍有效(内容可能已变);强保证=要么成功、要么完全保持原样;不抛出保证=承诺不抛异常(析构、
noexcept移动/swap)。copy-and-swap 是实现强保证的一种常见做法:先在临时对象上完成所有可能失败的工作,再用不抛出的swap提交。
把操作重排成“准备(允许失败)→ 提交(不失败)”两段:Buffer::operator= 先构造副本,再 swap 与 *this 交换;副本构造失败时原对象未被触碰,交换阶段只做不抛出的成员交换。拷贝构造会复制元素,移动构造用 exchange 将源对象 size_ 归零,维持 data_/size_ 不变量。
noexcept 移动构造有助于容器扩容时维持强保证;若可拷贝但移动可能抛,容器可能选择拷贝,而仅可移动且移动可抛的类型在某些操作下未必能维持强保证。运行时异常传播、栈展开的过程米哈游一面已讲,这里关注的是“保证级别”和实现手法。
// q22:异常安全保证与 copy-and-swap;移动后维持 size/data 不变量
// 验证:g++ -std=c++17 -Wall -Wextra -pedantic q22_exception_safety.cpp
#include <algorithm>
#include <cstddef>
#include <iostream>
#include <memory>
#include <type_traits>
#include <utility>
class Buffer {
public:
explicit Buffer(std::size_t n) : data_(new int[n]{}), size_(n) {}
Buffer(const Buffer& other) // 先分配,再复制;失败时本对象未构造完成
: data_(new int[other.size_]{}), size_(other.size_) {
if (size_ != 0) std::copy_n(other.data_.get(), size_, data_.get());
}
Buffer(Buffer&& other) noexcept
: data_(std::move(other.data_)), size_(std::exchange(other.size_, 0)) {}
Buffer& operator=(const Buffer& other) {
Buffer tmp(other); // 准备阶段可失败,*this 尚未改变
swap(tmp); // 提交阶段不抛出
return *this;
}
void swap(Buffer& other) noexcept {
std::swap(data_, other.data_);
std::swap(size_, other.size_);
}
std::size_t size() const noexcept { return size_; }
int& at(std::size_t i) { return data_[i]; } // 示例调用均保证 i < size()
int at(std::size_t i) const { return data_[i]; }
private:
std::unique_ptr<int[]> data_;
std::size_t size_ = 0; // 移动后 size_=0、data_=nullptr
};
static_assert(std::is_nothrow_move_constructible_v<Buffer>);
int main() {
Buffer a(4);
a.at(0) = 42;
Buffer b(8);
b = a; // 拷贝赋值:准备副本,swap 提交
a.at(0) = 99; // 深拷贝:不会改变 b
Buffer c(std::move(a));
std::cout << "b.size=" << b.size() << " b[0]=" << b.at(0)
<< " c[0]=" << c.at(0) << " moved_from.size=" << a.size() << '\n';
}b.size=4 b[0]=42 c[0]=99 moved_from.size=0易错点
- 默认
noexcept的析构函数若抛出,或在栈展开期间又抛出未处理异常,会调用std::terminate;C++17 已删除旧式throw(T)动态异常规格,用noexcept表达“不抛”。 noexcept写错比不写更危险:标了noexcept的函数一旦抛异常直接终止进程,所以只给真正不抛的操作(比如指针交换)标注。- 强保证不是免费的:先拷贝再交换比就地修改贵;对“失败可以接受”的操作(写日志、更新缓存),基本保证就足够。
- 提交阶段必须只包含不会失败的操作;把可能抛异常的代码混进提交段,强保证立刻失效。
与已有资料的关系:米哈游一面已讲 noexcept 对移动构造的意义与异常栈展开过程;这里补的是三种保证的划分与实现手法。
通用原则:先给每个操作定“保证级别”,再按“准备—提交”重排步骤;跨模块接口用 noexcept 传递“能不能抛”的契约。