最佳化 C++/編寫高效程式碼/效能提升特性
C++ 語言的一些特性,如果使用得當,可以提高生成的軟體的速度。
在本節中,將介紹利用這些特性的指南。
在定義一個物件來儲存整數時,使用 int 或 unsigned int 型別,除非需要更長的型別;在定義一個物件來儲存字元時,使用 char 型別,除非需要 wchar_t 型別;在定義一個物件來儲存浮點數時,使用 double 型別,除非需要 long double 型別。如果生成的聚合物件是中等或大型的,用最小整數型別替換每個整數型別(但不要使用位域),該型別足以包含它(但不要使用位域),並用 float 型別替換浮點型別,除非需要更高的精度。
根據定義,int 和 unsigned int 型別是在平臺上最有效的型別,這些平臺至少可以容納 16 位範圍。如果你只需要 8 位寬度,並且正在為 8 位處理器編譯,那麼 char 可能會更有效,但除此之外,int 型別之一可能是你可以使用的最有效的型別。
double 型別比 float 型別效率低兩到三倍,但它具有更高的精度。
某些處理器型別對 signed char 物件的處理效率更高,而其他處理器型別對 unsigned char 物件的處理效率更高。因此,在 C 和 C++ 中,char 型別與 signed char 型別不同,被定義為目標處理器最有效的字元型別。
char 型別只能包含小的字元集;通常最多可以包含 255 個不同的字元。為了處理更大的字元集,你應該使用 wchar_t 型別,儘管它效率較低。
在包含在中等或大型聚合物件中或可能為中等或大型的集合中的數字的情況下,最好將聚合物件或集合的位元組大小最小化。這可以透過用處理器字大小的原語替換大於字大小的原語來完成。short 實際上佔用的記憶體量與字大小相同,即使 short 的欄位大小更小。
位域也可以用來最小化聚合物件的大小,但是由於它們的處理速度較慢,這可能會適得其反。因此,請將它們的引入推遲到最佳化階段。
不要將函式指標作為引數傳遞給函式,而是傳遞一個 函式物件(或者,如果使用 C++11 標準,則傳遞一個 lambda 表示式)。
例如,如果你有以下結構陣列
struct S {
int a, b;
};
S arr[n_items];
… 並且你希望按 b 欄位對其進行排序,你可以定義以下比較函式
bool compare(const S& s1, const S& s2) {
return s1.b < s2.b;
}
… 並將其傳遞給標準排序演算法
std::sort(arr, arr + n_items, compare);
但是,定義以下函式物件類(也稱為仿函式)可能效率更高
struct Comparator {
bool operator()(const S& s1, const S& s2) const {
return s1.b < s2.b;
}
};
… 並將它的一個臨時例項傳遞給標準排序演算法
std::sort(arr, arr + n_items, Comparator());
函式物件通常會被內聯擴充套件,因此與就地程式碼一樣高效,而透過指標傳遞的函式很少被內聯。lambda 表示式是作為函式物件實現的,因此它們的效能相同。
不要使用 qsort 和 bsearch C 標準庫函式,而是使用 std::sort 和 std::lower_bound C++ 標準庫函式。
前兩個函式需要一個函式指標作為引數,而後兩個函式可以接受一個函式物件引數(或者,使用 C++11 標準,可以接受一個 lambda 表示式)。函式指標通常不會被內聯擴充套件,因此效率低於函式物件,函式物件幾乎總是被內聯的。
封裝(使用類)一個可以從多個編譯單元訪問的集合。
在設計時,很難決定在使用軟體時哪個資料結構的效能最優。在最佳化時,可以測量效能,並可以檢視更改容器型別是否會導致效能提升,例如從 std::vector 更改為 std::list。但是,這種實現更改可能會傳播到程式碼的使用者。
如果一個集合是一個編譯單元的私有集合,那麼實現更改只會影響該單元的原始碼,並且封裝集合是不必要的。但是,如果集合不是私有的(換句話說,它可以直接從其他編譯單元訪問),那麼實現更改可能會導致需要進行大量的更改。因此,為了使這種最佳化變得可行,將集合封裝在一個類中,當容器實現更改時,該類的介面不會更改。
STL 容器已經使用了這個原則,但是某些操作仍然只對某些容器可用(如 operator[],對 std::vector 可用,但對 std::list 不可用)。
使用 STL 容器時,如果多個等效表示式具有相同的效能,請選擇更通用的表示式。
例如,呼叫 a.empty() 而不是 a.size() == 0,呼叫 iter != a.end() 而不是 iter < a.end(),呼叫 distance(iter1, iter2) 而不是 iter2 - iter1。前幾個表示式對每種容器型別都有效,而後幾個表示式只對某些容器型別有效。前者也比後者效率不低,甚至可能更高。例如,要獲取連結串列的大小,必須遍歷連結串列,而要檢視連結串列是否為空是一個常數時間操作。
不幸的是,並不總是能夠編寫對每種容器型別都同樣正確和高效的程式碼。但是,減少依賴於容器型別的語句的數量,將減少如果容器型別稍後更改,必須更改的語句的數量。
選擇可變長度容器時,如果有疑問,請選擇 vector。
對於具有少量元素的資料集,vector 是對任何操作最有效的可變長度容器。
對於較大的集合,其他容器在某些操作中可能效率更高,但是 vector 仍然具有最低的空間開銷(只要沒有多餘的容量)和最大的引用區域性性。
如果您的編譯器允許進行整個程式最佳化和自動行內函數擴充套件,請使用這些選項,並且不要將任何函式宣告為 inline。如果這些編譯器功能不可用,請在標頭檔案中將合適的函式宣告為 inline;合適的函式包含不超過三行程式碼,並且沒有迴圈。
行內函數擴充套件可以避免函式呼叫的開銷。隨著函式引數數量的增加,開銷也會增加。此外,由於內聯程式碼靠近呼叫者程式碼,因此具有更好的區域性性。並且由於編譯器為行內函數生成的中間程式碼與呼叫者程式碼合併,因此編譯器可以更輕鬆地對其進行最佳化。
內聯擴充套件一個很小的函式,例如僅包含簡單賦值或簡單 return 語句的函式,會導致生成的機器程式碼大小減小。
相反,每次內聯包含大量程式碼的函式時,機器程式碼都會被複制,程式的總大小也會增加。增加程式大小也可能降低指令快取的效能,從而增加延遲。
內聯程式碼更難分析。如果一個非行內函數是瓶頸,分析器可以找到它。但是,如果相同的函式在任何呼叫它的位置都內聯了,它的執行時間就會分散在許多函式中,分析器無法檢測到瓶頸。
對於包含大量程式碼的函式,在最佳化期間應僅將效能關鍵的函式宣告為 inline。
要表示內部符號,請使用列舉而不是字串。
例如,不要使用以下程式碼
const char* const directions[] = { "North", "South", "East", "West" };
請使用以下程式碼
enum directions { North, South, East, West };
列舉被實現為一個整數。與整數相比,字串佔用更多空間,並且複製和比較速度更慢。(此外,使用字串而不是整數來表示內部狀態可能會在處理多個區域設定的程式碼中引入字串比較錯誤。)
如果必須將整數值與一組常數值進行比較,請使用 switch 語句,而不是一系列 if 語句。
例如,不要使用以下程式碼
if (a[i] == 1) f();
else if (a[i] == 2) g();
else if (a[i] == 5) h();
編寫以下程式碼
switch (a[i]) {
case 1: f(); break;
case 2: g(); break;
case 5: h(); break;
}
編譯器可能會利用 switch 語句的規律性來應用一些最佳化,特別是如果應用了本節中的“switch 語句的 case 值”指南。
作為 switch 語句 case 的常量,請使用緊湊的序列值,即沒有間隙或只有少量小間隙的序列。
編譯一個 case 值包含整數區間中大多數值的 switch 語句時,最佳化編譯器將生成一個跳轉表,而不是生成一系列 if 語句。該表是一個數組,包含每個 case 的程式碼的起始地址。執行 switch 語句時,該表用於跳轉到與 case 編號關聯的程式碼。
例如,以下 C++ 程式碼
switch (i) {
case 10:
case 13:
func_a();
break;
case 11:
func_b();
break;
}
可能會生成與以下虛擬碼相對應的機器程式碼
// N.B.: This is not C++ code
static address jump_table[] = { case_a, case_b, end, case_a };
unsigned int index = i - 10;
if (index > 3) goto end;
goto jump_table[index];
case_a: func_a(); goto end;
case_b: func_b();
end:
相反,以下 C++ 程式碼
switch (i) {
case 100:
case 130:
func_a();
break;
case 110:
func_b();
break;
}
可能會生成與以下程式碼相對應的機器程式碼
if (i == 100) goto case_a;
if (i == 130) goto case_a;
if (i == 110) goto case_b;
goto end;
case_a: func_a(); goto end;
case_b: func_b();
end:
對於如此少的 case,這兩種情況之間可能沒有太大區別,但隨著 case 數量的增加,前一種程式碼變得更加高效,因為它只執行一次計算的 goto,而不是一系列分支。
在 switch 語句中,請將典型的 case 放在最前面。
如果編譯器不使用跳轉表,則 case 按出現順序進行評估;因此,更頻繁的 case 執行的比較次數更少。
在呼叫引數數量超過暫存器數量的函式的迴圈中,請考慮傳遞一個結構或物件。
例如,不要使用以下程式碼
for (int i = 0; i < 1000; ++i) {
f(i, a1, a2, a3, a4, a5, a6, a7, a8);
}
請考慮編寫以下內容
struct {
int i;
type a1, a2, a3, a4, a5, a6, a7, a8;
} s;
s.a1 = a1; s.a2 = a2; s.a3 = a3; s.a4 = a4;
s.a5 = a5; s.a6 = a6; s.a7 = a7; s.a8 = a8;
for (int i = 0; i < 1000; ++i) {
s.i = i;
f(s);
}
如果所有函式引數都可以直接放入處理器暫存器中,則可以快速傳遞和操作這些引數。如果引數數量超過可用暫存器數量,則無法放入暫存器的那些引數將在每次函式呼叫開始時被推入堆疊,並在呼叫結束時從堆疊中移除。如果傳遞一個結構或物件,則可以使用一個暫存器,並且在初始化結構或物件之後,只需要更新在連續呼叫之間發生更改的結構或物件的部分。
編譯器在用於函式引數的暫存器數量方面有所不同。依賴於任何特定編譯器版本的使用的暫存器數量是不明智的。假設使用 4 個暫存器是合理的。
要搜尋容器中的元素,請使用容器成員函式,而不是 STL 演算法。
如果容器提供了一個重複通用 STL 演算法的成員函式,那麼是因為該成員函式更有效。
例如,要搜尋一個 std::set 物件,您可以使用 std::find 通用演算法,或者 std::set::find 成員函式。前者具有線性複雜度 (O(n)),而後者具有對數複雜度 (O(log(n)))。
要搜尋排序的序列,請使用 std::lower_bound、std::upper_bound、std::equal_range 或 std::binary_search 通用演算法。
鑑於所有引用的演算法都使用對數複雜度 (O(log(n))) 二分搜尋,它們比使用線性複雜度 (O(n)) 順序掃描的 std::find 演算法更快。
在每個類中,將所有不訪問類非static 成員的成員函式宣告為 static。
換句話說,將所有可以宣告為 static 的成員函式都宣告為 static。
這樣,就不會傳遞隱式 this 引數。