五、基本操作

Link


对象的拷贝和转移有五种情形:

  • 赋值给另一个对象;
  • 为一个对象提供初值;
  • 作为函数参数;
  • 作为函数的返回值;
  • 作为一个异常。

赋值操作采用拷贝或者转移运算符。原则上,其它情形均使用拷贝或者构造函数。

不过,拷贝或转移构造函数的调用通常会被优化掉,方法是把初始值直接在该对象上进行构造。例如:

X make(SomeType);
X x = make(value);

此处,编译器通常把make()返回的X在x上构造;从而消除(“省略”)掉一次拷贝。

除了初始化具名对象和分配在自由存储区上的对象之外,构造函数也被用于构造临时对象,还被用于实现显式类型转换。

除了“常规构造函数”,以下这些成员函数也会在按需被编译器生成。 如果想显式操控默认(=default)实现的生成,可以这样:

class Y {
public:
    Y(Sometype);
    Y(const Y&) = default;  // 我确定想要默认的拷贝构造函数
    Y(Y&&) = default;       // 以及默认的转移构造函数
    // ...
};

注:如果你显式生成了一部分默认实现,那其它的缺省定义就不会再生成了。

如果某个类具有指针成员变量,那么【显式定义拷贝和转移】操作通常是比较明智的。原因是指针可能指向某个资源,需要类去delete,这种情况下,默认将成员采用的逐个复制操作会导致错误。 又或者,这个指针指向的资源,要求类绝对不能delete。无论属于哪种情况,代码的读者都需要弄清楚。

一个值得力荐的规则(有时候也叫零规则(the rule of zero))是: 要么定义全部基础操作,要么全不定义(全用默认实现)。例如:

struct Z {
    Vector v;
    string s;
};

Z z1;       // 默认初始化 z1.v 和 z1.s
Z z2 = z1;  // 默认拷贝 z1.v 和 z1.s

本例中,在有需求的情况下, 编译器会合成逐成员操作的默认构造函数、拷贝、转移和析构函数,且语意全都正确。

=default相对,=delete用于表示拒绝生成某个操作。类层次中的基类是个经典的例子,这种情况下,我们要禁止将成员逐个复制的操作。例如:

class Shape {
public:
    Shape(const Shape&) =delete;            // 没有拷贝操作
    Shape& operator=(const Shape&) =delete;
    // ...
};

void copy(Shape& s1, const Shape& s2) {
    s1 = s2;    // 报错:Shape的拷贝操作已移除
}

=delete会导致被delete函数的使用触发编译器报错;=delete可用于屏蔽任何函数,不仅仅是基本成员函数。


**仅接受单个参数的构造函数定义了源自参数类型的转换。
**例如,complex有个源自double的构造函数:

complex z1 = 3.14;  // z1 成为 {3.14,0.0}
complex z2 = z1*2;  // z2 成为 z1*{2.0,0} == {6.28,0.0}

有时候这种隐式转换很理想,但也不总那么理想。 例如,Vector有一个源自int的构造函数:

Vector v1 = 7; // OK:v1有7个元素

这通常都不是个隐患,标准库的vector就禁止int到vector的“类型转换”。

避免这一问题的途径是仅允许显式(explicit)“类型转换”;就是说这样定义构造函数:

class Vector {
public:
    explicit Vector(int s); // 不会隐式从 int 转换到 Vector
    // ... 
};

Vector v1(7);   // OK: v1 有 7 个元素
Vector v2 = 7;  // 报错:不能从int隐式转换到Vector

涉及类型转换到时候,更多的类型像Vector,而不像complex, 因此,应该把explicit用于单参数的构造函数,除非有好理由不用它。


当某个类定义了数据成员,我们应该为其提供默认初始值,这被称为成员变量默认初始值。
考虑一下complex的这个新版本:

