C++17剖析:string_view的實現,以及性能


主要內容

C++17標准發布,string_view是標准新增的內容。這篇文章主要分析string_view的適用范圍、注意事項,並分析string_view帶來的性能提升,最后從gcc 8.2的libstdc++庫源碼級別分析性能提升的原因。
C++標准演變

背景知識:靜態字符串的處理

所謂靜態字符串,就是編譯時已經固定的字符串,他們存儲在二進制文件的靜態存儲區,而且程序只能讀取,不能改動。
一個例子:


        //指針指向靜態字符串
        const char* str_ptr = "this is a static string";

        //字符串數組
        char str_array[] = "this is a static string";

        //std::string
        std::string str = "this is a static string";

        //std::string_view
        std::string_view sv = "this is a static string";

反匯編:


g++ -O0 -o static_str static_str.cc -std=c++17 -g && objdump -S -t -D static_str > static_str.s

匯編代碼如下:


int main()
{
  4013b8:       55                      push   %rbp
  4013b9:       48 89 e5                mov    %rsp,%rbp
  4013bc:       53                      push   %rbx
  4013bd:       48 83 ec 68             sub    $0x68,%rsp
        //指針指向靜態字符串
        const char* str_ptr = "this is a static string!";
        ##直接設置字符串指針
  4013c1:       48 c7 45 e8 30 1e 40    movq   $0x401e30,-0x18(%rbp)  
  4013c8:       00 

        //字符串數組
        char str_array[] = "this is a static string!";
        ##這里使用一個很取巧的辦法,不使用循環,而是使用多個mov語句把字符串設置到堆棧
  4013c9:       48 b8 74 68 69 73 20    mov    $0x2073692073696874,%rax 
  4013d0:       69 73 20 
  4013d3:       48 ba 61 20 73 74 61    mov    $0x6369746174732061,%rdx
  4013da:       74 69 63 
  4013dd:       48 89 45 c0             mov    %rax,-0x40(%rbp)
  4013e1:       48 89 55 c8             mov    %rdx,-0x38(%rbp)
  4013e5:       48 b8 20 73 74 72 69    mov    $0x21676e6972747320,%rax
  4013ec:       6e 67 21 
  4013ef:       48 89 45 d0             mov    %rax,-0x30(%rbp)
  4013f3:       c6 45 d8 00             movb   $0x0,-0x28(%rbp)
        
        //std::string
        std::string str = "this is a static string!";
        #esi保存了字符串開始地址$0x401e30,調用std::string的構造函數
  4013f7:       48 8d 45 e7             lea    -0x19(%rbp),%rax
  4013fb:       48 89 c7                mov    %rax,%rdi
  4013fe:       e8 15 fe ff ff          callq  401218 <_ZNSaIcEC1Ev@plt>
  401403:       48 8d 55 e7             lea    -0x19(%rbp),%rdx
  401407:       48 8d 45 a0             lea    -0x60(%rbp),%rax
  40140b:       be 30 1e 40 00          mov    $0x401e30,%esi
  401410:       48 89 c7                mov    %rax,%rdi
  401413:       e8 fe 01 00 00          callq  401616 <_ZNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEC1IS3_EEPKcRKS3_>
  401418:       48 8d 45 e7             lea    -0x19(%rbp),%rax
  40141c:       48 89 c7                mov    %rax,%rdi
  40141f:       e8 c4 fd ff ff          callq  4011e8 <_ZNSaIcED1Ev@plt>
        
        //std::string_view
        std::string_view sv = "this is a static string!";
        #直接設置字符串的長度0x18,也就是24Bytes,還有字符串的起始指針$0x401e30,沒有堆內存分配
  401424:       48 c7 45 90 18 00 00    movq   $0x18,-0x70(%rbp)
  40142b:       00 
  40142c:       48 c7 45 98 30 1e 40    movq   $0x401e30,-0x68(%rbp)
  401433:       00 
        
        return 0;
  401434:       bb 00 00 00 00          mov    $0x0,%ebx

        //字符串數組
        ## 對象析構:字符串數組分配在棧上,無需析構
        char str_array[] = "this is a static string!";
        
        //std::string
        ## 對象析構:調用析構函數
        std::string str = "this is a static string!";
  401439:       48 8d 45 a0             lea    -0x60(%rbp),%rax
  40143d:       48 89 c7                mov    %rax,%rdi
  401440:       e8 a9 01 00 00          callq  4015ee <_ZNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEED1Ev>
  401445:       89 d8                   mov    %ebx,%eax
  401447:       eb 1a                   jmp    401463 <main+0xab>
  401449:       48 89 c3                mov    %rax,%rbx
  40144c:       48 8d 45 e7             lea    -0x19(%rbp),%rax
  401450:       48 89 c7                mov    %rax,%rdi
  401453:       e8 90 fd ff ff          callq  4011e8 <_ZNSaIcED1Ev@plt>
  401458:       48 89 d8                mov    %rbx,%rax
  40145b:       48 89 c7                mov    %rax,%rdi
  40145e:       e8 e5 fd ff ff          callq  401248 <_Unwind_Resume@plt>
        
        //std::string_view
        ## 對象析構:std::string_view分配在棧上,無需析構
        std::string_view sv = "this is a static string!";
        
        return 0;
}
  • 靜態字符串:會把指針指向靜態存儲區,字符串只讀。如果嘗試修改,會導致段錯誤(segment fault)。
  • 字符串數組:在棧上分配一塊空間,長度等於字符串的長度+1(因為還需要包括末尾的'\0'字符),然后把字符串拷貝到緩沖區。上述代碼,我之前一直以為會使用循環(類似memmove),但是一直找不到循環的語句,卻找到一堆莫名其妙的數字($0x2073692073696874,$0x6369746174732061)仔細觀察發現,原來編譯器把一個長字符串分開為幾個64bit的長整數,逐次mov到棧緩沖區中, 那幾個長長的整數其實是: 0x2073692073696874=[ si siht],$0x6369746174732061=[citats a],0x21676e6972747320=[!gnirts],剛好就是字符串的反序,編譯器是用這種方式來提高運行效率的。我覺得其實末尾的0是可以和字符串一起寫在同一個mov指令中的,這樣執行的指令就可以少一個了,不知道為什么不這樣做。
  • std::string:只在寄存器設置了字符串的起始指針,調用了basic_string( const CharT* s,const Allocator& alloc = Allocator() )構造函數,中間涉及各種檢測和字符串拷貝,后面會在另一篇講述std::string原理的文章中詳細分析,總之動態內存分配與字符串拷貝是肯定會發生的事情。值得一提的是,如果在構造函數里面至少會有如下操作:確定字符串長度(如strlen,遍歷一遍字符串),按字符串長度(或者預留更多的長度)新建一塊內存空間,拷貝字符串到新建的內存空間(第二次遍歷字符串)。
  • std::string_view:上面的匯編代碼很簡單,只是單純設置靜態字符串的起始指針和長度,沒有其他調用,連內存分配都是棧上的!跟std::string相比,在創建std::string_view對象的時候,沒有任何動態內存分配,沒有對字符串多余的遍歷。一直以來,對於C字符串而言,如果需要獲取它的長度,至少需要strlen之類的函數。但是我們似乎忽略了一點,那就是,如果是靜態字符串,編譯器其實是知道它的長度的,也就是,靜態字符串的長度可以在編譯期間確定,那就可以減少了很多問題。
  • 題外話:編譯期確定字符串長度、對象大小,這種並不是什么奇技淫巧,因為早在operator new運算符重載的時候,就有一個size_t參數,這個就是編譯器傳入的對象大小,而std::string_view,則是在編譯期間傳入字符串的指針和長度,構建對象。但是,std::string和std::string_view這兩個類同時提供了只帶字符串指針同時帶字符串指針和字符串長度兩個版本的構造函數,默認的情況下,std::string str = "this is a static string!"會調用basic_string( const CharT* s,const Allocator& alloc = Allocator() )構造,但是std::string_view sv = "this is a static string!"會調用帶長度的basic_string_view(const _CharT* __str, size_type __len) noexcept版本,這一點我一直沒弄明白(TODO)。但是,標准庫提供了一個方法,可以讓編譯器選擇帶長度的std::string構造函數,下一小節講述。

