从零开始 / 一次只解释眼前的一步

真实资源与RAII:
独占拥有。

先跟踪一份真实对象由谁申请、由谁释放,再把每条退出路径上的责任交给拥有者。用具体程序区分独占转交、借用观察和主动释放。

先备:基础12(含此前基础课与M1)5 小节 · 预计共 8 小时阅读、推演与编码合计

8小时是包含阅读、推演和编码的设计估算,未经真人试学校准。可分多次学习,遇到不清楚的地方保留预测、实际结果和疑问,之后再回修教材。手机可读图与做预测,编译需要电脑终端。

这一章怎样学

先读一小段,写下预测,再运行程序。每次只改一个条件,最后关掉示例,从空文件独立写一次。遇到错误,把第一条报错和自己的修复记下来;不用赶着把页面滚到底。

每次写下动态对象、拥有者和借用者,再检查正常结束与提前返回时谁负责释放。先完成第12章复制与移动,再开始本章;此前的M1仍按自己的独立材料核对,打开本页不会替你判定M1或G0通过。

13.1 new/delete与泄漏:地址变量结束,对象也会结束吗?

先完成复制与移动。你已经能区分对象与访问它的指针,也知道普通局部对象在离开作用域时销毁。本章增加一种对象:程序主动申请存储,在其中建立对象,再由明确的负责人结束它的寿命并归还存储。

先记下两个独立问题:对象在哪里、现在还活着吗?谁负责释放它?拿到地址只解决访问问题,不会自动回答释放责任。我们只讨论单个int或Trace对象、默认释放方式,以及正常结束和提前return的路径。以下确定输出以分配成功为前提;分配失败的异常处理到第16章再接入。

一个new表达式做什么

new int{9}申请适合一个int的动态存储,在其中初始化整数9,然后产生指向该对象的int*int* raw = new int{9};里的raw是一个局部指针变量;它指向的整数是另一个对象,有独立的寿命。这种存储常被称为堆(heap);这里不要求预测物理地址或操作系统究竟怎样分配内存。

new Trace{9}同理:申请一个Trace所需的存储,调用已经在07章讲过的构造函数,得到Trace*。下面的Trace把initial存入value,析构函数打印自己的值。读取raw->value沿用07章的指针成员访问;new没有给你另一套成员语法。

delete做什么,没做什么

对本章由单对象new得到、尚未释放的对象,delete raw;结束对象寿命,调用类对象的析构函数,并归还相应动态存储。raw本身不会被自动改成nullptr;其他复制过这个地址的指针也不会自动清空。释放后不能再通过它们读对象,也不能再次释放同一个对象。

反过来,局部raw离开作用域,只会结束这个指针变量自己的寿命,不会沿着地址自动delete对象。如果所有访问路径都丢失,而对象仍未释放,就形成内存泄漏(memory leak)。错误的关键是少了一条释放路径,不能靠“进程最后退出了”证明这个函数正确管理了对象。

这里的delete只配对相应的单对象new;普通局部变量的地址和容器元素的地址不能交给它。空指针可以交给delete,但这不使重复释放变安全:一个悬空指针不会自行变成空指针。动态数组、特殊分配方式和自定义释放器不在本章实例范围。

先手动列清每一条出口

下面是已经补齐两条释放路径的程序。先分别预测run(false)run(true),再运行;不要把两次调用看成同一个Trace在重复销毁。

#include <iostream>

struct Trace {
    int value;
    explicit Trace(int initial) : value{initial} {}
    ~Trace() {
        std::cout << "destroy " << value << '\n';
    }
};

void run(bool stop) {
    Trace* raw = new Trace{9};
    std::cout << "value " << raw->value << '\n';
    if (stop) {
        std::cout << "early\n";
        delete raw;
        return;
    }
    std::cout << "normal\n";
    delete raw;
}

int main() {
    run(false);
    run(true);
    return 0;
}

第一次调用:申请值9的Trace,打印value 9;stop为false,跳过早退分支,打印normal,执行末尾delete,打印destroy 9。第二次调用重新申请另一个Trace;stop为true,打印early,先delete再return,因此也打印一次destroy 9。两个对象各有一次匹配释放。

现在只做静态分析:假如删掉早退分支中的delete raw;,第二次调用在return处离开,末尾的delete不会被执行。Trace仍然没有被销毁,raw这个局部名字却消失了。不要实际运行这个泄漏版本;请在纸上画出“已申请→early→return”这条没有释放的路径。它很容易写出来,而且通常仍能编译——编译成功不等于所有路径都配对。

