i007.cc

i007.cc

优先队列-降维打击

05.价值资料

讲讲 Effective Modern C++ 里面的重点章节

这本书是 Senior C++ 工程师的必读清单,Scott Meyers 把 C++11/14 里最容易踩坑的地方系统梳理了一遍。针对你的情况,按重要程度和面试频率过一遍核心章节。


第五章:右值引用、移动语义、完美转发(最重要)

这章是全书精华,NVIDIA 面试必问。

Item 23:理解 std::move 和 std::forward

std::move 不移动任何东西,它只是一个强制转换成右值引用的 cast:

cpp
// std::move 的真实实现
template<typename T>
decltype(auto) move(T&& param) {
    using ReturnType = std::remove_reference_t<T>&&;
    return static_cast<ReturnType>(param);
}

// 这意味着:
std::string s = "hello";
std::string s2 = std::move(s);  // s 变成右值,触发移动构造
// s 现在处于"有效但未指定"状态,不要再使用 s 的值

// 常量对象 move 没有意义!
const std::string cs = "world";
std::string s3 = std::move(cs);  // 实际调用的是拷贝构造!
// const T&& 无法绑定到 T&& 参数,退化为拷贝

 

std::forward 是条件转换:只有当参数绑定的是右值时才转成右值:

cpp
// 完美转发:保持原始值类别传递给目标函数
template<typename T>
void wrapper(T&& arg) {
    // 不加 forward:arg 是左值(有名字的变量都是左值)
    target(arg);               // 总是拷贝

    // 加 forward:保留原始类型
    target(std::forward<T>(arg));  // 左值传左值,右值传右值
}

 

Item 24:区分通用引用与右值引用

这是很多人搞混的点:

cpp
// 这是右值引用,只能绑定右值
void f(Widget&& w);

// 这是通用引用(Universal Reference):T 必须被推导
template<typename T>
void f(T&& param);     // 可以绑定左值也可以绑定右值

auto&& x = expr;      // 也是通用引用

// 区别的关键:有没有类型推导
Widget&& r = w;       // 右值引用,W 已确定,不推导

 

通用引用能绑定左值时,T 被推导为 T&,利用引用折叠规则最终得到左值引用:

cpp
// 引用折叠规则(Item 28)
T& &   → T&
T& &&  → T&
T&& &  → T&
T&& && → T&&   // 只有这一种情况最终是右值引用

 

Item 29:不要假设移动操作又快又廉价

面试高频坑:

cpp
// 这些情况 move 不比 copy 快:

// 1. std::array:元素在栈上,move 仍然要拷贝所有元素
std::array<int, 1000> a1;
std::array<int, 1000> a2 = std::move(a1);  // O(n) !

// 2. SSO(Small String Optimization):短字符串在栈内
std::string short_str = "hi";
std::string s2 = std::move(short_str);  // 可能仍然拷贝

// 3. noexcept 不满足时,vector 扩容用 copy 不用 move
struct MyType {
    MyType(MyType&&);   // 没有 noexcept!
};
// vector<MyType> 扩容时会用拷贝构造而非移动构造,因为需要强异常安全

 

最佳实践:自定义类型的移动构造/赋值务必加 noexcept

cpp
class Buffer {
    char* data_;
    size_t size_;
public:
    Buffer(Buffer&& other) noexcept   // 必须加 noexcept
        : data_(other.data_), size_(other.size_) {
        other.data_ = nullptr;
        other.size_ = 0;
    }
    Buffer& operator=(Buffer&& other) noexcept { ... }
};

 


第四章:智能指针

Item 18/19/20:三种智能指针的选择原则

cpp
// unique_ptr:独占所有权,零开销(和裸指针一样大)
// 游戏里:游戏对象、资源的唯一持有者
auto player = std::make_unique<Player>(uid);

