较新版本的cpp容器支持一些 emplace
操作,比如 vector::empalce 和 map::emplace ,其原地构造特性对于一些拷贝成本较高的对象容器来说着实吸引人。
不过 emplace
动作有些时候会退化回拷贝,近期恰好有需求要用到拷贝成本高的对象,希望在它们的容器中善用 emplace
来节约开销,因此在业余时间浅做了一些实验,希望这些认知可以指导后续的实践。
1 emplace左值——退化为拷贝
为了便于观察对象的移动/拷贝,简单实现一个对象(经典做法):
class Obj {
public:
Obj() { std::cout << "ctor" << std::endl; }
~Obj() { std::cout << "dtor" << std::endl; }
Obj(const Obj &rhs) { std::cout << "copy ctor" << std::endl; }
Obj(Obj &&rhs) { std::cout << "move ctor" << std::endl; }
Obj &operator=(const Obj &rhs) { std::cout << "copy operator=" << std::endl; return *this; }
Obj &operator=(Obj &&rhs) { std::cout << "move operator=" << std::endl; return *this; }
void Echo() { std::cout << "Hello world" << std::endl; }
};
对于通常的业务代码,容器一般是封装在某个类中的,在这里为了更加还原实际场景,简单设置一个 Shelter
类,其成员是一个 multimap
,提供一个对外的 Push
接口来往 multimap
中添加数据,然后在 Push
接口里调用 multimap::emplace
方法。
class Shelter {
public:
void Push(std::pair<int, Obj> item);
void Dump();
private:
std::multimap<int, Obj> mmp_;
};
void Shelter::Push(std::pair<int, Obj> item)
{
std::cout << "Shelter::Push =========>" << std::endl;
mmp_.emplace(item);
}
void Shelter::Dump()
{
for (auto &[k, v] : mmp_) {
v.Echo();
}
}
1.1 传值
上面给出的 Shelter
实现中, Push
参数按值传递。
在 main
函数分别向 Push
接口传一个右值和一个左值:
int main(int argc, const char * argv[])
{
Shelter shelter;
shelter.Push({1, Obj()});
auto input = std::make_pair(2, Obj());
shelter.Push(input);
shelter.Dump();
return 0;
}
观察输出:
ctor
move ctor
Shelter::Push =========>
copy ctor
dtor
dtor
ctor
move ctor
dtor
copy ctor
Shelter::Push =========>
copy ctor
dtor
Hello world
Hello world
dtor
dtor
dtor
分析程序分别执行了什么动作导致这些输出的产生:
# shelter.Push({1, Obj()});
ctor # 临时对象创建
move ctor # 临时对象移动到pair
Shelter::Push =========>
copy ctor # emplace,产生复制
dtor # Push参数对象析构
dtor # pair里的临时对象析构
# auto input = std::make_pair(2, Obj());
# shelter.Push(input);
ctor # make_pair临时Obj创建
move ctor # 临时Obj移动到pair
dtor # 临时Obj销毁
copy ctor # 值传递
Shelter::Push =========>
copy ctor # emplace,复制
dtor # Push参数对象析构
# shelter.Dump()
Hello world
Hello world
# 本地obj对象和shelter.mmp_里面的两个对象析构
dtor
dtor
dtor
可见,对于一个纯粹的左值来说, emplace
会退化成为拷贝构造,显然不太符合实际业务中原地构造、节省开销的诉求。
1.2 传左值/右值引用
简单改写一下 Push
接口,使其可以区分右值和左值:
void Shelter::Push(std::pair<int, Obj> &item)
{
std::cout << "Shelter::Push lvalue ref =========>" << std::endl;
mmp_.emplace(item);
}
void Shelter::Push(std::pair<int, Obj> &&item)
{
std::cout << "Shelter::Push rvalue ref =========>" << std::endl;
mmp_.emplace(item);
}
输出却毫无变化:
# 以下只关注Push内部发生的事情,外部其他构造/析构输出省略
Shelter::Push rvalue ref =========>
copy ctor # emplace,还是拷贝
Shelter::Push lvalue ref =========>
copy ctor
诶?怎么传入右值和左值,都在 emplace
的时候拷贝?注意:右值引用本身是一个左值类型,因此本质上传给 emplace
的还是一个左值,为了在 Push(Obj &&)
接口内充分利用移动语义,需要再执行一回类型转换:
void Shelter::Push(std::pair<int, Obj> &&item)
{
std::cout << "Shelter::Push rvalue ref =========>" << std::endl;
mmp_.emplace(std::move(item));
}
# 调用方法仍旧如前
Shelter::Push rvalue ref =========>
move ctor # 符合预期,入参是右值,移动
Shelter::Push lvalue ref =========>
copy ctor # 入参是左值,拷贝
2 减少重复代码
实际上分别实现了参数类型为左值引用和右值引用的 Push
后,我们已然能够借助移动语义来提高程序性能,不过,从代码上看,却发现这两个版本大差不差,令人很想将它们合并起来。
自动区分入参是左值还是右值,并原原本本传递给 multimap::emplace
,这听着真是一个再熟悉不过的场景了,简直是 万能引用 + 完美转发 的模板场景:
template<typename T>
void Push(T &&val)
{
std::cout << "Shelter::Push =========>" << std::endl;
mmp_.emplace(std::forward<T>(val));
}
坏处是由于模板参数没法自动推导 {xxx}
的类型,之前向接口传入右值的写法必须改写:
shelter.Push({1, Obj()}); // 编译错误
shelter.Push(std::make_pair(1, Obj())); // fine
输出则是符合预期的:
Shelter::Push =========>
move ctor # 入参是右值,传给emplace的就是右值
Shelter::Push =========>
copy ctor # 入参是左值,传给emplace的就是左值
如果希望对接口的自由度进行进一步限制,也可以只允许 T
被推导为 pair<int, Obj>
:
using MapItem = std::pair<int, Obj>;
template<typename T, std::enable_if_t<std::is_same_v<std::remove_reference_t<T>, MapItem>, int> = 0>
void Push(T &&val)
{
std::cout << "Shelter::Push =========>" << std::endl;
mmp_.emplace(std::forward<T>(val));
}