C++17剖析:string在Modern C++中的實現 C++17剖析:string在Modern C++中的實現

C++17剖析:string在Modern C++中的實現

2019-01-26 19:17  猴子頂呱呱  閱讀(388)  評論(0)  編輯  收藏

概述

GCC 8.2提供了兩個版本的std::string:一個是基於Copy On Write的,另一個直接字符串拷貝的。前者針對C++11以前的,那時候沒有移動構造,一切以效率爲先,需要使用COW這種奇技淫巧。後者針對C++11,也就是_GLIBCXX_USE_CXX11_ABI宏被設置爲非零時會被用到,並沒有COW的功能,更簡單,用戶可以在必要時使用移動構造。如果不使用移動構造,字符串的頻繁拷貝將會是異常災難,這一點,在我的項目中已經踩過坑。
本文主要研究Modern C++中的string實現,因爲使用C++11以後的標準都使用它。

數據組織:16字節的棧上存儲,如果超出16字節則在堆上分配

數據的組織很簡單,如果字符串長度不超過15字節(加上後綴0一共16字節),則在棧上保存,否則會在堆上分配內存。


      // Use empty-base optimization: http://www.cantrip.org/emptyopt.html
      struct _Alloc_hider : allocator_type // TODO check __is_final
      {
#if __cplusplus < 201103L
        _Alloc_hider(pointer __dat, const _Alloc& __a = _Alloc())
        : allocator_type(__a), _M_p(__dat) { }
#else
        _Alloc_hider(pointer __dat, const _Alloc& __a)
        : allocator_type(__a), _M_p(__dat) { }

        _Alloc_hider(pointer __dat, _Alloc&& __a = _Alloc())
        : allocator_type(std::move(__a)), _M_p(__dat) { }
#endif

        pointer _M_p; // The actual data.
      };

      _Alloc_hider      _M_dataplus;       ////數據指針,指向本地或者堆內存
      size_type         _M_string_length;  ////實際長度,如果大於_S_local_capacity則是堆內存,否則,是棧內存

      enum { _S_local_capacity = 15 / sizeof(_CharT) };

      ////注意這是Union,16字節的棧內存和整個堆的容量(Capacity)共用一塊存儲
      union
      {
        _CharT           _M_local_buf[_S_local_capacity + 1];
        size_type        _M_allocated_capacity;
      };

裏面那個union挺有意思,如果_M_string_length>=_S_local_capacity,那麼_M_allocated_capacity保存了整塊緩衝區的大小(注意不是字符串的大小);如果_M_string_length<_S_local_capacity則_M_dataplus._M_p指針指向_M_local_buf這塊棧上內存區。union的特性是全部元素共用一塊空間,根據程序的語義只使用其中一個變量,在這裏_M_local_buf有效的時候_M_allocated_capacity無效,反之亦然,巧妙地運用空間,減少內存浪費。

對象的構造:第一組,不帶參數的構造函數(默認構造函數)

  • 始化長度爲0,數據指針指向棧內存_M_local_buf,包括以下函數:
    • basic_string()
    • basic_string(const _Alloc& __a)