class complex {
    double re = 0;
    double im = 0;  // 表征数据:两个默认值为 0.0 的 double
public:
    complex(double r, double i) :re{r}, im{i} {}  // 用两个标量构造complex:{r,i}
    complex(double r) :re{r} {}                   // 用一个标量构造complex:{r,0}
    complex() {}                                  // 默认的complex:{0,0}
    // ...
}

只要构造函数未给定一个值,就会应用默认值。 这可以简化编码,并有助于避免粗心大意导致的成员变量未初始化。


当某个类是一个资源执柄(resource handle)——就是说,当该类负责通过指针访问某个对象——默认的逐成员复制通常是个灾难。逐成员复制会违反资源执柄的不变式。例如,默认复制会导致Vector的一个副本指向与原件相同的元素:

void bad_copy(Vector v1) {
    Vector v2 = v1; // 把v1的表征数据复制到v2
    v1[0] = 2;      // v2[0] 现在也是2!
    v2[1] = 3;      // v1[1] 现在也是3!
}

假设v1有四个元素,结果可以图示如下:


万幸的是,Vector具有析构函数的事实强烈暗示了“默认(逐成员)的复制语意不对”, 而编译器起码应该对本例给出警告。 我们需要定义一个更优的复制语意。

某个类对象的复制由两个成员函数定义:拷贝构造函数(copy constructor)拷贝赋值函数(copy assignment)

class Vector {
private:
    double* elem;   // elem指向一个数组,该数组承载sz个double
    int sz;
public:
    Vector(int s);                          // 构造函数:建立不变式,申请资源
    ~Vector() { delete[] elem; }            // 析构函数:释放资源

    Vector(const Vector& a);                // 拷贝构造
    Vector& operator=(const Vector& a);     // 拷贝赋值

    double& operator[](int i);
    const double& operator[](int i) const;

    int size() const;
};

一个合格的Vector拷贝构造函数的定义,要按元素数量所需分配存储空间,然后把元素复制到里面,以便在复制后每个Vector都有它自己的元素副本:

/** 定义【拷贝构造函数】*/
Vector::Vector(const Vector& a)
    :elem{new double[a.sz]}, sz{a.sz}  // 为元素分配存储空间
{
    for (int i=0; i!=sz; ++i)   // 复制元素
        elem[i] = a.elem[i];
}

现在,v2=v1示例的结果可以这样表示:


在成员函数里,名称this是预定义的,它指向调用该成员函数的那个对象。


我们可以通过定义拷贝构造函数拷贝赋值函数来控制复制操作,但对于庞大的容器,复制操作代价高昂。在传递一个对象给函数时,可以采用引用,从而避免复制的代价,但却不能返回一个指向局部对象的引用作为结果(在调用者查看的时候,局部变量就已经被销毁了),考虑这个:

Vector operator+(const Vector& a, const Vector& b) {
    if (a.size()!=b.size())
        throw Vector_size_mismatch{};

    Vector res(a.size());
    for (int i=0; i!=a.size(); ++i)
        res[i]=a[i]+b[i];
    return res;
}

+返回一个值,涉及到复制局部变量res出去,并且放置于某个调用者可以访问的位置。该+操作可以这么用:

void f(const Vector& x, const Vector& y, const Vector& z) {
    Vector r;
    // ...
    r = x+y+z;
    // ...
}

这起码要复制Vector两次(在每次调用+时候)。 如果某个Vector很大,比方说有 10,000 个 double,可就太令人汗颜了。最汗颜的是operator+()里的res复制后再没用过。我们不是真的想复制;而是想把结果从函数里弄出来:想要转移(move)一个Vector,而非复制(copy)它。万幸的是,我们可以表明这个意图:

class Vector {
    // ...
    Vector(const Vector& a);            // 拷贝构造函数
    Vector& operator=(const Vector& a); // 拷贝赋值函数

    Vector(Vector&& a);                 // 转移构造函数
    Vector& operator=(Vector&& a);      // 转移赋值函数
};

