本文起源于一个编译错误:
push_back vs emplace_back
区别
众所周知,在c++11之后,向vector的末尾新增元素有 push_back
和 emplace_back
两种方法,两者的核心区别在于:
emplace_back
可以节省一次拷贝(或移动)的消耗,比如std::vector<std::pair<int, int>> pr;
pr.emplace_back(1, 2);这里
pr.emplace_back(1, 2);
会让std::pair<int, int>(1, 2)
的构造直接在vector
中的对应位置发生,即所谓的“原地构造”。而
push_back
就必须写成pr.push_back({1, 2});
或者pr.push_back(std::pair<int, int>(1, 2));
或者其他需要构造函数的形式,这就必然会产生一个临时对象,然后再将这个临时对象拷贝(或移动)至vector的对应位置。当然,c++编译器实际上可以对平凡类型做出“原地构造”的优化,(开启O2等编译选项时)省去这个临时对象的构造。
msvc源码
接下来,我们仔细对比一下这两个方法在msvc(本文是c++20标准)中的源码:
template <class... _Valty>
_CONSTEXPR20 decltype(auto) emplace_back(_Valty&&... _Val) {
// insert by perfectly forwarding into element at end, provide strong guarantee
_Ty& _Result = _Emplace_one_at_back(_STD forward<_Valty>(_Val)...);
#if _HAS_CXX17
return _Result;
#else // ^^^ _HAS_CXX17 / !_HAS_CXX17 vvv
(void) _Result;
#endif // _HAS_CXX17
}
_CONSTEXPR20 void push_back(const _Ty& _Val) { // insert element at end, provide strong guarantee
_Emplace_one_at_back(_Val);
}
_CONSTEXPR20 void push_back(_Ty&& _Val) {
// insert by moving into element at end, provide strong guarantee
_Emplace_one_at_back(_STD move(_Val));
}
容易注意到,这两个方法的本质区别仅仅在于参数传递的不同,可以简单地总结为以下三个调用:
emplace_back(_Valty&&... _Val)
push_back(const _Ty& _Val)
push_back(_Ty&& _Val)
这里,emplace_back
用到了变参模板+完美转发;push_back
则是对左值和右值分类处理(左值拷贝、右值移动)。因此,emplace_back
只需要将能够匹配构造函数的参数传入,并不需要构造出临时对象;而 push_back
则必须传入一个 _Ty
类型的临时对象。
三种方法接受参数后都会调用 _Emplace_one_at_back
这个方法:
template <class... _Valty>
_CONSTEXPR20 _Ty& _Emplace_one_at_back(_Valty&&... _Val) {
// insert by perfectly forwarding into element at end, provide strong guarantee
auto& _My_data = _Mypair._Myval2;
pointer& _Mylast = _My_data._Mylast;
if (_Mylast != _My_data._Myend) {
return _Emplace_back_with_unused_capacity(_STD forward<_Valty>(_Val)...);
}
return *_Emplace_reallocate(_Mylast, _STD forward<_Valty>(_Val)...);
}
容易注意到,这个函数就是判断一下 vector
的剩余空间是否足够,不过空间不足就先进行扩容再插入元素,如果空间充足就直接在对应位置插入元素。
这里我们先分析剩余空间充足时的函数:
template <class... _Valty>
_CONSTEXPR20 _Ty& _Emplace_back_with_unused_capacity(_Valty&&... _Val) {
// insert by perfectly forwarding into element at end, provide strong guarantee
auto& _My_data = _Mypair._Myval2;
pointer& _Mylast = _My_data._Mylast;
_STL_INTERNAL_CHECK(_Mylast != _My_data._Myend); // check that we have unused capacity
if constexpr (conjunction_v<is_nothrow_constructible<_Ty, _Valty...>,
_Uses_default_construct<_Alloc, _Ty*, _Valty...>>) {
_ASAN_VECTOR_MODIFY(1);
_Construct_in_place(*_Mylast, _STD forward<_Valty>(_Val)...);
} else {
_ASAN_VECTOR_EXTEND_GUARD(static_cast<size_type>(_Mylast - _My_data._Myfirst) + 1);
_Alty_traits::construct(_Getal(), _Unfancy(_Mylast), _STD forward<_Valty>(_Val)...);
_ASAN_VECTOR_RELEASE_GUARD;
}
_Orphan_range(_Mylast, _Mylast);
_Ty& _Result = *_Mylast;
++_Mylast;
return _Result;
}
虽然这里的代码做了很多异常处理,但我们可以快速抓住重点:_Construct_in_place(*_Mylast, _STD forward<_Valty>(_Val)...);
,编译器会在编译期判断(if constexpr
):
- 用这些参数构造
_Ty
不会抛异常(可保证强异常安全,is_nothrow_constructible
) - 分配器类型允许在无需先“默认构造再赋值”的情况下,直接在目标内存上构造(
_Uses_default_construct
)
这时就会进入原地构造的分支 _Construct_in_place
:
constexpr _Ty* construct_at(_Ty* const _Location, _Types&&... _Args) noexcept(
noexcept(::new(_Voidify_iter(_Location)) _Ty(_STD forward<_Types>(_Args)...))) /* strengthened */ {
_MSVC_CONSTEXPR return ::new (_Voidify_iter(_Location)) _Ty(_STD forward<_Types>(_Args)...);
}
template <class _Ty, class... _Types>
_CONSTEXPR20 void _Construct_in_place(_Ty& _Obj, _Types&&... _Args) noexcept(
is_nothrow_constructible_v<_Ty, _Types...>) {
#if _HAS_CXX20
if (_STD is_constant_evaluated()) { // c++20会走这里
_STD construct_at(_STD addressof(_Obj), _STD forward<_Types>(_Args)...);
} else
#endif // _HAS_CXX20
{ // c++17会走这里
::new (_Voidify_iter(_STD addressof(_Obj))) _Ty(_STD forward<_Types>(_Args)...);
}
}
可以看到我们终于看到了最底层的调用(用于原地构造的placement new),在已知的那块内存地址(addressof(_Obj)
或者 _Voidify_iter(_Location)
)上直接根据参数 _STD forward<_Types>(_Args)...
构造对象。
扩容机制
前文中,我们已经分析了内存足够时的分支,那么如果 vector
需要扩容呢?
template <class... _Valty>
_CONSTEXPR20 pointer _Emplace_reallocate(const pointer _Whereptr, _Valty&&... _Val) {
// reallocate and insert by perfectly forwarding _Val at _Whereptr
_Alty& _Al = _Getal();
auto& _My_data = _Mypair._Myval2;
pointer& _Myfirst = _My_data._Myfirst;
pointer& _Mylast = _My_data._Mylast;
_STL_INTERNAL_CHECK(_Mylast == _My_data._Myend); // check that we have no unused capacity
const auto _Whereoff = static_cast<size_type>(_Whereptr - _Myfirst);
const auto _Oldsize = static_cast<size_type>(_Mylast - _Myfirst);
if (_Oldsize == max_size()) {
_Xlength();
}
const size_type _Newsize = _Oldsize + 1;
const size_type _Newcapacity = _Calculate_growth(_Newsize);
const pointer _Newvec = _Al.allocate(_Newcapacity);
const pointer _Constructed_last = _Newvec + _Whereoff + 1;
pointer _Constructed_first = _Constructed_last;
_TRY_BEGIN
_Alty_traits::construct(_Al, _Unfancy(_Newvec + _Whereoff), _STD forward<_Valty>(_Val)...);
_Constructed_first = _Newvec + _Whereoff;
if (_Whereptr == _Mylast) { // at back, provide strong guarantee
if constexpr (is_nothrow_move_constructible_v<_Ty> || !is_copy_constructible_v<_Ty>) {
_Uninitialized_move(_Myfirst, _Mylast, _Newvec, _Al);
} else {
_Uninitialized_copy(_Myfirst, _Mylast, _Newvec, _Al);
}
} else { // provide basic guarantee
_Uninitialized_move(_Myfirst, _Whereptr, _Newvec, _Al);
_Constructed_first = _Newvec;
_Uninitialized_move(_Whereptr, _Mylast, _Newvec + _Whereoff + 1, _Al);
}
_CATCH_ALL
_Destroy_range(_Constructed_first, _Constructed_last, _Al);
_Al.deallocate(_Newvec, _Newcapacity);
_RERAISE;
_CATCH_END
_Change_array(_Newvec, _Newsize, _Newcapacity);
return _Newvec + _Whereoff;
}
这个函数比较长,需要抓住处理扩容的重点:
const size_type _Newsize = _Oldsize + 1;
const size_type _Newcapacity = _Calculate_growth(_Newsize); // 这里会计算出新的capacity
const pointer _Newvec = _Al.allocate(_Newcapacity); // 这里做扩容 新的首地址保存在_Newvec
接着在新开辟的内存空间的对应位置构造新元素 _Alty_traits::construct(_Al, _Unfancy(_Newvec + _Whereoff), _STD forward<_Valty>(_Val)...);
。
然后,该函数会根据是否“尾插”(_Whereptr == _Mylast
)进入不同的分支,由于不论 push_back
还是 emplace_back
都是往末尾插入,所以这里一定会进入
if constexpr (is_nothrow_move_constructible_v<_Ty> || !is_copy_constructible_v<_Ty>) {
_Uninitialized_move(_Myfirst, _Mylast, _Newvec, _Al);
} else {
_Uninitialized_copy(_Myfirst, _Mylast, _Newvec, _Al);
}
编译器根据情况对其余元素进行移动或拷贝操作。
扩容因子
最后,我们再看一下扩容因子是怎么计算的(也就是怎么计算出 _Newcapacity
):
_CONSTEXPR20 size_type _Calculate_growth(const size_type _Newsize) const {
// given _Oldcapacity and _Newsize, calculate geometric growth
const size_type _Oldcapacity = capacity();
const auto _Max = max_size();
if (_Oldcapacity > _Max - _Oldcapacity / 2) {
return _Max; // geometric growth would overflow
}
const size_type _Geometric = _Oldcapacity + _Oldcapacity / 2;
if (_Geometric < _Newsize) {
return _Newsize; // geometric growth would be insufficient
}
return _Geometric; // geometric growth is sufficient
}
可以简单地理解为:msvc在常规情况下,假设当前capacity为 $x$,扩容后会变成 $1.5x$。
那么为什么msvc的扩容因子取的是 $1.5$?明明gcc和clang都是 $2$?这实际上和内存重用有关。
假设我们从空的vector开始逐步扩容,每一次扩容需要的空间是一个等比数列:$1,k,k^2,k^3,\cdots,k^n,\cdots$。假设我们当前进行了第 $n$ 次扩容,并且希望能够复用前 $n-1$ 次扩容中已经开辟的内存空间,那就需要满足
$$
\begin{aligned}
1+k+k^2+\cdots+k^{n-1} &\ge k^n\\
\frac{k^n-1}{k-1} &\ge k^n
\end{aligned}
$$
当 $n\to +\infty$ 时,显然有 $k\lt 2$。也就是说,如果扩容因子取2,那么我们永远无法复用前 $n-1$ 次申请的内存。举个例子,假设我们已经申请了 $1,2,4,8$ 的内存空间,下一次就需要申请 $16$,但显然 $16\gt 1+2+4+8$,于是先前开辟的内存就浪费了。
实际场景中,$n$ 显然不应趋向于无穷,$n=2$ 是一个相对合理的选择,此时就有 $1+k\le k^2$,可以解出 $k\le \frac{\sqrt 5 + 1}{2}\approx 1.618$,不难猜测msvc为了工程上的便利选择了1.5作为扩容因子。
这是一个非常理想化的内存模型,实际场景会复杂的多,扩容因子不论取1.5还是2都未必是最优解。
编译错误的原因
实际上,分析过上述源码之后,我们“可能”已经能够解答开头的编译错误了。
根据上文可知,push_back
和 emplace_back
的本质区别仅仅在于参数传递的不同:
emplace_back(_Valty&&... _Val)
push_back(const _Ty& _Val)
push_back(_Ty&& _Val)
对于
std::vector<std::vector<int>> a;
a.push_back({1, 2, 3});
而言,这个 push_back
实际上对应于 a.push_back(std::vector<int>{1, 2, 3})
,走 push_back(_Ty&& _Val)
。
但是,对于 a.emplace_back({1, 2, 3})
而言,编译器需要推导出变参模板中的参数类型 _Valty
,这个参数类型不同于 _Ty
类型早在vector声明时已经定义,这个变参模板的推导需要对于每一次 emplace_back
进行。
但是,花括号定义出的 initializer_list
并没有类型!这里的花括号是braced initializer list,编译器会将这个符号映射到 initializer_list<T>
,但它本身并不是一个表达式,也不存在可推导的类型,于是变参模板 emplace_back
就无法推导类型,这也就是编译错误的原因了。