首頁 > 軟體

C++ STL標準庫std::vector擴容時進行深複製原因詳解

2022-08-15 18:06:26

引子

但是筆者卻發現了一個奇怪的現象,std::vector擴容時,對其中的元素竟然進行的是深複製。請看範例程式碼:

#include <iostream>
#include <vector>
struct Test {
    Test() {std::cout << "Test" << std::endl;}
    ~Test() {std::cout << "~Test" << std::endl;}
    Test(const Test &) {std::cout << "Test copy" << std::endl;}
    Test(Test &&) {std::cout << "Test move" << std::endl;}
};
int main(int argc, const char *argv[]) {
    std::vector<Test> ve;
    ve.emplace_back();
    ve.emplace_back();
    ve.emplace_back();
    return 0;
}

列印結果如下:

Test
Test
Test copy
~Test
Test
Test copy
Test copy
~Test
~Test
~Test
~Test
~Test

由於我們沒有呼叫reverse函數,所以預設只分配了一個元素的大小。第一次emplace_back時,僅進行了一次普通構造。第二次emplace_back時,就需要進行擴容,然後把第一個元素拷貝過去,再釋放原來的物件。所以這裡除了有一次新的構造以外,還有一次複製和釋放。後面的行為類似,不再贅述,

但關鍵問題就在於,Test類明明實現了移動構造(淺複製),可這裡竟然呼叫了拷貝構造(深複製)。

如果vector擴容無腦呼叫拷貝構造,那麼這個物件如果含有很多外連的成員(比如說指向buffer的指標、指向其他物件的指標等),呼叫拷貝構造就意味著要把這些連結的物件全部都重新構造一遍。這對於vector自身擴容來說,顯然是沒有必要的,會極度浪費記憶體空間。

查詢原因

基於上述理由,我認為STL的開發者不可能連這個問題都考慮不到,但想不通為什麼我明明實現了移動構造,卻不能呼叫。

帶著這樣的疑問我去研讀了STL的原始碼(GNU版本),在vector擴容時,會呼叫_M_realloc_insert函數,該函數在vector.tcc檔案中實現。在這個函數裡面對已有元素進行拷貝的時候,看到了類似這樣的程式碼:

__new_finish
		= std::__uninitialized_move_if_noexcept_a
		(__old_start, __position.base(),
		 __new_start, _M_get_Tp_allocator());
	      ++__new_finish;

有趣的就是這個__uninitialized_move_if_noexcept_a,我們找到這個函數的實現:

template<typename _InputIterator, typename _ForwardIterator,
	   typename _Allocator>
    inline _ForwardIterator
    __uninitialized_move_if_noexcept_a(_InputIterator __first,
				       _InputIterator __last,
				       _ForwardIterator __result,
				       _Allocator& __alloc)
    {
      return std::__uninitialized_copy_a
	(_GLIBCXX_MAKE_MOVE_IF_NOEXCEPT_ITERATOR(__first),
	 _GLIBCXX_MAKE_MOVE_IF_NOEXCEPT_ITERATOR(__last), __result, __alloc);
    }

再看一下_GLIBCXX_MAKE_MOVE_IF_NOEXCEPT_ITERATOR的實現

#if __cplusplus >= 201103L
#define _GLIBCXX_MAKE_MOVE_IF_NOEXCEPT_ITERATOR(_Iter) std::__make_move_if_noexcept_iterator(_Iter)
#else
#define _GLIBCXX_MAKE_MOVE_IF_NOEXCEPT_ITERATOR(_Iter) (_Iter)
#endif // C++11

也就是說,在C++11以前,這玩意就是物件本身(畢竟C++11以前還沒有移動構造),而在C++11以後被定義成了__make_move_if_noexcept_iterator,繼續檢視其定義。

template<typename _Iterator, typename _ReturnType
    = typename conditional<__move_if_noexcept_cond
      <typename iterator_traits<_Iterator>::value_type>::value,
                _Iterator, move_iterator<_Iterator>>::type>
    inline _GLIBCXX17_CONSTEXPR _ReturnType
    __make_move_if_noexcept_iterator(_Iterator __i)
    { return _ReturnType(__i); }

這裡用了一個conditional,來判斷這個迭代器的型別,如果__move_if_noexcept_cond為真,就取迭代器本身,否則就取移動迭代器。看起來問題就在這裡了,之前我們的例程中的Test一定就是符合了這個__move_if_noexcept_cond,導致用了原始迭代器。

繼續深挖這個__move_if_noexcept_cond,看到這樣的程式碼:

template<typename _Tp>
    struct __move_if_noexcept_cond
    : public __and_<__not_<is_nothrow_move_constructible<_Tp>>,
                    is_copy_constructible<_Tp>>::type { };

也就是說,如果一個類,不存在不會丟擲異常的移動建構函式並且可拷貝,那麼就為真。

Test類顯然符合,所以vector<Test>在複製時用了普通的迭代器進行了遍歷,自然就會呼叫拷貝建構函式進行復制了。

解決方法

所以,我們需要讓Test不符合__move_if_noexcept_cond的條件,也就是這裡要將移動建構函式宣告為noexcept表示它不會丟擲異常,這樣vector<Test>在複製時就會使用移動迭代器(就是會包裝一層std::move),從而觸發移動構造。

順道我們也看一眼移動迭代器的原理:

template<typename _Iterator>
class move_iterator {
    _Iterator _M_current;
    // ...
  public:
    using iterator_type = _Iterator;
	explicit _GLIBCXX17_CONSTEXPR
      	move_iterator(iterator_type __i)
      	: _M_current(std::move(__i)) { }
    // ...
}

確實呼叫了std::move,證明我們的思路沒錯。

所以,修改Test程式碼,實現noexcept移動構造:

struct Test {
    long a, b, c, d;
    Test() {std::cout << "Test" << std::endl;}
    ~Test() {std::cout << "~Test" << std::endl;}
    Test(const Test &) {std::cout << "Test copy" << std::endl;}
    Test(Test &&) noexcept {std::cout << "Test move" << std::endl;}
};
int main(int argc, const char *argv[]) {
    std::vector<Test> ve;
    ve.emplace_back();
    ve.emplace_back();
    ve.emplace_back();
    return 0;
}

列印結果如下:

Test
Test
Test move
~Test
Test
Test move
Test move
~Test
~Test
~Test
~Test
~Test

這次如我們所願,呼叫了移動構造。

結論

STL中考慮到異常的情況,因此,像這種容器內部的複製行為,是要求不能夠發生異常的,因此,只有當移動建構函式宣告為noexcept的時候才會呼叫,否則將統一呼叫拷貝建構函式。

然而,在移動建構函式中本來就不應該丟擲異常,因此,在大多數情況下,移動建構函式都應該用noexcept來宣告。

到此這篇關於C++ STL標準庫std::vector擴容時進行深複製原因詳解的文章就介紹到這了,更多相關C++ std::vector內容請搜尋it145.com以前的文章或繼續瀏覽下面的相關文章希望大家以後多多支援it145.com!


IT145.com E-mail:sddin#qq.com