这暴露了手工管理的维护成本:每新增一个return,都要重新核对清理。如果让“离开作用域时必经的析构”承担释放,调用者就不需要在每个出口重写delete。下一节把这个想法落实在真实申请的整数上。

13.2 RAII:让拥有者的析构承担真实释放

这里把“最终要负责释放”的关系称为拥有(ownership)。仅暂时访问对象、没有释放责任,称为借用(borrow)。这是接口与程序设计的约定;裸指针的类型int*本身没有编码这两种责任,所以单看一个地址不能断定该由谁delete。

RAII全称Resource Acquisition Is Initialization。用本章的对象表达,就是:拥有者在初始化时取得资源,把清理写在析构函数里;正常离开其作用域或通过return离开时,拥有者析构,执行清理。之前07章只是观察局部对象的销毁顺序,这里第一次把它与真实动态资源的释放连接起来。

一个很小、只负责本章场景的拥有者

IntOwner的pointer成员保存地址。构造函数的pointer{new int{initial}}申请并保存一个真实整数;析构函数的delete归还它。value() const只读取整数并按值返回,不把地址或释放责任交出去。

本例把复制构造与复制赋值声明为= delete。这不是“释放对象”的delete表达式,而是12章已经教过的禁止操作声明。若允许两个这种拥有者只复制裸地址,它们会把同一个对象当成各自负责的资源;两个析构就会重复释放。这里也没有提供转交操作,只观察一个局部拥有者;完整、可移动的独占接口从下一节交给标准库。

#include <iostream>

class IntOwner {
    int* pointer;
public:
    explicit IntOwner(int initial) : pointer{new int{initial}} {}
    IntOwner(const IntOwner&) = delete;
    IntOwner& operator=(const IntOwner&) = delete;
    ~IntOwner() {
        std::cout << "release " << *pointer << '\n';
        delete pointer;
    }
    int value() const {
        return *pointer;
    }
};

int main() {
    {
        IntOwner owner{9};
        std::cout << "value " << owner.value() << '\n';
        std::cout << "scope end\n";
    }
    std::cout << "after scope\n";
    return 0;
}

输出依次是value 9scope endrelease 9after scope。内层右花括号使owner析构;析构先读取尚存活的整数并打印release,然后实际执行delete,外层才打印after scope。release日志位于delete之前;它只能标出走进了清理代码,不能单凭这一行证明内存已经归还。释放行为的依据是紧随其后的实际delete及其有效前提。

这里有两个不同的寿命:局部owner按作用域销毁,动态整数由owner的析构释放。RAII把二者连接起来。它并没有把动态整数变成普通局部int,也没有要求所有资源都必须动态分配;我们选这个例子,是为了让取得与归还都能从源码直接指出。

本章只追踪正常控制流与return。程序被强制终止时不能套用这里的析构时间线;异常如何沿调用栈清理在16章讲授。现在要能指出的不是一条口号,而是“谁的析构、执行哪一条释放、在哪个出口发生”。

13.3 unique_ptr与make_unique:把独占合同交给标准库

使用#include <memory>后,std::unique_ptr<T>表示本节的独占拥有者类型。沿08章“填入模板实参”的读法,把T换成int或Trace即可使用;不需要现在实现模板。我们使用单对象形式与默认释放方式:非空拥有者销毁时,会delete它管理的那个T对象;空拥有者销毁时没有对象需要释放。

std::make_unique<T>(参数)建立一个新的T对象,并返回拥有它的unique_ptr。std::make_unique<int>(9)得到拥有整数9的unique_ptr<int>std::make_unique<Trace>(9)用9调用Trace构造函数,得到unique_ptr<Trace>auto owner = ...接住这个拥有者作为初值。工厂函数的内部转发与模板实现不是本节先备。

先理解访问,再运行完整例子

对本章非空且所管对象仍活着的owner,*owner访问整个T对象,得到其引用;owner->value访问Trace的value成员。虽然写法像裸指针,owner本身是具有管理操作的类对象。使用这些访问前仍须有有效对象;智能指针不会把空对象解引用变成合法操作。

下面完整例一与手工清理例子做同样的工作,调用者在每次run结束后打印returned。先从源码找出负责delete的地方:run中没有手写delete,责任在owner的析构合同中。

#include <iostream>
#include <memory>

