learn.ochresign
阶段 0 · 补前置

任务 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,直译“资源获取即初始化”。名字很绕,意思很简单:

  1. 在构造函数里拿到资源(打开文件、申请内存、加锁)。
  2. 在析构函数里释放资源(关闭文件、释放内存、解锁)。
  3. 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,一直到程序结束都没有。这就是循环引用导致的内存泄漏:

  1. 大括号结束时,局部变量 x 和 y 被销毁,X 和 Y 的计数各减 1。
  2. 但 X 的 next 指着 Y,Y 的 next 指着 X,两个计数都还是 1。
  3. 没有人能再访问到它们,可它们的计数永远不会变成 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

然后不看本页,自己完成下面三个改写,能做出来才算掌握:

  1. 写一个 LockGuard 类,构造时调用 std::mutex 的 lock(),析构时调用 unlock(),并禁止拷贝。(这就是标准库的 std::lock_guard。)
  2. 把 shared.cpp 第二段改成不泄漏的版本,并在输出里看到 X destroyed 和 Y destroyed。
  3. 给 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(),拿到空指针说明对象已经没了,直接返回。