Kodewick
第 18 课,共 30 课目录

复制和移动

第 18 课,共 30 课 · 第 4 部分 · 类 · 约 15 分钟

int 赋给另一个变量是复制一份,对象也一样:Playlist b = a; 以后,b 是一个新的歌单,里面的歌也是复制过去的。改 b 不影响 a:

main.cpp
1#include <iostream>2#include <string>3#include <vector>4 5class Playlist {6public:7    void add(const std::string& song) {8        songs_.push_back(song);9    }10 11    int count() const {12        return songs_.size();13    }14 15private:16    std::vector<std::string> songs_;17};18 19int main() {20    Playlist a;21    a.add("第一首");22    a.add("第二首");23    Playlist b = a;24    b.add("第三首");25    std::cout << a.count() << " " << b.count() << '\n';26    return 0;27}
输出
2 3

点单步,看画面上 a 和 b 是两个对象,各有自己的一排歌。这一课讲复制是怎么发生的、怎么不让复制,以及比复制更省事的移动。

拷贝构造函数

用一个已有的对象做出一个新对象时,调用的是拷贝构造函数。上面没写,编译器替你生成了一个:一个成员一个成员地复制。自己写一个,让它显示一行,就能看见复制在什么时候发生:

main.cpp
1#include <iostream>2#include <string>3 4class Playlist {5public:6    Playlist(const std::string& name) : name_(name) {}7 8    Playlist(const Playlist& other) : name_(other.name_) {9        std::cout << "复制了 " << name_ << '\n';10    }11 12private:13    std::string name_;14};15 16void by_value(Playlist p) {17}18 19void by_ref(const Playlist& p) {20}21 22int main() {23    Playlist a{"跑步"};24    Playlist b = a;25    Playlist c{a};26    by_value(a);27    by_ref(a);28    return 0;29}
输出
复制了 跑步
复制了 跑步
复制了 跑步

拷贝构造函数的参数是 const Playlist&:从 other 那里抄,不改它。Playlist b = a;、Playlist c{a}; 都是复制;按值传参也是复制(第 8 课说的「函数拿到的是一份复制」就是它做的)。by_ref 拿的是引用,没有复制。

先猜一猜

check 调用了 3 次,一共显示几个「复制」?

1#include <iostream>2 3class Ticket {4public:5    Ticket(int number) : number_(number) {}6 7    Ticket(const Ticket& other) : number_(other.number_) {8        std::cout << "复制 ";9    }10 11private:12    int number_;13};14 15void check(Ticket t) {16}17 18int main() {19    Ticket t{7};20    for (int i = 0; i < 3; i++) {21        check(t);22    }23    std::cout << '\n';24    return 0;25}

拷贝赋值

b = a; 和 Playlist b = a; 看起来很像,其实不一样:前者的 b 早就有了,是把 a 的内容抄过来盖掉旧的,调用的是拷贝赋值运算符 operator=:

main.cpp
1#include <iostream>2#include <string>3 4class Playlist {5public:6    Playlist(const std::string& name) : name_(name) {}7 8    Playlist(const Playlist& other) : name_(other.name_) {9        std::cout << "拷贝构造:" << name_ << '\n';10    }11 12    Playlist& operator=(const Playlist& other) {13        name_ = other.name_;14        std::cout << "拷贝赋值:" << name_ << '\n';15        return *this;16    }17 18private:19    std::string name_;20};21 22int main() {23    Playlist a{"跑步"};24    Playlist b{"睡前"};25    Playlist c = a;26    b = a;27    return 0;28}
输出
拷贝构造:跑步
拷贝赋值:跑步

看等号左边是不是正在声明的新变量:Playlist c = a; 声明了新的 c,是拷贝构造;b = a; 的 b 已经存在,是拷贝赋值。和上一课的 += 一样,operator= 返回 *this 的引用。

= delete:不许复制