struct Trace {
    int value;
    explicit Trace(int initial) : value{initial} {}
    ~Trace() {
        std::cout << "destroy " << value << '\n';
    }
};

void run(bool stop) {
    auto owner = std::make_unique<Trace>(9);
    std::cout << "value " << owner->value << '\n';
    if (stop) {
        std::cout << "early\n";
        return;
    }
    std::cout << "normal\n";
}

int main() {
    run(false);
    std::cout << "returned\n";
    run(true);
    std::cout << "returned\n";
    return 0;
}
调用 函数内走过的语句 调用者接着看到什么
run(false) 建立Trace9,输出value 9;跳过早退,输出normal;到作用域末尾,owner析构并销毁Trace,输出destroy 9 returned
run(true) 建立另一个Trace9,输出value 9与early;执行return,离开owner的作用域,销毁Trace,输出destroy 9 returned

两次destroy属于两次创建的不同对象。提前return只省略了normal那条语句,没有绕过这个已构造局部拥有者的析构。释放已由owner负责,调用者不再手工delete所管对象。

停下来,看一次变化

提前return仍经过拥有者的析构

局部owner拥有Trace本次Trace值9,存活此前调用已结束并释放1 / 5 · unique-early-return第二次run(true);第一条正常路径已结束局部owner拥有Trace本次Trace值9,存活此前调用已结束并释放1 / 5
1 · 新调用建立自己的对象

run(true)用make_unique<Trace>(9)建立第二次调用的真实Trace。它由本次局部owner管理,与已经结束的run(false)没有共享对象。

1 / 5
查看所有步骤的文字与数值
  1. 1 · 新调用建立自己的对象

    局部owner:拥有Trace;本次Trace:值9,存活;此前调用:已结束并释放

    run(true)用make_unique<Trace>(9)建立第二次调用的真实Trace。它由本次局部owner管理,与已经结束的run(false)没有共享对象。

  2. 2 · 先观察有效对象

    owner:仍独占管理;Trace:9,存活;本次输出:value 9

    owner->value读取仍存活的Trace,打印value 9。owner是拥有者,读取成员没有改变释放责任。

  3. 3 · 选择提前返回

    分支:stop=true;本次输出:value 9 / early;Trace:清理前仍存活

    stop为true,程序打印early并到达return。这个普通return会结束局部owner的寿命,不会绕开它的析构。

  4. 4 · 退出时实际清理

    局部owner:退出并完成清理;本次Trace:已销毁;新增输出:destroy 9

    owner析构通过默认删除操作销毁Trace并释放其存储。Trace析构打印destroy 9;清理完成后此对象不再存活。

  5. 5 · 调用者继续执行

    执行位置:main;最后输出:returned;本次Trace:已清理,不可再读

    run(true)已经返回,main随后打印returned。本次Trace没有留给调用者清理;两次调用各自只销毁自己的对象一次。

空拥有者、值0的对象,是两种状态

std::unique_ptr<int> empty{};建立一个不管理对象的空拥有者;不带初始化式的std::unique_ptr<int> empty;同样调用默认构造,结果也为空。这与未初始化的裸指针不同。

本章有三种明确的检查读法:owner == nullptr检查是否为空;在if条件中的owner检查是否非空;!owner检查是否为空。后两种使用unique_ptr提供的显式布尔转换及逻辑非;03章的&&||所需布尔操作数也可以用这种条件转换。它们仍按原有短路规则求值,不代表unique_ptr支持任意位置自动变成bool。判断的是有没有对象,不是对象内的整数是否等于0。

#include <iostream>
#include <memory>

int main() {
    std::unique_ptr<int> empty{};
    std::cout << (empty == nullptr) << '\n';
    auto zero = std::make_unique<int>(0);
    if (zero) {
        std::cout << "value " << *zero << '\n';
    }
    if (!empty) {
        std::cout << 1 << '\n';
    }
    return 0;
}

先打印empty与nullptr比较的结果1。zero拥有一个真实整数,内容恰好为0,因此if (zero)成立,打印value 0;最后empty仍为空,if (!empty)成立,再打印1。程序退出时zero释放那个整数;empty没有对象可释放。把“0是合法内容”和“空指针表示没有对象”分清,后面的接管接口才不会错误丢弃输入0。

本节没有假定多个拥有者能共享同一个对象。unique_ptr的复制操作被禁止;下一节使用已经学过的移动,明确地转交这一份责任。