對象的構造:第二組,根據字符串構造

  • 調用_M_construct()構造對象,也就是把字符串拷貝一個(而不是增加引用計數);關於_M_construct()的細節,在下面會分析。包括以下函數:
  • 2.1 基於basic_string構建字符串拷貝
    • basic_string(const basic_string& __str):拷貝__str的完整字符串;
    • basic_string(const basic_string& __str, const _Alloc& __a):同上,指定構造器;
    • basic_string(const basic_string& __str, size_type __pos, const _Alloc& __a = _Alloc()):拷貝__str從__pos位置到末尾的所有字符;
    • basic_string(const basic_string& __str, size_type __pos, size_type __n):拷貝__str從__pos位置開始的N個字符(或者到末尾);
    • basic_string(const basic_string& __str, size_type __pos, size_type __n, const _Alloc& __a):同上,指定構造器;
  • 2.2 基於指針構建字符串拷貝,指針可以是字符串指針,也可以是其他指針,只要編譯器不抱怨
    • basic_string(const _CharT* __s, const _Alloc& __a = _Alloc()):拷貝__s的全部字符串,構造時會計算__s的長度,使用char_traits<char>::length()內聯函數;
    • basic_string(const _CharT* __s, size_type __n, const _Alloc& __a = _Alloc()):拷貝__s字符串的前__n個字符,不檢測越界問題,留給調用方保證
    • basic_string(const _Tp& __t, size_type __pos, size_type __n, const _Alloc& __a = _Alloc()):這個比上面兩個構造函數更加寬泛,可以是任意數據指針,編譯器會對數據能否正確轉換進行檢測(warnning或者error);
  • 2.3 其他
    • basic_string(initializer_list<_CharT> __l, const _Alloc& __a = _Alloc()):使用初始化列表構造;
    • basic_string(size_type __n, _CharT __c, const _Alloc& __a = _Alloc()):以n個相同的字符填充緩衝區:調用_M_construct()初始化緩衝區並填充字符。

對象的構造:第三組:移動構造函數

  • 對於棧上保存的字符串,會拷貝到目標字符串(最多16Bytes);如果是堆上分配的字符串,則把指針轉到目標字符串。完成這些拷貝和轉移後,右值字符串迴歸到默認構造的狀態(長度爲0,字符串指針指向棧緩衝區的起始位置);
    • basic_string(basic_string&& __str)
    • basic_string(basic_string&& __str, const _Alloc& __a)

對象的構造:第四組:基於迭代器構造

  • 調用_M_construct(),根據迭代器的類型進行分類處理:
    • basic_string(_InputIterator __beg, _InputIterator __end, const _Alloc& __a = _Alloc()):把迭代器區間內的數據(允許不是char/wchar_t,只要編譯器不抱怨);

對象的構造:第五組:根據std::string_view構造

  • 可以輕量級使用std::string_view構造一個std::string,同樣是使用字符串拷貝
    • basic_string(const _Tp& __t, const _Alloc& __a = _Alloc()):需要__t是std::string_view類型,取整個std::string_view字符串構建std::string;
    • basic_string(const _Tp& __t, size_type __pos, size_type __n, const _Alloc& __a = _Alloc()):需要__t是std::string_view類型,並且取它的字符串一部分構建新的字符串,調用上面的構造函數完成構造。
    • basic_string(__sv_wrapper __svw, const _Alloc& __a):允許某個類型,如果可以轉換成std::string_view,則使用此構造函數,類似於basic_string(sv.data(), sv.size(), alloc)

對象的構造:第六組:賦值

  • 調用assign()函數,進而調用_M_assign()系列函數,這部分下面會詳細分析
    • operator=(const basic_string& __str) :代碼相對比較複雜,其實就是字符串拷貝,沒有COW。
    • operator=(const _CharT* __s):同上
    • operator=(_CharT __c):只插入一個字符

先談談構造時的調用流程

上面大部分版本的構造函數都是給定一個起始區間,調用_M_construct(_InIterator __beg, _InIterator __end)函數。下面分析這個函數的來龍去脈。

  • _M_construct(_InIterator __beg, _InIterator __end):根據std::__is_integer<_InIterator>::__type分析這個“迭代器”是否數值類型;
    • 如果是數值類型,則表示調用方是basic_string(size_type __n, _CharT __c)這一組的函數,那麼就調用_M_construct_aux(_Integer __beg, _Integer __end, std::__true_type)去生成,最終調用_M_construct(size_type __req, _CharT __c)函數;
    • 如果不是數值類型,則表示調用方是上面第二組的調用方式,則調用_M_construct_aux(_Integer __beg, _Integer __end, std::__true_type),繼而調用_M_construct(_InIterator __beg, _InIterator __end)函數;