// 工厂函数返回 unique_ptr,调用方可以选择转成 shared_ptr
std::unique_ptr<Shape> make_shape(ShapeType type) {
    switch(type) {
        case CIRCLE: return std::make_unique<Circle>();
        default:     return nullptr;
    }
}

// shared_ptr:引用计数共享所有权
// 代价:控制块在堆上,引用计数是原子操作(多线程开销)
// 游戏里谨慎使用——高频创建/销毁的对象不要用 shared_ptr
auto mesh = std::make_shared<Mesh>("player.fbx");

// weak_ptr:不影响生命周期的观察者
// 解决 shared_ptr 循环引用,以及"我想引用但不想延长生命"
std::weak_ptr<Player> target_weak = enemy->target;  // AI 瞄准目标

void AI::update() {
    auto target = target_weak.lock();  // 安全地尝试获取
    if (!target) {
        // 目标已死亡,切换状态
        state_ = IDLE;
        return;
    }
    move_towards(target->position());
}

 

Item 21:优先用 make_unique / make_shared

cpp
// 错误写法(存在异常安全漏洞)
process(std::shared_ptr<Widget>(new Widget), compute_priority());
// 编译器可能的执行顺序:
// 1. new Widget
// 2. compute_priority() ← 如果这里抛异常,Widget 泄漏!
// 3. 构造 shared_ptr

// 正确写法
process(std::make_shared<Widget>(), compute_priority());
// make_shared 还有额外好处:把对象和控制块分配在一块内存,减少一次 new

 

Item 22:Pimpl 惯用法要在实现文件里定义析构函数

cpp
// Widget.h
class Widget {
    struct Impl;
    std::unique_ptr<Impl> pImpl_;  // 不完整类型
public:
    Widget();
    ~Widget();  // 只声明,不在头文件定义!
};

// Widget.cpp
struct Widget::Impl { ... };  // Impl 在这里完整定义

Widget::Widget() : pImpl_(std::make_unique<Impl>()) {}
Widget::~Widget() = default;  // 在 Impl 完整定义之后才能 default
// 如果在头文件 default,unique_ptr 的 deleter 看到不完整类型 → 编译错误

 


第三章:迈向现代 C++

Item 14:noexcept 的意义远超”表示不抛异常”

cpp
// noexcept 是接口的一部分,影响编译器优化

// 没有 noexcept:编译器必须维护栈展开信息,生成额外代码
void f();

// 有 noexcept:编译器可以激进优化,省去 exception table
void g() noexcept;

// 最重要的影响:STL 容器扩容策略
// vector::push_back 扩容时需要搬移元素
// 如果移动构造是 noexcept,用 move(O(1))
// 如果不是 noexcept,用 copy(提供强异常安全保证)

// 实际原则:析构函数、swap、移动构造/赋值,能 noexcept 就加

 

Item 15:constexpr 的正确理解

cpp
// constexpr 不只是"编译期常量",它是"如果可以就在编译期,否则在运行期"

constexpr int factorial(int n) {
    return n <= 1 ? 1 : n * factorial(n - 1);
}

constexpr int f6 = factorial(6);   // 编译期计算,结果直接嵌入二进制
int runtime_n = get_from_user();
int fn = factorial(runtime_n);      // 运行期计算,普通函数调用

// 游戏里常见用法:编译期计算 hash、buffer size、位掩码
constexpr uint32_t hash_str(const char* s, size_t len) { ... }
constexpr uint32_t PLAYER_TAG = hash_str("player", 6);

 

Item 17:特殊成员函数的自动生成规则

这是很多 bug 的来源,必须烂熟于心:

cpp
// 只要你声明了任何一个,编译器的自动生成行为就会改变

class MyClass {
    // 声明了析构函数 →
    //   拷贝构造/赋值:仍然生成(deprecated 行为,不要依赖)
    //   移动构造/赋值:不再自动生成!

    // 声明了移动构造 →
    //   移动赋值:不自动生成
    //   拷贝构造/赋值:被 delete