13.4 转交与观察:对象没搬家,负责人可以更换

12章说明std::move只改变表达式参与选择的方式;具体移动做什么,由类型的合同决定。对本章的unique_ptr,移动构造把原对象的管理责任交给新拥有者,来源随后为空。对象本身没有因此销毁或重新建立。这条“移动后为空”来自unique_ptr的专门合同,不能反过来套到所有string、vector或自定义类型。

get只给地址,不交责任

a.get()取得a当前保存的裸指针;a为空时返回nullptr。这个调用不释放、不转交,也不会建立第二个拥有者。把结果存进int* borrowed,只是保存一份借用地址。能否读取它,仍取决于对应对象是否存活。

完整例二把两种关系分开画:a到对象的拥有关系,以及borrowed到同一对象的借用关系。

#include <iostream>
#include <memory>
#include <utility>

int main() {
    auto a = std::make_unique<int>(9);
    int* borrowed = a.get();
    auto b = std::move(a);
    std::cout << "a empty " << (a == nullptr) << '\n';
    std::cout << "b " << *b << '\n';
    std::cout << "borrow " << *borrowed << '\n';
    return 0;
}

先建立整数9,由a拥有;borrowed记住它的地址。auto b = std::move(a);用移动构造建立b,a变空,b接管原对象。随后输出a empty 1b 9borrow 9。最后一次借用仍有效,因为b还活着、没有释放或替换这个对象。main结束时b负责释放;borrowed只是一个指针变量,没有清理动作。

停下来,看一次变化

拥有关系改变,借用仍指向同一活对象

a拥有int目标int9,存活b尚未建立1 / 5 · transfer-borrow真实逻辑关系,不表示具体地址数值a拥有int目标int9,存活b尚未建立1 / 5
1 · a管理一个整数

a由make_unique<int>(9)建立,独占管理值为9的整数。此时b尚未创建。

1 / 5
查看所有步骤的文字与数值
  1. 1 · a管理一个整数

    a:拥有int;目标int:9,存活;b:尚未建立

    a由make_unique<int>(9)建立,独占管理值为9的整数。此时b尚未创建。

  2. 2 · get只提供观察地址

    a:仍是拥有者;borrowed:借用同一int;目标int:9,存活

    borrowed=a.get()取得该整数的地址。a仍负责释放,borrowed只借用,没有独立删除权。

  3. 3 · 把独占关系交给b

    a:空;b:拥有同一int;int / borrowed:值9;借用仍有效

    b从std::move(a)构造。unique_ptr合同保证a为空,b承担释放责任;同一个整数继续存活,先前的borrowed仍指向它。

  4. 4 · 目标存活时读取两条路径

    拥有者:b;两次读取:b=9 / borrow=9;a判空:1

    程序在b仍拥有对象时读取*b与*borrowed,二者都为9。输出a empty 1、b 9、borrow 9,未从空a解引用。

  5. 5 · b退出后借用失效

    b:退出并释放;目标int:已销毁;borrowed:不再可解引用

    main结束时b释放整数。borrowed从未拥有它,也不能在释放后再读取。最后这帧说明寿命边界,源码没有执行失效访问。

只把起始值9改成0再预测:应为a empty 1b 0borrow 0。空的是a这个来源拥有者,不是b管理的整数。若把std::move(a)改成a,程序试图复制unique_ptr,应在编译时被拒绝;不要删除复制限制来“修好”它。

移动赋值还要处理目标原来拥有的对象

构造b之前没有b所管的旧对象;给已有b移动赋值时则可能有。对本例两个不同拥有者,b = std::move(a);会接管a转来的对象,同时释放b先前管理的对象;赋值完成后a变空。赋值完成后,借用旧b对象的指针已失效,借用原a对象的指针仍可在新拥有者b保证其寿命期间使用。

#include <iostream>
#include <memory>
#include <utility>

struct Trace {
    int value;
    explicit Trace(int initial) : value{initial} {}
    ~Trace() {
        std::cout << "destroy " << value << '\n';
    }
};

int main() {
    auto a = std::make_unique<Trace>(9);
    auto b = std::make_unique<Trace>(4);
    std::cout << "before " << a->value << ' ' << b->value << '\n';
    b = std::move(a);
    std::cout << "a empty " << (a == nullptr) << '\n';
    std::cout << "b " << b->value << '\n';
    return 0;
}