根据这个定义,编译器在把返回值传出函数时, 将使用转移构造函数(move constructor)执行。这意味着,r=x+y+z将不涉及Vector的复制。取而代之的是,Vector仅被转移了。

与之相似,Vector转移构造函数的定义也是小菜一碟:

Vector::Vector(Vector&& a)
    :elem{a.elem}, sz{a.sz}     // 从a“拿来元素”
{
    a.elem = nullptr;   // a现在没有元素了
    a.sz = 0;
}

&&的意思是“右值引用(rvalue reference)”,是个可以绑定到右值的引用。“右值(rvalue)”这词有意跟“左值(lvalue)”相对,“左值”大体上是“可以出现在赋值左侧的东西”。因此右值——大概其——是不能对其赋值的东西,比如函数调用返回的某个整数。这样,右值引用就是一个没有其他人能为其赋值的东西,所以可以安全地“偷取”其值。

转移构造函数不接受const参数:毕竟,它本该从其参数中把值移除。转移赋值函数(move assignment)的定义相似。

每当右值引用作为初值,或作为赋值右侧的值,就应用转移操作。

转移操作之后,“移自”对象所处的状态应允许执行析构函数。一般来说,也允许向一个“移自”对象赋值。标准库算法假定如此。我们的Vector也是这样。

程序员知道某个值再也不会用到了,但编译器不见得这么聪明,程序员可以指明:

Vector f() {
    Vector x(1000);
    Vector y(2000);
    Vector z(3000);
    z = x;              // 执行复制(x可能在f()后续被用到)
    y = std::move(x);   // 执行转移(转移复制函数)
    // ... 最好别在这用x了 ...
    return z;           // 执行转移!因为已定义了Vector的转移构造函数
}

标准库函数move()并不真移动什么。而是返回其(我们想转移的)参数的引用——右值引用;实际上进行了类型转换。

在return执行之前,情况是:



当我们从f()返回后,z就在其元素被return移出f()后而销毁了。不过y的析构函数则将delete[]其元素。

std::move

// move example
#include <utility>      // std::move
#include <iostream>     // std::cout
#include <vector>       // std::vector
#include <string>       // std::string

int main () {
  std::string foo = "foo-string";
  std::string bar = "bar-string";
  std::vector<std::string> myvector;

  myvector.push_back (foo);                    // copies
  myvector.push_back (std::move(bar));         // moves

  std::cout << "myvector contains:";
  for (std::string& x:myvector)
      std::cout << ' ' << x;
  return 0;
}

#Output
myvector contains: foo-string bar-string

第一次调用myvector.push_backfoo的值复制到vector中(foo在调用之前保持原有的值)。第二次调用将bar的值移动到向量中。这将其内容转移至vector中(同时bar失去了它的值,现在处于有效但未指定的状态)。

编译器(由C++标准规定)对消除大多数跟初始化相关的复制操作负有义务,因此转移构造函数的调用并不如你想的那样频繁。这种拷贝消除(copy elision)甚至消除了转移操作中极微小的性能损失。 另一方面,隐式消除赋值操作中的复制和转移操作,几乎是不可能的,因此,转移赋值对性能影响巨大。


定义构造函数、拷贝操作、转移操作和析构函数后,程序员能完全控制所持资源(比如容器的元素)的生命期。此外,转移构造函数可以将对象从一个作用域移到另一个,轻而易举且代价低廉。这样,对于无法或者不该通过复制方式取出作用域的对象,就能轻易低廉地转移出来。

考量标准库的thread,它相当于一个并发行为,以及承载上百万个double的Vector。前者无法复制,后者不该复制。

std::vector<thread> my_threads;

Vector init(int n) {
    thread t {heartbeat};               // 并发运行心跳(在别的线程里)
    my_threads.push_back(std::move(t)); // 把 t 转移到 my_threads
    // ... 其它初始化操作 ...

    Vector vec(n);
    for (int i=0; i!=vec.size(); ++i)
        vec[i] = 777;
    return vec;                         // 把 vec 转移出 init()
}