std::string_view的實現(GCC 8.2)

std::string_view類的成員變量只包含兩個:字符串指針和字符串長度。字符串指針可能是某個連續字符串的其中一段的指針,而字符串長度也不一定是整個字符串長度,也有可能是某個字符串的一部分長度。std::string_view所實現的接口中,完全包含了std::string的所有只讀的接口,所以在很多場合可以輕易使用std::string_view代替std::string。一個通常的用法是,生成一個std::string后,如果后續的操作不再對其進行修改,那么可以考慮把std::string轉換成為std::string_view,后續操作全部使用std::string_view來進行,這樣字符串的傳遞變得輕量級。雖然在很多實現上,std::string都使用引用計數進行COW方式管理,但是引用計數也會涉及鎖和原子計數器,而std::string_view的拷貝只是單純拷貝兩個數值類型變量(字符串指針及其長度),效率上前者是遠遠無法相比的。std::string_view高效的地方在於,它不管理內存,只保存指針和長度,所以對於只讀字符串而言,查找和拷貝是相當簡單的。下面主要以筆記的形式,了解std::string_view的實現。

  • 只讀操作:沒有std::string的c_str()函數。因為std::string_view管理的字符串可能只是一串長字符串中的一段,而c_str()函數的語義在於返回一個C風格的字符串,這會引起二義性,可能這就是設計者不提供這個接口的原因。但是與std::string一樣提供了data()接口。對於std::string而言,data()與c_str()接口是一樣的。std::string_view提供的data()接口只返回它所保存的數據指針,語義上是正確的。在使用std::string_view的data()接口的時候,需要注意長度限制,例如cout<<sv.data();cout<<sv;的輸出結果很可能是不一樣的,前者會多輸出一部分字符。

  • std::string_view的前身,google的abseil::string_view的文檔中有如下描述(https://abseil.io/tips/1):
    abseil::string_view

  • std::string_view與std::string的生成:C++17新增了operator""sv(const char* __str, size_t __len)operator""s(const char* __str, size_t __len)操作符重載,因此,生成字符串的方法可以使用這兩個操作符。令人驚奇的是,使用這種方法,生成std::string調用的是basic_string_view(const _CharT* __str, size_type __len) noexcept版本的構造函數,這就意味着免去了構造時再一次獲取字符串長度的開銷(實際上是編譯器在幫忙)


        //std::string
        std::string str = "this is a static string!"s;

        //std::string_view
        std::string_view sv = "this is a static string!"sv;

反匯編如下(其實讀者可以使用gdb調試,查看實際調用的構造函數):


      //std::string
      std::string str = "this is a static string!"s;
      ## esi存放字符串起始地址,edx存放字符串長度,0x18就是字符串長度24字節
    4014b7:   48 8d 45 a0             lea    -0x60(%rbp),%rax
    4014bb:   ba 18 00 00 00          mov    $0x18,%edx
    4014c0:   be 50 1e 40 00          mov    $0x401e50,%esi
    4014c5:   48 89 c7                mov    %rax,%rdi
    4014c8:   e8 da 00 00 00          callq  4015a7 <_ZNSt8literals15string_literalsli1sB5cxx11EPKcm>
  • 修改操作:如前所述,std::string_view並不提供修改接口,因為它保存的數據指針是const _CharT*類型的,無法運行時修改。
  • 字符串截取substr():這部分特別提出。因為使用std::string::substr()函數,會對所截取的部分生成一個新的字符串返回(中間又涉及內存動態分配以及拷貝),而std::string_view::substr(),也是返回一個std::string_view,但是依舊不涉及內存的動態分配。只是簡單地用改變后的指針和長度生成一個新的std::string_view對象,O(1)操作。代碼如下:

  constexpr basic_string_view
  substr(size_type __pos, size_type __n = npos) const noexcept(false)
  {
__pos = _M_check(__pos, "basic_string_view::substr");
const size_type __rlen = std::min(__n, _M_len - __pos);
return basic_string_view{_M_str + __pos, __rlen};
  }
  • 關於字符串截取,引用一下其他人的測試結果,性能提高不是一星半點。(來自這里
    std::string_view與std::string的字符串截取substr性能對比

使用注意事項

std::string_view/std::string用於項目中,我認為有下面幾點需要注意的:

  • C++17中,std::string與std::string_view的轉換仍然需要手動調用構造函數,沒有to_string/to_string_view之類的函數可以調用。
  • 以std::string為key的map,如果需要使用std::string_view檢索,則需要把less函數設置為通用的,也就是map<std::string, int, std::less<>>,使用全局的比較函數代替std::string的專屬std::lessstd::string。C++14后可以使用不同類型的key檢索map,使用默認的std::less<>作為比較函數,可以調用通用的比較函數,而std::lessstd::string只能是兩個std::string比較。

std::map<std::string, int> map1;
//插入數據到map1,略
std::string k1 = "123";
int v1 = map1[k1];   //OK
auto it_1 = map1.find(k1);  //OK

std::string_view k2 = "456";
int v2 = map1[k1];   //ERR,因為默認std::less<std::string>只能兩個std::string比較
auto it_2 = map1.find(k1);  //ERR,同上

std::map<std::string, int, std::less<>> map2;
//插入數據到map2,略
std::string k3 = "123";
int v3 = map1[k3];   //OK
auto it_3 = map1.find(k3);  //OK

std::string_view k4 = "456";
int v4= map1[k4];   //ERR,因為operator []的類型只能與key一樣的類型,也就是只能是std::string
auto it_4 = map1.find(k4);  //OK,因為C++14可以提供不同類型的key模板

  • std::string_view管理的只是指針,試用期間應該注意指針所指向的內存是可訪問的;
  • 如果使用靜態字符串初始化std::string,建議使用operator s()重載,但是使用這個運算符重載需要使用std::literals,反正我經常會忘記。
  • 如果在項目中需要使用下面這種方式生成字符串的:

       int num = 100;
       //process @num
      std::string err_message = "Invalid Number: " + std::to_string(num);

在c++11有可能會報錯,因為 "Invalid Number: " 是一個const char*,無法使用operator +(const std::string&),或者改為

  std::string err_message = std::string("Invalid Number: ") + std::to_string(num);

在C++17中,可以使用如下方法:

   
    using namespace std::literals;
    std::string err_message = "Invalid Number: "s + std::to_string(num);

這樣,可以讓編譯器在構造時調用帶長度的構造函數,免去一次使用strlen獲取長度的開銷。

上古時代的std::string_view及其類似實現

所謂“上古時代”,指的是C++11之前的C++98時代,當時標准庫還沒有這么充實,開發時需要用到的一些庫需要自己實現。那時候一些注重效率的程序就提供了這類的庫作為附屬實現。如:

我的項目中用到的std::string_view的類似實現:針對libhiredis

在上古時代,我的項目中也用到類似std::string_view這種“輕量級字符串”的功能,下面曬曬代碼,說說使用這種設計的初衷。
在項目中,我需要用到redis庫hiredis,經常需要從庫里面取得字符串。比如這樣的操作:從redis中scan出一堆key,然后從redis中取出這些key,這些key-value有可能用於輸出,有可能用於返回。hiredis是一個C庫,快速而簡單,然而我不希望在我的應用層庫中處理太多細節(諸如分析返回數據的類型,然后又進行錯誤處理,等等),因為那樣會造成大量重復代碼(對返回數據的處理),而且會讓應用層代碼變得很臃腫。於是我自己寫了一個簡單的adaptor,實現了使用C++的string、vector等類作為參數對hiredis的調用。那么redis返回的字符串,如果封裝成std::string,字符串的拷貝會成為瓶頸(因為項目中的value部分是一些稍長的字符串),而且這些來自redis的value返回到應用層只會做一些json解析、protobuf解析之類的操作就被釋放掉,所以這就考慮到字符串的拷貝和釋放完全是重復勞動,於是自己設計了一個基於RedisReply的Slice實現。
下面只貼出頭文件,實現部分就不多貼出來占地方了(代碼其實是使用C++11開發的,但是類似的實現可以在C++98中輕易做到,在這里作為一個例子並不過分=_=):

    //字符串
    //創建這個類,是因為在性能調優的時候發現,生成字符串太多,影響性能
    class Slice
    {
    public:
        Slice() = default;
        ~Slice() = default;

        Slice(const char* str, size_t len, 
            const std::shared_ptr<const redisReply>& reply): str_(str), len_(len), reply_(reply) {}
        Slice(const char* str, size_t len):str_(str), len_(len) {}
        
        //下面幾個接口,兼容std::string
        const char* c_str() const {return str_;}
        const char* data() const {return str_;}
        size_t length() const {return len_;}
        bool empty() const {return str_ == NULL || len_ == 0;}
        
        bool begin_with(const std::string& str) const;
        std::string to_string() const;
        bool operator==(const char* right) const;
        bool operator==(const Slice& right) const;
        bool operator!=(const char* right) const;
        bool operator!=(const Slice& right) const;

    private:

        //字符串
        const char* str_{NULL};
        size_t len_{0};

        //字符串所屬的Redis返回報文
        std::shared_ptr<const redisReply> reply_;
    };

之所以不重用LevelDB的Slice,是因為這些字符串都是struct redisReply中分配的,所以使用shared_ptr管理struct redisReply對象,這樣就可以不需要擔心struct redisReply的釋放問題了。
為了這個類的使用方式兼容std::stringSlice,我使用模板實現,下面是我的Redis適配層的實現(局部):


    /**********頭文件************/
    class CustomizedRedisClient
    {
    public:
        //GET
        template<class StringType>
        std::pair<Status, Slice> get(const StringType& key)
        {
            return this->get_impl(key.data(), key.length());
        }
        
        //....
    };
   
    /***********這部分在代碼部分實現***********/
    
    //GET實現
    //CustomizedRedisClient::Status是另外實現的一個狀態碼,不在這里講述
    std::pair<CustomizedRedisClient::Status, CustomizedRedisClient::Slice> 
        CustomizedRedisClient::get_impl(const char* key, size_t key_len)
    {
        constexpr size_t command_item_count = 2;
        const char* command_str[command_item_count];
        size_t command_len[command_item_count];

        command_str[0] = "GET";
        command_len[0] = 3;

        command_str[1] = key;
        command_len[1] = key_len;

        //reply
        //get_reply()函數使用redisAppendCommandArgv()和redisGetReply()函數實現,參考libhiredis文檔,這樣做是為了兼顧key/value中可能有二進制字符
        const auto& reply_status = this->get_reply(command_str, command_len, command_item_count);
        const redisReply* reply = reply_status.first.get();
        if(reply == NULL)
        {
            return std::make_pair(reply_status.second, 
                CustomizedRedisClient::Slice());
        }
        else if(reply->type == REDIS_REPLY_STATUS
            || reply->type == REDIS_REPLY_ERROR)
        {
            return std::make_pair(CustomizedRedisClient::Status(std::string(reply->str, reply->len)), 
                CustomizedRedisClient::Slice());
        }
        else if(reply->type == REDIS_REPLY_NIL)
        {
            return std::make_pair(CustomizedRedisClient::Status(STATUS_NOT_FOUND), 
                CustomizedRedisClient::Slice());
        }
        else if(reply->type != REDIS_REPLY_STRING)
        {
            return std::make_pair(CustomizedRedisClient::Status(STATUS_INVALID_MESSAGE), 
                CustomizedRedisClient::Slice());
        }

        return std::make_pair(CustomizedRedisClient::Status(), 
            CustomizedRedisClient::Slice(reply->str, reply->len, reply_status.first));
    }

后記

追本溯源,是一個極客的優秀素質。
作為C++17文章的第一篇,略顯啰嗦,希望以后有恆心把自己的研究成果一直進行下去。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM