跳转至

快手 — 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++ 中的多态分两种:

类型 机制 绑定时机
编译期多态(静态多态) 函数重载、模板 编译期
运行期多态(动态多态) 虚函数 + 继承 运行期

面试说"多态"一般指 动态多态

动态多态原理

  1. 虚函数表(vtable):编译器为每个含虚函数的类生成一张函数指针表,表中按声明顺序存放虚函数的地址
  2. 虚表指针(vptr):每个对象内部隐含一个指向 vtable 的指针,通常在对象内存布局的 最前面
  3. 调用流程:通过基类指针/引用调用虚函数时 → 先读对象的 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(名称修饰),将参数类型编码进符号名,比如:

void foo(int)    → _Z3fooi     (GCC)
void foo(double) → _Z3food

而 C 编译器不做修饰,foo 就是 foo

如果 C++ 代码要调用 C 编写的库函数,链接器会按 C++ 修饰后的名字去找符号,找不到就报 undefined referenceextern "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 语言

编译器生成的符号名 只包含函数名本身。两个重载函数(同名不同参数)在符号表中产生同名冲突,链接器无法区分。

void foo(int)    → 符号: foo
void foo(double) → 符号: foo   // 冲突!

C++

编译器将 函数名 + 参数类型(+ 命名空间等) 一起编码进符号名:

void foo(int)    → _Z3fooi
void foo(double) → _Z3food    // 不冲突

这样链接器就能准确区分不同的重载版本。

完整流程

源码 → 编译(生成 .o,符号表含修饰后的名字)→ 链接(按符号名匹配)

注意事项

  • C++ 重载只看 参数列表(类型、数量、顺序),不看返回值——因为调用处不一定使用返回值,编译器无法仅靠返回值区分
  • extern "C" 的函数由于使用 C 命名规则,同样不能重载
  • 面试追问:name mangling 的具体规则可以用 c++filt 工具反解:c++filt _Z3fooifoo(int)

细看: STL · 模板 · 编译链接


Q4. TCP/IP 是流式的,怎么知道一段数据大小、包含多少字节、怎么解包

回答

TCP 是 字节流协议,没有消息边界的概念。应用层需要自行设计 成帧协议(framing protocol) 来划分消息。常见方案:

方案一:固定长度

每条消息恒定 N 字节。接收端每次读 N 字节即为一条完整消息。

[  msg1 (128B)  ][  msg2 (128B)  ][  msg3 (128B)  ]

简单但不灵活,适合定长结构体传输。

方案二:特殊分隔符

用特定字符(如 \r\n\0)标记消息结束。HTTP/1.1 的请求头就是用 \r\n\r\n 分隔。

msg1\r\nmsg2\r\nmsg3\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}\),中奖概率为:

\[P = \frac{1}{\binom{N}{K}} = \frac{K!(N-K)!}{N!}\]

期望收益

\[E = P \times \text{奖金} - \text{票价}\]

如果 \(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_tstd::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

class Base {
public:
    virtual ~Base() = default;  // 虚析构
};

这样 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

auto p = std::make_unique<int>(42);
// auto p2 = p;            // 编译错误:不可拷贝
auto p2 = std::move(p);    // 移动转移所有权

shared_ptr

auto p1 = std::make_shared<int>(42);  // 引用计数 = 1
auto p2 = p1;                         // 引用计数 = 2
p1.reset();                            // 引用计数 = 1
// p2 析构时引用计数 → 0,释放内存

weak_ptr 打破循环引用

struct Node {
    std::shared_ptr<Node> next;
    std::weak_ptr<Node> prev;   // 用 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 替代 NULLNULL 是整数 0,可能引起重载二义性)
constexpr 编译期常量表达式
范围 for for (auto& x : container)
lambda 表达式 [capture](params) { body }
变参模板 template<typename... Args>
enum class 强类型枚举,不隐式转 int
override / final 显式标记虚函数重写/禁止继承
委托构造 构造函数调用同类其他构造函数
类内初始化 int x = 0; 直接在类声明中初始化成员

标准库

特性 说明
智能指针 unique_ptrshared_ptrweak_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 constexprstd::optionalconcepts 等)

细看: 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] 会:

  1. 创建一个 临时 string 对象,将 result 的全部内容拷贝进去
  2. 追加 s[i]
  3. 用 move 赋值给 result
  4. 临时对象析构

循环 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 isize() 比较 signed/unsigned 不匹配警告 改为 size_t

细看: STL 容器深入


二面总结

# 知识域 核心考点
Q1 C++ 基础 static 五种用法
Q2 对象模型 虚析构与多态删除
Q3 智能指针 三种智能指针与所有权模型
Q4 并发 shared_ptr 引用计数原子 ≠ 对象安全
Q5 C++11 核心特性概览
Q6 算法 两有序数组中位数,二分法
Q7 代码审查 string 拼接复杂度、isprint、const&