先输出before 9 4,表明有两个不同Trace。赋值语句中销毁旧b对象,打印destroy 4。接着a为空、b值9;程序退出时b销毁后来接管的Trace9,打印destroy 9。没有第三个Trace,也不应把destroy 4错判为移动销毁了来源9。

reset与release:名字相近,释放行为相反

操作 本章的效果 接下来的责任
owner.get() 返回当前地址;owner保持原样 owner继续负责,调用者只借用
owner.reset() 若原来非空,释放原对象;owner变空 没有原对象可再访问
owner.reset(new Trace{4}) 新表达式先建立Trace4;reset使owner改管新对象,并释放它此前管理的旧对象 owner之后负责Trace4;旧对象的借用失效
owner.release() 返回原地址,把owner变空;不销毁原对象 调用者必须接住并安排释放,不能丢掉责任

本表只使用默认单对象释放方式;给reset的非空新地址必须有合法且唯一的管理责任,不能把旧地址又当成新对象交回。

#include <iostream>
#include <memory>

struct Trace {
    int value;
    explicit Trace(int initial) : value{initial} {}
    ~Trace() {
        std::cout << "destroy " << value << '\n';
    }
};

int main() {
    auto owner = std::make_unique<Trace>(9);
    std::cout << "owned " << owner->value << '\n';
    owner.reset(new Trace{4});
    std::cout << "owned " << owner->value << '\n';
    Trace* raw = owner.release();
    std::cout << "empty " << (owner == nullptr) << '\n';
    std::cout << "raw " << raw->value << '\n';
    delete raw;
    return 0;
}

最初打印owned 9。reset的实参先建立Trace4,再由reset释放旧Trace9,打印destroy 9;下一行打印owned 4。release把地址交给raw,owner变空,打印empty 1,但没有destroy 4;raw指向的Trace仍然活着,所以能打印raw 4。最后的delete才销毁它并输出destroy 4。owner退出时已经为空,不会再释放一次。

这个例子故意展示release留下的手工责任。常规转交优先使用拥有者之间的移动;只有确实需要把责任交给一个明确接受原始地址的接口时,才考虑release。忽略它的返回地址可能造成泄漏,误以为它已经销毁对象也会误判后续寿命。

常见错误:把借用当成释放凭据

在完整例二中,若在main末尾额外执行delete borrowed;,整数会先被手工释放,而b仍把自己当作拥有者。稍后b析构就会再次释放同一个对象。这段错误只分析和编译,不运行;第一次手工delete之后,也不能再通过b或borrowed读取它来“检查有没有问题”。正确修复是删掉多余的手工delete,保留唯一的拥有者b。

关键边界是对象寿命,不是指针变量的名字还在不在。get得到的借用在转交后可能仍有效;在reset销毁其对象后就失效;release后则要追踪接走责任的人是否已释放。每一种情况都沿着同一个对象来判断。

13.5 接口怎么写:只看一眼,还是接走对象?

按值与按引用不只是性能选择,也在表达责任。一个同步函数如果只读取Trace,const Trace&表达借用:调用时必须已有有效对象,函数不获得delete责任。一个函数如果接管独占资源,参数可写成std::unique_ptr<Trace> owner,让调用者明确移动交接。

这次借用参数是Trace的引用,调用borrow(*zero)时先解引用非空zero,得到Trace对象,再按05章的规则绑定const引用。它不会复制Trace,也不会复制unique_ptr。这个函数只在调用期间读取,不保存借用供将来使用。

consume(std::move(zero))则移动构造参数owner,zero变空。参数owner最迟在这条调用语句结束时销毁,释放它最终仍管理的对象;调用者没有把责任取回来。函数应先处理空输入,再解引用;if (!owner)的意思是没有对象,与对象里是否存了0无关。本例参数的销毁会在调用后的下一条语句之前完成,因此调用者看到caller empty前已经有对应destroy日志。

#include <iostream>
#include <memory>
#include <utility>

struct Trace {
    int value;
    explicit Trace(int initial) : value{initial} {}
    ~Trace() {
        std::cout << "destroy " << value << '\n';
    }
};

void borrow(const Trace& value) {
    std::cout << "borrow " << value.value << '\n';
}

void consume(std::unique_ptr<Trace> owner) {
    if (!owner) {
        std::cout << "empty\n";
        return;
    }
    std::cout << "consume " << owner->value << '\n';
}

