面灵AI→

柠檬微趣 C++客户端秋招:智能指针、移动语义与类型转换

轮次
秋招
时间
2026-10
来源
牛客网

《面试题目》

  1. 请做一下自我介绍
  2. C++ 中 new 和 malloc 的行为有什么不同?分别适合什么场景?
  3. shared_ptr 的引用计数为什么通常不会因为对象销毁而归零?它的控制块通常保存什么?
  4. 什么情况下应选择 unique_ptr 而不是 shared_ptr?unique_ptr 如何转移所有权?
  5. weak_ptr 怎样判断共享对象是否仍然存活?为什么它不能直接访问对象?
  6. shared_ptr 的引用计数线程安全,是否意味着多个线程可以安全地修改同一个 shared_ptr 变量?
  7. new 表达式在构造函数抛出异常时会发生什么?如果使用 placement new 又有什么不同?
  8. std::move 会移动对象吗?它的实际作用是什么?
  9. 如果某个类定义了析构函数,却没有显式声明移动构造函数,编译器一定会生成移动构造函数吗?
  10. static_cast、dynamic_cast、const_cast 和 reinterpret_cast 的适用边界是什么?
  11. dynamic_cast 为什么要求源类型通常是多态类型?它和 static_cast 的安全性差异在哪里?
  12. 派生类通过基类指针销毁时,基类析构函数是否必须是虚函数?

《参考解析》

new 与 malloc 的本质区别是「构造」这一步。 new 是运算符,做两件事——分配内存并调用构造函数;delete 先调析构再释放内存。malloc/free 是 C 风格函数,只管申请和释放原始内存,不构造也不析构对象。所以两者不能混用:malloc 出来的要 free,new 出来的要 delete。想在已有内存上构造对象就用 placement new(new (raw) Item),此时底层内存不归它管,构造成功要显式调 ~Item(),构造失败则那块内存仍需原所有者自己释放。

「引用计数不会归零」问的其实是控制块的生命周期。 shared_ptr 的对象生命周期由控制块管理,控制块里至少有强引用计数和弱引用计数:强计数归零时销毁被管理对象并调删除器,弱计数也归零后控制块才真正释放。只要还有 weak_ptr 存活,弱计数就不为零,所以控制块不会提前消失——这正是 weak_ptr 能安全判断对象是否存活的机制基础。weak_ptr 不能直接访问对象,因为它不增加强计数、不阻止对象销毁;正确写法是 lock() 原子地提升为 shared_ptr,拿到非空指针才访问。典型用途是打断父子对象之间的循环引用。

选型的默认答案是 unique_ptr。 当资源只有一个明确所有者时优先用 unique_ptr:没有原子引用计数、没有控制块、开销接近裸指针。它通过移动操作转移所有权,auto b = std::move(a); 之后 a 为空。它还能带自定义删除器管理文件句柄、socket 这类非内存资源。shared_ptr 只在确实多方共同持有同一生命周期时才用——共享所有权把「谁最后释放」变成了运行时问题。绝不要拿同一个裸指针分别构造两个 shared_ptr(会产生两个控制块、导致 double free),要共享就用 enable_shared_from_this 或拷贝已有的 shared_ptr。

线程安全的边界要分两层说。 多个 shared_ptr 实例共享同一控制块时,引用计数的增减是线程安全的;但多个线程无同步地读写同一个 shared_ptr 变量仍然是数据竞争(要动的除了计数还有指针本身),需要加锁或 C++20 的 std::atomic<std::shared_ptr<T>>。还有第三层常被漏掉:引用计数不保护被管理对象内部成员的访问。

构造函数抛异常时的内存回收。 普通 new 表达式分两步:先分配内存、再构造对象。构造函数抛异常时,编译器会自动调用对应的释放函数回收刚分配的内存——所以不会泄漏。placement new 不同:它只在指定地址上构造,不负责申请底层内存,构造失败时那块内存不会被释放,仍需原所有者管理;构造成功则要显式调析构。这也解释了为什么容器在 emplace_back 时构造失败不会泄漏——分配和构造是分开记账的。

std::move 不移动任何东西。 它只做一次 static_cast 到右值引用,作用是让重载决议选到移动构造/移动赋值;对象是否真的发生资源搬运,取决于目标类型有没有可用的移动操作(比如 std::string 有,而只定义了拷贝构造的类没有)。移动后的对象处于「有效但状态未指定」,可以重新赋值,但不要假定它还保存原内容。

移动构造的隐式生成规则是个陷阱。 用户只要声明了析构函数、拷贝构造函数或拷贝赋值运算符,编译器就不再隐式生成移动构造和移动赋值——这些是「被抑制」的。结果是原本该走移动的地方静默退化成拷贝,性能问题还很难发现。所以要移动语义就显式声明(最好加 noexcept,否则 vector 扩容时为了强异常保证仍会拷贝而不是移动);同时如果资源所有权不应该被复制,就把拷贝操作 = delete。

四种 cast 的边界。 static_cast 做编译期可检查的转换(数值转换、已知的基类/派生类转换),不做运行时验证;dynamic_cast 用于多态类型的运行时向下转型,失败时指针返回 nullptr、引用抛 std::bad_cast,它要求源类型是多态类型是因为需要对象携带运行时可用的类型信息(虚表里的 RTTI)。const_cast 用于增删 const/volatile 限定,但修改本来定义为 const 的对象是未定义行为。reinterpret_cast 是最底层的重新解释,不验证布局也不验证有效性,只应在与硬件、序列化协议这类确实需要位级重解释的场合使用。

基类析构必须是虚的。 用基类指针 delete 派生类对象时,如果基类析构不是虚函数,只有基类析构会被调用,派生类的资源全部泄漏——这是 C++ 里最容易踩的资源管理坑之一。规则可以记成:只要类会被作为多态基类使用(有虚函数),就应该提供虚析构。反过来,如果不打算被继承,可以把类标记 final 或把析构设为 protected 非虚,避免引入虚表开销。