learn.ochresign
阶段 0 · 补前置

现代 C++:写网络库要用到的部分

RAII、智能指针、移动语义和 lambda。打竞赛用不到,但网络库从头到尾都离不开它们。

竞赛 C++ 和工程 C++ 的区别

打 ACM 时,程序跑几秒就结束,内存泄漏也无所谓。服务器则要连续运行几个月,任何泄漏都会越积越多。另外,网络库是靠回调驱动的,对象之间的所有权关系很复杂。现代 C++ 的这几样工具,就是为了解决“谁负责释放、什么时候释放”的问题。

RAII:资源跟着对象走

RAII 的意思是:在构造函数里获取资源,在析构函数里释放资源。对象离开作用域时,析构函数会被自动调用,所以资源一定会被释放,即使中途 return 或者抛出异常也一样。

// 用 RAII 管理 fd:这个对象销毁时,fd 一定会被 close
class Socket {
public:
    explicit Socket(int fd) : fd_(fd) {}
    ~Socket() { if (fd_ >= 0) ::close(fd_); }
    Socket(const Socket&) = delete;             // 禁止拷贝:两个对象 close 同一个 fd 会出事
    Socket& operator=(const Socket&) = delete;
    int fd() const { return fd_; }
private:
    int fd_;
};

阶段 1 的 Socket、MutexLock 都是这种写法。

智能指针

类型 所有权 什么时候用
unique_ptr<T> 独占,只能移动、不能拷贝 默认首选,比如 EventLoop 独占它的 Poller
shared_ptr<T> 共享,有引用计数,计数归零时释放 多方都可能持有,比如 TcpConnection
weak_ptr<T> 不持有,只观察;用前要 lock() 检查对象是否还活着 防止循环引用,或者回调里判断对象还在不在
auto loop = std::make_unique<EventLoop>();
auto conn = std::make_shared<TcpConnection>(loop.get(), fd);
std::weak_ptr<TcpConnection> weak = conn;
if (auto c = weak.lock()) {     // 对象还活着才会进来
    c->send("hello");
}

移动语义

“移动”就是把资源的所有权直接转给另一个对象,而不是复制一份。对 std::string、std::vector 这种持有堆内存的对象来说,移动只需要交换几个指针,拷贝则要把全部数据复制一遍。

std::string buf = readAll(fd);          // 假设有 1 MB
messages.push_back(std::move(buf));     // 只转交指针,不复制 1 MB 的数据
// 之后 buf 处于“有效但内容未指定”的状态,不要再使用它的内容

lambda 与 std::function

网络库的用法通常是:上层注册回调,事件发生时网络库调用它。std::function 用来存放回调,lambda 用来写回调。

server.setMessageCallback(
    [](const TcpConnectionPtr& conn, Buffer* buf) {
        conn->send(buf->retrieveAllAsString());   // echo
    });

捕获列表要小心:[&] 按引用捕获,如果回调执行时被引用的变量已经销毁了,程序就会崩溃。异步回调里优先按值捕获,或者捕获 shared_ptr / weak_ptr。

常见的坑

  • 用 shared_ptr 造成循环引用:A 持有 B 的 shared_ptr,B 也持有 A 的,两者的计数永远不会归零,都不会被释放。其中一方改用 weak_ptr 就能解决。
  • 在构造函数里调用 shared_from_this():此时对象还没有被 shared_ptr 接管,调用会抛出异常。
  • 使用移动之后的对象:移动之后,除了重新赋值和析构,不要再假设它里面还有什么内容。

自测

为什么 Socket 类要禁止拷贝?

如果允许拷贝,两个对象会持有同一个 fd,它们析构时都会 close。第二次 close 关掉的可能是这期间被新连接复用的同一个编号,导致别人的连接被误关。

unique_ptr 能放进 std::vector 吗?

能。vector<unique_ptr<T>> 是常见写法,只是放进去时要用 std::move,或者直接用 emplace_back(std::make_unique<T>(...))。

什么时候必须用 weak_ptr?

需要引用一个对象,但又不能延长它的生命周期时。典型场景是打破循环引用,以及异步回调里先判断对象是否还活着。

面试常问

什么是 RAII?你在项目里怎么用的?

参考回答: RAII 是“资源获取即初始化”:在构造函数里拿资源,在析构函数里释放资源,让资源的生命周期跟对象绑定。对象离开作用域时析构函数一定会执行,哪怕中途抛了异常,所以不会泄漏。我的项目里 Socket 类在析构时 close(fd),锁用 std::lock_guard,内存用智能指针,代码里基本没有手写的 close 和 delete。

shared_ptr 是怎么实现的?它线程安全吗?

参考回答: shared_ptr 里有两个指针,一个指向对象,一个指向控制块。控制块里存强引用计数、弱引用计数和删除器。拷贝时强引用计数加一,析构时减一,减到 0 就销毁对象;弱引用计数也到 0 时再释放控制块。

线程安全要分开说:引用计数的增减是原子操作,所以多个线程各自持有一份拷贝是安全的;但通过它访问的对象本身不受保护,要自己加锁;多个线程同时读写同一个 shared_ptr 变量(比如一个线程给它赋值,另一个线程拷贝它)也不安全。

可能的追问: make_shared 和 shared_ptr<T>(new T) 有什么区别?make_shared 只分配一次内存,把对象和控制块放在一起,更快,也没有异常时泄漏的风险;缺点是只要还有 weak_ptr 在,整块内存就释放不了。

std::move 做了什么?移动之后原对象还能用吗?

参考回答: std::move 本身什么都不移动,它只是一个类型转换,把左值转成右值引用,让编译器去匹配移动构造或移动赋值。真正的“搬运”是移动构造函数做的,比如把 std::string 内部的指针拿过来,再把原对象的指针置空。移动之后原对象处于“有效但未指定”的状态,可以重新赋值或者析构,但不应该再依赖它的值。

可能的追问: 完美转发是什么?配合模板里的万能引用 T&& 和 std::forward<T>,把参数原本是左值还是右值的属性原样传给下一层函数,emplace_back 就是这么实现的。

虚函数是怎么实现的?析构函数为什么要声明成 virtual?

参考回答: 有虚函数的类,编译器会生成一张虚函数表(vtable),每个对象里多存一个指向这张表的指针(vptr)。通过基类指针调用虚函数时,先通过 vptr 找到表,再找到实际类型的函数,这就是运行时多态。

如果基类析构函数不是 virtual,用基类指针 delete 一个派生类对象时只会调用基类的析构函数,派生类的部分不会被析构,造成资源泄漏,标准上算未定义行为。所以只要类打算被继承并通过基类指针删除,析构函数就要是 virtual。

unique_ptr 和 shared_ptr 怎么选?

参考回答: 默认用 unique_ptr,它没有额外开销,所有权清楚。只有确实需要多个地方共同拥有同一个对象时才用 shared_ptr,比如网络库里一个 TcpConnection 既被连接表持有,回调执行期间又要保证它不被销毁。两个对象互相持有 shared_ptr 会循环引用导致泄漏,这时其中一方要改成 weak_ptr。

原文与延伸

  • cppreference:标准库的权威参考,有中文版
  • Scott Meyers《Effective Modern C++》:条款 1–5(类型推导)、18–22(智能指针)、23–30(移动语义)