    // Rule of Five:要么都不声明,要么五个都写
    ~MyClass();
    MyClass(const MyClass&);
    MyClass& operator=(const MyClass&);
    MyClass(MyClass&&) noexcept;
    MyClass& operator=(MyClass&&) noexcept;
};

// 最常见的坑:
class Buffer {
    char* data_;
public:
    ~Buffer() { delete[] data_; }  // 声明了析构函数
    // 移动构造没有自动生成!
    // vector<Buffer> 扩容时会调用拷贝构造,浅拷贝 data_ 导致 double free
};

 


第六章:Lambda

Item 31:不要用默认捕获

cpp
// 默认引用捕获 [&]:容易悬空引用
auto create_adder(int x) {
    return [&]() { return x + 1; };  // x 是局部变量,lambda 逃逸后悬空!
}

// 默认值捕获 [=]:看起来安全,实际暗藏陷阱
class Widget {
    int value_ = 42;
    auto create_lambda() {
        return [=]() { return value_; };  // 捕获的是 this,不是 value_!
        // 等价于 [this]() { return this->value_; }
        // Widget 析构后,lambda 再调用 → UB
    }
};

// 正确写法:明确捕获
auto create_lambda() {
    int v = value_;
    return [v]() { return v; };   // 捕获值的副本,安全
}

 

Item 32:用初始化捕获移动对象进 lambda

cpp
// C++14 引入,解决"我想把一个只能移动的对象放进 lambda"的问题
auto upw = std::make_unique<Widget>();

// C++14 init capture
auto func = [w = std::move(upw)]() {
    w->do_something();
};
// upw 被移动进了 lambda,lambda 外部 upw 变为 nullptr

 


第七章:并发 API

Item 40:atomic 用于并发,volatile 用于特殊内存

这是最容易混淆的两个关键字:

cpp
// std::atomic:多线程可见,编译器/CPU 不会重排相关操作
std::atomic<int> counter{0};
counter++;                    // 原子递增,线程安全
counter.load(std::memory_order_acquire);   // 带 memory order 控制

// volatile:告诉编译器"不要优化这个变量的读写"
// 用途:内存映射 IO、信号处理器修改的变量
volatile int* hardware_register = (int*)0xDEADBEEF;
*hardware_register = 1;  // 编译器不会把这个写入优化掉

// volatile 不是线程安全的!
// std::atomic 才是线程安全的!
// 两者完全不同,不要混用

// 游戏里常见错误:
volatile bool game_running = true;   // 错误!用 atomic
std::atomic<bool> game_running{true}; // 正确

 

Item 35:优先用 std::async 而非直接 std::thread

cpp
// thread-based:自己管理线程生命周期,容易出错
int do_work();
std::thread t(do_work);   // 如果 do_work 抛异常,thread 析构时 terminate()
t.join();

// task-based:更安全,返回 future 可以拿结果/异常
auto fut = std::async(std::launch::async, do_work);
// 异常会被传播到 future.get() 处,不会 terminate
int result = fut.get();   // 异常在这里重新抛出,可以 try-catch

 


面试里怎么用这些知识

NVIDIA 最喜欢考的几个组合:

“解释 move semantics” → Item 23/24/29,说 move 是 cast 不是操作,说 noexcept 对 vector 扩容的影响,说 const 对象 move 退化为 copy。

“shared_ptr 和 unique_ptr 怎么选” → Item 18/19,说控制块的内存开销和原子引用计数,游戏高频对象用 unique_ptr 或对象池。

“volatile 和 atomic 区别” → Item 40,这道题答对的人不多,答出来立刻加分。

“你写过 RAII 吗” → Item 15/17,结合析构函数保证资源释放,Rule of Five,移动语义。

你看这本书的时候,建议把每个 Item 配一个你在游戏项目里遇到过的实际案例,面试时用真实场景来解释比纯粹背书更有说服力。

发表回复