本文面向 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 valid
setBudget(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=1
Direction 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=1
void 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
int
f(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]。
  • mutable lambda 的 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_maprehash 使全部迭代器失效(指针/引用仍有效);删除只失效被删元素长期引用用指针/引用,别存迭代器
// 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 传递“能不能抛”的契约。