STL 番外:push_back 与 emplace_back
前言
本来想接着把 deque
的部分给总结完的,但在看后面的函数部分时突然发现自己不是很清楚清楚 push_back
与 emplace_back
的区别,在这里总结一下。
emplace_back()
是 C++11 之后,新加入的方法,和 push_back()
一样的是都是在容器末尾添加一个新的元素进去,不同的是 emplace_back()
在效率上相比较于 push_back()
有了一定的提升。
push_back()
首先分析较为简单直观的 push_back()
方法。对于 push_back()
而言,最开始只有 void push_back( const T& value );
这个函数声明,后来从 C++11 ,新加了void push_back( T&& value )
函数,以下为 C++ 中的源码实现:
1 | /** |
在 C++20 之后,对这两个重载方法进行了修改,变成了 constexpr void push_back( const T& value );
以及 constexpr void push_back( T&& value );
。详情参考 版本修改计划。
emplace_back()
emplace_back() 是从 C++11 起新增到 vector 中的方法,最初的函数声明为:
1 | template< class... Args > |
之后在 C++14 之后,将无返回值 void 改为了返回对插入元素的引用:
1 | template< class... Args > |
在 STL 源码中,可以看到 emplace_back()
的实现是这样的:
1 | /** |
二者的区别
仔细观察这两个函数的签名,会发现这样的一件事情:
1 | void push_back(const value_type &__x); |
push_back()
接受的参数为 value_type
类型,即容器存放的元素类型,且只能接受一个参数;而 emplace()
接受的则是任意类型的参数。
这一点,直接决定了两个函数的区别,我们以一个例子进行分析。
1 | class testDemo |
定义了一个 testDemo
类,在调用不同的构造函数时会打印出相应的输出。
1 | void stdTest() { |
分别调用两个函数,看一下结果:
1 | emplace_back 2: |
会发现,emplace_back()
比 push_back()
少了一次移动构造,逐个分析:
emplace_back()
:
emplace_back()
内部直接调用 forward
,完美转发所有参数,再调用 construct
。因此是 直接在容器末尾带参构造了一个元素。
push_back()
:
重点来了,由于 push_back()
接受的参数类型为 value_type
,因此在调用时会先进行一次隐式类型转换。这便是第一个 “调用带参构造函数” 的由来。
在进行了隐式类型转换后,push_back()
会调用 emplace_back()
,由 emplace_back()
转发 __x
,再调用 construct
在容器尾部构造一个元素。需要注意的是,此时 __x
已经被转换为了 value_type
类型,并且经 move
后变成了右值。因此这里的 construct
调用的就是移动构造函数了,这便是 push_back
调用的第二次构造函数的历程。
1 | // 如果 C++ 版本为 C++11 及以上(也就是从 C++11 开始新加了这个方法),使用 emplace_back() 代替 |
进一步思考
前面已经分析了 push_back()
和 emplace_back()
在传入非 value_type
类型的右值对象的区别,那其他情况呢?依旧使用上面的 testDemo
,再进行分析。
传入参数为非 value_type
的左值
1 | void stdTest() { |
这个例子是想纠正一些博客中的观点,emplace_back()
与 push_back()
的差异仅体现在传入的参数是 非 value_type 的情况下,与传入参数是左值还是右值没什么关系。这个例子中我们传入的是非 value_type
的左值,push_back
依然多调用了一次移动构造。
传入对象为 value_type
类型的左值
1 | void stdTest() { |
在传入的是 value_type
类型的左值时,emplace_back()
与 push_back()
都只会调用一次拷贝构造函数。这一点理解了上文的读者应该很容易就能明白,因为这两个函数的差别仅在于 push_back()
在调用时可能会发生一次隐式类型转换,在传入参数类型本身就是 value_type
时,不会发生类型转换。在这种情况下,二者的调用过程几乎相同。
传入对象为 value_type
类型的右值
1 | void stdTest() { |
可以看出,这种情况下二者也没有任何差别,都调用了一次移动构造函数。
explict
为了验证 push_back()
多出的这一次隐式类型转换,我们继续实验:
1 | class testDemo |
我们将 testDemo(int num)
声明为 explict
,这样就禁止了使用一个 int
类型对象直接转化为 testDemo
类型对象的功能。再使用如下的测试:
1 | void stdTest() { |
会发现编译器毫不意外地报错了,log 的末尾,错误是这样的:
1 | note: no known conversion for argument 1 from ‘int’ to ‘tinystl::vector<testDemo>::value_type&&’ {aka ‘testDemo&&’} |
无法从 int
转化为 value_type&&
类型。至此,最后一个疑问也终于解开,push_back()
确实会使用隐式类型转换。
总结
至此,我们已经可以大概总结出 emplace_back()
与 push_back()
的不同了:emplace_back()
可以接受多个参数来在容器的末尾直接使用元素的带参构造构造出一个元素,整个流程只需用到一次构造函数;push_back()
只能接受一个 value_type& / value_type&&
类型的参数,也是在容器末尾构造元素。二者的不同之处在于:当传入参数不是 value_type
类型时,push_back()
会发生一次隐式类型转换,将参数转换为 value_type
类型,这个过程中会多调用一次元素的带参构造,其他情况下,二者的流程几乎相同。
后日谈
实际上在分析这些内容的过程中还发现了一件奇怪的事情:当传入参数为非 value_type
类型时,push_back()
的调用过程与 value_type
类型有没有移动构造有关,具体来说:
1 | class testDemo |
1 | void stdTest() { |
当 testDemo
中没有移动构造方法时,push_back()
的第二步会使用拷贝构造,而定义了移动构造时使用的则是移动构造,这使我百思不得其解。因此又试验了其他情况
1 | void stdTest() { |
直接传入一个 value_type
类型的右值,在 value_type
没有移动构造的情况下也会调用拷贝构造,这样看来可能是编译器优化了这个过程,如果有看到这里并且知道为什么的大佬,欢迎在下方留言。
后后日谈 (2024-7-18)
在与同学的激烈讨论下,这个神秘的问题终于被解开了!
先说结论:在没有定义移动构造函数的情况下,编译器会根据用户是否定义了拷贝构造函数等一系列条件自动生成一个移动构造函数,这就是上述调用过程产生差异的原因。
-
如果需要用一个右值去拷贝构造一个类对象或赋值给一个类对象,则在该类自定义了移动构造函数/移动赋值函数的情况下,会调用该类自定义的移动构造函数/移动赋值函数
而在没有自定义移动构造函数/移动赋值函数的情况下,如果该类自定义了拷贝构造函数/赋值运算符或析构函数之一,都只会调用拷贝构造函数/赋值运算符(前提是形参为const T&而不是T&,因为const T&可以兼容右值实参,而T&不能)而不会生成默认移动构造函数。注意即使没有自定义拷贝构造函数/赋值运算符,只自定义了析构函数,也不会生成默认移动构造函数,这是因为自定义析构函数表明该类在析构时可能需要回收内存,如果生成了默认移动构造函数可能会出错(比如同一地址被释放两次的错误)。
-
如果没有为类类型提供用户定义的移动构造函数,并且以下所有条件均为 true:
- 没有用户声明的 copy constructors ;
- 没有用户声明的 copy assignment operators ;
- 没有用户声明的 move assignment operators ;
- 没有用户声明的 destructor 。
然后,编译器将使用签名
T::T(T&&)
将移动构造函数声明为其类的非 explicit 内联公共成员。一个类可以有多个移动构造函数,例如
T::T(const T&&)
和T::T(T&&)
。如果存在一些用户定义的移动构造函数,用户仍然可以使用关键字 default 强制生成隐式声明的移动构造函数。
因此,在上述没有定义移动构造函数的情况下,由于定义了拷贝构造函数,编译器会自动生成一个移动构造函数,内部调用的是拷贝构造函数,这就是为什么在没有定义移动构造函数的情况下,push_back()
会调用拷贝构造函数的原因。