有些东西复制了就乱了。一个银行账户复制出一份,两份都有 100 块钱,到底算谁的?在拷贝构造函数和拷贝赋值后面写 = delete,就把它们删掉了,谁复制都是编译错误:

main.cpp
1#include <iostream>2#include <string>3 4class Account {5public:6    Account(const std::string& owner, int balance) : owner_(owner), balance_(balance) {}7    Account(const Account&) = delete;8    Account& operator=(const Account&) = delete;9 10    int balance() const {11        return balance_;12    }13 14private:15    std::string owner_;16    int balance_;17};18 19int main() {20    Account mine{"小林", 100};21    Account copy = mine;22    std::cout << copy.balance() << '\n';23    return 0;24}
输出
main.cpp: In function 'int main()':
main.cpp:21:20: error: use of deleted function 'Account::Account(const Account&)'
   21 |     Account copy = mine;
      |                    ^~~~

use of deleted function 'Account::Account(const Account&)':用了被删掉的函数。不能复制的对象,传给函数时就用引用(const Account& 或 Account&)。

移动:搬走,不复制

复制一个装着几千首歌的歌单,要把每首歌都抄一遍。可如果原来那个反正不要了,直接把里面的东西搬过去就行,快得多。std::move(在 <utility> 里)就是说「这个可以搬走」:

main.cpp
1#include <iostream>2#include <string>3#include <utility>4#include <vector>5 6int main() {7    std::string draft = "今天的日记";8    std::vector<std::string> diary;9    diary.push_back(draft);10    diary.push_back(std::move(draft));11    std::cout << diary.size() << '\n';12    std::cout << "[" << draft << "]\n";13    return 0;14}
输出
2
[]

第一次 push_back(draft) 是复制,draft 还在;第二次 push_back(std::move(draft)),文字被搬进了 vector,draft 变空了。点单步,看 draft 在哪一步变空。

注意: 搬走以后的对象还能用来赋新值,也能正常死掉,但它里面是什么,标准只说「有效但不确定」(vector 的移动构造保证变空;string 在 g++ 上是空的)。所以搬走以后,别再读它,要用就先给它一个新值。

移动构造函数

自己的类也能写移动构造函数。它的参数是 Playlist&&,两个 & 表示「一个可以被搬走的东西」。做法是把每个成员也 std::move 过来:

main.cpp
1#include <iostream>2#include <string>3#include <utility>4#include <vector>5 6class Playlist {7public:8    Playlist(const std::string& name) : name_(name) {}9 10    Playlist(Playlist&& other) : name_(std::move(other.name_)), songs_(std::move(other.songs_)) {11        std::cout << "搬走了 " << name_ << '\n';12    }13 14    void add(const std::string& song) {15        songs_.push_back(song);16    }17 18    int count() const {19        return songs_.size();20    }21 22private:23    std::string name_;24    std::vector<std::string> songs_;25};26 27int main() {28    Playlist old_list{"跑步"};29    old_list.add("第一首");30    old_list.add("第二首");31    Playlist new_list = std::move(old_list);32    std::cout << new_list.count() << " " << old_list.count() << '\n';33    return 0;34}
输出
搬走了 跑步
2 0

歌全都到了 new_list,old_list 剩 0 首。点单步,看 songs_ 那排格子从一个对象整个跑到另一个对象里,一首歌都没有复制。这里读 old_list.count() 只是为了看它空了:vector 搬走以后保证是空的。平时照上面的注意,别去读搬走的对象。

std::move 自己什么都不搬,它只是给对象贴上「可以搬」的标签;真正动手的是移动构造函数。要是你写了拷贝构造函数、却没写移动构造函数,贴了标签也只会复制。(两个都没写的话,编译器会自己生成一个移动构造函数。)

三五法则

析构函数、拷贝构造、拷贝赋值、移动构造、移动赋值,这五个是一伙的:自己写了其中一个,多半另外四个也要想一想。这叫「三五法则」(早先只有前三个,叫三法则)。