int main() {
    auto zero = std::make_unique<Trace>(0);
    borrow(*zero);
    consume(std::move(zero));
    std::cout << "caller empty " << (zero == nullptr) << '\n';
    auto nine = std::make_unique<Trace>(9);
    borrow(*nine);
    consume(std::move(nine));
    std::cout << "caller empty " << (nine == nullptr) << '\n';
    std::unique_ptr<Trace> empty;
    consume(std::move(empty));
    return 0;
}

输入0时依次是borrow 0consume 0destroy 0caller empty 1;输入9同理,四行中的值换成9。borrow调用之后对象还由原拥有者管理,所以随后还能接管;consume调用之后来源为空,不能继续写*zerozero->value。最后传一个空拥有者只打印empty,没有Trace被创建,也就没有对应的destroy。

若调用者还需要继续拥有对象,应选择借用接口,而不是为了调用consume先取裸地址伪造新的拥有者。接口名称可以任取,但参数与约定应让责任可见。此处只接管单个T;不引入共享计数或延迟回调的寿命,那是14章的内容。

补充认读 · 拥有派生对象(另估10分钟,可选)

沿用07章的继承简介,派生对象拥有者示例07-b写有const std::unique_ptr<Metric> metric = std::make_unique<ConstantMetric>();。右侧先创建一个ConstantMetric,并得到它的独占拥有者;初始化左侧时,这份拥有关系转入指向基类Metric的unique_ptr。这里采用默认删除器,且派生类指针可以隐式转换到公开基类指针,才符合这次转换构造的条件。

实际对象始终是同一个ConstantMetric,没有复制成一个单独的Metric,也没有新增第二个拥有者。左侧const限制拥有者自身再转移或改管其他对象,不是把被管理对象的类型改成const。原例经虚函数调用得到7;退出时依次执行派生类、基类的析构输出。通过基类指针销毁派生对象需要虚析构,原Metric满足这一点。

这里只要求认出这行已有代码的拥有关系,不要求独立编写多态层次,不设练习,不进验收。另估10分钟是可选认读增量,不计入原章学时。转换条件依据:unique_ptr转换构造

四步练习阶梯

先完成本节以前的讲解,再做以下四步。每一步都先写自己的状态或代码;展开答案不是个人通过记录。

1. 预测:谁拥有9,谁只是借用?

不运行transfer-borrow,画出移动前后a、b、borrowed与同一个整数的关系。写出三行输出,并标出哪一个拥有者最后释放对象。再只把初值9改成0,重新预测。

对照预测

移动前a拥有9,borrowed借用它;移动后a为空,b拥有同一个9,borrowed的目标仍活着。输出a empty 1、b 9、borrow 9。结束时b释放;初值改0只让后两行的数值变0,a仍为空,b仍有对象。

2. 补全:让早退拥有一个明确释放者

把unique-early-return中的拥有者初始化那一行遮住,自己补回。要求创建真实Trace9,读取其值,提前return时销毁一次,调用者随后打印returned。然后仅把main里的第二次run(true)改为run(false),预测哪些输出会变。

对照关键一步与变体

补回auto owner = std::make_unique<Trace>(9);。原两次调用各有value 9与destroy 9,normal/early由stop决定,destroy都先于各自的returned。只改第二次调用后,第二段的early变成normal;其余各行和两次destroy不变。无需在任一return之前手写delete。

3. 找错:为什么不能delete借用?

有人在transfer-borrow的最后增加delete borrowed;,理由是“我通过get拿到了指针,应该自己收尾”。不运行这个版本,指出第一次非法责任安排和随后会发生的重复释放。用一处删除恢复正确版本。

对照错误与修复

get只提供借用,b仍是唯一拥有者。额外delete先释放整数,b却没有被清空或解除责任;b析构再释放会产生未定义行为。删除这条额外delete,由b负责清理。不能在手工释放后再读取b或borrowed,把偶然输出当作正确证据。

4. 独立迁移:用接口表达接管

关掉示例,从空文件实现一个同步接管函数:按值接收unique_ptr<Trace>;有对象就读出值,没有对象就报告empty。分别用0和9创建两个独占对象,先借用读取,再移动交接;每次调用后检查来源为空。写出每个真实对象只销毁一次且先于调用后观察的理由。不得用一个模拟资源计数替代真实对象。

对照实现责任

Trace保存整数并在析构打印destroy;make_unique创建真实对象。borrow接收const Trace&且只读取,consume接收unique_ptr<Trace>,先用条件判断空,再访问owner->value。调用consume时使用std::move,参数接管,调用语句结束前完成对应释放。两个独立输入0与9分别有一次consume及destroy,随后来源为空;空输入只报告empty。对照上方同源consume-owner程序核对每一行,而不是只核对打印次数。