現在回到_M_construct()函數

_M_construct()函數執行字符串的實際構造操作。它按照迭代器/參數的類型,有兩種實現:

  • _M_construct(size_type __req, _CharT __c):實現的算法很簡單,申請一段__req的內存空間(如果小於15字節則直接寫在棧上),並拷貝__req個__c字符;
  • _M_construct(_InIterator __beg, _InIterator __end, std::forward_iterator_tag):只有當迭代器類型是forward_iterator或者比它更寬的迭代器時使用,它會根據迭代器之間的長度來確定字符串的長度,並逐字符拷貝。關於迭代器的“更寬”,可以參考迭代器類型
  • _M_construct(_InIterator __beg, _InIterator __end, std::input_iterator_tag):用於input_iterator迭代器,如輸入迭代器等。這個構造比較低效,每次輸入一個字符,會檢測是否達到緩衝區的末尾,如果是,則把緩衝區的大小加1,重新分配空間並拷貝字符串。
  • 講完了

字符串賦值專用的_M_assign()函數

  • 賦值類的函數都會在預分配足夠內存後,調用_M_assign()函數,該函數的邏輯很簡單,只是單純的字符串拷貝,拷貝到需要的位置。

結論:std::string在程序在使用的注意事項(劃重點)

  • 兩個字符串連接:根據std::string的實現代碼,字符串的連接一定會引起內存的重新分配和字符串拷貝。很遺憾,C++沒有使用C的realloc()函數。如果使用realloc()函數,有可能在擴大內存的同時,不需要拷貝字符串(也就是原來分配的內存區域末尾還有足夠的字符串)。C++很單純地分配一塊新內存,再把就內存拷貝過去。這一點在效率上會比較低下。個人認爲解決辦法是,使用std::deque代替std::string。在字符串不斷變動的時候,字符數組可以寫入std::deque,因爲它的插入、追加操作比std::string高效。等字符串穩定後,再使用basic_string(_InputIterator __beg, _InputIterator __end)構造函數生成std::string
    • google的abseil提供了一個可用的辦法:也就是先計算多個字符串的長度,預分配一大塊內存來存放,然後分別拷貝字符串,這樣避免反覆分配內存和拷貝字符串,請參考abseil中的string庫。但是這種方法只適用於已知固定個數的字符串連接。
    • 有人測試出C++拼接字符串的比較中,std::string::append和operator+的效率是最高的,同樣的代碼,我用C++98和C++11、C++17分別測試過,O3級別優化,最終結果類似。代碼和測試結果見這裏
  • 字符串的賦值:C++11下,因爲有移動構造,所以std::string的複雜性比之前的版本有所降低,但是如果直接使用賦值,而不使用移動賦值,效率反而較“史前”版本慢,因爲C++11以前,使用基於引用計數的COW,拷貝更加輕量級,而C++11出現以後,移動賦值成爲提升效率幾乎唯一的途徑了,因此C++11以後的程序儘可能使用移動賦值
  • 另外,字符串的中間插入,也是需要重新分配內存,並完整拷貝整個字符串,這一點需要在使用中注意。
  • 如果只是需要字符串的只讀操作,可以使用std::string_view代替。
  • 最後說一個地方,雖然比較少用,就是基於std::input_iterator的構造,它每次重新分配內存只會分配“剛剛適合需要的大小”,而不會如std::vector那樣分配2倍,所以將會有頻繁分配內存和拷貝的操作。解決的辦法還是用std::deque,等字符足夠之後再寫入std::string
  • 總結上述幾條:任何對已有字符串的插入、追加操作,都很可能造成內存的重新分配和整個字符串拷貝,這種重新分配和拷貝,並不是realloc(),而是new+memcpy,這是必須注意的。解決的辦法,一是使用reserver()函數申請足夠的內存,二是使用std::deque臨時代替。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章