優先使用函數物件自定義STL演算法規則

2020-07-16 10:05:23
作為一門物件導向的程式語言,使用 C++ 編寫程式有一個缺點,即隨著程式碼物件導向程度的提高,其執行效率反而會降低。例如,經實驗證明幾乎在所有情況下,直接操作一個 double 型別變數的執行效率,要比操作一個含 double 型別成員屬性的類物件更高。

對於大多數讀者來說,以上所說是很容易想通的,因為它符合我們對高階程式語言的認知。但本節要介紹的內容,一定程式上會打破這個認知。

前面章節中,我們學習了 STL 標準庫中所有的排序演算法,比如 sort()、stable_sort() 以及 nth_element() 等。不知讀者有沒有發現,這些排序演算法都單獨提供了帶有 comp 引數的語法格式,藉助此引數,我們可以自定義排序規則。

以 sort() 排序函數為例,其語法格式有以下 2 種:
//無 comp 引數
void sort (RandomAccessIterator first, RandomAccessIterator last);
//有 comp 引數
void sort (RandomAccessIterator first, RandomAccessIterator last, Compare comp);
顯然僅從使用語法上看,它們唯一的區別在於,第 2 種多了一個 comp 引數。

事實上,對於 STL 標準庫中的每個演算法,只要使用者需要自定義規則,該演算法都會提供有帶 comp 引數的語法格式。

本質上講,comp 引數用於接收使用者自定義的函數,其定義的方式有 2 種,既可以是普通函數,也可以是函數物件。例如:
#include <iostream>     // std::cout
#include <algorithm>    // std::sort
#include <vector>       // std::vector
//以普通函數的方式實現自定義排序規則
inline bool mycomp(int i, int j) {
    return (i < j);
}
//以函數物件的方式實現自定義排序規則
class mycomp2 {
public:
    bool operator() (int i, int j) {
        return (i < j);
    }
};

int main() {
    std::vector<int> myvector{ 32, 71, 12, 45, 26, 80, 53, 33 };
    //呼叫普通函數定義的排序規則
    std::sort(myvector.begin(), myvector.end(), mycomp);
    //呼叫函數物件定義的排序規則
    //std::sort(myvector.begin(), myvector.end(), mycomp2());
   
    for (std::vector<int>::iterator it = myvector.begin(); it != myvector.end(); ++it) {
        std::cout << *it << ' ';
    }
    return 0;
}
程式執行結果為:

12 26 32 33 45 53 71 80

注意,為了提高執行效率,其函數都定義為行內函式(在類內部定義的函數本身就是行內函式)。至於為什麼行內函式比普通函數的執行效率高,可閱讀《C++ inline行內函式》一文。

要知道,函數物件可以理解為偽裝成函數的物件,根據以往的認知,函數物件的執行效率應該不如普通函數。但事實恰恰相反,即便如上面程式那樣,將普通函數定義為更高效的行內函式,其執行效率也無法和函數物件相比。

通過在 4 個不同的 STL 平台上,對包含 100 萬個 double 型別資料的 vector 容器進行排序,最差情況下使用函數物件的執行效率要比普通行內函式高 50%,最好情況下則高 160%。

那麼,是什麼原因導致了它們執行效率上的差異呢?以 mycomp2() 函數物件為例,其 mycomp2::operator() 也是一個行內函式,編譯器在對 sort() 函數進行範例化時會將該函數直接展開,這也就意味著,展開後的 sort() 函數內部不包含任何函數呼叫。

而如果使用 mycomp 作為引數來呼叫 sort() 函數,情形則大不相同。要知道,C++ 並不能真正地將一個函數作為引數傳遞給另一個函數,換句話說,如果我們試圖將一個函數作為引數進行傳遞,編譯器會隱式地將它轉換成一個指向該函數的指標,並將該指標傳遞過去。

也就是說,上面程式中的如下程式碼:
std::sort(myvector.begin(), myvector.end(), mycomp);
並不是真正地將 mycomp 傳遞給 sort() 函數,它傳遞的僅是一個指向 mycomp() 函數的指標。當 sort() 函數被範例化時,編譯器生成的函數宣告如下所示:
std::sort(vector<int>::iterator first,
          vector<int>::iterator last,
          bool (*comp)(int, int));
可以看到,引數 comp 只是一個指向函數的指標,所以 sort() 函數內部每次呼叫 comp 時,編譯器都會通過指標產生一個間接的函數呼叫。
也正是基於這個原因,C++ sort() 函數要比 C 語言 qsort() 函數的執行效率更高。讀者可能會問,程式中 comp() 函數也是行內函式,為什麼 C++ 不像函數物件那樣去處理呢?具體原因我們無從得知,事實上也沒必要關心,也許是編譯器開發者覺得這種優化不值得去做。
除了效率上的優勢之外,相比普通函數,以函數物件的方式自定義規則還有很多隱藏的優勢。例如在某些特殊情況下,以普通函數的形式編寫的程式碼看似非常合理,但就是無法通過編譯,這也許是由於 STL 標準庫的原因,也許是編譯器缺陷所至,甚至兩者都有可能。而使用函數物件的方式,則可以有效避開這些“坑”,而且還大大提升的程式碼的執行效率。

總之,以函數物件的方式為 STL 演算法自定義規則,具有效率在內的諸多優勢。當呼叫帶有 comp 引數的 STL 演算法時,除非呼叫 STL 標準庫自帶的比較函數,否則應優先以函數物件的方式自定義規則。