Post

Move Semantics Ⅳ

C++移动语义.

本文是《C++ Move Semantics, the complete guide》第5章内容的读书笔记以及部分翻译。

重载引用限定符

如果不知道限定符是什么,本节中重载限定符有简单的限定符介绍

get方法的返回类型

在实现有较高拷贝成本的成员类型的get方法时,C++11之前一般有两种方式:

  • 以值返回;
  • const左值引用返回。

这里简要介绍这两种方案。

以值返回

get方法以值返回例子如下(注意:前面的章节提到过,不要返回const值,除非对于返回的值就是不想用移动语义):

1
2
3
4
5
6
7
8
9
10
class Person
{
private:
    std::string name;
public:
    // ...
    std::string getName() const {
        return name;
    }
};

代码很安全,但每次调用该方法时,都需要进行一次拷贝。因为这里name生命周期和Person对象绑定,既不会触发NVO也不会使用移动语义。在很多场景会造成大幅开销增长,比如

1
2
3
4
5
6
7
std::vector<Person> coll;
// ...
for (const auto& person : coll) {
    if (person.getName().empty()) { // OOPS: copies the name
        std::cout << "found empty name\n";
    }
}

const引用返回

const引用返回的例子如下

1
2
3
4
5
6
7
8
9
class Person
{
private:
    std::string name;
public:
    const std::string& getName() const {
        return name;
    }
};

这样实现性能优于以值返回但是稍微不安全,需要调用者注意引用的使用需要在所调用的对象的生命周期内。参考以下例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
for (char c : returnPersonByValue().getName()) { // OOPS: undefined behavior
    if (c == ' ') {
        // ...
    }
}

// 等价于
reference range = returnPersonByValue().getName();
// OOPS: returned temporary object destroyed here
for (auto pos = range.begin(), end = range.end(); pos != end; ++pos) {
    char c = *pos;
    if (c == ' ') {
        // ...
    }
}

例子中,range引用可以延长被引用对象的生命周期,但是其指向的是临时Person对象的成员。初始化语句结束后,临时Person对象调用析构函数,引用将使用析构后的字符串对象,产生未定义行为。

使用移动语义

引入移动语义,实现上可以在对象安全时返回引用,而对象可能存在生命周期结束的情况下返回值,例子如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Person
{
private:
    std::string name;
public:
    // 这里的返回值标记了std::move,但标记的对象并不是局部对象,与之前的条款并不冲突
    std::string getName() && {      // when we no longer need the value
        return std::move(name);     // we steal and return by value
    }

    const std::string& getName() const & {   // in all other cases
        return name;                        // we give access to the member
    }
};

这里通过使用不同的引用限定符重载get方法来达到不同场景下调用不同的函数:

  • 当使用临时对象或者std::move标记的对象调用函数,使用&&限定符的函数;
  • 标记&限定符的函数可以应用于所有情况,一般如果不满足&&限定符函数的条件时,该函数可以作为其回退版本调用。

此情况下,get方法同时兼顾了性能和安全。参考以下调用的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
Person p{"Ben"};
std::cout << p.getName();                           // 1) fast (returns reference)
std::cout << returnPersonByValue().getName();       // 2) fast (uses move())
std::vector<Person> coll;

// ...
for (const auto& person : coll) {
    if (person.getName().empty()) {                 // 3) fast (returns reference)
        std::cout << "found empty name\n";
    }
}

for (char c : returnPersonByValue().getName()) {    // 4) safe and fast (uses move())
    if (c == ' ') {
        // ...
    }
}

void foo()
{
    Person p{ ... };
    coll.push_back(p.getName());                // calls getName() const&
    coll.push_back(std::move(p).getName());     // calls getName() && (OK, p no longer used)
}

使用std::move标记的对象调用函数后,对象进入有效但未指定状态。返回的值可以使用移动语义插入到coll向量中。

C++标准库中,std::optional<>就使用了移动语义来优化其get方法。

重载限定符

函数的括号后的限定符用于限定一个不通过参数传递的对象,即该成员函数所属的对象本身。

使用移动语义后,有了更多使用不同限定符的重载函数。以下例子给出所有引用限定符重载函数的例子和使用场景:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include <iostream>
class C {
public:
    void foo() const& {
        std::cout << "foo() const&\n";
    }
    void foo() && {
        std::cout << "foo() &&\n";
    }
    void foo() & {
        std::cout << "foo() &\n";
    }
    void foo() const&& {
        std::cout << "foo() const&&\n";
    }
};

int main()
{
    C x;
    x.foo();        // calls foo() &
    C{}.foo();      // calls foo() &&
    std::move(x).foo();     // calls foo() &&
    const C cx;
    cx.foo();       // calls foo() const&
    std::move(cx).foo();    // calls foo() const&&
}

注意!同时重载引用和无引用限定符是非法的!比如这样:

1
2
3
4
5
class C {
public:
    void foo() &&;
    void foo() const; // ERROR: can’t overload by both reference and value qualifiers
};

何时使用引用限定符

引用限定符是我们可以根据对象的不同值类型来调用其不同的成员函数实现。

下文将讨论引用限定符使用场景,特别是使用此特性保证将亡值不再被修改(将亡值指即将被销毁的对象,因其即将被销毁,大部分对其进行修改的操作无意义)。

赋值操作符的引用限定符

一般的赋值运算符实现赋值给一个临时对象:

1
2
3
4
std::string getString();

getString() = "hello";  // OK
foo(getString() = "");  // passes string instead of bool

可以通过添加引用限定符的方式,限制只能赋值给左值,上述的赋值的操作将不再被允许。

1
2
3
4
5
6
7
8
9
10
11
namespace std {
template<typename charT, ... >
class basic_string {
    public:
    // ...
    constexpr basic_string& operator=(const basic_string& str) &;
    constexpr basic_string& operator=(basic_string&& str) & noexcept( ... );
    constexpr basic_string& operator=(const charT* s) &;
    // ...
};
}

以下给出实现类时定义赋值操作运算符的例子,使用&限定之后则会自动禁用对临时对象的赋值(虽然我感觉没什么必要···谁会往临时对象上赋值啊···这么想的话C++标准库拒绝在所有的代码上应用这个修改好像也有道理···)。事实上,引用限定符也应该用于限制每一个可能修改对象的成员函数

1
2
3
4
5
6
7
8
9
10
class MyType {
public:
    // disable assigning value to temporary objects:
    MyType& operator=(const MyType& str) & =default;
    MyType& operator=(MyType&& str) & =default;
    // because this disables the copy/move constructor, also:
    MyType(const MyType&) =default;
    MyType(MyType&&) =default;
    // ...
};

其他成员函数的引用限定符

正如第一小节所讨论的,当返回值为引用时,应该使用引用限定符。通过引用运算符重载,可以减少访问已销毁对象的成员的风险。当前的string就使用了此特性,当使用[]front()back()等操作访问对象中元素时,都能保证临时对象的销毁不会影响已获取的元素的使用。(这里代码就不贴了,有点长)

This post is licensed under CC BY 4.0 by the author.