任务 3:现代 C++ 五个小例子(RAII、智能指针、移动、lambda)
刷题用的 C++ 和写项目用的 C++ 差别很大。这篇用五个能跑的小程序,把网络库会用到的现代 C++ 特性一个个跑给你看,每个都有完整代码、实际输出和逐行解释。
前提: 完成了 任务 2,会用 g++ 编译。
这篇做完你会: 用 RAII 管理资源;在
unique_ptr、shared_ptr、weak_ptr之间做选择;看懂移动语义省掉了什么;用 lambda 和std::function写回调。预计用时: 4 到 6 小时,可以分两三天做。
先说为什么
刷题的代码有三个特点:只跑一次、几毫秒就结束、没人管内存。所以 new 了不 delete、全局数组开到 1e6,都无所谓,程序结束操作系统全回收了。
服务器不一样:它要连续跑几个月。每处理一个请求漏掉 100 字节,一天一亿个请求就漏掉 10 GB,最后被系统杀掉。每个连接漏掉一个 fd,几万个连接后就报 Too many open files,再也接不了新连接。
所以写服务器的第一原则是:每个资源都要有明确的主人,主人消失时资源自动释放。 下面五个例子都围绕这一点。
建目录:
mkdir -p ~/code/cpp-practice/task3
cd ~/code/cpp-practice/task3
每个例子的编译运行命令都一样,把 raii 换成对应的文件名:
g++ -std=c++17 -Wall -Wextra -g -o raii raii.cpp
./raii
例子 1:RAII,资源跟着对象走
概念
RAII 的全称是 Resource Acquisition Is Initialization,直译“资源获取即初始化”。名字很绕,意思很简单:
- 在构造函数里拿到资源(打开文件、申请内存、加锁)。
- 在析构函数里释放资源(关闭文件、释放内存、解锁)。
- C++ 保证:一个局部对象离开它的作用域(比如函数返回、跳出大括号,甚至是抛出异常)时,析构函数一定会被调用。
这样,资源就不可能忘记释放。
代码
raii.cpp 下载
#include <fcntl.h>
#include <unistd.h>
#include <iostream>
#include <stdexcept>
// 不用 RAII:每条退出路径都要记得 close
void withoutRaii(bool fail) {
int fd = ::open("/etc/hostname", O_RDONLY);
std::cout << "opened fd " << fd << std::endl;
if (fail) {
throw std::runtime_error("something went wrong"); // 忘了 close,fd 泄漏
}
::close(fd);
std::cout << "closed fd " << fd << std::endl;
}
// 用 RAII:构造时拿到 fd,析构时一定 close
class File {
public:
explicit File(const char* path) : fd_(::open(path, O_RDONLY)) {
std::cout << "File opened fd " << fd_ << std::endl;
}
~File() {
if (fd_ >= 0) ::close(fd_);
std::cout << "File closed fd " << fd_ << std::endl;
}
File(const File&) = delete; // 禁止拷贝:否则两个对象会 close 同一个 fd
File& operator=(const File&) = delete;
int fd() const { return fd_; }
private:
int fd_;
};
void withRaii(bool fail) {
File f("/etc/hostname");
if (fail) {
throw std::runtime_error("something went wrong"); // f 的析构函数照样会执行
}
std::cout << "using fd " << f.fd() << std::endl;
} // 离开作用域,f 析构
int main() {
try { withoutRaii(true); } catch (const std::exception& e) { std::cout << "caught: " << e.what() << std::endl; }
try { withRaii(true); } catch (const std::exception& e) { std::cout << "caught: " << e.what() << std::endl; }
withRaii(false);
return 0;
}
实际输出
opened fd 3
caught: something went wrong
File opened fd 4
File closed fd 4
caught: something went wrong
File opened fd 4
using fd 4
File closed fd 4
逐行看懂
- 第一段
withoutRaii(true):打开文件拿到 fd 3,然后抛了异常,::close(fd)那一行根本没执行到。输出里没有closed fd 3。 - 第二段
withRaii(true):注意File opened fd 4,是 4 不是 3。因为 fd 3 被上一段泄漏了,到现在还没关,系统只能分配下一个号码。这就是 fd 泄漏的直接证据。 - 同样抛了异常,但输出里有
File closed fd 4,而且出现在caught之前。说明异常往外传的过程中,C++ 自动调用了f的析构函数。 - 第三段正常执行,函数结束时自动关闭。
两个细节
File(const File&) = delete; 为什么要禁止拷贝? 如果允许 File g = f;,g 会得到同一个 fd 的副本。两个对象析构时会 close 同一个 fd 两次。第二次 close 的时候,这个号码可能已经被分配给了别的连接,你就把别人的连接关掉了。这种 bug 极难查。所以管理资源的类一律禁止拷贝。
explicit 是干什么的? 防止隐式转换。没有它的话,File f = "/etc/hostname"; 这种写法也能编译通过,容易误用。只有一个参数的构造函数都加上 explicit。
在项目里会怎么用
阶段 1 的网络库里,Socket 类就是这个 File 类的翻版:构造时接管一个 socket fd,析构时 close。连接对象被销毁时,fd 自动关闭,不会泄漏。
例子 2:unique_ptr,独占所有权
概念
new 出来的对象必须 delete,忘了就泄漏,删两次就崩溃。std::unique_ptr 是一个用 RAII 管理堆对象的类:它析构时自动 delete 指向的对象。
“unique” 的意思是独占:同一时刻只能有一个 unique_ptr 指向这个对象。所以它不能拷贝,只能移动。
代码
unique.cpp 下载
#include <iostream>
#include <memory>
#include <string>
#include <vector>
struct Connection {
explicit Connection(int id) : id(id) { std::cout << "Connection " << id << " created" << std::endl; }
~Connection() { std::cout << "Connection " << id << " destroyed" << std::endl; }
int id;
};
int main() {
{
std::unique_ptr<Connection> a = std::make_unique<Connection>(1);
std::cout << "a->id = " << a->id << std::endl;
// std::unique_ptr<Connection> b = a; // 编译错误:unique_ptr 不能拷贝
std::unique_ptr<Connection> b = std::move(a); // 可以移动:所有权从 a 转给 b
std::cout << "a is " << (a ? "not null" : "null") << ", b->id = " << b->id << std::endl;
} // b 离开作用域,Connection 1 被自动 delete
std::cout << "--- vector of unique_ptr ---" << std::endl;
std::vector<std::unique_ptr<Connection>> conns;
conns.push_back(std::make_unique<Connection>(2));
conns.push_back(std::make_unique<Connection>(3));
conns.erase(conns.begin()); // 从容器里删掉,就自动销毁
std::cout << "conns.size() = " << conns.size() << std::endl;
std::cout << "--- end of main ---" << std::endl;
return 0;
}
实际输出
Connection 1 created
a->id = 1
a is null, b->id = 1
Connection 1 destroyed
--- vector of unique_ptr ---
Connection 2 created
Connection 3 created
Connection 2 destroyed
conns.size() = 1
--- end of main ---
Connection 3 destroyed
逐行看懂
std::make_unique<Connection>(1):在堆上创建一个Connection,参数1传给构造函数,返回管理它的unique_ptr。永远用make_unique,不要写new。a->id:用法和普通指针一样,->访问成员,*a取对象。- 被注释掉的那行
b = a,如果取消注释,编译会报错:
error: use of deleted function 'std::unique_ptr<...>::unique_ptr(const std::unique_ptr<...>&)'
意思是“拷贝构造函数被删除了”,和我们在 File 里写的 = delete 是一回事。
b = std::move(a):所有权从a转给b,之后a变成空的(输出a is null)。- 大括号结束时
b析构,自动销毁了Connection 1。全程没有写delete。 vector<unique_ptr<Connection>>:容器拥有所有连接。erase掉一个,它就被销毁了(Connection 2 destroyed)。main结束时vector析构,剩下的Connection 3也被销毁。
在项目里会怎么用
EventLoop 用 unique_ptr<EpollPoller> 持有 poller;TcpServer 用 unique_ptr<Acceptor> 持有接收器。“这个对象只归我管”,就用 unique_ptr。这是默认选择。
例子 3:shared_ptr 和 weak_ptr,共享所有权
概念
有时候一个对象确实需要多个主人。比如一个网络连接,既被服务器的“连接表”持有,又正在被某个回调函数使用。连接断开时,连接表把它删了,但回调还在执行,如果这时对象就被销毁,回调再访问它就崩了。
std::shared_ptr 内部有一个引用计数:每多一个 shared_ptr 指向这个对象,计数加 1;每少一个,计数减 1;减到 0 时自动销毁对象。
std::weak_ptr 也能指向这个对象,但不增加计数。它像是“我知道这个对象,但我不负责让它活着”。用之前要调用 lock(),如果对象还在,得到一个临时的 shared_ptr;如果已经被销毁了,得到空指针。
代码
shared.cpp 下载
#include <iostream>
#include <memory>
struct Node {
explicit Node(const char* name) : name(name) { std::cout << name << " created" << std::endl; }
~Node() { std::cout << name << " destroyed" << std::endl; }
const char* name;
std::shared_ptr<Node> next; // 强引用
std::weak_ptr<Node> prev; // 弱引用:不增加计数
};
int main() {
std::cout << "--- shared_ptr 计数 ---" << std::endl;
{
auto a = std::make_shared<Node>("A");
std::cout << "use_count = " << a.use_count() << std::endl;
{
auto b = a; // 拷贝:计数 +1
std::cout << "use_count = " << a.use_count() << std::endl;
} // b 析构:计数 -1
std::cout << "use_count = " << a.use_count() << std::endl;
} // a 析构:计数变成 0,对象被销毁
std::cout << "--- 循环引用:两个 shared_ptr 互相指着 ---" << std::endl;
{
auto x = std::make_shared<Node>("X");
auto y = std::make_shared<Node>("Y");
x->next = y;
y->next = x; // 循环了
} // 看输出:X 和 Y 都没有被销毁,内存泄漏
std::cout << "--- 用 weak_ptr 打破循环 ---" << std::endl;
{
auto p = std::make_shared<Node>("P");
auto q = std::make_shared<Node>("Q");
p->next = q;
q->prev = p; // 反向用 weak_ptr
if (auto locked = q->prev.lock()) { // 使用前先 lock,拿到一个临时的 shared_ptr
std::cout << "Q's prev is " << locked->name << std::endl;
}
}
std::cout << "--- end of main ---" << std::endl;
return 0;
}
实际输出
--- shared_ptr 计数 ---
A created
use_count = 1
use_count = 2
use_count = 1
A destroyed
--- 循环引用:两个 shared_ptr 互相指着 ---
X created
Y created
--- 用 weak_ptr 打破循环 ---
P created
Q created
Q's prev is P
P destroyed
Q destroyed
--- end of main ---
逐行看懂
第一段: 计数从 1 变成 2(b = a 拷贝了一份),b 离开作用域后回到 1,a 离开作用域后变成 0,对象被销毁。
第二段(重点): X created、Y created 之后,再也没有出现 X destroyed 和 Y destroyed,一直到程序结束都没有。这就是循环引用导致的内存泄漏:
- 大括号结束时,局部变量
x和y被销毁,X 和 Y 的计数各减 1。 - 但 X 的
next指着 Y,Y 的next指着 X,两个计数都还是 1。 - 没有人能再访问到它们,可它们的计数永远不会变成 0。
第三段: 把反向的指针改成 weak_ptr。q->prev = p 不增加 P 的计数。大括号结束时,P 的计数变成 0,P 被销毁;P 销毁时它的 next 也被释放,Q 的计数变成 0,Q 也被销毁。
q->prev.lock() 的写法要记住:if (auto locked = q->prev.lock()) 同时完成了“尝试获取”和“判断是否还活着”。
怎么选
| 情况 | 用什么 |
|---|---|
| 对象只有一个主人 | unique_ptr(默认) |
| 确实有多个主人,谁最后用完谁负责销毁 | shared_ptr |
| 需要访问对象,但不应该延长它的寿命;或者要打破循环 | weak_ptr |
在项目里会怎么用
阶段 1 的 TcpConnection 用 shared_ptr 管理,这是整个网络库最关键的设计之一。Channel 会用一个 weak_ptr “拴住”它的连接,在处理事件期间 lock() 一下,保证处理到一半时连接不会被销毁。阶段 1 第 5 步会详细讲。
例子 4:移动语义,搬家而不是复印
概念
拷贝一个 1 MB 的缓冲区,要再申请 1 MB 内存、复制 1 MB 数据。但很多时候原来的对象马上就不用了(比如一个临时对象,或者函数返回值),复制一遍再把原来的扔掉,纯属浪费。
移动语义的做法是:既然原对象不要了,直接把它内部的指针“拿过来”,再把原对象的指针清空。不分配内存,不复制数据,只是几个指针赋值。
代码
move.cpp 下载
#include <cstring>
#include <iostream>
#include <utility>
#include <vector>
// 一个自己管理内存的缓冲区,演示拷贝和移动的区别
class Buffer {
public:
explicit Buffer(size_t size) : data_(new char[size]), size_(size) {
std::cout << "construct " << size_ << " bytes" << std::endl;
}
~Buffer() { delete[] data_; }
// 拷贝构造:新开一块内存,把内容复制过来(深拷贝,慢)
Buffer(const Buffer& other) : data_(new char[other.size_]), size_(other.size_) {
std::memcpy(data_, other.data_, size_);
std::cout << "copy " << size_ << " bytes" << std::endl;
}
// 移动构造:直接把对方的指针拿过来,再把对方置空(快,不复制数据)
Buffer(Buffer&& other) noexcept : data_(other.data_), size_(other.size_) {
other.data_ = nullptr;
other.size_ = 0;
std::cout << "move " << size_ << " bytes" << std::endl;
}
Buffer& operator=(const Buffer&) = delete;
Buffer& operator=(Buffer&&) = delete;
size_t size() const { return size_; }
private:
char* data_;
size_t size_;
};
Buffer makeBuffer() {
Buffer b(1024);
return b; // 编译器会直接在调用方构造(返回值优化),连移动都省了
}
int main() {
Buffer a(1024 * 1024);
Buffer b = a; // a 还要用,只能拷贝
Buffer c = std::move(a); // a 不再用了,把它的内存“搬”给 c
std::cout << "a.size() = " << a.size() << ", c.size() = " << c.size() << std::endl;
Buffer d = makeBuffer();
std::cout << "--- vector ---" << std::endl;
std::vector<Buffer> v;
v.reserve(2);
v.push_back(Buffer(16)); // 临时对象是右值,自动走移动
v.emplace_back(32); // 直接在 vector 里构造,连移动都没有
return 0;
}
实际输出
construct 1048576 bytes
copy 1048576 bytes
move 1048576 bytes
a.size() = 0, c.size() = 1048576
construct 1024 bytes
--- vector ---
construct 16 bytes
move 16 bytes
construct 32 bytes
逐行看懂
Buffer b = a;:a之后还要用,只能拷贝,打印copy,真的复制了 1 MB。Buffer c = std::move(a);:打印move。c直接拿走了a的内存,之后a.size()是 0。std::move本身什么都不移动。 它只是告诉编译器“把a当作一个马上要扔掉的值(右值)来对待”,于是编译器选择了移动构造函数Buffer(Buffer&&),而不是拷贝构造函数。真正的“搬运”是移动构造函数里那几行代码做的。- 移动之后不要再使用
a的值,它处于“有效但内容不确定”的状态,只能重新赋值或者等它析构。 makeBuffer()只打印了一次construct,连move都没有。这是编译器的返回值优化(RVO):直接在调用方的位置构造对象。所以函数直接return局部变量就行,不要写return std::move(b);,那样反而会阻止这个优化。v.push_back(Buffer(16)):Buffer(16)是一个临时对象,天然就是右值,自动走移动,不需要写std::move。v.emplace_back(32):直接在 vector 的内存里构造,把参数32转发给构造函数,连移动都省了。
移动构造函数后面的 noexcept 不能省。vector 扩容时要把旧元素挪到新内存,只有移动构造函数保证不抛异常,它才敢用移动,否则会退回去用拷贝。
在项目里会怎么用
网络库里的回调函数、数据缓冲区经常要在对象之间传来传去,用 std::move 传递可以避免无谓的复制。比如 setReadCallback(std::move(cb))。
例子 5:lambda 和 std::function,回调的基础
概念
lambda 是写在代码中间的匿名函数:
[捕获列表](参数列表) { 函数体 }
捕获列表决定 lambda 能使用外面的哪些变量,以及怎么用:[x] 拷贝一份 x;[&x] 引用外面的 x;[=] 拷贝所有用到的;[&] 引用所有用到的;[this] 捕获当前对象的指针。
std::function<返回类型(参数类型)> 是一个能装下“任何可调用的东西”的容器:普通函数、lambda、函数对象都行,只要参数和返回值类型对得上。
回调是把“某件事发生时要做什么”提前交给别人保存,等那件事发生时由别人来调用。网络库的核心就是回调:你告诉它“收到数据时调这个函数”,它在数据到达时调用。
代码
lambda.cpp 下载
#include <functional>
#include <iostream>
#include <map>
#include <string>
#include <vector>
// 一个“按钮”:被点击时调用事先设置好的回调。网络库里的 Channel 就是这个思路
class Button {
public:
using Callback = std::function<void(int)>;
void setOnClick(Callback cb) { onClick_ = std::move(cb); }
void click(int times) {
if (onClick_) onClick_(times);
}
private:
Callback onClick_;
};
int add(int a, int b) { return a + b; }
int main() {
// 1. 最简单的 lambda:[捕获](参数) { 函数体 }
auto square = [](int x) { return x * x; };
std::cout << "square(5) = " << square(5) << std::endl;
// 2. 按值捕获 [=] 和按引用捕获 [&]
int count = 0;
auto byValue = [count]() { return count + 1; }; // 拷了一份 count,之后外面怎么改都不影响
auto byRef = [&count]() { ++count; }; // 引用外面的 count,能修改它
byRef();
byRef();
std::cout << "count = " << count << ", byValue() = " << byValue() << std::endl;
// 3. std::function 能装下任何“可调用的东西”:普通函数、lambda
std::function<int(int, int)> op = add;
std::cout << "op(2, 3) = " << op(2, 3) << std::endl;
op = [](int a, int b) { return a * b; };
std::cout << "op(2, 3) = " << op(2, 3) << std::endl;
// 4. 回调:把“发生某事时做什么”交给别人保存,事件发生时由别人来调用
Button button;
int total = 0;
button.setOnClick([&total](int times) {
total += times;
std::cout << "clicked " << times << " times, total " << total << std::endl;
});
button.click(1);
button.click(3);
// 5. 用 map 存“命令名 -> 处理函数”,这就是阶段 2 命令分发的雏形
std::map<std::string, std::function<std::string(const std::string&)>> commands;
commands["PING"] = [](const std::string&) { return std::string("PONG"); };
commands["ECHO"] = [](const std::string& arg) { return arg; };
for (std::string name : {"PING", "ECHO", "GET"}) {
auto it = commands.find(name);
if (it == commands.end()) {
std::cout << name << " -> ERR unknown command" << std::endl;
} else {
std::cout << name << " -> " << it->second("hello") << std::endl;
}
}
return 0;
}
实际输出
square(5) = 25
count = 2, byValue() = 1
op(2, 3) = 5
op(2, 3) = 6
clicked 1 times, total 1
clicked 3 times, total 4
PING -> PONG
ECHO -> hello
GET -> ERR unknown command
逐行看懂
byValue在创建时拷贝了count,当时count是 0,所以之后外面count变成 2,byValue()还是返回 0 + 1 = 1。byRef引用了外面的count,调用两次后count变成 2。- 同一个
std::function变量op,先装了普通函数add,又装了一个做乘法的 lambda,调用方式完全一样。 Button自己不知道点击后要做什么,它只负责“在被点击时调用保存的回调”。具体做什么由使用者通过setOnClick决定。阶段 1 的Channel类就是这个Button:它保存“可读时调什么”“可写时调什么”,epoll 报告事件时,它调用对应的回调。- 最后的
map<string, function>是“命令名到处理函数”的表,阶段 2 的 mini-Redis 就用这种方式分发SET、GET等命令。
最常见的坑:引用捕获了一个已经销毁的变量
std::function<void()> makeCallback() {
int x = 42;
return [&x]() { std::cout << x << std::endl; }; // 错!x 在函数返回时就销毁了
}
返回的 lambda 里引用着已经销毁的 x,调用时读到的是垃圾值,或者直接崩溃。规则:lambda 如果会被保存起来、以后再调用(回调都是这样),就不要用 [&] 引用捕获局部变量,改成按值捕获。 例子里的 Button 能用 [&total],是因为 button 和 total 都活到 main 结束。
交作业
把五个文件都提交到 GitHub:
cd ~/code/cpp-practice
git add task3
git commit -m "Add modern C++ examples: RAII, smart pointers, move, lambda"
git push
然后不看本页,自己完成下面三个改写,能做出来才算掌握:
- 写一个
LockGuard类,构造时调用std::mutex的lock(),析构时调用unlock(),并禁止拷贝。(这就是标准库的std::lock_guard。) - 把
shared.cpp第二段改成不泄漏的版本,并在输出里看到X destroyed和Y destroyed。 - 给
Button加一个setOnDoubleClick回调,并写一个doubleClick()方法触发它。
检查清单
- 五个例子都自己敲过、编译过,输出和本页一致
- 能说出 RAII 解决了什么问题,举出
withoutRaii泄漏 fd 的证据 - 知道为什么管理资源的类要禁止拷贝
- 能说出
unique_ptr、shared_ptr、weak_ptr各在什么情况下用 - 知道
std::move本身不移动任何东西 - 知道保存起来的回调不能引用捕获局部变量
- 完成了三个改写练习
自测
unique_ptr 能放进 std::vector 吗?
能。vector 只要求元素能移动,unique_ptr 可以移动。push_back 时传临时对象(make_unique 的返回值)或者用 std::move。
raii.cpp 的输出里,第二段为什么是 fd 4 而不是 fd 3?
Linux 分配 fd 时总是给出当前最小的空闲号码。fd 3 在第一段被打开后因为异常没有关闭,一直被占用,所以下一次只能分配到 4。这是 fd 泄漏的直接证据。
return std::move(local); 为什么不推荐?
直接 return local; 时编译器可以做返回值优化,在调用方的位置直接构造对象,连移动都省掉;就算不能优化,也会自动当作右值来移动。写了 std::move 反而阻止了返回值优化。
面试常问
什么是 RAII?你在项目里怎么用的?
参考回答: RAII 是在构造函数里获取资源、在析构函数里释放资源,让资源的生命周期和对象绑定。C++ 保证局部对象离开作用域时一定调用析构函数,包括抛异常的情况,所以资源不会泄漏。我的网络库里 Socket 类在析构时 close fd,锁用 std::lock_guard,堆对象用智能指针,代码里基本没有手写的 delete 和 close。
shared_ptr 是怎么实现的?线程安全吗?
参考回答: shared_ptr 里有两个指针,一个指向对象,一个指向控制块。控制块里有强引用计数、弱引用计数和删除器。拷贝时强引用计数加一,析构时减一,减到 0 销毁对象;弱引用计数也到 0 时释放控制块。
线程安全分三层说:引用计数的增减是原子操作,多个线程各自持有自己的拷贝是安全的;通过它访问的对象本身不受保护,要自己加锁;多个线程同时读写同一个 shared_ptr 变量也不安全。
可能的追问: make_shared 有什么好处?只分配一次内存(对象和控制块在一起),更快;缺点是只要还有 weak_ptr,整块内存都不能释放。
std::move 做了什么?
参考回答: std::move 只是一个类型转换,把左值转换成右值引用,让编译器在重载决议时选择移动构造或移动赋值。真正的资源转移是移动构造函数做的,通常是把对方的指针拿过来,再把对方置空。移动之后原对象处于有效但未指定的状态,可以析构或重新赋值,但不应该再依赖它的值。
什么情况下会用到 weak_ptr?
参考回答: 两种情况。一是打破 shared_ptr 的循环引用,比如双向链表、父子节点互相指着,其中一个方向用 weak_ptr。二是“观察”一个对象但不延长它的寿命,比如回调里需要访问一个连接,但连接可能已经断开被销毁了,就保存 weak_ptr,用的时候 lock(),拿到空指针说明对象已经没了,直接返回。