1. 程式人生 > >[譯]C++17,使用 string_view 來避免複製

[譯]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.