亚信安全C++开发工程师面经
- 时间
- 2026-09
- 来源
- 牛客网
《面试题目》
智能指针与对象生命周期
- unique_ptr、shared_ptr 和 weak_ptr 分别适合解决什么问题?
- 如果函数返回局部变量的引用,会造成什么问题?
容器与移动语义
- vector 的扩容策略会对程序性能和迭代器造成哪些影响?
- std::move 本身会不会移动对象?
- 为什么移动构造函数最好声明为 noexcept?
并发与哈希容器
- shared_ptr 的引用计数为什么不能解决所有线程安全问题?
- unordered_map 的平均复杂度为什么是 O(1),最坏情况又是什么?
vector<bool>和普通vector<T>有哪些区别?
资源管理
- 如何设计一个不会发生资源泄漏的文件描述符封装类?
Qt 多线程
- Qt 信号槽跨线程调用时,如何确定槽函数在哪个线程运行?
- Qt 中对象移动到其他线程后,为什么仍然不能随意调用它的成员函数?
网络编程
- Linux 网络服务如何处理半包、粘包和恶意长度字段?
《参考解析》
1. 三种智能指针的所有权语义:unique_ptr 表达独占所有权,禁止拷贝、只允许移动,析构时直接 delete,零运行时开销,适合文件描述符、设备句柄、独占的堆对象这类”只有一个管理者”的资源。shared_ptr 表达共享所有权,控制块里维护强引用计数,最后一个持有者析构时释放资源,用来解决生命周期无法静态确定的问题;代价是控制块的一次额外分配(make_shared 可以合并成一次)、原子计数的开销以及潜在的循环引用。weak_ptr 是 shared_ptr 的观察者,只增加弱计数、不延长对象寿命,用 lock() 原子地拿到一个临时的 shared_ptr 再判空使用。做缓存、观察者列表、父子双向引用时用 weak_ptr 打断环,是比较常见的组合。
2. 引用计数线程安全 ≠ 对象线程安全:shared_ptr 保证的是控制块操作安全——多个线程各自拷贝、重置、析构不同的 shared_ptr 实例不会破坏计数;但被管理对象内部的成员完全没有保护。两个线程同时对 *sp 里的同一个 int 做自增,依然是数据竞争。要区分”生命周期安全”和”状态访问安全”这两件事:前者靠智能指针,后者要靠 mutex、原子变量、消息传递或线程封闭。另外,同一个 shared_ptr 实例被多个线程同时赋值(而不是各自的副本)也不是安全的,因为指针本身和控制块指针的读写不是原子的。
3. 返回局部变量的引用:局部变量在栈帧上,函数返回即销毁,返回它的引用/指针就是悬空引用,之后任何读写都是未定义行为——可能”看起来正常”,也可能读到被后续调用覆盖的垃圾值、崩溃或数据错乱。正确的写法是直接按值返回,C++17 起有强制(非可选)的拷贝消除,返回 std::string("backend") 或返回局部对象都不会真的发生拷贝;即使没有消除,也会走移动构造。真正需要返回引用时,返回的应该是生命周期长于函数的东西:成员变量、static 变量或调用方传入的对象;返回引用参数虽合法,但要注意不能返回对临时对象的引用。
4. vector 扩容的成本与迭代器失效:vector 的 size 追上 capacity 时会按通常 1.5~2 倍申请一块更大的连续内存,把旧元素逐个移动(移动构造是 noexcept 时)或拷贝过去,再释放旧内存。代价有两块:一次性的大块分配与元素搬迁,以及所有指向元素的指针、引用和迭代器全部失效——这条比性能更容易写出 bug,比如在循环里缓存了迭代器又 push_back。规避手段是提前 reserve()(只改容量不改 size,和会真正构造元素的 resize() 不是一回事)、改用 deque/对象池/环形缓冲,或者用下标而不是迭代器跨越可能扩容的操作。
5. std::move 只是类型转换:std::move 本质是一次到右值引用的 static_cast,它自己不搬任何数据,只是让重载决议选中移动构造/移动赋值。真正转移资源的是被调用类型的移动操作。移动之后源对象处于”有效但未指定”的状态:可以安全地析构、赋值或 clear,但不能假设它还是原来的内容。因此对后续还要按原值使用的对象调用 std::move 属于逻辑错误,最典型的是把成员 std::move 出去之后又在别的分支里读它。
6. 移动构造函数声明 noexcept 的意义:vector 扩容时需要在强异常保证和性能之间取舍——如果元素的移动构造可能抛异常,搬一半失败就无法回滚,因此标准库会退化成拷贝(std::move_if_noexcept)。移动构造标了 noexcept,容器才敢用移动。但 noexcept 不是写上去就完事的:一旦异常真的穿过 noexcept 边界,会直接 std::terminate(),所以只有在确认内部操作(如指针交换、容器成员移动)不会抛时才能标;有疑问时宁可标 noexcept(false) 或显式写清楚不抛的条件。
7. unordered_map 的 O(1) 与最坏 O(n):哈希表用哈希函数把键映射到桶,平均每桶元素很少,插入/查找/删除接近常数。最坏情况是所有键落进同一个桶,退化成一条链表的线性扫描。常见诱因是哈希函数质量差、负载因子过高、输入数据有规律(比如全是 2 的幂取模后的低位相同)以及人为构造的冲突攻击。工程上的做法是 reserve() 预估规模、把 max_load_factor 调低一些、自定义类型时保证”相等必同哈希”的哈希与相等逻辑一致;对外网输入还可以换用防碰撞的哈希算法(如带随机种子的 SipHash)避免哈希洪水。
8. vector<bool> 的位压缩陷阱:标准为 vector<bool> 做了特化,一个元素通常只占 1 bit,因此它不满足一般容器的要求:operator[] 返回的是代理对象而不是 bool&,拿不到 bool*,data() 基本不可用,&v[0] 这类写法编译不过;多个线程写不同下标时,底层可能落在同一个机器字上,仍会竞争;单 bit 修改是读-改-写,比写一个字节慢。需要普通容器语义就用 std::vector<unsigned char> 或 std::deque<bool>,明确要位图语义就直接用 std::bitset 或自己写位图。
9. RAII 封装文件描述符:核心是让析构函数负责 close,并显式禁用拷贝(否则两个对象持同一 fd,析构时重复关闭,可能误关别人刚申请的 fd),只提供移动语义,移动时把源对象的 fd 置为 -1。典型实现:默认构造为 -1,析构调用 reset(),移动构造用 std::exchange(other.fd_, -1),移动赋值先 reset() 自己再接管并判自赋值。reset(int fd = -1) 里先关旧 fd 再赋值新 fd,天然幂等,重复 reset 也安全。注意别把 fd 交给 errno 相关的失败路径(比如 close 返回 EINTR 的处理),并考虑是否要加 [[nodiscard]] 的 get() 防止误用裸 fd。
10. Qt 信号槽跨线程的执行位置:槽函数在哪个线程跑,由连接类型和接收者对象的线程归属(thread())共同决定。Qt::DirectConnection 在发射信号的线程里同步调用,等于普通函数调用;Qt::QueuedConnection 把调用包装成事件投递到接收者线程的事件队列,由接收者线程的事件循环执行,因此参数必须能被 Qt 的元对象系统拷贝(自定义类型要 Q_DECLARE_METATYPE 并 qRegisterMetaType);Qt::AutoConnection 是默认值,发送者与接收者同线程就走直连,不同线程自动转队列。队列连接依赖目标线程的事件循环,线程没跑 exec() 就会一直不执行。后台线程绝不能直接碰 GUI 对象,跨线程更新界面只能通过信号槽回到主线程。
11. moveToThread 只改线程归属:moveToThread() 做的是把 QObject 的线程亲和性改掉,它不会让调用者的代码”跳到”目标线程执行。所以 worker->moveToThread(thread) 之后紧接着直接调用 worker->doWork(),函数体仍在当前线程里跑,线程安全并没有变好。正确做法是通过队列信号触发,或者用 QMetaObject::invokeMethod(worker, &Worker::doWork, Qt::QueuedConnection) 投递到目标线程。还有几条约束必须满足:被移动的对象不能有父对象;目标线程要有正在运行的事件循环;QTimer、socket 这类资源要在所属线程里创建和停止;对象的销毁要安排在线程退出之前,并且线程结束前不能再有任务访问它。
12. TCP 粘包、半包与恶意长度字段:TCP 是字节流,一次 recv 可能返回半条消息,也可能一次返回三条消息,所以必须由应用层定界。常见方案有定长头 + 长度字段、分隔符(要处理转义)、或者长度前缀 + 校验。带长度字段的协议解析要按顺序做四件事:先判断缓存里有没有完整的固定头,再校验魔数/版本(不合法时按字节滑动重同步,而不是直接丢弃整段缓存),然后校验长度字段是否超过业务允许的最大值,最后判断缓存是否已经凑齐一整帧,凑齐才切走。长度字段是最容易被攻击的地方:负数、超大值(比如 0x7FFFFFFF)会让服务端预分配巨大缓冲区,必须设上限并在越界时直接断开连接;同时要有单连接缓存上限和读超时,防止慢速攻击把内存耗光。