本章的运行与检查边界

八份下载源码都是安全的完整程序,使用C++20标准库,确定输出只涉及固定小整数和本章的正常/提前return路径。例子、上述两个单条件变体和复制拒绝由本章验证器核对;故意泄漏与delete借用的派生版本只编译,不执行。编译错误应区分语法/类型限制与动态责任错误:前者可能被拒绝,后者即使能编译也不正确。

继续使用普通函数章的严格编译方式,替换当前文件名即可。分析工具只能检查实际走到的路径;本机macOS不把LeakSanitizer当作泄漏检测已通过的证据。真实释放责任仍需从对象、拥有者和每条出口逐一说明。现在保存自己的预测、运行结果与一份独立实现;编译成功、看过答案和完成教材构建都不会自动通过G0。

先留下自己的答案

两道动手练习

先完成正文的练习阶梯,再用下面两题检查自己的解释。先写预测或代码;需要时展开提示,完成后再对照答案。

练习 1

在unique-early-return中,只把main里唯一的run(true)改为run(false)。先预测两段完整输出,说明每次returned之前是否仍有destroy 9。

查看提示
  1. 只改变第二次调用的分支选择,不改变对象值或释放责任。
  2. 正常离开run同样会销毁局部owner。
对照答案与推理

两段现在都依次输出value 9、normal、destroy 9、returned。每次调用各自建立一个Trace 9并清理一次;把早退改为正常路径不会让析构消失,也不会把两次创建合成一个对象。

练习 2

在transfer-borrow中,只把make_unique<int>(9)的9改成0。a移动后是否为空?b和此前的borrowed分别读到什么?

查看提示
  1. 目标整数的值与拥有者中保存的地址是两件事。
  2. 转交独占关系不会因为整数是0就把目标当成不存在。
对照答案与推理

输出a empty 1、b 0、borrow 0。a因移动而为空,b仍拥有一个真实的int对象;borrowed在该对象存活时读取0。对象的值为0并不等于拥有者为空。

关掉参考,再做一次

把理解说出来

先完成正文的独立迁移,再回答下面六题。它们只检验第13.1–13.5节已经讲过的内容。打开答案、编译成功或填写用时,都不会自动通过本章,更不代表通过 G0。

动态寿命与漏释放

manual-cleanup中局部raw离开作用域,为什么不自动释放new出来的Trace?删掉提前return前的delete会发生什么?

对照推理与英文回答

raw只是一个保存地址的局部指针,结束它的寿命不会自动delete所指动态对象。若早退前漏掉delete,本次Trace没有走到后面的正常清理语句,释放责任就丢失,形成泄漏。错误路径只分析,不运行。

Raw is a local pointer value; ending its lifetime does not delete the dynamically created object. Removing the early-path delete skips the later cleanup and loses the release responsibility. That leaking path is analyzed, not executed.

真实RAII与不可复制

IntOwner为什么把delete写在析构中,又删除复制构造和复制赋值?release 9这一行本身能证明释放已经完成吗?

对照推理与英文回答

IntOwner把真实new int的释放责任绑定到自己的寿命,析构中执行delete。若只默认复制地址,就可能让两个拥有者都删除同一对象,所以本例禁止复制。release 9在delete之前打印,只说明已经进入这段清理代码;仍须结合后续delete和实际检查,不能把日志本身当作释放完成。

IntOwner ties an actual allocation to its destructor. Copying only the pointer could give two owners the same deletion responsibility, so copying is disabled. The release message is printed before delete; the message alone does not prove that deletion has completed.

空拥有者与有效零值

unique-empty中empty为空,zero却管理整数0。为什么if(zero)为真?if(!empty)又说明什么?

对照推理与英文回答

条件判断检查拥有者是否保存非空指针,不检查目标整数的数值。zero拥有真实对象,所以if(zero)进入并输出value 0;empty没有对象,if(!empty)进入。解引用前必须有非空依据,不能把数值0等同于空地址。

The condition checks whether the owner stores a non-null pointer, not the integer stored in its object. Zero owns a real object containing zero, so its condition is true. Empty owns no object, so the negated condition is true. Dereferencing still requires a nonempty owner.

转交、覆盖与借用期限

