push_back vs emplace_back

本文起源于一个编译错误:

push_back vs emplace_back

区别

众所周知,在c++11之后,向vector的末尾新增元素有 push_backemplace_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):

  1. 用这些参数构造 _Ty 不会抛异常(可保证强异常安全,is_nothrow_constructible
  2. 分配器类型允许在无需先“默认构造再赋值”的情况下,直接在目标内存上构造(_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_backemplace_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 就无法推导类型,这也就是编译错误的原因了。

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