关于c++:重学C04-说透C右值引用上

3次阅读

共计 4967 个字符,预计需要花费 13 分钟才能阅读完成。

文章首发

【重学 C ++】04 | 说透 C ++ 右值援用(上)

引言

大家好,我是只讲技术干货的会玩 code,明天是【重学 C ++】的第四讲,在后面《03 | 手撸 C ++ 智能指针实战教程》中,咱们或多或少接触了右值援用和挪动的一些用法。

右值援用是 C++11 规范中一个很重要的个性。第一次接触时,可能会很乱,不分明它们的目标是什么或者它们解决了什么问题。接下来两节课,咱们具体讲讲右值援用及其相干利用。内容很干,留神珍藏!

左值 vs 右值

简略来说,左值是指能够应用 & 符号获取到内存地址的表达式,个别呈现在赋值语句的右边,比方变量、数组元素和指针等。

int i = 42;
i = 43; // ok, i 是一个左值
int* p = &i; // ok, i 是一个左值,能够通过 & 符号获取内存地址

int& lfoo() { // 返回了一个援用,所以 lfoo()返回值是一个左值
    int a = 1;
    return a; 
};
lfoo() = 42; // ok, lfoo() 是一个左值
int* p1 = &lfoo(); // ok, lfoo()是一个左值

相同,右值是指无奈获取到内存地址的表白是,个别呈现在赋值语句的左边。常见的有字面值常量、表达式后果、长期对象等。

int rfoo() { // 返回了一个 int 类型的长期对象,所以 rfoo()返回值是一个右值
    return 5;
};

int j = 0;
j = 42; // ok, 42 是一个右值
j = rfoo(); // ok, rfoo()是右值
int* p2 = &rfoo(); // error, rfoo()是右值,无奈获取内存地址

左值援用 vs 右值援用

C++ 中的援用是一种别名,能够通过一个变量名拜访另一个变量的值。

上图中,变量 a 和变量 b 指向同一块内存地址,也能够说变量 a 是变量 b 的别名。

在 C ++ 中,援用分为左值援用和右值援用两种类型。左值援用是指对左值进行援用的援用类型,通常应用 & 符号定义;右值援用是指对右值进行援用的援用类型,通常应用 && 符号定义。

class X {...};
// 接管一个左值援用
void foo(X& x);
// 接管一个右值援用
void foo(X&& x);

X x;
foo(x); // 传入参数为左值,调用 foo(X&);

X bar();
foo(bar()); // 传入参数为右值,调用 foo(X&&);

所以,通过重载左值援用和右值援用两种函数版本,满足在传入左值和右值时触发不同的函数分支。

值得注意的是,void foo(const X& x);同时承受左值和右值传参。

void foo(const X& x);
X x;
foo(x); // ok, foo(const X& x)可能接管左值传参

X bar();
foo(bar()); // ok, foo(const X& x)可能接管右值传参

// 新增右值援用版本
void foo(X&& x);
foo(bar()); // ok,精准匹配调用 foo(X&& x)

到此,咱们先简略对右值和右值援用做个小结:

  1. 像字面值常量、表达式后果、长期对象等这类无奈通过 & 符号获取变量内存地址的,称为右值。
  2. 右值援用是一种援用类型,示意对右值进行援用,通常应用 && 符号定义。

右值援用次要解决一下两个问题:

  1. 实现挪动语义
  2. 实现完满转发

这一节咱们先具体讲讲右值是如何实现挪动成果的,以及相干的注意事项。完满转发篇幅有点多,咱们留到下节讲。

复制 vs 挪动

假如有一个自定义类 X,该类蕴含一个指针成员变量,该指针指向另一个自定义类对象。假如O 占用了很大内存,创立 / 复制 O 对象须要较大老本。

class O {
public:
    O() {std::cout << "call o constructor" << std::endl;};
    O(const O& rhs) {std::cout << "call o copy constructor." << std::endl;}
};