好消息是,大多数类一个都不用写。成员是 std::string、std::vector、int 这些时,编译器生成的那五个已经会正确地复制、搬走、收拾,这有时被叫做「零法则」。这一课自己写它们,只是为了让你看见它们什么时候被调用。

写法调用的是
Playlist b = a; / Playlist b{a};拷贝构造
b = a;(b 已经有了)拷贝赋值
Playlist b = std::move(a);移动构造
b = std::move(a);(b 已经有了)移动赋值
按值传参 f(a)拷贝构造

练一练

写好代码,点检查。提示随便看,实在想不出来再看答案。

练习 1

修好它:账户不能复制

Account 不许复制,所以程序编译不过。不要改 Account,只改 report,让程序显示 小林:100。

要显示
小林:100
exercise1.cpp
1#include <iostream>2#include <string>3 4class Account {5public:6    Account(const std::string& owner, int balance) : owner_(owner), balance_(balance) {}7    Account(const Account&) = delete;8    Account& operator=(const Account&) = delete;9 10    void show() const {11        std::cout << owner_ << ":" << balance_ << '\n';12    }13 14private:15    std::string owner_;16    int balance_;17};18 19void report(Account acc) {20    acc.show();21}22 23int main() {24    Account mine{"小林", 100};25    report(mine);26    return 0;27}
输出
点「运行」或按 Ctrl+Enter
练习 2

复制出来的是副本

给 Document 写一个拷贝构造函数:标题后面加上「(副本)」,正文照抄。程序要显示下面两行。

要显示
周末计划:去爬山
周末计划(副本):去爬山
exercise2.cpp
1#include <iostream>2#include <string>3 4class Document {5public:6    Document(const std::string& title, const std::string& text) : title_(title), text_(text) {}7 8    void show() const {9        std::cout << title_ << ":" << text_ << '\n';10    }11 12private:13    std::string title_;14    std::string text_;15};16 17int main() {18    Document plan{"周末计划", "去爬山"};19    Document copy = plan;20    plan.show();21    copy.show();22    return 0;23}
输出
点「运行」或按 Ctrl+Enter
练习 3

搬走,别复制

Inbox 只有拷贝构造函数,所以 std::move 也只能复制。给它加一个移动构造函数,显示「搬走了」,把 messages_ 搬过去。程序要显示下面两行。(最后读 old_box 只是为了看它空了:搬走的 vector 保证是空的。)

要显示
搬走了
新:3 封,旧:0 封
exercise3.cpp
1#include <iostream>2#include <string>3#include <utility>4#include <vector>5 6class Inbox {7public:8    Inbox(const std::vector<std::string>& messages) : messages_(messages) {}9 10    Inbox(const Inbox& other) : messages_(other.messages_) {11        std::cout << "复制了\n";12    }13 14    int count() const {15        return messages_.size();16    }17 18private:19    std::vector<std::string> messages_;20};21 22int main() {23    Inbox old_box{{"你好", "开会", "账单"}};24    Inbox new_box = std::move(old_box);25    std::cout << "新:" << new_box.count() << " 封,旧:" << old_box.count() << " 封\n";26    return 0;27}
输出
点「运行」或按 Ctrl+Enter

这一课学会了

  • 对象赋给新变量、按值传参,都会复制出一个独立的新对象,调用拷贝构造函数 T(const T& other)。
  • 已经存在的对象 b = a; 调用拷贝赋值 T& operator=(const T& other),最后返回 *this。
  • 在拷贝构造和拷贝赋值后面写 = delete 就不许复制(use of deleted function);这样的对象用引用传。
  • std::move(x) 表示 x 可以搬走;移动构造函数 T(T&& other) 把成员搬过来,不复制。
  • 搬走以后的对象只能赋新值或者让它死,别再读它的值。
  • 五个特殊成员函数(析构、拷贝构造、拷贝赋值、移动构造、移动赋值)写了一个就想想其余的;成员都是 string、vector 这类时,一个都不用写。