从零开始 / 一次只解释眼前的一步
复制与移动:
值怎样传递。
先分清是在建立对象,还是给已有对象赋值,再追踪哪条操作被选中。用具体程序观察副本的独立状态,说明std::move与实际移动之间的关系。
7小时是包含阅读、推演和编码的设计估算,未经真人试学校准。可分多次学习,遇到不清楚的地方保留预测、实际结果和疑问,之后再回修教材。手机可读图与做预测,编译需要电脑终端。
这一章怎样学
先读一小段,写下预测,再运行程序。每次只改一个条件,最后关掉示例,从空文件独立写一次。遇到错误,把第一条报错和自己的修复记下来;不用赶着把页面滚到底。
每次写下源对象、接收对象和实际选中的操作,再说明后续哪些访问有合同保证。先完成第11章关联容器与适配器,再开始本章;此前的M1仍按自己的独立材料核对,打开本页不会替你判定M1或G0通过。
12.1 值类别与临时对象:同一个对象,可以有不同的访问方式
第08章已经让你观察过vector的独立副本,第11章又区分了结构化绑定的副本与借用。这一章继续追问:建立副本时到底调用了什么?写上std::move,是否就把数据搬走了?先完成关联容器与适配器,再用本章的整数、字符串和vector逐步核对。
先把对象的类型与表达式的值类别(value category)分开。类型回答“这是int还是string,是否const”;值类别帮助决定表达式怎样参与绑定和重载选择。它不是变量贴上后永远不变的标签,也不能靠表达式写在等号左边还是右边判断。
本章先用一个有限、能预测代码的模型:
| 表达式 | 本章需要的读法 |
|---|---|
具名整数value |
是左值(lvalue),通过这个名字指定已经存在的对象 |
整数字面量7、整数计算value + 1 |
是纯右值(prvalue),提供用于计算或初始化的值 |
后面将教的std::move(value) |
是将亡值(xvalue),仍指向value,但允许参与相应的移动候选选择 |
右值(rvalue)包括prvalue与xvalue。不能把所有右值都解释成“没有对象可访问”,也不能把所有左值都解释成“可以改”:const整数的名字仍是左值,但不能通过它赋值。本章不要求背完整分类树;要求能判断表里这些实际表达式。
int&与int&&接收什么
新05的int&是可写左值引用。本节增加右值引用(rvalue reference):int&&可以绑定本章的整数右值,不能直接绑定具名整数左值。这里的&&在类型声明中表示引用种类;不是03章布尔表达式里的逻辑与。
int&& named{7};让named绑定值为7的临时整数。但是随后在表达式中写名字named本身,得到的是左值。这种普通局部右值引用直接绑定新临时int时,该临时int会持续存活到named的寿命结束,所以后面的三次调用都能合法读取它。引用的声明类型与使用这个名字时的表达式类别不是一件事。你可以给named赋值,也可以把它传给int&参数;不能因为声明里出现两个&,就认为每次用named都自动交出对象。
新04已教普通函数重载。本例进一步比较select(int& value)与select(int&& value):对本例可写的int左值,前者匹配;对int右值,后者匹配。函数体都只读取并输出参数,不进行资源操作。这里的int是确定类型;日后模板里的某些T&&还有推导规则,留第15章,不套用到这里。
#include <iostream>
void select(int& value) {
std::cout << "lvalue " << value << '\n';
}
void select(int&& value) {
std::cout << "rvalue " << value << '\n';
}
int main() {
int value{4};
int&& named{7};
select(value);
select(7);
select(named);
return 0;
}
按三次调用依次预测:value是具名整数,输出lvalue 4;字面量7匹配右值引用,输出rvalue 7;named虽然声明为int&&,使用名字时仍是左值,输出lvalue 7。这些标签由程序里已经展示的重载函数打印,不是终端自动识别出来的属性。
临时对象什么时候销毁
临时对象(temporary object)是表达式求值过程中需要的对象,也有构造和销毁的时点。例如把Mark{7}直接交给一个const引用时,需要一个实际Mark对象供引用访问。Mark仍按07章简单聚合的成员顺序初始化;本例只有整数value,析构函数输出其值,没有文件、堆资源或锁。
对下面这种普通局部const引用,直接绑定新建的完整临时对象,const Mark& kept{Mark{7}};会把该临时对象的寿命延长到kept的寿命结束。不是复制一份Mark给kept,kept仍然是引用。此前05章的“借用具名对象不续命”仍有效,这里是特定临时绑定形式的专门规则。
另一个边界是函数参数:observe(const Mark& mark)可以在一次调用中借读Mark{9}。这个临时对象活到包含调用的完整表达式结束;在本例独立的observe(Mark{9});语句中,就是调用完成、该语句结束时销毁,而不会因为函数参数是const引用就活到main结束。
#include <iostream>
struct Mark {
int value;
~Mark() {
std::cout << "destroy " << value << '\n';
}
};
void observe(const Mark& mark) {
std::cout << "observe " << mark.value << '\n';
}
int main() {
std::cout << "direct\n";
{
const Mark& kept{Mark{7}};
std::cout << "value " << kept.value << '\n';
std::cout << "scope end\n";
}
std::cout << "after scope\n";
std::cout << "call\n";
observe(Mark{9});
std::cout << "after call\n";
return 0;
}
先输出direct,创建值7的临时Mark后,经kept读取7。打印scope end时它仍活着;离开局部块才输出destroy 7,然后输出after scope。第二段先输出call,observe读到9;它返回后,该调用所在语句结束,输出destroy 9;之后才是after call。先在纸上画两条不同的寿命线,再运行核对。
把另一条引用绑定到kept,不会再把原临时对象的期限延长一遍。把引用参数原样返回给外面,也不会替原调用中的临时对象续命。若外面把这个返回引用保存到下一条语句再读,就可能悬空;此类反例只分析期限,不运行失效读取。本章也不将上述局部直接绑定规则推广到new表达式、引用成员或任意函数调用链。
12.2 复制与移动操作:先看目标是否已经存在
Reading b{a};正在建立新的b,可能选择复制构造函数(copy constructor);b = a;中的b已经存在,是复制赋值(copy assignment)。虽然都能让b取得a的值,发生的阶段不同。01章的初始化与后续赋值区别在类对象上仍然成立。
移动也有同样的两种位置:建立新对象时可能选择移动构造函数(move constructor),给已有对象赋值时可能选择移动赋值(move assignment)。先问“目标已有了吗”,再看来源表达式如何绑定,不能只搜索一行里有没有等号。
std::move本身只改变表达式的类别
头文件<utility>提供std::move。对本章的具名对象x,std::move(x)把访问x的表达式转换为相应的将亡值;它仍指定原对象,不会因此创建一份独立对象、释放x或把x自动清空。const也不会被它去掉。
例如int&& ready = std::move(value);让ready继续引用value。这里的等号出现在声明里,是初始化引用;不是给一个已存在的引用重新绑定。这里只建立引用关系,没有调用一个把整数“搬走”的函数。以后通过ready写9,仍在修改同一个value。下面在转换前后分别读值,再用已经教过的两个select重载观察区别。
#include <iostream>
#include <utility>
void select(int& value) {
std::cout << "lvalue " << value << '\n';
}
void select(int&& value) {
std::cout << "rvalue " << value << '\n';
}
int main() {
int value{4};
int&& ready = std::move(value);
std::cout << value << '\n';
ready = 9;
std::cout << value << '\n';
select(value);
select(std::move(value));
std::cout << value << '\n';
return 0;
}
绑定ready后原值仍为4;通过ready赋9后value成为9。select(value)选择左值引用版本,select(std::move(value))选择右值引用版本;函数体都只输出9,末尾value仍为9。看到右值引用候选被选中,不等于发生了一次资源转移。真正做什么要看接下来被调用的函数。
读懂四个特殊成员的签名
本章用一个只含int的Reading观察调用。先沿07章构造函数与成员初始化列表的读法拆开签名:
| 声明形式 | 本例的职责 |
|---|---|
Reading(const Reading& other) |
从只读来源建立新Reading,即复制构造 |
Reading(Reading&& other) |
从可写右值来源建立新Reading,即移动构造 |
Reading& operator=(const Reading& other) |
修改已有对象,即复制赋值 |
Reading& operator=(Reading&& other) |
从可写右值来源修改已有对象,即移动赋值 |
operator=是类为赋值语法提供的成员函数名字;本例的d = b会调用d的相应赋值成员。最前面的Reading&说明返回当前对象的引用。07章已教this指向本次成员函数的对象,因此return *this;先解引用this,再把当前对象作为引用结果交回;不会返回局部副本。正文不要求设计其他运算符重载。
现在还差一步:先判断候选能否绑定,再比较同时可行的候选。限上表这对可用候选,同类型、非const右值既可以绑定const Reading&,也可以绑定Reading&&;此时绑定右值引用的候选更优,所以优先选择后者。这个比较对本例构造和赋值都成立:Reading c{std::move(a)};选择移动构造,d = std::move(c);选择移动赋值。不是因为复制候选不能绑定,而是移动候选在这组比较中更优;不要把这条窄规则推广到存在其他转换、模板或不同重载集合的任意调用。
other虽可能声明为Reading&&,在函数体内使用other这个名字仍是左值。下面的移动成员只是读取other.value,复制一个int数值;没有主动修改other,也没有资源可以转交。因此本例移动后来源的整数保持原值。这是函数体给出的确定行为,不是所有移动构造的统一后置条件。
#include <iostream>
#include <utility>
struct Reading {
int value;
explicit Reading(int initial) : value{initial} {}
Reading(const Reading& other) : value{other.value} {
std::cout << "copy constructor\n";
}
Reading(Reading&& other) : value{other.value} {
std::cout << "move constructor\n";
}
Reading& operator=(const Reading& other) {
value = other.value;
std::cout << "copy assignment\n";
return *this;
}
Reading& operator=(Reading&& other) {
value = other.value;
std::cout << "move assignment\n";
return *this;
}
};
int main() {
Reading a{4};
Reading b{a};
b.value = 9;
Reading c{std::move(a)};
Reading d{0};
d = b;
d = std::move(c);
std::cout << a.value << ' ' << b.value << ' ' << c.value << ' ' << d.value << '\n';
return 0;
}
逐步追踪:a先是4;b从a复制得到4,随后b的值改为9;c从std::move(a)构造,选中移动构造,但按这个函数体a与c都仍为4。d已存在,先从b复制赋值,再从c移动赋值,最后为4。最终四个整数分别是a=4、b=9、c=4、d=4;构造和赋值日志要对应到不同语句,不能把日志行数当成内存分配次数或性能测量。
新建对象与已有对象,选择不同操作
Reading a{4}调用接收整数的构造函数。此时b、c、d尚不存在,也没有复制或移动日志。
Reading b{a}打印copy constructor,先得到4;随后b.value=9只改b。图停在这两条语句执行完的位置。
Reading c{std::move(a)}打印move constructor。源码里的移动构造只读取other.value,因此a没有被清零,c也得到4。
Reading d{0}建立新对象d。它调用整数构造函数,没有新增复制/移动日志;下一条赋值面对的是已经存在的d。
d=b调用复制赋值成员,打印copy assignment。函数把b.value写入当前d,d成为9;返回*this仍是d本身的引用。
d=std::move(c)打印move assignment。这个函数只读取c的int值,不改变c;最终四个值为4、9、4、4。
查看所有步骤的文字与数值
- 1 · 建立最初对象
a:4;b / c / d:尚未建立
Reading a{4}调用接收整数的构造函数。此时b、c、d尚不存在,也没有复制或移动日志。
- 2 · b复制后独立修改
a:4;b:9;已输出:copy constructor
Reading b{a}打印copy constructor,先得到4;随后b.value=9只改b。图停在这两条语句执行完的位置。
- 3 · c选择移动构造
a:4;b:9;c:4
Reading c{std::move(a)}打印move constructor。源码里的移动构造只读取other.value,因此a没有被清零,c也得到4。
- 4 · 先建立d
a / b / c:4 / 9 / 4;d:0;下一步:已有对象赋值
Reading d{0}建立新对象d。它调用整数构造函数,没有新增复制/移动日志;下一条赋值面对的是已经存在的d。
- 5 · 对d复制赋值
b:9;d:9;本次日志:copy assignment
d=b调用复制赋值成员,打印copy assignment。函数把b.value写入当前d,d成为9;返回*this仍是d本身的引用。
- 6 · 对d移动赋值
a / b:4 / 9;c:4;d:4
d=std::move(c)打印move assignment。这个函数只读取c的int值,不改变c;最终四个值为4、9、4、4。
为什么const来源可能仍然复制
常见移动构造参数是可写的Reading&&,因为实现可能要改变来源的内部状态。若来源为const Reading,std::move(source)仍保留const,不能绑定这个会允许写入的参数。下面类的另一个候选是const Reading&复制构造,它能够接收这个来源,于是实际调用复制。
#include <iostream>
#include <utility>
struct Reading {
int value;
explicit Reading(int initial) : value{initial} {}
Reading(const Reading& other) : value{other.value} {
std::cout << "copy\n";
}
Reading(Reading&& other) : value{other.value} {
std::cout << "move\n";
}
};
int main() {
const Reading original{4};
Reading result{std::move(original)};
std::cout << original.value << ' ' << result.value << '\n';
return 0;
}
程序打印copy,并保留两个4。这里的结论限定于源码展示的这一对候选;不能泛化成“名字叫move就一定移动”,也不必去编写一个const Reading&&重载来强行得到move标签。先确认需要保留原值还是允许转移,再选择来源和接口。
没写成员函数,为什么也能复制
对本章只含普通值成员、没有自己声明特殊成员的简单类型,编译器会按语言规则提供相应操作。默认生成的复制逐成员复制;默认生成的移动逐成员按对应类型选择操作。int成员没有独立堆资源,移动其数值与复制可以得到相同结果。
= default写在特殊成员声明后,表示请求编译器提供默认定义,不是“函数什么都不做”,也不是运行时赋值。= delete表示禁止调用该函数;这不同于下一章用于释放对象的delete表达式。默认定义能否成立还取决于成员能否完成所需操作,不保证任何类型写上default都能使用。
本例Defaulted()是不接收参数的构造函数,Defaulted original{};调用它,成员仍按int value{0};初始化为0。像Defaulted(const Defaulted&) = default;这样的声明只写参数类型、不写参数名字,也是合法的;由编译器提供函数体,这里不需要自己在函数体中用名字读取来源。
#include <iostream>
#include <utility>
struct Plain {
int value{0};
};
struct Defaulted {
int value{0};
Defaulted() = default;
Defaulted(const Defaulted&) = default;
Defaulted(Defaulted&&) = default;
Defaulted& operator=(const Defaulted&) = default;
Defaulted& operator=(Defaulted&&) = default;
};
int main() {
Plain first{4};
Plain second{first};
second.value = 9;
std::cout << first.value << ' ' << second.value << '\n';
Defaulted original{};
original.value = 5;
Defaulted moved{std::move(original)};
std::cout << original.value << ' ' << moved.value << '\n';
return 0;
}
前一组隐式复制得到两个独立的标量值,把副本改9,原值仍为4。后一组显式默认的移动对int成员同样只传递数值,两个值仍为5。核对类中到底声明了什么,不从“输出没变”推断编译器没有调用任何操作。
#include <iostream>
#include <utility>
struct Ticket {
int value;
explicit Ticket(int initial) : value{initial} {}
Ticket(const Ticket&) = delete;
Ticket& operator=(const Ticket&) = delete;
Ticket(Ticket&&) = default;
Ticket& operator=(Ticket&&) = default;
};
int main() {
Ticket a{4};
Ticket b{std::move(a)};
std::cout << a.value << ' ' << b.value << '\n';
return 0;
}
Ticket禁止复制,仍显式保留默认移动。合法的std::move(a)路径建立目标,两个int值均为4。若把这个来源只改为a,左值不能绑定可写右值引用;复制候选又被删除,应在编译时拒绝,不能当作一个可以正常运行的空操作。
编译器的生成规则有相互影响:在本书这类普通非union类中,要自动声明移动操作,类不能已经由用户声明复制构造、复制赋值、移动构造、移动赋值或析构函数。自己写~Type() = default;也属于用户声明析构,不能继续假定移动一定自动生成;需要移动时可以显式设计合适的操作。声明移动操作又可能让原本隐式的复制定义为删除。这里不是要求背完整规则表,而是提醒你:改动一项特殊成员后,重新检查复制和移动接口是否仍成立。
12.3 移动后的源对象与返回值:检查有保证的状态
前一个Reading的移动函数没有改来源,因而可精确预测a仍为4。标准库string、vector等类型的移动后状态则要读各自合同。没有特别说明时,标准库类型的被移动来源处于有效但未指定的状态(valid but unspecified state):对象的内部约束仍成立,能够按各操作的前提继续使用,但不能依赖它保留某一组内容。
这不是“对象已经销毁”,也不是“随便访问都行”。例如可以销毁它、重新赋值,或调用无非空要求的size/empty;读取首元素仍需要先确定非空。不能无条件写source[0],也不能把当前机器恰好输出空字符串当成所有实现都必须如此。这个标准库范围的承诺不自动替任意用户自定义的移动函数设计合同。
把来源重新放回已知状态
沿用08章vector的独立复制,下面专门加入移动和重新赋值。std::vector<int>{7}是已学过的构造表达式;把它赋给现有a会使a取得新的单元素值,之后读取首元素有明确依据。这里不要求猜测重赋之前a是否为空。
#include <iostream>
#include <utility>
#include <vector>
int main() {
std::vector<int> a{3, 1};
std::vector<int> b{a};
std::vector<int> c{std::move(a)};
b[0] = 9;
std::cout << "b " << b[0] << ' ' << b[1] << '\n';
std::cout << "c " << c[0] << ' ' << c[1] << '\n';
a = std::vector<int>{7};
std::cout << "a " << a[0] << '\n';
return 0;
}
输入a为[3,1]。b是独立副本;c从a移动取得原来的[3,1]。把b首项改9,b变成[9,1],c仍为[3,1]。a重新赋为[7]后读到7。你的预测表应把“a移动后的中间内容”写成未指定,而把b、c和重赋后的a写成确定值,不能为每个空格都编一个数字。
确定的结果与不应猜测的中间状态
最初a为[3,1]。b和c尚未创建;图中的方框表示逻辑对象与值,不是标准库内存布局。
std::vector<int> b{a}复制元素。a和b此刻的值相同,但后续修改b不会回写a。
c取得a原先的[3,1]。a对象仍然有效;本例不查询其移动后内容,也不把“空”作为要求。
b[0]=9之后,b为[9,1],c仍为[3,1]。两行输出读取的是b和c,没有读取a的首元素。
a=std::vector<int>{7}完成赋值后,a确定含一个7。现在读取a[0]满足非空前提,输出a 7。
查看所有步骤的文字与数值
- 1 · a拥有两个整数
a:[3,1];b / c:尚未建立
最初a为[3,1]。b和c尚未创建;图中的方框表示逻辑对象与值,不是标准库内存布局。
- 2 · b成为独立副本
a:[3,1];b:[3,1];c:尚未建立
std::vector<int> b{a}复制元素。a和b此刻的值相同,但后续修改b不会回写a。
- 3 · c从a移动构造
a:有效,内容不作假定;b:[3,1];c:[3,1]
c取得a原先的[3,1]。a对象仍然有效;本例不查询其移动后内容,也不把“空”作为要求。
- 4 · 只改b首元素
a:尚未重赋,不读首项;b:[9,1];c:[3,1]
b[0]=9之后,b为[9,1],c仍为[3,1]。两行输出读取的是b和c,没有读取a的首元素。
- 5 · 把a重新放回已知状态
a:[7];b:[9,1];c:[3,1]
a=std::vector<int>{7}完成赋值后,a确定含一个7。现在读取a[0]满足非空前提,输出a 7。
再看string。移动目标应得到来源原先的cat;来源重新赋new后再打印,得到一个新的确定状态。程序没有读取或断言来源在这两步之间必须为空。
#include <iostream>
#include <string>
#include <utility>
int main() {
std::string source{"cat"};
std::string target{std::move(source)};
std::cout << target << '\n';
source = "new";
std::cout << source << ' ' << target << '\n';
return 0;
}
先得到cat,重赋之后输出new与cat。把目标和来源画成两个仍存活的string对象;修改重新赋值后的来源不回写目标。不要把移动理解成两者从此成为引用别名,也不要从这几个字符推断标准库一定转交了某个内部指针。
按值返回不等于留下悬空借用
新05已比较过返回局部对象的引用与返回值。这里返回类型是std::vector<int>,调用者取得自己的结果对象。函数里建立result,最后自然写return result;;它不是返回vector引用,调用结束后结果依然能使用。
#include <iostream>
#include <vector>
std::vector<int> make_values() {
std::vector<int> result{2, 5};
return result;
}
int main() {
auto first = make_values();
auto second = make_values();
first[0] = 9;
std::cout << "first " << first[0] << ' ' << first[1] << '\n';
std::cout << "second " << second[0] << ' ' << second[1] << '\n';
return 0;
}
两次调用各自得到[2,5]。把first的首项改9,最终first为[9,5]、second仍为[2,5]。要求验证的是结果的值、寿命与独立性,不是猜一个固定的复制次数。
这里要区分两种返回形式。在C++20中,按值返回相同类型的直接构造表达式,例如返回类型为vector时写return std::vector<int>{2,5};,可以直接初始化结果对象,不要求先建立一份额外vector再移动它;这是C++17起同类型prvalue初始化规则的一部分。自然返回具名result则允许具名返回值优化(NRVO):实现可把result直接构造在结果位置,但此种省略不是每次都保证发生。
对上面可写的局部vector,在不采用NRVO的路径中,返回规则允许选择它可用的移动构造。因此按值返回不要求你先手写std::move(result)。把return里的result替换成std::move(result)会失去这项具名局部对象的NRVO条件;它不能作为“肯定更快”的通用改法。不要给本例添加依赖某次优化选择的固定日志答案。
12.4 Rule of Zero:让值成员承担自己的复制和移动
前面手写Reading的四个成员,是为了看清重载选中了哪一个操作。真实的数据类型若只是组合string和vector,通常不需要把同样的工作再写一遍。零规则(Rule of Zero)建议:能够由现成成员类型正确管理的值与资源,就让成员完成其默认操作;类专注表达自己的数据和业务接口。
下面Batch的name是一个string,readings是一个vector。它没有手写析构、复制构造、复制赋值、移动构造或移动赋值。它仍能复制和移动,因为成员类型支持这些操作,默认生成的操作按成员分别处理。零规则不是“类不能有成员函数”,也不是删除这五个接口;重点是不重复实现本来已经正确的管理职责。
Batch a{"red", {2,5}};沿07章聚合初始化按成员顺序给值:外层第一项建立name字符串,第二项的内层花括号为readings建立两个整数元素。b = Batch{"new", {7}};则先表达一份新的Batch值,再给已经存在的b赋值;不能把这行读成新声明一个b。
#include <iostream>
#include <string>
#include <utility>
#include <vector>
struct Batch {
std::string name;
std::vector<int> readings;
};
int main() {
Batch a{"red", {2, 5}};
Batch b{a};
b.name[0] = 'b';
b.readings[0] = 9;
std::cout << a.name << ' ' << a.readings[0] << '\n';
std::cout << b.name << ' ' << b.readings[0] << '\n';
Batch c{std::move(b)};
std::cout << c.name << ' ' << c.readings[0] << '\n';
b = Batch{"new", {7}};
std::cout << b.name << ' ' << b.readings[0] << '\n';
return 0;
}
从name为red、首个读数为2的a开始。b复制a;将b名字首字符改为b,名字成为bed,再把b首读数改9。a仍为red和2。随后c从b移动,c得到bed和9;b重新赋为name为new、读数为7的Batch后,再读它便有确定结果。先解释每次操作发生在哪个对象上,再考虑接口是否需要允许复制。
这和“复制一个裸指针,就得到独立对象”不同:指针是地址值,默认复制地址不会额外复制它所指的对象。若你的类承担一个尚未被现成成员正确表达的释放责任,不能只套用默认复制便宣称安全;第13章会引入真实资源、独占拥有者与RAII。本章不为尚未出现的资源手写释放逻辑。
最后保留一个以后回访的问题:vector扩容时是否使用元素的移动,还可能取决于元素操作的异常保证。noexcept及其选择理由在错误处理章之后再结合实例解释;本章只预测源码明确给出的直接构造和赋值,不把“有移动成员”变成“所有库调用必用移动”的承诺。
四步练习阶梯
- 预测:运行前分别写出vector-transfer中b、c及重新赋值后的a。标明不允许假定的中间状态,再逐项核对输出。
- 补一步:在scalar-operations的个人副本中遮住一个复制构造签名和一个移动赋值签名,依据目标是否已经存在、来源的const与值类别补回。保持原函数体,只恢复声明,不把它改成资源管理例子。
- 找错:有人预测const-move一定打印move,因为调用写了std::move。用参数绑定规则指出错误,再预测把来源改为可写对象后选择哪个候选。
- 独立迁移:关掉natural-return,从空文件写一个按值返回[2,5]的函数。调用两次,只改第一个结果的首项为9,证明另一个仍为[2,5]。不要返回局部引用,也不强加std::move。
提示:先判目标,再判来源,最后读函数体
先在每条语句旁写“新建”或“已有对象赋值”。给来源写上const与值类别,再从源码中找可以绑定它的候选。选中移动后,仍要读函数体或库合同才知道来源状态;不能由std::move这个名字跳到“来源清空”。
对照四步的答案与验收边界
第一步b=[9,1]、c=[3,1]、重赋后的a=[7];中间a内容不作为固定答案。第二步复制构造采用Reading(const Reading& other),移动赋值采用Reading& operator=(Reading&& other),赋值返回*this。第三步const来源不能绑定本例可写Reading&&参数,因而复制;只去掉来源const后,同样的std::move调用可以选中本例移动构造。第四步自然按值返回独立vector,最终两个结果为[9,5]和[2,5]。运行结果、自己写出的代码与口头解释分别保留,不由阅读记录自动判定通过。
先留下自己的答案
两道动手练习
先完成正文的练习阶梯,再用下面两题检查自己的解释。先写预测或代码;需要时展开提示,完成后再对照答案。
练习 1
const-move明明写了std::move(original),为什么输出copy?先用两个构造参数解释;然后只去掉original声明的const,预测日志与两个整数。
查看提示
- std::move保留来源的const。
- 本例的移动构造接收Reading&&,复制构造接收const Reading&;先核对绑定,再读函数体。
对照答案与推理
原来的const来源不能绑定可写Reading&&,因此调用复制构造,输出copy与4 4。只把const Reading original{4}改为Reading original{4}后,选择移动构造,输出move与4 4;本例移动函数仍只复制int数值,所以来源并没有清零。
练习 2
关掉natural-return,从空文件写按值返回vector<int>[2,5]的函数,调用两次,只把第一个结果首项改为9。给出两行结果并解释它们为什么在函数返回后仍能使用。
查看提示
- 返回类型写std::vector<int>,不能改成局部vector的引用。
- 可以自然return局部result;不要为了猜测优化次数而强加std::move。
对照答案与推理
自然按值返回使调用者获得独立结果。与本章输出标签一致时,两行是first 9 5、second 2 5;修改first不改second。也可直接return std::vector<int>{2,5}取得相同最终值,但不能据此指定具名局部返回发生了几次复制或移动。
关掉参考,再做一次
把理解说出来
先完成正文的独立迁移,再回答下面六题。它们只检验第12.1–12.4节已经讲过的内容。打开答案、编译成功或填写用时,都不会自动通过本章,更不代表通过 G0。
表达式类别与转换
int&& named{7}中的named是什么引用?调用select(named)为何选择int&?std::move(value)会自动清空整数吗?
对照推理与英文回答
named声明为右值引用,但使用这个名字的表达式是左值,因此本例选择int&。std::move只把访问具名对象的表达式转换为相应将亡值,保留const;它本身不复制、不释放、不清空对象。后续变化要看所选函数体。
Named is declared as an rvalue reference, but the expression using its name is an lvalue. The int-reference overload is therefore selected. Std::move produces an xvalue referring to the same object and preserves const; it does not itself clear or transfer anything.临时对象的有效期限
temporary-reference中为什么destroy 7在scope end之后、after scope之前,而destroy 9在after call之前?把借用再传出去会继续延寿吗?
对照推理与英文回答
普通局部const引用直接绑定完整临时Mark{7},其寿命延长到该引用的局部作用域结束。Mark{9}是引用参数的实参临时对象,只活到包含调用的完整表达式结束,因此下一条after call之前已销毁。再绑定或返回引用不会重新延长原临时对象期限。
The local const reference directly binds the complete temporary Mark containing seven and extends its lifetime to the reference’s scope end. The argument temporary containing nine lives through the full expression containing the call. Rebinding or returning a reference does not extend that original lifetime again.构造、赋值与默认接口
Reading b{a}和d=b分别选择什么?operator=返回*this是什么意思?=default与=delete是否表示同一种空函数?
对照推理与英文回答
前者建立新b并选择复制构造;后者修改已有d并选择复制赋值。返回*this把当前d作为引用结果交回,不建立局部副本。=default请求语言规则允许的默认定义,=delete禁止调用;两者都不是一个什么也不做的普通函数体。
Constructing b from a selects copy construction, while assigning b to an existing d selects copy assignment. Returning *this returns a reference to that current object. Default requests the rule-defined implementation; delete forbids calling the function. Neither means an empty no-op body.const移动与源码行为
const-move为什么打印copy?scalar-operations中的移动为什么保留来源整数,而不是清成0?
对照推理与英文回答
const来源经std::move后仍保留const,不能绑定本例可写Reading&&,但可以绑定const Reading&复制候选。scalar-operations的移动函数只读取other.value并写入目标,没有给来源赋0。候选选择和选中后的行为是两步,不能由函数名推断清空。
The moved expression still refers to a const source, so it cannot bind to this mutable Reading-rvalue-reference parameter; the const-reference copy candidate remains viable. The scalar move body only reads the integer and writes the target. Selecting a move operation does not automatically zero the source.有保证的移动后状态
vector-transfer为什么能预测c=[3,1],却不要求a移动后必为空?a重赋[7]后可以读首项吗?
对照推理与英文回答
本例vector移动构造让c取得来源原先的元素值;来源a按有效但未指定的状态处理,不读它的首元素,也不依赖某个实现清空它。a重赋为[7]后有确定的单元素值,因此读首项满足前提,结果为7。string-source也只在重新赋值后读取来源内容。
The target c receives the original element values. The source is handled as valid with unspecified contents, so the program neither indexes it nor assumes it is empty. Assigning the known one-element vector containing seven establishes the precondition for reading its first element.自然返回与Rule of Zero
natural-return为什么不返回局部悬空借用,也不需要手写std::move(result)?Batch没写四个复制移动成员,为什么还能得到独立副本?
对照推理与英文回答
函数按vector值返回,调用者获得自己的结果。具名局部可能采用NRVO;即使不采用,本例可用的移动也可参与返回,不能断言固定复制次数。Batch由string与vector值成员组成,默认操作逐成员完成正确的复制或移动;不重复编写这些管理操作符合Rule of Zero。
The function returns a vector value, so the caller owns its result rather than a local reference. NRVO is permitted for the named local; an available move can serve the non-elided path. Batch relies on its string and vector members for correct memberwise value operations, following the Rule of Zero.和正文是同一份源码
示例文件
先自己输入和预测,卡住时再下载对照。文件名相同不代表内容相同;把它们放在单独的练习目录中,避免覆盖自己的作品。
- value-categories.cpp同一个名字仍是左值
- temporary-reference.cpp两种临时绑定,两条销毁时间线
- move-is-cast.cppstd::move不自动修改原对象
- scalar-operations.cpp完整例一:区分四个特殊成员的调用
- const-move.cppconst来源为什么仍然复制
- defaulted-members.cpp隐式生成与显式请求默认定义
- deleted-copy.cpp禁止复制,保留移动
- vector-transfer.cpp完整例二:副本、移动目标与重新赋值的来源
- string-source.cpp不猜移动后string的中间内容
- natural-return.cpp自然按值返回两个独立结果
- rule-of-zero.cpp让string和vector成员完成值操作
下载清单来自本章元数据,正文代码与下载同源。按各例明确的输入、输出和验证状态使用;标注故意编译报错的文件只用于观察对应诊断。涉及非法范围或悬空的讨论按正文作静态分析,不通过运行未定义行为来猜答案。
可选的学习反馈
记下你真正花的时间
每完成一个学习时段,再填实际分钟。环境准备、阅读推演、独立编码和卡点排查分别记录,避免同一段时间重复计算。离开吃饭或做其他事情的时间不算进去。
记录只保存在你的浏览器,可导出给我复盘。留空表示尚未记录,不等于零耗时;页面停留时间不会自动计为学习。不要把开发者检查时间填进来。
尚无真实试学用时。
换设备:导入记录,或取回损坏的旧记录
导入会合并时段,相同编号不重复累加;发生冲突会保留现有记录。
阅读记录与课程验收分别保存。
本章资料与查证
- WG21 N4861:C++20工作草案 ↗
固定2020-04-01版本。12.1核对[basic.lval]、[dcl.init.ref]与[class.temporary];12.2核对[forward]、[class.copy.ctor]、[class.copy.assign]、[dcl.fct.def.default]及[dcl.fct.def.delete];12.3核对[lib.types.movedfrom]、[container.requirements.general]、[class.copy.elision]与[dcl.init]的同类型prvalue初始化。
- C++ Core Guidelines:C.20 Rule of Zero ↗
12.4只补充C.20的设计建议:已有成员能正确承担所需操作时,避免重复定义默认管理操作。本章不提前要求该站其他条目的资源释放、异常保证或完美转发实现。
本章独立解释所需读法;资料用于核对与补充。工具版本、操作系统和实际执行状态见自己的运行记录。