class X {
public:
    O* o_p;
    X() {o_p = new O();
    }
    ~X() {delete o_p;}
};

X 对应的拷贝赋值函数如下:

X& X::operator=(X const & rhs) {
    // 依据 rhs.o_p 生成的一个新的 O 对象资源
    O* tmp_p = new O(*rhs.o_p);
    // 回收 x 以后的 o_p;
    delete this->o_p;
    // 将 tmp_p 赋值给 this.o_p;
    this->o_p = tmp_p;
    return *this;
}

假如对 X 有以下应用场景:

X x1;
X x2;
x1 = x2;

上述代码输入:

call o constructor
call o constructor
call o copy constructor

x1x2 初始化时,都会执行 new O(), 所以会调用两次O 的构造函数;执行 x1=x2 时,会调用一次 O 的拷贝构造函数,依据 x2.o_p 复制一个新的 O 对象。

因为 x2 在后续代码中可能还会被应用,所以为了防止影响 x2,在赋值时调用O 的拷贝构造函数复制一个新的 O 对象给 x1 在这种场景下是没问题的。

但在某些场景下,这种拷贝显得比拟多余:

X foo() {return X();
};

X x1;
x1 = foo();

代码输入与之前一样:

call o constructor
call o constructor
call o copy constructor

在这个场景下,foo()创立的那个长期 X 对象在后续代码是不会被用到的。所以咱们不须要放心赋值函数中会不会影响到那个长期 X 对象,没必要去复制一个新的 O 对象给x1

更高效的做法,是间接应用 swap 替换长期 X 对象的 o_px1.o_p。这样做有两个益处:1. 不必调用耗时的 O 拷贝构造函数,提高效率;2. 替换后,长期 X 对象领有之前 x1.o_p 指向的资源,在析构时能主动回收,防止内存透露。

这种防止昂扬的复制老本,而间接将资源从一个对象 ” 挪动 ” 到另外一个对象的行为,就是 C ++ 的挪动语义。

哪些场景实用挪动操作呢?无奈获取内存地址的右值就很适合,咱们不须要放心后续的代码会用到该右值。

最初,咱们看下挪动版本的赋值函数

X& operator=(X&& rhs) noexcept {std::swap(this->o_p, rhs.o_p);
    return *this;
};

看下应用成果:

X x1;
x1 = foo();

输入后果:

call o constructor
call o constructor

右值援用肯定是右值吗?

假如咱们有以下代码:

class X {
public:
    // 复制版本的赋值函数
    X& operator=(const X& rhs);

    // 挪动版本的赋值函数
    X& operator=(X&& rhs) noexcept;
};

void foo(X&& x) {
    X x1;
    x1 = x;
}

X 重载了复制版本和挪动版本的赋值函数。当初问题是:x1=x这个赋值操作调用的是 X& operator=(const X& rhs) 还是 X& operator=(X&& rhs)
针对这种状况,C++ 给出了相干的规范:

Things that are declared as rvalue reference can be lvalues or rvalues. The distinguishing criterion is: _if it has a name_, then it is an lvalue. Otherwise, it is an rvalue.

也就是说,只有一个右值援用有名称,那对应的变量就是一个左值,否则,就是右值。

回到下面的例子,函数 foo 的入参尽管是右值援用,但有变量名 x,所以x 是一个左值,所以 operator=(const X& rhs) 最终会被调用。

再给一个没有名字的右值援用的例子

X bar();
// 调用 X & operator=(X&& rhs),因为 bar()返回的 X 对象没有关联到一个变量名上
X x = bar();

这么设计的起因也挺好了解。再改下 foo 函数的逻辑:

void foo(X&& x) {
    X x1;
    x1 = x;
    ...
    std::cout << *(x.inner_ptr) << std::endl;
}

咱们并不能保障在 foo 函数的后续逻辑中不会拜访到 x 的资源。所以这种状况下如果调用的是挪动版本的赋值函数,x的外部资源在实现赋值后就乱了,无奈保障后续的失常拜访。

