天天看點

C++ 性能優化篇四《優化字元串的使用:案例研究》

隻有少數人才能觸摸到魔法琴弦(string), 可是聒噪的名聲卻企圖擊敗他們; 悲哀于那些從來都不歌唱的人們, 死亡時卻要帶着他們的音樂陪葬!

​ ——奧利弗 • 溫德爾 • 霍姆斯 1 ,“無聲”(1858)

C++ 的

std::string

類模闆是 C++ 标 準 庫 中 使 用 最 廣 泛 的 特 性 之 一。 例 如, 谷 歌 Chromium2 開 發 者 論 壇(https://groups.google.com/a/chromium.org/forum/#!msg/chromiumdev/EUqoIz2iFU4/kPZ5ZK0K3gEJ)上的一篇文章中提到,“在 Chromium 中,

std::string

對記憶體管理器的調用次數占到了記憶體管理器被調用的總次數的一半”。隻要操作字元串的 代碼會被頻繁地執行,那麼那裡就有優化的用武之地。本章将會通過讨論“優化字元串處 理”來闡釋優化中反複出現的主題。

4.1 為什麼字元串很麻煩

字元串在概念上很簡單,但是想要實作高效的字元串卻非常微妙。由于

std::string

中特性的特定組合的互動方式,使得實作高效的字元串幾乎不可能。的确,在編寫本書時,幾 種流行的編譯器曾經實作的 std::string 在許多方面都不符合标準。

而且,為了能夠跟上 C++ 标準的變化,

std::string

的行為也在不斷地變化。這意味着, 在 C++98 編譯器中實作的符合标準的 std::string 的行為可能與在 C++11 之後實作的 std::string 的行為是不同的。

字元串的某些行為會增加使用它們的開銷,這一點與實作方式無關。字元串是動态配置設定 的,它們在表達式中的行為與值相似,而且實作它們需要大量的複制操作。

4.1.1 字元串是動态配置設定的

字元串之是以使用起來很友善,是因為它們會為了儲存内容而自動增長。相比之下,C 的 庫函數(strcat()、strcpy() 等)工作于固定長度的字元數組上。為了實作這種靈活性, 字元串被設計為動态配置設定的。相比于 C++ 的大多數其他特性,動态配置設定記憶體耗時耗力。因 此無論如何,字元串都是性能優化熱點。當一個字元串變量超出了其定義範圍或是被賦予 了一個新的值後,動态配置設定的存儲空間會被自動釋放。與下面這段代碼展示的需要為動态 配置設定的 C 風格的字元數組手動釋放記憶體相比,這樣無疑友善了許多。

char* p = (char*) malloc(7);
strcpy(p, "string");
 ...
free(p);
           

盡管如此,但字元串内部的字元緩沖區的大小仍然是固定的。任何會使字元串變長的操 作,如在字元串後面再添加一個字元或是字元串,都可能會使字元串的長度超出它内部的 緩沖區的大小。當發生這種情況時,操作會從記憶體管理器中擷取一塊新的緩沖區,并将字 符串複制到新的緩沖區中。

為了能讓字元串增長時重新配置設定記憶體的開銷“分期付款”,

std::string

使用了一個小技巧。字元串向記憶體管理器申請的字元緩沖區的大小并非與字元串所需存儲的字元數完全一 緻,而是比該數值更大。例如,有些字元串的實作方式所申請的字元緩沖區的大小是需要 存儲的字元數的兩倍。這樣,在下一次申請新的字元緩沖區之前,字元串的容量足夠允許它增長一倍。下一次某個操作需要增長字元串時,現有的緩沖區足夠存儲新的内容,可以 避免申請新的緩沖區。這個小技巧帶來的好處是随着字元串變得更長,在字元串後面再添 加字元或是字元串的開銷近似于一個常量;而其代價則是字元串攜帶了一些未使用的記憶體 空間。如果字元串的實作政策是字元串緩沖區增大為原來的兩倍,那麼在該字元串的存儲 空間中,有一半都是未使用的。

4.1.2 字元串就是值

在指派語句和表達式中,字元串的行為與值是一樣的(請參見 6.1.3 節)。2 和 3.14159 這 樣的數值常量是值。可以将一個新值賦予給一個變量,但是改變這個變量并不會改變這個 值。例如:

int i,j;
i = 3; // i的值是3
j = i; // j的值也是3
i = 5; // i的值現在是5,但是j的值仍然是3
           

将一個字元串指派給另一個字元串的工作方式是一樣的,就仿佛每個字元串變量都擁有一 份它們所儲存的内容的私有副本一樣:

std::string s1, s2;
s1 = "hot"; // s1是"hot"
s2 = s1; // s2是"hot"
s1[0] = 'n'; // s2仍然是"hot",但s1變為了"not"
           

由于字元串就是值,是以字元串表達式的結果也是值。如果你使用

s1 = s2 + s3 + s4;

這 條語句連接配接字元串,那麼

s2 + s3

的結果會被儲存在一個新配置設定的臨時字元串中。連接配接

s4

後的結果則會被儲存在另一個臨時字元串中。這個值将會取代

s1

之前的值。接着,為第一 個臨時字元串和

s1

之前的值動态配置設定的記憶體将會被釋放。這會導緻多次調用記憶體管理器。

4.1.3 字元串會進行大量複制

由于字元串的行為與值相似,是以修改一個字元串不能改變其他字元串的值。但是字元串 也有可以改變其内容的變值操作。正是因為這些變值操作的存在,每個字元串變量必須表 現得好像它們擁有一份自己的私有副本一樣。實作這種行為的最簡單的方式是當建立字元 串、指派或是将其作為參數傳遞給函數的時候進行一次複制。如果字元串是以這種方式實 現的,那麼指派和參數傳遞的開銷将會變得很大,但是變值函數(mutating function)和非常量引用的開銷卻很小。

有一種被稱為“寫時複制”(

copy on write

)的著名的程式設計慣用法,它可以讓對象與值具有 同樣的表現,但是會使複制的開銷變得非常大。在 C++ 文獻中,它被簡稱為 COW(詳見 6.5.5 節)。在 COW 的字元串中,動态配置設定的記憶體可以在字元串間共享。每個字元串都可 以通過引用計數知道它們是否使用了共享記憶體。當一個字元串被指派給另一個字元串時, 所進行的處理隻有複制指針以及增加引用計數。任何會改變字元串值的操作都會首先檢查 是否隻有一個指針指向該字元串的記憶體。如果多個字元串都指向該記憶體空間,所有的變值 操作(任何可能會改變字元串值的操作)都會在改變字元串值之前先配置設定新的記憶體空間并 複制字元串:

COWstring s1, s2;
s1 = "hot"; // s1是"hot"
s2 = s1; // s2是"hot"(s1和s2指向相同的記憶體)
s1[0] = 'n';// s1會在改變它的内容之前将目前記憶體空間中的内容複制一份
 // s2仍然是"hot",但s1變為了"not"
           

寫時複制這項技術太有名了,以至于開發人員可能會想當然地以為

std::string

就是以 這種方式實作的。但是實際上,寫時複制甚至是不符合

C++11

标準的實作方式,而且問題百出。

如果以寫時複制方式實作字元串,那麼指派和參數傳遞操作的開銷很小,但是一旦字元串 被共享了,非常量引用以及任何變值函數的調用都需要昂貴的配置設定和複制操作。在并發代 碼中,寫時複制字元串的開銷同樣很大。每次變值函數和非常量引用都要通路引用計數 器。當引用計數器被多個線程通路時,每個線程都必須使用一個特殊的指令從主記憶體中得 到引用計數的副本,以確定沒有其他線程改變這個值(詳見 12.2.7 節)。

在 C++11 及之後的版本中,随着“右值引用”和“移動語義”(詳見 6.6 節)的出現,使用 它們可以在某種程度上減輕複制的負擔。如果一個函數使用“右值引用”作為參數,那麼 當實參是一個右值表達式時,字元串可以進行輕量級的指針複制,進而節省一次複制操作。

4.2 第一次嘗試優化字元串

假設通過分析一個大型程式揭示出了代碼清單 4-1 中的

remove_ctrl()

函數的執行時間在 程式整體執行時間中所占的比例非常大。這個函數的功能是從一個由 ASCII 字元組成的字 符串中移除控制字元。看起來它似乎很無辜,但是出于多種原因,這種寫法的函數确實性 能非常糟糕。實際上,這個函數是一個很好的例子,向大家展示了在編碼時完全不考慮性 能是多麼地危險。

代碼清單 4-1 需要優化的 remove_ctrl()
std::string remove_ctrl(std::string s) {
 std::string result;
 for (int i=0; i<s.length(); ++i) {
 if(s[i] >= 0x20)
 result = result + s[i];
 }
 return result;
}
           

remove_ctrl()

在循環中對通過參數接收到的字元串 s 的每個字元進行處理。循環中的代碼 就是導緻這個函數成為熱點的原因。if 條件語句從字元串中得到一個字元,然後與一個字面常量進行比較。這裡沒有什麼問題。但是第 5 行的語句就不一樣了。

正如之前所指出的,字元串連接配接運算符的開銷是很大的。它會調用記憶體管理器去建構一個 新的臨時字元串對象來儲存連接配接後的字元串。如果傳遞給

remove_ctrl()

的參數是一個由可列印的字元組成的字元串,那麼

remove_ctrl()

幾乎會為 s 中的每個字元都建構一個臨時字元串對象。對于一個由 100 個字元組成的字元串而言,這會調用 100 次記憶體管理器來 為臨時字元串配置設定記憶體,調用 100 次記憶體管理器來釋放記憶體。

除了配置設定臨時字元串來儲存連接配接運算的結果外,将字元串連接配接表達式指派給 result 時可能 還會配置設定額外的字元串。當然,這取決于字元串是如何實作的。

  • 如果字元串是以寫時複制慣用法實作的,那麼指派運算符将會執行一次高效的指針複制 并增加引用計數。
  • 如果字元串是以非共享緩沖區的方式實作的,那麼指派運算符必須複制臨時字元串的内 容。如果實作是原生的,或者 result 的緩沖區沒有足夠的容量,那麼指派運算符還必 須配置設定一塊新的緩沖區用于複制連接配接結果。這會導緻 100 次複制操作和 100 次額外的内 存配置設定。
  • 如果編譯器實作了 C++11 風格的右值引用和移動語義,那麼連接配接表達式的結果是一個 右值,這表示編譯器可以調用 result 的移動構造函數,而無需調用複制構造函數。是以, 程式将會執行一次高效的指針複制。

每次執行連接配接運算時還會将之前處理過的所有字元複制到臨時字元串中。如果參數字元串 有 n 個字元,那麼 remove_ctrl() 會複制O(n2 ) 個字元。所有這些記憶體配置設定和複制都會導緻 性能變差。

因為

remove_ctrl()

是一個小且獨立的函數,是以我們可以建構一個測試套件,通過反複 地調用該函數來測量通過優化到底能将該函數的性能提升多少。關于建構測試套件和測 優化字元串的使用:案例研究 | 57 量性能的内容,我們已經在第 3 章中讨論過了。讀者可以從我的個人網站(http://www.guntheroth.com)下載下傳這個函數的測試套件以及本書中的其他代碼。

我所編寫的這個時間測試會以一個長達 222 個字元且其中包含多個控制字元的字元串作為 參數,反複地調用

remove_ctrl()

函數。測量結果是平均每次調用花費

24.8

微秒。這個數 字自身并不重要,因為這是在我的 PC(英特爾 i7 平闆)、作業系統(Windows 8.1)和編 譯器(Visual Studio 2010,32 位,正式版)上得出的測試結果。重要的是,它是測量性能改善的基準值。

在下面的小節中,我将會介紹

remove_ctrl()

函數的性能優化步驟和結果。

4.2.1 使用複合指派操作避免臨時字元串

我首先通過移除記憶體配置設定和複制操作來優化

remove_ctrl()

。代碼清單 4-2 是

remove_ ctrl()

改善後的版本,其中第 5 行中會産生很多臨時字元串對象的連接配接表達式被替換為了 複合指派操作符 +=。

代碼清單 4-2 remove_ctrl_mutating():複合指派操作符
std::string remove_ctrl_mutating(std::string s) {
 std::string result;
 for (int i=0; i<s.length(); ++i) {
 if(s[i] >= 0x20)
 result += s[i];
 }
 return result;
}
           

這個小的改動卻帶來了很大的性能提升。在相同的測試下,現在,平均每次調用隻花費 1.72 微秒,性能提升了 13 倍。這次改善源于移除了所有為了配置設定臨時字元串對象來儲存 連接配接結果而對記憶體管理器的調用,以及相關的複制和删除臨時字元串的操作。指派時的配置設定和複制操作也可以被移除,不過這取決于字元串的實作方式。

4.2.2 通過預留存儲空間減少記憶體的重新配置設定

remove_ctrl_mutating()

函數仍然會執行一個導緻 result 變長的操作。這意味着 result 會被反複地複制到一個更大的内部動态緩沖區中。正如之前所讨論的,每次字元串的字 符緩沖區發生溢出時,

std::string

的一種可能的實作方式 3 會申請兩倍的記憶體空間。如果 std::string 是以這種規則實作的,那麼對于一個含有 100 個字元的字元串來說,重新分 配記憶體的次數可能會多達

8

次。

假設字元串中絕大多數都是可列印的字元,隻有幾個是需要被移除的控制字元,那麼參數 字元串 s 的長度幾乎等于結果字元串的最終長度。代碼清單 4-3 通過使用 std::string() 的

reserve()

成員函數預先配置設定足夠的記憶體空間來優化

remove_ctrl_mutating()

。使用

reserve()

不僅移除了字元串緩沖區的重新配置設定,還改善了函數所讀取的資料的緩存局部 性(

cache locality

),是以我們從中得到了更好的改善效果。

代碼清單 4-3 remove_ctrl_reserve():預留存儲空間
std::string remove_ctrl_reserve(std::string s) {
 std::string result;
 result.reserve(s.length());
 for (int i=0; i<s.length(); ++i) {
 if (s[i] >= 0x20)
 result += s[i];
 }
 return result;
}
           

移除了幾處記憶體配置設定後,程式性能得到了明顯的提升。對 remove_ctrl_reserve() 進行測 試的結果是每次調用耗時 1.47 微秒,相比 remove_ctrl_mutating() 提高了 17%。

4.2.3 消除對參數字元串的複制

到目前為止,我已經通過移除對記憶體管理器的調用成功地優化了 remove_ctrl() 函數。因 此,繼續尋找和移除其他記憶體配置設定操作是合理的。

如果通過值将一個字元串表達式傳遞給一個函數,那麼形參(在本例中即 s)将會通過複 制構造函數被初始化。這可能會導緻複制操作,當然,這取決于字元串的實作方式。

  • 如果字元串是以寫時複制慣用法方式實作的,那麼編譯器會調用複制構造函數,這将執 行一次高效的指針複制并增加引用計數。
  • 如果字元串是以非共享緩沖區的方式實作的,那麼複制造函數必須配置設定新的緩沖區并複 制實參的内容。
  • 如果編譯器實作了 C++11 風格的右值引用和移動語義,而且實參是一個表達式,那麼 它就是是一個右值,這樣編譯器将會調用移動構造函數,這會執行一次高效的指針複制。 如果實參是一個變量,那麼将會調用形參的構造函數,這會導緻一次記憶體配置設定和複制。6.6 節中将詳細講解右值引用和移動語義。

代碼清單 4-4 中的

remove_ctrl_ref_args()

是改善後的永遠不會複制 s 的函數。由于該函 數不會修改 s,是以沒有理由去複制一份 s。取而代之的是,

remove_ctrl_ref_args()

會給 s 一個常量引用作為參數。這省去了另外一次記憶體配置設定。由于記憶體配置設定是昂貴的,是以哪 怕隻是一次記憶體配置設定,也值得從程式中移除。

代碼清單 4-4 remove_ctrl_ref_args():移除實參複制
std::string remove_ctrl_ref_args(std::string const& s) {
 std::string result;
 result.reserve(s.length());
 for (int i=0; i<s.length(); ++i) {
 if (s[i] >= 0x20)
 result += s[i];
 }
 return result;
}
           

改善後的結果令人大吃一驚。remove_ctrl_ref_args() 的測試結果是每次調用花費 1.60 微秒,相比

remove_ctrl_reserve()

性能下降了 8%。

到底發生了什麼呢?當這段函數運作時,Visual Studio 2010 應該會複制字元串。是以,這 次修改本應該能夠省去一次記憶體配置設定。原因可能是并沒有真正省去這次記憶體配置設定,或是将 s 從字元串修改為字元串引用後導緻其他相關因素抵消了節省記憶體配置設定帶來的性能提升。

引用變量是作為指針實作的。因在,當在

remove_ctrl_ref_args()

中每次出現 s 時,程式 都會解引指針,而在 remove_ctrl_reserve() 中則不會發生解引。我推測這些額外的開銷 可能足以導緻性能下降。

4.2.4 使用疊代器消除指針解引

解決方法是在字元串上使用疊代器,如代碼清單 4-5 所示。字元串疊代器是指向字元緩沖 區的簡單指針。與在循環中不使用疊代器的代碼相比,這樣可以節省兩次解引操作。

代碼清單 4-5 remove_ctrl_ref_args_it():remove_ctrl_ref_args() 的使用了疊代器的版本
std::string remove_ctrl_ref_args_it(std::string const& s) {
 std::string result;
 result.reserve(s.length());
 for (auto it=s.begin(),end=s.end(); it != end; ++it) {
 if (*it >= 0x20)
 result += *it;
 }
 return result;
}
           

測試結果令人滿意,每次調用

remove_ctrl_ref_args_it()

的時間為 1.04 微秒。與不使用 疊代器的版本相比,這絕對是非常棒的結果。但是如果将 s 變為字元串引用的話會怎麼樣 呢?為了确定這項優化到底是否有助于改善性能,我編寫了一個使用了疊代器的

remove_ ctrl_reserve()

。測試結果是每次調用 remove_ctrl_reserve_it() 的時間為 1.26 微秒,比 修改前的 1.47 微秒略有減少。這說明将參數類型修改為字元串引用确實提高了程式性能。

實際上,我為函數名以 remove_ctrl() 開頭的函數都編寫過對應的使用疊代器的版本。在 所有這些函數中,使用疊代器都比不使用疊代器要快。(不過,在 4.3 節中,我們将會看到 這個技巧并非總是有效。)

在 remove_ctrl_ref_args_it() 中還包含另一個優化點,那就是用于控制 for 循環的 s.end() 的值會在循環初始化時被緩存起來。這樣可以節省 2n 的間接開銷,其中 n 是參數 字元串的長度。

4.2.5 消除對傳回的字元串的複制

remove_ctrl() 函數的初始版本是通過值傳回處理結果的。

C++

會調用複制構造函數将處 理結果設定到調用上下文中。雖然隻要可能的話,編譯器是可以省去(即簡單地移除)調 用複制構造函數的,但是如果我們想要確定不會發生複制,那麼有幾種選擇。其中一種選 擇是将字元串作為輸出參數傳回,這種方法适用于所有的

C++

版本以及字元串的所有實作方式。這也是編譯器在省去調用複制構造函數時确實會進行的處理。代碼清單

4-6

展示了

代碼清單 4-6 remove_ctrl_ref_result_it():移除對傳回值的複制
void remove_ctrl_ref_result_it (
 std::string& result,
 std::string const& s)
{
 result.clear();
 result.reserve(s.length());
 for (auto it=s.begin(),end=s.end(); it != end; ++it) {
 if (*it >= 0x20)
 result += *it;
 }
}
           

當程式調用 remove_ctrl_ref_result_it() 時,一個指向字元串變量的引用會被傳遞給形參 result。如果 result 引用的字元串變量是空的,那麼調用 reserve() 将配置設定足夠的記憶體空 間用于儲存字元。如果程式之前使用過這個字元串變量,例如程式循環地調用了 remove_ ctrl_ref_result_it(),那麼它的緩沖區可能已經足夠大了,這種情況下可能無需配置設定 新的記憶體空間。當函數傳回時,調用方的字元串變量将會接收傳回值,無需進行複制。 remove_ctrl_ref_result_it() 的優點在于多數情況下它都可以移除所有的記憶體配置設定。

remove_ctrl_ref_result_it() 的性能測量結果是每次調用花費 1.02 微秒,比修改之前的版 本快了大約 2%。 remove_ctrl_ref_result_it() 已經非常高效了,但是相比 remove_ctrl() 而言,它的接口 很容易導緻調用方誤用這個函數。引用——即使是常量引用——的行為與值的行為并非完 全相同。下面的函數調用将會傳回一個空字元串,這與預想的結果不同:

std::string foo("this is a string");
remove_ctrl_ref_result_it(foo, foo);
           

4.2.6 用字元數組代替字元串

當程式有極其嚴格的性能需求時,可以如代碼清單 4-7 所示,不使用 C++ 标準庫,而是利 用 C 風格的字元串函數來手動編寫函數。相比 std::string,C 風格的字元串函數更難以 使用,但是它們卻能帶來顯著的性能提升。要想使用 C 風格的字元串,程式員必須手動分 配和釋放字元緩沖區,或者使用靜态數組并将其大小設定為可能發生的最差情況。如果内 存的使用量非常嚴格,那麼可能無法聲明很多靜态數組。不過,在局部存儲區(即函數調 用棧)中往往有足夠的空間可以靜态地聲明大型臨時緩沖區。當函數退出時,這些緩沖區 将會被回收,而産生的運作時開銷則微不足道。除了一些限制極度嚴格的嵌入式環境外, 在棧上聲明最差情況下的緩沖區為 1000 甚至 10 000 個字元是沒有問題的。

代碼清單 4-7 remove_ctrl_cstrings():在底層編碼
void remove_ctrl_cstrings(char* destp, char const* srcp, size_t size) {
 for (size_t i=0; i<size; ++i) {
 if (srcp[i] >= 0x20)
 *destp++ = srcp[i];
 }
 *destp = 0;
}
           

測試結果是每次調用 remove_ctrl_cstrings() 的時間為 0.15 微秒。這比上一個版本的函數 快了 6 倍,比最初的版本更是快了足足 170 倍。獲得這種改善效果的原因之一是移除了若 幹函數調用以及改善了緩存局部性。

不 過, 優 秀的 緩 存 局 部 性 可 能 會 誤 導 性 能 測 量。 通 常, 在 兩 次 調 用 remove_ctrl_ cstrings() 之間的其他操作會重新整理緩存。但是當在一個循環中頻繁地調用該函數時,指令 和資料可能會駐留在緩存中。

另一個影響 remove_ctrl_cstrings() 的因素是它的接口與初始函數相比發生了太多改 變。如果有許多地方都調用了初始版本函數,那麼将那些代碼修改為調用現在的這個函 數需要花費很多人力和時間,而且修改後代碼也可能需要優化。盡管如此,remove_ctrl_ cstrings() 這個例子仍然說明,隻要開發人員願意完全重寫函數和改變它的接口,他們可 以獲得很大的性能提升。

停下來思考
正如在之前的章節中所提到的,在進行性能優化時,要注意權衡簡單性、安全性與所 獲得的性能提升效果。相比 remove_ctrl(),remove_ctrl_ref_result_it() 需要改變函 數簽名,這可能會引入潛在的錯誤。remove_ctrl_cstrings() 的性能改善代價是手動 管理臨時存儲空間。對于某些開發團隊來說,這個代價太大了。 對于一項性能改善是否值得增加接口的複雜性或是增加需要評審函數調用的工作量, 開發人員有不同的觀點,有時候觀點還非常強硬。特别鐘愛通過輸出參數傳回函數值 的開發人員可能會認為危險用例不太可能出現,而且可以記錄下來。通過輸出參數返 回字元串還可以讓函數傳回值發揮其他作用,例如傳回錯誤代碼。那些反對這項優 化的開發人員可能會說,這裡沒有明顯地警告使用者遠離危險的用例,而且一個微妙的 bug 帶來的麻煩遠比性能優化的價值大。最後,團隊必須回答一個問題:“我們需要将 程式性能提高多少?” 我無法告訴你什麼時候優化過度了,因為這取決于性能改善有多重要。但是開發人員 應當注意性能的轉變,然後停下來多多思考。 C++ 為開發人員提供了很多選擇,從編寫簡單、安全但效率低下的代碼,到編寫高效 但必須謹慎使用的代碼。其他程式設計語言的提倡者可能會認為這是一個缺點,但是就優 化而言,這是 C++ 最強有力的武器之一。

4.2.7 第一次優化總結

表 4-1 中總結了對 remove_ctrl() 采取各種優化手段後的測試結果。這些結果都來自于遵 循一個簡單的規則:移除記憶體配置設定和相關的複制操作。第一個優化手段帶來的性能提升效 果最顯著。

許多因素都會影響絕對時間,包括處理器、基礎時鐘頻率、記憶體總線頻率、編譯器和優化 器。我已經提供了調試版和正式(優化後)版的測試結果來證明這一點。雖然正式版代碼 比調試版代碼的運作速度快得多,但是在調試版和正式版中都可以看出改善效果。

表4-1:性能總結VS 2010,i7
函數 調試版 Δ 正式版 Δ 正式版與調試版
remove_ctrl() 967 微秒 24.8 微秒 3802%
remove_ctrl_mutating() 104 微秒 834% 1.72 微秒 1341% 5923%
remove_crtl_reserve() 102 微秒 142% 1.47 微秒 17% 6853%
remove_ctrl_ref_args_it() 215 微秒 9% 1.04 微秒 21% 20559%
remove_ctrl_ref_result_it() 215 微秒 0% 1.02 微秒 2% 21012%
remove_ctrl_cstrings() 1 微秒 9698% 0.15 微秒 601% 559%

正式版本的性能提升百分比看起來更具有戲劇性。這可能是受到了阿達姆法則的影響。在 調試版本中,函數的内聯展開被關閉了,這增加了每個函數調用的開銷,也導緻記憶體配置設定 的執行時間所占的比重降低了。

4.3 第二次嘗試優化字元串

開發人員還可以通過其他途徑尋求更好的性能。我們将在本節中讨論幾種優化選擇。

4.3.1 使用更好的算法

一種優化選擇是嘗試改進算法。初始版本的 remove_ctrl() 使用了一種簡單的算法,一次 将一個字元複制到結果字元串中。這個不幸的選擇導緻了最差的記憶體配置設定行為。代碼清單 4-8 在初始設計的基礎上,通過将整個子字元串移動至結果字元串中改善了函數性能。這 個改動可以減少記憶體配置設定和複制操作的次數。remove_ctrl_block() 中展示的另外一種優化 選擇是緩存參數字元串的長度,以減少外層 for 循環中結束條件語句的性能開銷。

代碼清單 4-8 remove_ctrl_block():一種更快的算法
std::string remove_ctrl_block(std::string s) {
 std::string result;
 for (size_t b=0, i=b, e=s.length(); b < e; b = i+1) {
     for (i=b; i<e; ++i) {
     if (s[i] < 0x20)
     break;
     }
 result = result + s.substr(b,i-b);
 }
 return result;
}
           

測試結果是每次調用 remove_ctrl_block() 的運作時間為 2.91 微秒,這個速度大約比初始 版本的 remove_ctrl() 快了 7 倍。

這個函數與以前一樣,可以通過使用複合指派運算符替換字元串連接配接運算符來改善 (remove_ctrl_block_mutatel(),每次調用的時間是 1.27 微秒)其性能,但是 substr() 仍 然生成臨時字元串。由于這個函數将字元添加到了 result 的末尾,開發人員可以通過重 載 std::string 的 append() 成員函數來複制子字元串,且無需建立臨時字元串。修改後的 remove_ctrl_block_mutate() 函數(如代碼清單 4-9 所示)的測試結果是每次調用耗時 0.65 微秒。這個結果輕松地戰勝了 remove_ctrl_ref_result_it() 的每次調用 1.02 微秒的成績, 比初始版本的 remove_ctrl() 快了 36 倍。這個簡單的例子向我們展示了選擇一種更好的算 法是一種多麼強大的優化手段。

代碼清單 4-9 remove_ctrl_block_append():一種更快的算法
std::string remove_ctrl_block_append(std::string s) {
 std::string result;
 result.reserve(s.length());
 for (size_t b=0,i=b; b < s.length(); b = i+1) {
 for (i=b; i<s.length(); ++i) {
 if (s[i] < 0x20) break;
 }
 result.append(s, b, i-b);
 }
 return result;
}
           

這個結果還可以通過預留 result 的存儲空間和移除參數複制(remove_ctrl_block_args(), 每次調用的時間是 0.55 微秒)以及通過移除傳回值的複制(remove_ctrl_block_ret(),每次調用的時間是 0.51 微秒)來改善。

有一件事情對性能沒有改善效果,至少在最開始沒有,那就是使用疊代器重寫 remove_ ctrl_block()。不過,如表 4-2 所示,在将參數和傳回值都變為引用類型後,使用了疊代 器的版本的開銷突然從增加 10 倍變為了減少 20%。

表4-2:第二種remove_ctrl算法的性能總結
每次調用時間 Δ(與上一次相比)
remove_ctrl() 24.8 微秒
remove_ctrl_block() 2.91 微秒 751%
remove_ctrl_block_mutate() 1.27 微秒 129%
remove_ctrl_block_append() 0.65 微秒 95%
remove_ctrl_block_args() 0 0.55 微秒 27%
remove_ctrl_block_ret() 0.51 微秒 6%
remove_ctrl_block_ret_it() 0.43 微秒 19%

另外一種改善性能的方法是,通過使用 std::string 的 erase() 成員函數移除控制字元來改變字元串。代碼清單 4-10 展示了這種修改方法。

代碼清單 4-10 remove_ctrl_erase():不建立新的字元串,而是修改參數字元串的值作 為結果傳回
std::string remove_ctrl_erase(std::string s) {
 for (size_t i = 0; i < s.length();)
 if (s[i] < 0x20)
 s.erase(i,1);
 else ++i;
 return s;
}
           

這種算法的優勢在于,由于 s 在不斷地變短,除了傳回值時會發生記憶體配置設定外,其他情 況下都不會再發生記憶體配置設定。修改後的函數性能非常棒,測試結果是每次調用耗時

0.81

毫秒,比初始版本的

remove_ctrl()

快了 30 倍。如果在第一次優化中取得了這個優異的 結果,開發人員可能認為自己勝利了,然後退出優化戰場,不會考慮如何進一步優化。 有時候,選擇一種不同的算法後程式會變得更快;即使沒有變快,也可能會變得比原來更容易優化。

4.3.2 使用更好的編譯器

我使用 Visual Studio 2013 運作了相同的測試。Visual Studio 2013 實作了移動語義,這應 當會讓一些函數更快。不過,結果卻有點讓人看不懂。在調試模式下的運作結果是 Visual Studio 2013 比 Visual Studio 2010 快了 5%~15%,不過從指令行運作的結果是 VS2013 慢了 5%~20%。我也試過 Visual Studio 2015 RC 版,結果更慢。這可能與容器類的改變有關。一 個新版本的編譯器可能會改善性能,不過這需要開發人員通過測試去驗證,而不是想當然。

4.3.3 使用更好的字元串庫

std::string 的定義曾經非常模糊,這讓開發人員在實作字元串時有更廣泛的選擇。後來, 對性能和可預測性的需求最終迫使 C++ 标準明确了它的定義,導緻很多新奇的實作方式不 再适用。定義 std::string 的行為是一種妥協,它是經過很長一段時間以後從各種設計思 想中演變出來的。

  • 與其他标準庫容器一樣,std::string 提供了用于通路字元串中單個字元的疊代器。
  • 與 C 風格的字元串一樣,std::string 提供了類似數組索引的符号,可以使用運算符 [] 通路它的元素。std::string 還提供了一種用于擷取指向以空字元結尾的 C 風格字元串 的指針的機制。
  • 與 BASIC 字元串類似,std::string 有一個連接配接運算符和可以賦予字元串值語義(value semantics)的傳回值的函數。
  • std::string 提供的操作非常有限,有些開發人員會感覺受到了限制。

希望 std::string 與 C 風格的字元數組一樣高效,這個需求推動着字元串的實作朝着在緊 鄰的記憶體中表現字元串的方向前進。C++ 标準要求疊代器能夠随機通路,而且禁止寫時複 制語義。這樣更容易定義 std::string,而且更容易推論出哪些操作會使在 std::string 中使用疊代器無效,但它同時也限制了更聰明的實作方式的範圍。

而且,商業 C++ 編譯器的 std::string 的實作必須足夠直接,使其可以被測試,以確定字 符串的行為符合标準,并且在所有可考慮到的情況下都具有可接受的性能。編譯器廠商犯 錯的代價是非常大的。這會推動 std::string 的實作趨于簡單。

标準所定義的 std::string 的行為有一些缺點。向一個含有 100 萬個字元的字元串中插 入一個字元會導緻整個字元串都被複制一份,而且可能會發生記憶體配置設定。類似地,所有 傳回值的子字元串的操作都必須配置設定記憶體和複制它們的結果。一些開發人員會通過避開 一個或多個之前提到的限制(疊代器、索引、C 風格的通路方式、值語義、簡單性)來 尋找優化機會。

  1. 采用更豐富的std::string庫

有時候,使用更好的庫也表示使用額外的字元串函數。許多庫都可以與 std::string 共同 工作,下面列舉了其中一部分。

Boost 字元串庫(http://www.boost.org/doc/libs/?view=category_String)

Boost 字元串庫提供了按标記将字元串分段、格式化字元串和其他操作 std::string 的 函數。這為那些喜愛标準庫中的 頭檔案的開發人員提供了很大的幫助。

C++ 字元串工具包(http://www.partow.net/programming/strtk/index.html)

另一個選擇是 C++ 字元串工具包(StrTk)。StrTk 在解析字元串和按标記将字元串分段 方面格外優秀,而且它相容 std::string。
  1. 使用std::stringstream避免值語義

C++ 已經有幾種字元串實作方式了:模闆化的、支援疊代器通路的、可變長度的 std::string 字元串;簡單的、基于疊代器的 std::vector;老式的、C 風格的以空 字元結尾的、固定長度的字元數組。

盡管很難用好 C 風格的字元串,但我們之前已經通過實驗看到了,在适當的條件下,使用 C 風格的字元數組替換 C++ 的 std::string 後可以極大程度地改善程式的性能。這兩種實 現方式都很難完美地适用于所有情況。

C++ 中還有另外一種字元串。std::stringstream 之于字元串,就如同 std::ostream 之于 輸出檔案。std::stringstream 類以一種不同的方式封裝了一塊動态大小的緩沖區(事實 上,通常就是一個 std::string),資料可以被添加至這個實體(請參見 6.1.3 節中的内容) 中。std::stringstream 是一個很好的例子,它展示了如何在類似的實作的頂層使用不同的 API 來提高代碼性能。代碼清單 4-11 展示了 std::stringstream 的使用方法。

代碼清單 4-11 std::stringstream:類似于字元串,但卻是一個對象
std::stringstream s;
for (int i=0; i<10; ++i) {
 s.clear();
 s << "The square of " << i << " is " << i*i << std::endl;
 log(s.str());
}
           

這段代碼展示了幾個優化代碼的技巧。由于 s 被修改為了一個實體,這個很長的插入表達 式不會建立任何會臨時字元串,是以不會發生記憶體配置設定和複制操作。另外一個故意的改動 是将 s 聲明了在循環外。這樣,s 内部的緩存将會被複用。第一次循環時,随着字元被添 加至對象中,可能會重新配置設定幾次緩沖區,但是在接下來的疊代中就不太可能會重新配置設定 緩沖區了。相比之下,如果将 s 定義在循環内部,每次循環時都會配置設定一塊空的緩沖區, 而且當使用插入運算符添加字元時,還有可能重新配置設定緩沖區。

如果 std::stringstream 是用 std::string 實作的,那麼它在性能上永遠不能勝過 std::string。 它的優點在于可以防止某些降低程式性能的程式設計實踐。

  1. 采用一種新奇的字元串實作方式

開發人員可能會發現字元串缺乏抽象性。C++ 最重要的特性之一是沒有内置字元串等抽象 性,卻以模闆或者函數庫的形式提供了這種抽象性。

std::string

等可選的實作方式成為 了這門程式設計語言的特性。是以一位非常聰明的開發人員實作的字元串的性能可能會更好。 通過移除一個或多個在本節開頭列舉出的限制(疊代器、索引、C 風格通路、簡單性), 可以定義出自己的字元串類來優化那些因使用了 std::string 而無法優化的代碼。

随着時間的推移,開發人員提出了許多聰明的字元串資料結構,承諾可以顯著地降低記憶體 配置設定和複制字元串内容的開銷。但是出于以下幾個原因,這可能會是“塞壬的歌聲” 。

  • 任何想要取代 std::string 的實作方式都必須具有足夠的表現力,且在大多數場合都比 std::string 更高效。提議的絕大多數可選實作方式都無法確定在多數情況下可以提高 性能。
  • 将一個大型程式中出現的所有 std::string 都換成其他字元串是一項浩大的工程,而且 無法確定這一定能提高性能。
  • 雖然有許多種可選的字元串概念被提出來了,而且有一些已經實作了,但是想要通過谷 歌找到一種像 std::string 一樣完整的、經過測試的、容易了解的字元串實作,卻需要 花費一些工夫。

在設計程式時考慮替換 std::string 可能比在進行優化時替換 std::string 更現實。雖然 對于一個有足夠時間和資源的大團隊來說,在進行優化時替換 std::string 也是可能的, 但是結果的不确定性太高,因而這種優化不太現實。但是仍然有其他實作方式的字元串可以幫助我們。

名詞 介紹
std::string_view string_view 可以解決 std::string 的某些問題。它包含一個指向字元串資料的無主指 針和一個表示字元串長度的值,是以它可以表示為 std::string 或字面字元串的子字 符串。與 std::string 的傳回值的成員函數相比,它的 substring 和 trim 等操作更高 效。std::string. string_view 可能會被加入到 C++14 中。有些編譯器現在已經實作了 std::experimental::string_view。string_view 與 std::string 的接口幾乎相同。 std::string 的問題在于指針是無主的。程式員必須確定每個 string_view 的生命周期 都不會比它所指向的 std::string 的生命周期長。
folly::fbstring Folly 是一個完整的代碼庫,它被 Facebook 用在了他們自己的伺服器上。它包含了高度 優化過的、可以直接替代 std::string 的 fbstring。在 fbstring 的實作方式中,對于短 的字元串是不用配置設定緩沖區的。fbstring 的設計人員聲稱他們測量到性能得到了改善。 由于這種特性,Folly 很可能非常健壯和完整。目前,隻有 Linux 支援 Folly。
字元串類的工具包 (http://johnpanzer.com/tsc_cuj/ToolboxOfStrings.html)這篇發表于 2000 年的文章和代碼描述了一個模闆化的字元串類型,其接口與 SGI5 的 std::string 相同。它提供了一個固定最大長度的字元串類型和一個可變長度的字元串 類型。這是模闆元程式設計(template metaprogramming)魔法的一個代表作,但可能會讓 一些人費解。對于那些緻力于設計更好的字元串類的開發人員來說,這是一個切實可行 的候選類庫。
C++03 表達式模闆 (http://craighenderson.co.uk/papers/exptempl/) 這是在 2005 年的一篇論文中展示的用于解決特定字元串連接配接問題的模闆代碼。表達 式模闆重寫了 + 運算符,這樣可以建立一個表示兩個字元串的連接配接或是一個字元串和 一個字元串表達式的連接配接的中間類型。當表達式模闆被指派給一個字元串時,表達式 模闆将記憶體配置設定和複制推遲至表達式結束,隻會執行一次記憶體配置設定。表達式模版相容 std::string。當既存的代碼中有一個連接配接一長串子字元串的表達式時,使用表達式模 闆可以顯著地提升性能。這個概念可以擴充至整個字元串庫。
Better String 庫 (http://bstring.sourceforge.net/) 這個代碼歸檔檔案中包含了一個通用的字元串實作。它與 std::string 的實作方式不 同,但是包含一些強大的特征。如果許多字元串是從其他字元串中的一部分建構出來 的,bstring 允許通過相對一個字元串的偏移量和長度來組成一個新的字元串。我用過 以這種思想設計實作的有專利權的字元串,它們确實非常高效。在 C++ 中有一個稱為 CBString 的 bstring 庫的包裝類。
rope (https://www.sgi.com/tech/stl/Rope.html) 這是一個非常适合在長字元串中進行插入和删除操作的字元串庫。它不相容 std::string。
Boost 字元串算法 (http://www.boost.org/doc/libs/1_60_0/doc/html/string_algo.html) 這是一個字元串算法庫,它是對 std::string 的成員函數的補充。這個庫是基于“查找 和替換”的概念建構起來的。

4.3.4 使用更好的記憶體配置設定器

每個 std::string 的内部都是一個動态配置設定的字元數組。std::string 看上去像是下面這樣 的通用模闆的一種特化:

namespace std {
 template < class charT,
 class traits = char_traits<charT>,
 class Alloc = allocator<charT>
 > class basic_string;
 typedef basic_string<char> string;
 ...
};
           

第三個模闆參數 Alloc 定義了一個配置設定器——一個通路 C++ 記憶體管理器的專用接口。預設 情況下,Alloc 是 std::allocator,它會調用 ::operator new() 和 ::operator delete()—— 兩個全局的 C++ 記憶體配置設定器函數。

我将會在第 13 章中詳細講解 ::operator new() 和 ::operator delete() 以及配置設定器對象 的行為。現在,我隻能告訴讀者,::operator new() 和 ::operator delete() 會做一項非 常複雜和困難的工作,為各種動态變量配置設定存儲空間。它們需要為大大小小的對象以及 單線程和多線程程式工作。為了實作良好的通用性,它們在設計上做出了一些妥協。有 時,選擇一種更加特化的配置設定器可能會更好。是以,我們可以指定預設配置設定器以外的為 std::string 定制的配置設定器作為 Alloc。

我編寫了一個極其簡單的配置設定器來展示可以獲得怎樣的性能提升。這個配置設定器可以管理幾 個固定大小的記憶體塊。如代碼清單 4-12 所示,我首先為使用這種配置設定器的字元串建立了一 個 typedef。接着,我修改初始的、非常低效的 remove_ctrl() 來使用這種特殊的字元串。

代碼清單 4-12 使用簡單的、管理固定大小記憶體塊的配置設定器的原始版本的 remove_ctrl()
typedef std::basic_string<
 char,
 std::char_traits<char>,
 block_allocator<char, 10>> fixed_block_string;
fixed_block_string remove_ctrl_fixed_block(std::string s) {
 fixed_block_string result;
 for (size_t i=0; i<s.length(); ++i) {
 if (s[i] >= 0x20)
 result = result + s[i];
 }
 return result;
}
           

測試結果非常有戲劇性。在相同的測試中,remove_ctrl_fixed_block() 的運作時間為 13 636 毫秒,大約比初始版本快了 7.7 倍。

修改配置設定器并不适用于怯懦的開發人員。你無法将基于不同配置設定器的字元串指派給另外一 個字元串。修改後的示例代碼之是以能夠工作,僅僅是因為 s[i] 是一個字元,而不是一個 隻有一個字元的 std::string。你可以通過将字元串轉換為 C 風格的字元串,将一個字元 串的内容複制到另一個字元串中。例如,可以将示例代碼修改為

result = s.c_str();

将代碼中所有的 std::string 都修改為

fixed_block_string

将會帶來很大的影響。是以, 如果一個團隊認為需要對他們使用的字元串做些改變,那麼最好在設計階段就定義全工程 範圍的 typedef:

之後,當要進行涉及大量代碼修改的實驗時,隻需要修改這一處代碼即可。僅在新的 字元串與要替換的字元串有相同的成員函數時,這種方法才奏效。不同配置設定器配置設定的 std::basic_strings 具有這種特性。

4.4 消除字元串轉換

現代世界的複雜性之一是不止有一種字元串。通常,字元串函數隻适用于對相同類型的字 符串進行比較、指派或是作為運算對象和參數,是以,程式員必須将一種類型的字元串轉 換為另外一種類型。任何時候,涉及複制字元和動态配置設定記憶體的轉換都是優化性能的機會。 轉換函數庫自身也可以被優化。更重要的是,大型程式的良好設計是可以限制這種轉換的。

4.4.1 将C字元串轉換為std::string

從以空字元結尾的字元串到 std::string 的無謂轉換,是浪費計算機 CPU 周期的原因之 一。例如:

std::string MyClass::Name() const {
 return "MyClass";
}
           

這個函數必須将字元串常量 MyClass 轉換為一個 std::string,配置設定記憶體和複制字元到 std::string 中。C++ 會自動地進行這次轉換,因為在 std::string 中有一個參數為

char*

的構造函數。

轉換為 std::string 是無謂的。std::string 有一個參數為 char* 的構造函數,是以當

Name()

的傳回值被指派給一個字元串或是作為參數傳遞給另外一個函數時,會自動進行轉 換。上面的函數可以簡單地寫為:

char const* MyClass::Name() const {
 return "MyClass";
}
           

這會将傳回值的轉換推遲至它真正被使用的時候。當它被使用時,通常不需要轉換:

char const* p = myInstance->Name(); // 沒有轉換
std::string s = myInstance->Name(); // 轉換為'std::string'
std::cout << myInstance->Name(); // 沒有轉換
           

一個大型軟體系統可能含有很多層(

layer

),這會讓字元串轉換成為一個大問題。如果在 某一層中接收的參數類型是

std::string

,而在它下面一層中接收的參數類型是

char

*,那 麼可能需要寫一些代碼将

std::string

反轉為

char*

void HighLevelFunc(std::string s) {
 LowLevelFunc(s.c_str());
}
           

4.4.2 不同字元集間的轉換

現代 C++ 程式需要将 C 的字面字元串(ASCII,有符号位元組)與來自 Web 浏覽器的 UTF-8 (無符号,每個字元都是可變長位元組)字元串進行比較,或是将由生成 UTF-16 的字流(帶 或者不帶端位元組)的 XML 解析器輸出的字元串轉換為 UTF-8。轉換組合的數量令人生畏。

移除轉換的最佳方法是為所有的字元串選擇一種固定的格式,并将所有字元串都存儲為這 種格式。你可能希望提供一個特殊的比較函數,用于比較你所選擇的格式和 C 風格的以空 字元結尾的字元串,這樣就無需進行字元串轉換。我個人比較喜歡 UTF-8,因為它能夠表 示所有的 Unicode 代碼點,可以直接與 C 風格的字元串進行比較(是否相同),而且多數 浏覽器都可以輸出這種格式。

在時間緊迫的情況下編寫的大型程式中,你可能會發現在将一個字元串從軟體中的一層傳 遞給另一層時,先将它從原來的格式轉換為一種新的格式,然後再将它轉換為原來的格式 的代碼。可以通過重寫類接口中的成員函數,讓它們接收相同的字元串類型來解決這個問 題。不幸的是,這項任務就像是在 C++ 程式中加入常量正确性(const-correctness)。這種 修改涉及程式中的許多地方,難以控制其範圍。

繼續閱讀