[譯]C++17,使用 string_view 來避免複製
看到一個介紹 C++17 的系列博文(原文),有十來篇的樣子,覺得挺好,看看有時間能不能都簡單翻譯一下,這是第五篇~
當字串資料的所有權已經確定(譬如由某個string物件持有),並且你只想訪問(而不修改)他們時,使用 std::string_view 可以避免字串資料的複製,從而提高程式效率,這(指程式效率)也是這篇文章的主要內容.
這次要介紹的 string_view 是 C++17 的一個主要特性.
我假設你已經瞭解了一些 std::string_view 的知識,如果沒有,可以看看我之前的這篇文章.C++ 中的 string 型別在堆上存放自己的字串資料,所以當你處理 string 型別的時候,很容易就會產生(堆)記憶體分配.
Small string optimisation
我們先看下以下的示例程式碼:
#include <iostream> #include <string> void* operator new(std::size_t count) { std::cout << " " << count << " bytes" << std::endl; return malloc(count); } void getString(const std::string& str) {} int main() { std::cout << std::endl; std::cout << "std::string" << std::endl; std::string small = "0123456789"; std::string substr = small.substr(5); std::cout << " " << substr << std::endl; std::cout << std::endl; std::cout << "getString" << std::endl; getString(small); getString("0123456789"); const char message[] = "0123456789"; getString(message); std::cout << std::endl; return 0; }
程式碼第4到第8行,我過載了全域性的 new 操作符,這樣我就能跟蹤(堆)記憶體的分配了,而後,程式碼分別在第18行,第19行,第27行,第29行建立了string物件,所以這幾處程式碼都會產生(堆)記憶體分配.相關的程式輸出如下:
咦, 程式竟然沒有產生記憶體分配?這是怎麼回事?其實 string 型別只有在字串超過指定大小(具體實現相關)時才會申請(堆)記憶體,對於 MSVC 來說,指定大小為 15, 對於 GCC 和 Clang,這個值則為 23.
這也就意味著,較短的字串資料是直接儲存於 string 的物件記憶體中的,不需要分配(堆)記憶體.
從現在開始,示例程式碼中的字串將擁有至少30個字元,這樣我們就不需要關注短字串優化了.好了,帶著這個前提(字串長度>=30個字元),讓我們重新開始講解.
No memory allocation required
現在, std::string_view 無需複製字串資料的優點就更加明顯了(std::string不進行短字串優化的情況下),下面的程式碼就是例證.
#include <cassert>
#include <iostream>
#include <string>
#include <string_view>
void* operator new(std::size_t count)
{
std::cout << " " << count << " bytes" << std::endl;
return malloc(count);
}
void getString(const std::string& str) {}
void getStringView(std::string_view strView) {}
int main()
{
std::cout << std::endl;
std::cout << "std::string" << std::endl;
std::string large = "0123456789-123456789-123456789-123456789";
std::string substr = large.substr(10);
std::cout << std::endl;
std::cout << "std::string_view" << std::endl;
std::string_view largeStringView{ large.c_str(), large.size() };
largeStringView.remove_prefix(10);
assert(substr == largeStringView);
std::cout << std::endl;
std::cout << "getString" << std::endl;
getString(large);
getString("0123456789-123456789-123456789-123456789");
const char message[] = "0123456789-123456789-123456789-123456789";
getString(message);
std::cout << std::endl;
std::cout << "getStringView" << std::endl;
getStringView(large);
getStringView("0123456789-123456789-123456789-123456789");
getStringView(message);
std::cout << std::endl;
return 0;
}
程式碼22行,23行,39行,41行因為建立了 string 物件 所以會分配(堆)記憶體,但是程式碼29行,30行,47行,48行,49行也相應的建立了 string_view 物件,但是並沒有發生(堆)記憶體分配!
這個結果令人印象深刻,(堆)記憶體分配是一個非常耗時的操作,儘量的避免(堆)記憶體分配會給程式帶來很大的效能提升,使用 string_view 能提升程式效率的原因也正是在此,當你需要建立很多 string 的子字串時, string_view 帶來的效率提升將更加明顯.
O(n) versus O(1)
std::string 和 std::string_view 都有 substr 方法, std::string 的 substr 方法返回的是字串的子串,而 std::string_view 的 substr 返回的則是字串子串的"檢視".聽上去似乎兩個方法功能上比較相似,但他們之間有一個非常大的差別: std::string::substr 是線性複雜度, std::string_view::substr 則是常數複雜度.這意味著 std::string::substr 方法的效能取決於字串的長度,而std::string_view::substr 的效能並不受字串長度的影響.
讓我們來做一個簡單的效能對比:
#include <chrono>
#include <fstream>
#include <iostream>
#include <random>
#include <sstream>
#include <string>
#include <vector>
#include <string_view>
static const int count = 30;
static const int access = 10000000;
int main()
{
std::cout << std::endl;
std::ifstream inFile("grimm.txt");
std::stringstream strStream;
strStream << inFile.rdbuf();
std::string grimmsTales = strStream.str();
size_t size = grimmsTales.size();
std::cout << "Grimms' Fairy Tales size: " << size << std::endl;
std::cout << std::endl;
// random values
std::random_device seed;
std::mt19937 engine(seed());
std::uniform_int_distribution<> uniformDist(0, size - count - 2);
std::vector<int> randValues;
for (auto i = 0; i < access; ++i) randValues.push_back(uniformDist(engine));
auto start = std::chrono::steady_clock::now();
for (auto i = 0; i < access; ++i)
{
grimmsTales.substr(randValues[i], count);
}
std::chrono::duration<double> durString = std::chrono::steady_clock::now() - start;
std::cout << "std::string::substr: " << durString.count() << " seconds" << std::endl;
std::string_view grimmsTalesView{ grimmsTales.c_str(), size };
start = std::chrono::steady_clock::now();
for (auto i = 0; i < access; ++i)
{
grimmsTalesView.substr(randValues[i], count);
}
std::chrono::duration<double> durStringView = std::chrono::steady_clock::now() - start;
std::cout << "std::string_view::substr: " << durStringView.count() << " seconds" << std::endl;
std::cout << std::endl;
std::cout << "durString.count()/durStringView.count(): " << durString.count() / durStringView.count() << std::endl;
std::cout << std::endl;
return 0;
}
展示程式結果之前,讓我先來簡單描述一下:測試程式碼的主要思路就是讀取一個大檔案的內容並儲存為一個 string ,然後分別使用 std::string 和 std::string_view 的 substr 方法建立很多子字串.我很好奇這些子字串的建立過程需要花費多少時間.
我使用了<格林童話>作為程式的讀取檔案.程式碼中的 grimmTales(第22行) 儲存了檔案的內容.程式碼34行中我向 std::vector 填充了 10000000 個範圍為[0, size - count - 2]的隨機數字.接著就開始了正式的效能測試.程式碼37行到40行我使用 std::string::substr 建立了很多長度為30的子字串,之所以設定長度為30,是為了規避 std::string 的短字串優化.程式碼46行到49行使用 std::string_view::substr 做了相同的工作(建立子字串).
程式的輸出如下,結果中包含了檔案的長度, std::string::substr 所花費的時間, std::string_view::substr 所花費的時間以及他們之間的比例.我使用的編譯器是 GCC 6.3.0.
Size 30
沒有開啟編譯器優化的結果:
開啟編譯器優化的結果:
編譯器的優化對於 std::string::substr 的效能提升並沒有多大作用,但是對於 std::string_view::substr 的效能提升則效果明顯.而 std::string_view::substr 的效率幾乎是 std::string::substr 的 45 倍!
Different sizes
那麼如果我們改變子字串的長度,上面的測試程式碼又會有怎樣的表現呢?當然,相關測試我都開啟了編譯器優化,並且相關的數字我都做了3位小數的四捨五入.
對於上面的結果我並不感到驚訝,這些數字正好反應了 std::string::substr 和 std::string_view::substr 的演算法複雜度. std::string::substr 是線性複雜度(依賴於字串長度), std::string_view::substr 則是常數複雜度(不依賴於字串長度).最後的結論就是: std::string_view::substr 的效能要大幅優於 std::string::substr.