【現代C++】性能控的工具箱之string_view

本篇文章從string_view引入的背景出發,依次介紹了其相關的知識點及使用方式,而後對常見的使用陷阱進行了說明,最後對該類型作總結。ios

1、背景

在平常C/C++編程中,咱們常進行數據的傳遞操做,好比,將數據傳給函數。當數據佔用的內存較大時,減小數據的拷貝能夠有效提升程序的性能。在C中指針是完成這一目的的標準數據結構,而C++引入了安全性更高的引用類型。因此在C++中若傳遞的數據僅僅只讀,const string&成了C++的自然的方式。但這並不是完美,從實踐來看,它至少有如下幾方面問題:數據庫

  1. 字符串字面值、字符數組、字符串指針的傳遞仍要數據拷貝
    這三類低級數據類型與string類型不一樣,傳入時,編譯器須要作隱式轉換,即須要拷貝這些數據生成string臨時對象。const string&指向的其實是這個臨時對象。一般字符串字面值較小,性能損耗能夠忽略不計;但字符串指針和字符數組某些狀況下可能會比較大(好比讀取文件的內容),此時會引發頻繁的內存分配和數據拷貝,會嚴重影響程序的性能。
  2. substr O(n)複雜度
    這是一個特別經常使用的函數,好在std::string提供了這個函數,美中不足的是其每次都返回一個新生成的子串,很容易引發性能熱點。實際上咱們本意並非要改變原字符串,爲何不在原字符串基礎上返回呢?

C++17中引入了string_view,能很好的解決以上兩個問題。編程

2、std::string_view

從名字出發,咱們能夠類比數據庫視圖,view表示該類型不會爲數據分配存儲空間,並且該數據類型只能用來讀。該數據類型可經過{數據的起始指針,數據的長度}兩個元素表示,實際上該數據類型的實例不會具體存儲原數據,僅僅存儲指向的數據的起始指針和長度,因此這個開銷是很是小的。數組

要使用字符串視圖,須要引入<string_view>,下面介紹該數據類型主要的API。這些API基本上都有constexpr修飾,因此能在編譯時很好地處理字符串字面值,從而提升程序效率。安全

2.1 構造函數

constexpr string_view() noexcept;
constexpr string_view(const string_view& other) noexcept = default;
constexpr string_view(const CharT* s, size_type count);
constexpr string_view(const CharT* s);

基本上都是自解釋的,惟一須要說明的是:爲何咱們代碼string_view foo(string("abc"))能夠編譯經過,但爲何沒有對應的構造函數?數據結構

實際上這是由於string類重載了stringstring_view的轉換操做符:
operator std::basic_string_view<CharT, Traits>() const noexcept;函數

因此,string_view foo(string("abc"))實際執行了兩步操做:性能

  1. string("abc")轉換爲string_view對象a
  2. string_view使用對象本篇文章從string_view引入的背景,

2.2 自定義字面量

自定義字面量也是C++17新增的特性,提升了常量的易讀。
下面的代碼取值cppreference,能很好地說明自定義字面值和字符串語義的差別。spa

#include <string_view>
#include <iostream>
 
int main()
{
    using namespace std::literals;
 
    std::string_view s1 = "abc\0\0def";
    std::string_view s2 = "abc\0\0def"sv;
    std::cout << "s1: " << s1.size() << " \"" << s1 << "\"\n";
    std::cout << "s2: " << s2.size() << " \"" << s2 << "\"\n";
}

輸出:翻譯

s1: 3 "abc"
s2: 8 "abc^@^@def"

以上例子能很好看清兩者的語義區別,\0對於字符串而言,有其特殊的意義,即表示字符串的結束,字符串視圖根本不care,它關心實際的字符個數。

2.3 成員函數

下面列舉其成員函數:忽略了函數的返回值,若函數有重載,括號內用...填充。這樣能夠對其有個總體輪廓。

// 迭代器
begin()
end()
cbegin()
cend()
rbegin()
rend()
crbegin()
crend()
 
// 容量
size()
length()
max_size()
empty()
 
// 元素訪問
operator[](size_type pos)
at(size_type pos)
front()
back()
data()
 
// 修改器
remove_prefix(size_type n)
remove_suffix(size_type n)
swap(basic_string_view& s)
 
copy(charT* s, size_type n, size_type pos = 0)
string_view substr(size_type pos = 0, size_type n = npos)
compare(...)
starts_with(...)
ends_with(...)
find(...)
rfind(...)
find_first_of(...)
find_last_of(...) 
find_first_not_of(...)
find_last_not_of(...)

從函數列表來看,幾乎跟string的只讀函數一致,使用string_view的方式跟string基本一致。有幾個地方須要特別說明:

  1. string_viewsubstr函數的時間複雜度是O(1),解決了背景部分的第二個問題。
  2. 修改器中的三個函數僅會修改string_view的數據指向,不會修改指向的數據。