auto v = init(1'000'000);               // 开始心跳并初始化 v

类似于Vector和thread这种资源执柄,在任何情况下,都是内置指针的优秀替代品。事实上,诸如unique_ptr这种标准库的“智能指针(smart pointer)”, 本身就是资源执柄。

就像让new和delete在应用代码中消失那样,我们可以让指针匿踪在资源执柄身后。这两种情况的结果都是更简洁、更易维护的代码,而且不增加额外负担。确切地说,可以达成强资源安全(strong resource safety);就是说,对于常规意义上的资源来说,可以消灭资源泄漏的情况。这种例子有:vector持有内存、thread持有系统线程,以及fstream持有文件执柄。

在很多语言中,资源管理主要是委派给某个资源回收器。C++也有个垃圾回收接口,以便你接入一个资源回收器。但是我认为垃圾回收器是个无奈之选,在更整洁、更通用也更接地气的资源管理器替代方案无能为力之后,才会用它。我的观点是不要制造垃圾,这样就化解了对垃圾回收器的需求:禁止乱丢垃圾!

垃圾回收大体是个全局内存管理机制。精巧的实现可以值回性能开销,但计算机系统越来越趋向于分布式(想想缓存、多核心,以及集群),局部性比以往更重要了。

还有,内存并非仅有的资源。资源是任何必须使用前(显式或隐式)申请,使用后释放的东西。 例子有内存、锁、socket、文件执柄以及线程执柄。如你所料,内存以外的资源被称为非内存资源(non-memory resource)。优秀的资源管理系统能处理所有类型的资源。在任何长时间运行的系统里,泄漏必须避免,但资源的过度占用跟泄漏一样糟糕。例如:如果一个系统把内存、锁、文件等,都持有双倍时长,那它就需要双倍的资源供给。

在采用垃圾回收器之前,请先系统化地使用资源执柄:让每个资源都有个位于某个作用域内的有所有者,并且所有者在作用域结束处释放该资源。

在C++里,这叫 RAII(资源请求即初始化 Resource Acquisition Is Initialization),它已经跟错误处理机制中的异常整合在一起。资源可以通过转移的语意或者“智能指针”,从一个作用域移到另一个作用域,还可以通过“共享指针(shared pointer)”表示共享的所有权。

在C++标准库里,RAII无处不在:例如内存(stringvectormapunordered_map等),文件(ifstreamofstream等),线程(thread), 锁(lock_guard,unique_lock等), 以及常规对象(通过unique_ptrshared_ptr)。其效果是隐式的资源管理,它在寻常使用中不可见,且降低了资源持有时长。


想要对二元操作符——比如==——的两个操作数一视同仁, 最好在类所在的命名空间里定义一个非成员函数。例如:

namespace NX {
   class X {
       // ...
   };
   bool operator==(const X&, const X&);
   // ...
};

除非违反的理由充分,否则设计容器应遵循标准库容器(第11章)的风格。 具体来说,需要达成容器资源安全,将其作为一个资源执柄来实现, 并附带适当的基础操作。

for (size_t i = 0; i<c.size(); ++i) // size_t 是标准库 size() 返回类型的名称
    c[i] = 0;

for (auto& x : c)// 这里隐式利用了c.begin()和c.end()
    x = 0;

for (auto p = c.begin(); p!=c.end(); ++p)
    *p = 0;

除了用从0到size()的下标遍历容器外, 标准算法依赖于由一对迭代器(iterator)界定的序列(sequence)的概念。

上例中,c.begin()是个指向c第一个元素的迭代器, 而c.end()指向c最后一个元素之后的位置。 跟指针一样,迭代器支持++操作移至下一个元素,还支持*以访问其指向的元素的值。迭代器模型(iterator model)带来了极佳的通用型和性能。迭代器还被用于把序列传递给标准库算法。例如:

sort(v.begin(), v.end());

对于一对整数,<<的意思是左移,>>的意思是右移。 但是,对于iostream,它们分别是输出和输入运算符。


类的一个目标是让程序员设计、实现类型,并尽可能模拟内置类型。 构造函数提供了初始化操作,在灵活性和效率方面已经等同或超越了内置那些的初始化, 但对于内置类型来说,可以有文本值:

  • 123是一个int
  • 0xFF00u是一个unsigned int
  • 123.456是一个double
  • "Surprise!"是一个const char[10]

如果用户定义类型也具备这样的文本值可就太有用了。 实现它的方法是为文本值定义适当的后缀,从而得到:

  • "Surprise!"s是一个std::string
  • 123ssecond(秒)
  • 12.7iimaginary(虚部), 因此12.7i+47是一个complex number(复数)(即:{47, 12.7}

具体来说,这些来自标准库的例子,可以借由适当的头文件和命名空间得到:


不难想见,带有用户定义后缀的文本值被称为 用户定义文本值(user-difined literal)或UDL。 这些文本值通过 文本值操作符(literal operator)定义。 文本值操作符用于转换文本值,从其带有后缀的参数类型,转化到返回值类型。 例如,imaginary后缀的i可能是这样实现的:

constexpr complex<double> operator""i(long double arg) { // 虚部文本值
    return {0,arg};
}

此处:

  • operator"" 表示我们要定义一个文本值操作符
  • “文本值提示符”""后的i是后缀,它从这个操作符获得意义
  • 参数类型long double,表示后缀(i)是为浮点型文本值定义的
  • 返回值类型complex<double>指明了结果文本值的类型

据此,可以这样写:

complex<double> z = 2.7182818 + 6.283185i;


有很多算法,尤其是sort(),会用一个swap()函数,交换两个对象的值。 这些算法通常假定swap()快速,并且不会抛出异常。 标准库提供了一个std::swap(a,b), 它的实现用了三次转移操作:(tmp=a, a=b, b=tmp)。 假设你设计一个类型,如果复制它代价高昂又很可能被交换(比方说,被sort函数), 那么就给它定义一个转移操作,或者一个swap(),又或者干脆一起定义。 稍微提一下,标准库容器和string具有快速转移操作。

标准库的unordered_map<K,V>是个哈希表,其中K是键类型,V是值类型。如果想用某个类型X作为键,就必须定义hash<X>。标准库为我们给常见类型定义了它,比如std::string。

忠告

[1] 去掌控对象的构造、复制、转移以及析构;
[2] 把构造函数、赋值以及析构函数作为一套相互配合的操作进行设计;
[3] 要么定义所有基本操作,要么全都别定义;
[4] 如果缺省的构造函数、赋值、析构函数得当,让编译器去生成(别造轮子);
[5] 如果类具有指针成员,考虑一下是否需要定义一套析构函数、复制和转移的操作,或者拒绝生成它们;
[6] 如果类具有用户定义的析构函数,那它很可能需要用户定义的复制和转移操作,或拒绝生成它们;
[7] 默认情况下,请把单参数构造函数声明为explicit;
[8] 如果类具有合理的默认值,就以成员变量初值的方式给定;
[9] 如果默认的复制操作对某个类型不得当,就重定义或者禁止它;
[10] 将容器以传值的方式返回(相信转移操作的效率);
[11] 请为大容量的操作数采用const引用参数类型;
[12] 提供强类型安全;就是说,不要泄漏任何可视为资源的东西;
[13] 如果某个类是资源执柄,那它就需要用户定义的构造函数、析构函数,以及非默认的复制操作;
[14] 请重载运算符,以便模仿约定俗成的用法;
[15] 请遵循标准库容器的设计。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 213,254评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,875评论 3 387
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 158,682评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,896评论 1 285
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,015评论 6 385
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,152评论 1 291
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,208评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,962评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,388评论 1 304
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,700评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,867评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,551评论 4 335
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,186评论 3 317
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,901评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,142评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,689评论 2 362
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,757评论 2 351

推荐阅读更多精彩内容