a移动给b后为何可断言a为空,而先前a.get()得到的借用还能读9?如果b原本还拥有Trace 4,移动赋值会怎样?

对照推理与英文回答

unique_ptr有明确的移动后源为空合同。转交给b不会因此销毁被转交的int,所以b仍拥有它时,旧借用仍可读9。若b已有另一个Trace 4,移动赋值会删除该旧目标;赋值语句完成后b管理Trace 9、a为空,destroy 4出现在后续b 9输出之前。这不延长任何指向已销毁Trace 4的借用。

Unique_ptr explicitly guarantees an empty moved-from source. The transferred object stays alive under b, so the old borrowed pointer still reads nine while b owns it. If b already owns a different object containing four, move assignment deletes that old target; borrows to the deleted object are no longer valid.

get、reset与release

get、reset(new Trace{4})和release分别改变什么?为什么不能对get结果随手delete,又不能在release后忘掉返回值?

对照推理与英文回答

get只借出地址,不转交释放责任;手动删除这个地址会让原拥有者仍以为对象由自己管理,之后可能重复释放。reset接管新指针并清理原有对象;release返回原地址并让拥有者为空,本身不销毁对象,因此接收方必须继续安排释放。reset-release最后用一次delete raw释放转出的Trace 4。

Get exposes a borrowed pointer without transferring deletion responsibility. Reset replaces the stored pointer and deletes the old target. Release returns the old pointer and empties the owner without deleting the object, so the recipient must arrange cleanup. The example eventually deletes that released object exactly once.

接口是否接管

borrow(const Trace&)与consume(unique_ptr<Trace>)的责任有什么不同?输入值0、9和空拥有者分别如何处理?

对照推理与英文回答

borrow只在同步调用内借读,调用后原拥有者仍负责释放。consume按值接管,非空时读取0或9都合法,调用语句结束前由形参拥有者清理对象,调用者变空;空入参先经if(!owner)输出empty并返回,没有可销毁的目标。接管接口需要表达责任转交,而不是仅因为函数需要读取一个值。

Borrow only reads during the synchronous call and leaves ownership with the caller. Consume takes unique ownership by value: both zero and nine are valid stored values, and cleanup occurs before execution continues after the call statement. An empty argument follows the checked empty path and owns nothing to destroy.

和正文是同一份源码

示例文件

先自己输入和预测,卡住时再下载对照。文件名相同不代表内容相同;把它们放在单独的练习目录中,避免覆盖自己的作品。

下载清单来自本章元数据,正文代码与下载同源。这里提供安全的完整程序;按各例明确的输入、输出和验证状态使用。泄漏、重复释放或悬空访问的错误片段按正文作静态分析,不通过运行危险程序来猜答案。

可选的学习反馈

记下你真正花的时间

每完成一个学习时段,再填实际分钟。环境准备、阅读推演、独立编码和卡点排查分别记录,避免同一段时间重复计算。离开吃饭或做其他事情的时间不算进去。

记录只保存在你的浏览器,可导出给我复盘。留空表示尚未记录,不等于零耗时;页面停留时间不会自动计为学习。不要把开发者检查时间填进来。

尚无真实试学用时。

    按开始学习本章前的情况选择;已经会 C++ 时选择“会 C++”。起点随每条时段保存,之后改选不会重标旧记录。旧记录缺少起点时单独保留;已有基础者的用时不用于校准零基础预算。

      换设备:导入记录,或取回损坏的旧记录

      导入会合并时段,相同编号不重复累加;发生冲突会保留现有记录。

      阅读记录与课程验收分别保存。

      本章资料与查证

      • WG21 N4861:C++20工作草案 ↗

        固定2020-04-01版本。13.1查[expr.new]、[expr.delete];13.2查[class.dtor]与[stmt.jump];13.3查[unique.ptr.single.ctor]、[unique.ptr.single.dtor]、[unique.ptr.single.observers]及[unique.ptr.create];13.4查[unique.ptr.single.asgn]、[unique.ptr.single.modifiers];13.5查[expr.call]与独占拥有者的析构合同。

      • C++ Core Guidelines:R.1 自动管理资源 ↗

        13.2/13.5补充R.1的设计动机:把释放职责绑定到拥有者生命周期。本章只完成单对象与同步接口的正常退出路径,不提前要求异常处理、锁或自定义删除器实现。

      本章独立解释所需读法;资料用于核对与补充。工具版本、操作系统和实际执行状态见自己的运行记录。