std::move

反过来想,如果咱们明确晓得在 x1=x 后,不会再拜访到x,那有没有方法强制走挪动赋值函数呢?

C++ 提供了 std::move 函数,这个函数做的工作很简略:通过暗藏掉入参的名字,返回对应的右值。

X bar();
X x1
// ok. std::move(x1)返回右值,调用挪动赋值函数
X x2 = std::move(x1);
// ok. std::move(bar())与 bar()成果雷同,返回右值,调用挪动赋值函数
X x3 = std::move(bar());

最初,用一个容易犯错的例子完结这一环节

class Base {
public:
    // 拷贝构造函数
    Base(const Base& rhs);
    // 挪动构造函数
    Base(Base&& rhs) noexcept;
};

class Derived : Base {
public:
    Derived(Derived&& rhs)
    // wrong. rhs 是左值,会调用到 Base(const Base& rhs).
    // 须要批改为 Base(std::move(rhs))
    : Base(rhs) noexcept {...}
}

返回值优化

按照常规,还是先给出类 X 的定义

class X {
public:
    // 构造函数
    X() {std::cout << "call x constructor" <<std::endl;};
    // 拷贝构造函数
    X(const X& rhs) {std::cout << "call x copy constructor" << std::endl;};
    // 挪动构造函数
    X(X&& rhs) noexcept {std::cout << "call x move constructor" << std::endl};
}

大家先思考下以下两个函数哪个性能比拟高?

X foo() {
  X x;
  return x;
};

X bar() {
  X x;
  return std::move(x);
}

很多读者可能会感觉 foo 须要一次复制行为:从 x 复制到返回值;bar因为应用了 std::move,满足挪动条件,所以触发的是挪动构造函数:从x 挪动到返回值。复制老本 > 挪动老本,所以 bar 性能更好。

实际效果与下面的推论相同,bar中应用 std::move 反倒多余了。古代 C ++ 编译器会有返回值优化。换句话说,编译器将间接在 foo 返回值的地位结构 x 对象,而不是在本地结构 x 而后将其复制进来。很显著,这比在本地结构后挪动效率更快。

以下是 foobar的输入:

// foo
call x constructor

// bar
call x constructor
call x move constructor

挪动须要保障异样平安

仔细的读者可能曾经发现了,在后面的几个大节中,挪动结构 / 赋值函数我都在函数签名中加了关键字noexcept,这是向调用者表明,咱们的挪动函数不会抛出异样。

这点对于挪动函数很重要,因为挪动操作会对右值造成 毁坏。如果挪动函数中产生了异样,可能会对程序造成不可逆的谬误。以上面为例

class X {
public:
    int* int_p;
    O* o_p;

    X(X&& rhs) {std::swap(int_p, rhs.int_p);
        ...
        其余业务操作
        ...
        std::swap(o_p, rhs.o_p);
    }
}

如果在「其余业务操作」中产生了异样,不仅会影响到本次结构,rhs外部也曾经被 毁坏 了,后续无奈重试结构。所以,除非明确标识 noexcept,C++ 在很多场景下会 慎用 挪动结构。

比拟经典的场景是 std::vector 扩缩容。当vector 因为 push_backinsertreserveresize 等函数导致内存重调配时,如果元素提供了一个noexcept 的挪动构造函数,vector会调用该挪动构造函数将元素 挪动 到新的内存区域;否则,则会调用拷贝构造函数,将元素复制过来。

总结

明天咱们次要学了 C ++ 中右值援用的相干概念和利用场景,并花了很大篇幅解说挪动语义及其相干实现。

右值援用次要解决实现挪动语义和完满转发的问题。咱们下节接着解说右值是如何实现完满转发。欢送关注,及时收到推送~

<center>- END -</center>
【往期举荐】

01 C++ 如何进行内存资源管理

02 脱离指针陷阱:深入浅出 C++ 智能指针

03 手撸 C ++ 智能指针实战教程

正文完
 0