除此以外,函數名基本是自解釋的。

2.4 示例

Haskell中有一個經常使用函數lines,會將字符串切割成行存儲在容器裏。下面咱們用C++來實現

string-版本

#include <string>
#include <iostream>
#include <vector>
#include <algorithm>
#include <sstream>

void lines(std::vector<std::string> &lines, const std::string &str) {
    auto sep{"\n"};
    size_t start{str.find_first_not_of(sep)};
    size_t end{};

    while (start != std::string::npos) {
        end = str.find_first_of(sep, start + 1);
        if (end == std::string::npos)
            end = str.length();

        lines.push_back(str.substr(start, end - start));
        start = str.find_first_not_of(sep, end + 1);
    }
}

上面咱們用const std::string &類型接收待分割的字符串,若咱們傳入指向較大內存的字符指針時,會影響程序效率。

使用std::string_view能夠避免這種狀況:
string_view-版本

#include <string>
#include <iostream>
#include <vector>
#include <algorithm>
#include <sstream>
#include <string_view>

void lines(std::vector<std::string> &lines, std::string_view str) {
    auto sep{"\n"};
    size_t start{str.find_first_not_of(sep)};
    size_t end{};

    while (start != std::string_view::npos) {
        end = str.find_first_of(sep, start + 1);
        if (end == std::string_view::npos)
            end = str.length();

        lines.push_back(std::string{str.substr(start, end - start)});
        start = str.find_first_not_of(sep, end + 1);
    }
}

上面的例子僅僅是把string類型修改爲了string_view就得到了性能上的提高。通常狀況下,將程序中的string換成string_view的過程是比較直觀的,這得益於二者的成員函數的類似性。但並非全部的「翻譯」過程都是這樣的,好比:

void lines(std::vector<std::string> &lines, const std::string& str) {
    std::stringstream ss(str);
    std::string line;

    while (std::getline(ss, line, '\n')) {
        lines.push_back(line);
    }
}

這個版本使用stringstream實現lines函數。因爲stringstream沒有相應的構造函數接收string_view類型參數,因此無法採用直接替換的方式,因此翻譯過程要複雜點。

3、使用陷阱

世上沒有免費的午飯。不恰當的使用string_view也會帶來一系列的問題。

  1. string_view範圍內的字符可能不包含\0

#include <iostream>
#include <string_view>

int main() {
    std::string_view str{"abc", 1};

    std::cout << str.data() << std::endl;

    return 0;
}

原本是要打印a,但輸出了abc。這是由於字符串相關的函數都有一條兼容C的約定:\0表明字符串的結尾。上面的程序打印從開始到字符串結束的全部字符,雖然str包含的有效字符是a,但cout\0。好在這塊內存空間有合法的字符串結尾符,若是str指向的是一個沒有\0的字符數組,程序頗有可能會出現內存問題,因此咱們在將string_view類型的數據傳入接收字符串的函數時要很是當心。

2.從[const] char*構造string_view對象時間複雜度O(n)
這是由於獲取字符串的長度須要從頭開始遍歷。若是對[const] char*類型僅僅是一些O(1)的操做,相比直接使用[const] char*,轉爲string_view是沒有性能優點的。只不過是相比const string&string_view少了拷貝的損耗。實際上咱們徹底能夠用[const] char*接收全部的字符串,但這個類型太底層了,不便使用。在某些狀況下,咱們轉爲string_view可能僅僅是想用其中的一些函數,好比substr

3.string_view指向的內容的生命週期可能比其自己短
string_view並不擁有其指向內容的全部權,用Rust的術語來講,它僅僅是暫時borrow(借用)了它。若是擁有者提早釋放了,你還在使用這些內容,那會出現內存問題,這跟懸掛指針(dangling pointer)或懸掛引用(dangling references)很像。Rust專門有套機制在編譯時分析變量的生命期,保證borrow的資源在使用期間不會被釋放,但C++沒有這樣的檢查,須要人工保證。下面列出一些典型的問題狀況:

std::string_view sv = std::string{"hello world"};
string_view foo() {
    std::string s{"hello world"};
    return string_view{s};
}
auto id(std::string_view sv) { return sv; }

int main() {
    std::string s = "hello";
    auto sv = id(s + " world"); 
}

4、總結

string_view解決了一些痛點,但同時也引入了指針和引用的一些老問題。C++標準並無對這個類型作太多的約束,這引來的問題是咱們能夠像日常的變量同樣以多種方式使用它,如,能夠傳參,能夠做爲函數返回值,能夠作廣泛變量,甚至咱們能夠放到容器裏。隨着使用場景的複雜,人工是很難保證指向的內容的生命週期足夠長。因此,推薦的使用方式:僅僅做爲函數參數,由於若是該參數僅僅在函數體內使用而不傳遞出去,這樣使用是安全的。

請關注個人公衆號哦。
圖片描述

相關文章
相關標籤/搜索