快手 — AI 引擎架构¶
轮次: 一面(2023-07-31)+ 二面(2023-08-08)
标签: C++ 基础 · 网络协议 · 智能指针手撕 · GDB · static · 虚析构 · 算法
侧重: 多态原理、extern "C"、TCP 解包、shared_ptr、代码审查
一面(2023-07-31)¶
侧重: 多态原理、extern "C"、TCP 解包、shared_ptr 实现
Q1. 多态及原理¶
回答¶
什么是多态¶
多态(Polymorphism)指 同一接口在不同对象上有不同的行为。C++ 中的多态分两种:
| 类型 | 机制 | 绑定时机 |
|---|---|---|
| 编译期多态(静态多态) | 函数重载、模板 | 编译期 |
| 运行期多态(动态多态) | 虚函数 + 继承 | 运行期 |
面试说"多态"一般指 动态多态。
动态多态原理¶
- 虚函数表(vtable):编译器为每个含虚函数的类生成一张函数指针表,表中按声明顺序存放虚函数的地址
- 虚表指针(vptr):每个对象内部隐含一个指向 vtable 的指针,通常在对象内存布局的 最前面
- 调用流程:通过基类指针/引用调用虚函数时 → 先读对象的 vptr → 查 vtable → 跳转到实际函数地址
class Base {
public:
virtual void foo() { std::cout << "Base::foo\n"; }
virtual ~Base() = default;
};
class Derived : public Base {
public:
void foo() override { std::cout << "Derived::foo\n"; }
};
Base* p = new Derived();
p->foo(); // 运行时查 vtable → 调用 Derived::foo
内存布局示意¶
Derived 对象:
┌──────────────┐
│ vptr ────────────→ Derived vtable:
│ │ ┌──────────────────────┐
│ Base 成员 │ │ &Derived::foo │
│ Derived 成员 │ │ &Derived::~Derived │
└──────────────┘ └──────────────────────┘
注意事项¶
- 虚函数调用有间接寻址开销(多一次指针跳转),但现代 CPU 分支预测通常能大幅缓解
- 构造函数 不能是虚函数(对象还没构造完,vptr 尚未设置好)
- 析构函数 应当设为虚函数(否则基类指针 delete 派生对象时只调基类析构 → 资源泄漏)
- 追问「纯虚函数」:
virtual void foo() = 0;使类成为抽象类,不能实例化
细看: 对象模型与内存布局
Q2. extern "C" 的作用¶
回答¶
extern "C" 告诉 C++ 编译器:对花括号内的函数使用 C 语言的命名规则(name mangling),即不进行 C++ 的名称修饰。
为什么需要¶
C++ 编译器对函数名会做 name mangling(名称修饰),将参数类型编码进符号名,比如:
而 C 编译器不做修饰,foo 就是 foo。
如果 C++ 代码要调用 C 编写的库函数,链接器会按 C++ 修饰后的名字去找符号,找不到就报 undefined reference。extern "C" 让编译器以 C 的规则生成符号名,确保链接成功。
典型用法¶
// C 头文件的跨语言兼容写法
#ifdef __cplusplus
extern "C" {
#endif
void c_function(int x);
int another_c_func(const char* s);
#ifdef __cplusplus
}
#endif
注意事项¶
extern "C"只影响链接符号名,不影响调用约定(除非平台特殊)extern "C"内的函数 不能重载(因为 C 的符号表只靠函数名区分)- C++ 的
<cstdio>等头文件内部就是用extern "C"包裹的
细看: STL · 模板 · 编译链接
Q3. 为什么 C 函数不能重载,C++ 可以重载¶
回答¶
根本原因在于符号命名规则(name mangling)的不同。
C 语言¶
编译器生成的符号名 只包含函数名本身。两个重载函数(同名不同参数)在符号表中产生同名冲突,链接器无法区分。
C++¶
编译器将 函数名 + 参数类型(+ 命名空间等) 一起编码进符号名:
这样链接器就能准确区分不同的重载版本。
完整流程¶
注意事项¶
- C++ 重载只看 参数列表(类型、数量、顺序),不看返回值——因为调用处不一定使用返回值,编译器无法仅靠返回值区分
extern "C"的函数由于使用 C 命名规则,同样不能重载- 面试追问:
name mangling的具体规则可以用c++filt工具反解:c++filt _Z3fooi→foo(int)
细看: STL · 模板 · 编译链接
Q4. TCP/IP 是流式的,怎么知道一段数据大小、包含多少字节、怎么解包¶
回答¶
TCP 是 字节流协议,没有消息边界的概念。应用层需要自行设计 成帧协议(framing protocol) 来划分消息。常见方案:
方案一:固定长度¶
每条消息恒定 N 字节。接收端每次读 N 字节即为一条完整消息。
简单但不灵活,适合定长结构体传输。
方案二:特殊分隔符¶
用特定字符(如 \r\n、\0)标记消息结束。HTTP/1.1 的请求头就是用 \r\n\r\n 分隔。
问题:消息体内出现分隔符需要转义。
方案三:长度前缀(最常用)¶
消息头部用固定字节(如 4 字节)标明后续数据长度:
┌─────────┬───────────────────────┐
│ len (4B)│ payload (len bytes) │
└─────────┴───────────────────────┘
// 发送端
void send_message(int sockfd, const std::string& msg) {
uint32_t len = htonl(msg.size()); // 网络字节序
send(sockfd, &len, sizeof(len), 0);
send(sockfd, msg.data(), msg.size(), 0);
}
// 接收端
std::string recv_message(int sockfd) {
uint32_t len;
recv_all(sockfd, &len, sizeof(len)); // 必须读满 4 字节
len = ntohl(len);
std::string buf(len, '\0');
recv_all(sockfd, buf.data(), len); // 必须读满 len 字节
return buf;
}
方案四:TLV(Type-Length-Value)¶
协议头包含类型、长度、值,Protobuf 的 wire format 本质就是 TLV 变体。
解包时必须处理的问题¶
| 问题 | 处理 |
|---|---|
| 粘包 | 一次 recv 可能收到多条消息,按长度前缀逐条解析 |
| 半包 | 一条消息可能分多次到达,必须循环 recv 直到读满预期长度 |
| 字节序 | 多字节整数用 htonl/ntohl 转换为网络字节序 |
注意事项¶
recv返回值可能小于请求的长度,必须写recv_all循环读满- 面试追问「UDP 需要这样做吗」:不需要,UDP 是数据报协议,每次
recvfrom收到的是完整的一个报文 - Protobuf / FlatBuffers 等序列化框架通常自带 framing 机制
细看: 网络编程:Socket · epoll · TCP · UDP · HTTP
Q5. GDB 调试,用过 watch 吗¶
回答¶
GDB watch 命令¶
watch 设置 数据断点(watchpoint),当监视的内存/变量 被写入(值发生改变) 时程序暂停。
(gdb) watch var # var 被修改时暂停
(gdb) watch *(int*)0x... # 监视指定地址
(gdb) rwatch var # var 被读取时暂停(硬件支持)
(gdb) awatch var # var 被读 或 写时暂停
典型使用场景¶
| 场景 | 说明 |
|---|---|
| 追踪变量被意外修改 | 不知道哪段代码改了某个全局变量 |
| 定位内存踩踏 | watch 某个结构体字段,看哪里越界写入 |
| 调试多线程竞态 | 结合 watch + info threads 定位哪个线程改了共享数据 |
硬件 vs 软件 watchpoint¶
- 硬件 watchpoint(默认优先):利用 CPU 的调试寄存器,几乎无性能损耗,但数量有限(x86 通常 4 个)
- 软件 watchpoint:GDB 单步执行每条指令后检查变量值,非常慢,仅在硬件 watchpoint 不可用时启用
常用 GDB 命令速查¶
b main # 在 main 设断点
r # 运行程序
n / s # step over / step into
p var # 打印变量
bt # 查看调用栈
info breakpoints # 列出所有断点/watchpoint
注意事项¶
- watch 作用域:如果 watch 一个局部变量,出了该函数作用域后 watchpoint 自动失效
- 面试只需说清楚 watch 是「数据断点」以及典型使用场景即可
- 追问「条件断点」:
b foo.cpp:42 if x > 10
Q6. 买彩票(概率题)¶
回答¶
这是一道开放性概率/数学题,面试中通常考查 建模能力和沟通表达。常见变体:
"彩票号码从 1~N 里选 K 个,中奖概率是多少?"
基本组合概率¶
从 N 个号码中选 K 个(不考虑顺序),总组合数为 \(\binom{N}{K}\),中奖概率为:
期望收益¶
如果 \(E < 0\)(通常如此),则买彩票的数学期望是亏损的。
注意事项¶
- 这类题面试官一般看你的建模过程和思路表达,不纠结具体数字
- 可以主动提一句「大数定律」和「期望为负」,展示概率直觉
Q7. 手撕 shared_ptr¶
思路¶
与网易游戏一面 Q2 相同。核心要素:堆上引用计数、拷贝 +1、析构 -1 归零释放、移动转移所有权。
完整代码¶
#include <iostream>
#include <utility>
template <typename T>
class SharedPtr {
public:
SharedPtr() : ptr_(nullptr), ref_count_(nullptr) {}
explicit SharedPtr(T* ptr) : ptr_(ptr), ref_count_(new size_t(1)) {}
// 拷贝构造
SharedPtr(const SharedPtr& other)
: ptr_(other.ptr_), ref_count_(other.ref_count_) {
if (ref_count_) ++(*ref_count_);
}
// 移动构造
SharedPtr(SharedPtr&& other) noexcept
: ptr_(std::exchange(other.ptr_, nullptr)),
ref_count_(std::exchange(other.ref_count_, nullptr)) {}
// 拷贝赋值(copy-and-swap)
SharedPtr& operator=(SharedPtr other) {
swap(other);
return *this;
}
~SharedPtr() { release(); }
T& operator*() const { return *ptr_; }
T* operator->() const { return ptr_; }
T* get() const { return ptr_; }
size_t use_count() const { return ref_count_ ? *ref_count_ : 0; }
explicit operator bool() const { return ptr_ != nullptr; }
void swap(SharedPtr& other) noexcept {
std::swap(ptr_, other.ptr_);
std::swap(ref_count_, other.ref_count_);
}
private:
void release() {
if (ref_count_ && --(*ref_count_) == 0) {
delete ptr_;
delete ref_count_;
}
ptr_ = nullptr;
ref_count_ = nullptr;
}
T* ptr_;
size_t* ref_count_;
};
注意事项¶
| 要点 | 说明 |
|---|---|
构造函数 explicit |
防止裸指针隐式转换 |
| copy-and-swap | 天然异常安全且处理自赋值 |
| 移动后置空 | 避免 double free |
| 线程安全版 | size_t → std::atomic<size_t> |
一面总结¶
| # | 知识域 | 核心考点 |
|---|---|---|
| Q1 | C++ 对象模型 | vtable/vptr、动态绑定 |
| Q2 | 编译链接 | extern "C"、name mangling |
| Q3 | 编译链接 | C vs C++ 符号表命名规则 |
| Q4 | 网络编程 | TCP 粘包/半包、framing 协议 |
| Q5 | 调试工具 | GDB watch、硬件/软件 watchpoint |
| Q6 | 概率 | 组合概率、期望 |
| Q7 | 智能指针 | 手撕 shared_ptr |
二面(2023-08-08)¶
侧重: static、虚析构、智能指针线程安全、C++11 特性、算法、代码审查
Q1. static 的作用¶
回答¶
static 在 C++ 中根据上下文有 五种含义:
面向对象(类内)¶
| 用法 | 说明 |
|---|---|
| 静态成员变量 | 属于类而非对象,存储在 全局/静态数据区;所有对象共享同一份;必须在 类外定义初始化(C++17 可用 inline static);受 public/protected/private 访问控制 |
| 静态成员函数 | 没有 this 指针,只能访问静态成员;通过 类名::func() 调用;因为不经过 vptr,调用开销略小 |
class Counter {
public:
static int count; // 声明
static void increment() { ++count; }
};
int Counter::count = 0; // 类外定义
面向过程(文件/函数作用域)¶
| 用法 | 说明 |
|---|---|
| 静态全局变量 | 限制链接属性为 内部链接(internal linkage),仅当前翻译单元可见,避免符号冲突 |
| 静态局部变量 | 生命周期为整个程序,但作用域仅在函数内;首次执行到声明时初始化,C++11 保证线程安全(Meyers' Singleton) |
| 静态函数 | 同静态全局变量,限制为内部链接,其他翻译单元 extern 不到 |
// Meyers' Singleton — 利用静态局部变量的线程安全初始化
class Singleton {
public:
static Singleton& instance() {
static Singleton s; // C++11 线程安全
return s;
}
private:
Singleton() = default;
};
注意事项¶
- 面试时分 「类内两种 + 文件/函数三种」 来回答,条理清晰
- 静态局部变量 + Meyers' Singleton 是高频追问
- C++11 前静态局部变量初始化不保证线程安全,需要加锁或用
std::call_once
细看: STL · 模板 · 编译链接
Q2. 为什么析构函数要定义成虚函数¶
回答¶
当通过 基类指针 delete 派生类对象 时,如果析构函数不是虚函数,编译器只会调用基类的析构函数(静态绑定),派生类的析构函数不会被调用,导致派生类中的资源泄漏。
class Base {
public:
~Base() { std::cout << "~Base\n"; } // 非虚析构
};
class Derived : public Base {
int* data_;
public:
Derived() : data_(new int[100]) {}
~Derived() { delete[] data_; std::cout << "~Derived\n"; }
};
Base* p = new Derived();
delete p; // 只调用 ~Base(),data_ 泄漏!
修复: 将基类析构声明为 virtual:
这样 delete p 时通过 vtable 找到 Derived::~Derived(),先析构派生类再析构基类,析构顺序正确。
什么时候不需要虚析构¶
- 类不打算被继承(可以用
final标记) - 不会通过基类指针 delete(如值语义类型)
- 性能极度敏感且不需要多态(避免 vtable 开销)
注意事项¶
- 经验规则: 只要类里有一个虚函数,就应该声明虚析构
- C++ Core Guidelines C.35: "A base class destructor should be either public and virtual, or protected and nonvirtual"
- 追问「纯虚析构函数」:可以声明
virtual ~Base() = 0;,但必须提供定义(因为派生类析构会隐式调用基类析构)
细看: 对象模型与内存布局
Q3. 智能指针¶
回答¶
C++11 提供三种智能指针(<memory> 头文件):
| 智能指针 | 所有权 | 引用计数 | 典型场景 |
|---|---|---|---|
unique_ptr |
独占 | 无 | 工厂函数返回、RAII 资源管理 |
shared_ptr |
共享 | 有(strong count) | 多个所有者共同管理生命周期 |
weak_ptr |
非拥有 | 仅观测 weak count | 打破 shared_ptr 循环引用、缓存 |
unique_ptr¶
shared_ptr¶
auto p1 = std::make_shared<int>(42); // 引用计数 = 1
auto p2 = p1; // 引用计数 = 2
p1.reset(); // 引用计数 = 1
// p2 析构时引用计数 → 0,释放内存
weak_ptr 打破循环引用¶
注意事项¶
make_shared优于shared_ptr<T>(new T):一次分配(对象 + 控制块),更高效且异常安全shared_ptr的控制块包含 strong_count + weak_count + deleter + allocator- 不要用
shared_ptr管理数组(C++17 前),C++20 才支持shared_ptr<T[]>
Q4. 智能指针的线程安全问题¶
回答¶
shared_ptr 的线程安全性分两层¶
| 层级 | 是否线程安全 | 说明 |
|---|---|---|
| 引用计数的增减 | ✅ 安全 | 控制块中的 strong_count / weak_count 是 std::atomic 类型,拷贝/析构时的引用计数操作是原子的 |
| shared_ptr 对象本身的读写 | ❌ 不安全 | 多个线程同时读写 同一个 shared_ptr 实例(如一个线程 reset,另一个线程读 get),需要加锁 |
// ❌ 危险:多线程对同一个 shared_ptr 实例读写
std::shared_ptr<int> global_ptr = std::make_shared<int>(0);
// 线程 1
global_ptr = std::make_shared<int>(1); // 写
// 线程 2
auto local = global_ptr; // 读(可能读到半写状态的指针)
安全做法¶
// 方案 1:互斥锁
std::mutex mtx;
{
std::lock_guard<std::mutex> lock(mtx);
global_ptr = std::make_shared<int>(1);
}
// 方案 2:std::atomic<std::shared_ptr> (C++20)
std::atomic<std::shared_ptr<int>> atomic_ptr;
// 方案 3:每个线程持有各自的 shared_ptr 拷贝(最常用)
auto local_copy = global_ptr; // 拷贝后各线程操作各自的实例
注意事项¶
- 面试关键区分:引用计数原子 ≠ shared_ptr 对象线程安全
shared_ptr管理的被指向对象本身的线程安全,是用户自己的责任- C++20
std::atomic<std::shared_ptr<T>>是最优雅的方案
细看: 并发编程
Q5. C++11 有哪些特性¶
回答¶
按分类整理,面试选 5-8 个重点 展开:
语言核心¶
| 特性 | 关键词/说明 |
|---|---|
| auto 类型推导 | auto x = 42; 编译器自动推导类型 |
| decltype | decltype(expr) 获取表达式类型 |
| 右值引用 & 移动语义 | T&&、std::move、移动构造/赋值,避免不必要的深拷贝 |
| 完美转发 | std::forward<T>(arg),模板参数传递时保持值类别 |
| nullptr | 替代 NULL(NULL 是整数 0,可能引起重载二义性) |
| constexpr | 编译期常量表达式 |
| 范围 for | for (auto& x : container) |
| lambda 表达式 | [capture](params) { body } |
| 变参模板 | template<typename... Args> |
| enum class | 强类型枚举,不隐式转 int |
| override / final | 显式标记虚函数重写/禁止继承 |
| 委托构造 | 构造函数调用同类其他构造函数 |
| 类内初始化 | int x = 0; 直接在类声明中初始化成员 |
标准库¶
| 特性 | 说明 |
|---|---|
| 智能指针 | unique_ptr、shared_ptr、weak_ptr |
| std::thread | 标准线程库 |
| std::mutex / std::lock_guard | 互斥与 RAII 锁 |
| std::atomic | 原子类型 |
| std::function / std::bind | 通用函数包装器 |
| std::unordered_map/set | 哈希容器 |
| std::array | 固定大小数组 |
| std::chrono | 时间库 |
| std::regex | 正则表达式 |
注意事项¶
- 面试不需要全部背出来,挑自己熟悉的 深入讲
- 最高频追问:右值引用 + 移动语义、智能指针、lambda
- 可以主动延伸到 C++14/17/20 新特性(
if constexpr、std::optional、concepts等)
细看: C++11-20 新特性
Q6. 寻找两个正序数组的中位数(LeetCode 4)¶
思路¶
这是 LeetCode Hard 经典题。要求时间复杂度 \(O(\log(m+n))\),核心思路是 二分查找。
关键思想¶
将问题转化为:在两个有序数组中找第 \(k\) 小的元素(\(k = (m+n+1)/2\) 或 \((m+n+2)/2\))。
每次比较两个数组中第 \(k/2\) 个元素,排除掉较小一方的前 \(k/2\) 个元素,将 \(k\) 减半,直到 \(k=1\) 或某个数组被排空。
代码¶
class Solution {
public:
double findMedianSortedArrays(vector<int>& nums1, vector<int>& nums2) {
int m = nums1.size(), n = nums2.size();
// 确保 nums1 是较短的数组
if (m > n) return findMedianSortedArrays(nums2, nums1);
int lo = 0, hi = m;
while (lo <= hi) {
int i = (lo + hi) / 2; // nums1 左半部分长度
int j = (m + n + 1) / 2 - i; // nums2 左半部分长度
int maxLeft1 = (i == 0) ? INT_MIN : nums1[i - 1];
int minRight1 = (i == m) ? INT_MAX : nums1[i];
int maxLeft2 = (j == 0) ? INT_MIN : nums2[j - 1];
int minRight2 = (j == n) ? INT_MAX : nums2[j];
if (maxLeft1 <= minRight2 && maxLeft2 <= minRight1) {
// 找到正确的划分
if ((m + n) % 2 == 1) {
return std::max(maxLeft1, maxLeft2);
} else {
return (std::max(maxLeft1, maxLeft2) +
std::min(minRight1, minRight2)) / 2.0;
}
} else if (maxLeft1 > minRight2) {
hi = i - 1; // i 太大
} else {
lo = i + 1; // i 太小
}
}
return 0.0; // 不会到达
}
};
复杂度¶
- 时间: \(O(\log(\min(m, n)))\),只在较短数组上二分
- 空间: \(O(1)\)
注意事项¶
- 始终让
nums1是较短的数组,保证j不会越界 - 边界用
INT_MIN/INT_MAX哨兵处理 - 奇偶总长度分开处理中位数
- 追问 \(O(\log(m+n))\) 的做法:转化为 findKth 递归,每次排除 \(k/2\) 个元素
Q7. 找代码问题¶
原始代码¶
std::string remove_ctrl(std::string s) {
std::string result;
for (int i = 0; i < s.size(); i++) {
if (s[i] >= 0x20)
result = result + s[i];
}
return result;
}
问题分析¶
问题 1:s[i] >= 0x20 判断不准确¶
0x20 是空格字符。虽然 ASCII 中 0x00-0x1F 是控制字符,但 0x7F(DEL)也是控制字符。单纯用 >= 0x20 会漏掉 DEL,而且对非 ASCII 字符(负值 char)可能产生误判。
修复: 使用 std::isprint(static_cast<unsigned char>(s[i])) 判断是否可打印字符。
问题 2:result = result + s[i] 导致 O(n²) 复杂度¶
每次 result + s[i] 会:
- 创建一个 临时 string 对象,将
result的全部内容拷贝进去 - 追加
s[i] - 用 move 赋值给
result - 临时对象析构
循环 n 次,每次拷贝当前长度 → 总拷贝量为 \(1 + 2 + \cdots + n = O(n^2)\)。
修复: 使用 result += s[i](operator+= 原地追加,均摊 O(1))。
问题 3:参数按值传递¶
std::string s 是值传递,调用时会拷贝整个字符串。
修复: 改为 const std::string& s。
修正后代码¶
std::string remove_ctrl(const std::string& s) {
std::string result;
result.reserve(s.size()); // 预分配,避免多次扩容
for (size_t i = 0; i < s.size(); ++i) {
if (std::isprint(static_cast<unsigned char>(s[i]))) {
result += s[i];
}
}
return result; // NRVO 优化,不会拷贝
}
或更现代的写法:
std::string remove_ctrl(const std::string& s) {
std::string result;
result.reserve(s.size());
std::copy_if(s.begin(), s.end(), std::back_inserter(result),
[](unsigned char c) { return std::isprint(c); });
return result;
}
注意事项¶
| 问题 | 影响 | 修复 |
|---|---|---|
>= 0x20 |
功能不完整 | std::isprint |
result = result + c |
O(n²) 性能 | result += c |
| 值传递参数 | 不必要的拷贝 | const & |
| 未 reserve | 多次扩容 | result.reserve(s.size()) |
int i 与 size() 比较 |
signed/unsigned 不匹配警告 | 改为 size_t |
细看: STL 容器深入
二面总结¶
| # | 知识域 | 核心考点 |
|---|---|---|
| Q1 | C++ 基础 | static 五种用法 |
| Q2 | 对象模型 | 虚析构与多态删除 |
| Q3 | 智能指针 | 三种智能指针与所有权模型 |
| Q4 | 并发 | shared_ptr 引用计数原子 ≠ 对象安全 |
| Q5 | C++11 | 核心特性概览 |
| Q6 | 算法 | 两有序数组中位数,二分法 |
| Q7 | 代码审查 | string 拼接复杂度、isprint、const& |