喵の窝

c++11中setter的写法

零 ‘老’写法有什么问题

c++11 之前,由于没有移动语义,所以类成员变量的 setter 方法的签名一般都是 void set_xxx(const XXX &xxx),实现也是简单的变量赋值。但是 c+11 引入右值引用和移动语义,而老写法最大的问题就是无法正确处理右值引用。

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
28
29
#include <iostream>

class Data {
public:
Data() {std::cout << "constructor" << std::endl;}
Data(const Data &) {std::cout << "copy constructor" << std::endl;}
Data(Data &&) {std::cout << "move constructor" << std::endl;}

Data &operator=(const Data &) {std::cout << "assignment" << std::endl; return *this;}
Data &operator=(Data &&) {std::cout << "move assignment" << std::endl; return *this;}
};

class TestStruct {
public:
void SetData(const Data &data) {data_ = data;}
private:
Data data_;
};

int main() {
TestStruct t;
Data d;
std::cout << "================ start test ================" << std::endl;
std::cout << "lval" << std::endl;
t.SetData(d);
std::cout << "rval" << std::endl;
t.SetData(std::move(d));
return 0;
}

上面这段代码执行的结果是

1
2
3
4
5
6
7
constructor
constructor
================ start test ================
lval
assignment
rval
assignment

可以看到,我们无论传入的是左值还是右值,最终调用的都是赋值操作符。那么有没有办法让右值调用移动赋值操作呢?方法当然是有的。

壹 土味方案

因为没有人妨碍我们为一个变量写两个 setter。所以我们可以针对右值重载一个 setter。代码如下

1
2
3
4
5
6
7
class TestStruct {
public:
void SetData(const Data &data) {data_ = data;}
void SetData(Data &&data) {data_ = std::move(data);}
private:
Data data_;
};

这样一来,测试结果就变成了

1
2
3
4
5
6
7
constructor
constructor
================ start test ================
lval
assignment
rval
move assignment

但是。为一个成员变量写两个 setter 怎么看都是一种很蠢的行为。所以我们需要改进我们的方案。

贰 性能上的最优解

由于右值引用的加入,c++11 多了一个概念叫‘完美转发’。其作用就是让函数形参接受到的引用跟传入函数的实参是一个对象。如果调用的时候实参传入一个左值(或者左值引用),那么形参类型就是个左值引用;如果实参传入右值(或者右值引用),那么形参类型会变成右值引用。写法如下

1
2
3
4
5
6
7
class TestStruct {
public:
template <class DataType>
void SetData(DataType &&data) {data_ = std::forward<DataType>(data);}
private:
Data data_;
};

这种写法也能避免多余的拷贝操作。而且每一个成员变量只有一个 setter 方法。使用这种 setter 的执行结果

1
2
3
4
5
6
7
constructor
constructor
================ start test ================
lval
assignment
rval
move assignment

但是,这种写法的弊端也是很明显的。使用模板会增加可执行文件的体积,也会增加编译时间。更加重要的是,如果你正在写一个暴露给其它模块的接口头文件的话,模板的使用也是应该避免的。于是乎,这种方案也不能完全满足我们的需求。

叁 妥协下的产物

如果仔细看的话,setter 方法涉及到两次赋值操作。第一次是实参到形参的赋值,第二次是形参到成员变量的赋值。接下来要讲的方案思路在于优化形参到实参的赋值。写法如下

1
2
3
4
5
6
class TestStruct {
public:
void SetData(Data data) {data_ = std::move(data);}
private:
Data data_;
};

乍一看,这种方案颠覆了我们的认知。在我们的认知中,对象都应该避免值传递。而这个 setter 中,我们直接采用了值传递的方式。这样写真的能提升效率吗?这种写法的测试结果如下

1
2
3
4
5
6
7
8
9
constructor
constructor
================ start test ================
lval
copy constructor
move assignment
rval
move constructor
move assignment

如果传入左值,调用的是复制构造函数+移动赋值。如果传入的是右值,调用的是移动构造+移动赋值。与理想情况比较下

参数类型 理想情况 实际情况
左值 一次拷贝 一次拷贝 + 一次移动
右值 一次移动 两次移动

大多数情况下,我们可以认为移动的效率远高于拷贝的效率。并且移动本身应该是非常高效的一个操作(但是也有例外,比如 std::array)。在这前提条件下,我们可以认为 一次拷贝 和 (一次拷贝 + 一次移动)的效率差不多。而一次移动和两次移动的效率也差不多(因为移动本身就是一个很高效的操作)。
所以,如果由于种种原因,不能使用模板,而需要 setter 的成员变量也实现了移动操作的话,那么不妨试试这种方案。但如果正好有一个类似于 std::array 这种无法移动,拷贝效率贼低,还不得不需要 setter 的成员变量,那么这种写法的效率是非常低的,这种情况下建议使用第零章中的‘老’写法。

肆 总结

下面我们来总结下文中说的几种方案的优缺点。

方案 优点 缺点 适用场景
‘老写法’ 通常情况下不推荐 无法处理右值 无法移动的对象可以采用这种方法
完美转发 写法‘简单’ 模板 不排斥使用模板的场景
值传递 + 移动 没有模板 需要额外进行一次移动操作 无法使用模板,并且有高效‘移动’函数的场景