C語言是一個簡單的語言。使用者針對每一個函式,只能設定一個唯一的函式簽名。但是C++而言,就給了我們很多的靈活性:
- 你可以將多個函式設定為相同的名字(overloading)
- 你可以使用內建操作符過載(built-in operators),例如
+
以及==
- 你可以使用函式模版(function templates)
- 你也可以使用名稱空間(namespaces)避免命名衝突
針對C++提供的這些特性,你可以實現str1 + str2
返回兩個字串的拼接;同樣,你也可以在一對2D點操作的基礎上,實現3D點對的操作,過載dot(a, b)
來處理不同的型別。你可以寫一堆的陣列類,並且實現一個sort
函式模版,在所有的類上都適用。
但是,在我們充分利用這些特性時,往往很容易將事情推向錯誤的一面。在某些情況下,編譯器在接受我們的程式碼時,會給出如下的報錯:
error C2666: 'String::operator ==': 2 overloads have similar conversions
note: could be 'bool String::operator ==(const String &) const'
note: or 'built-in C++ operator==(const char *, const char *)'
note: while trying to match the argument list '(const String, const char *)'
和很多C++程式設計師一樣,我也經常苦惱與這樣的問題。每次出現這樣的報錯,我總是大腦一片空白,在網上查詢更好的理解,然後修改程式碼知道程式可以執行。在最近的一些專案開發中,我再次被這樣的問題阻擾;它變得和我認知中這類問題的理解完全對立,我因此意識到我對於這類問題的理解還不夠充分,仍有缺失。
幸運的是,如今是2021年了,網路資訊如此發達;在此,我尤其感謝cppreference.com,如今我知道我對於此類問題缺失的理解:一個隱藏演算法的清晰全貌,用來在編譯時的每一次函式呼叫。
這也是給定編譯器,一個函式呼叫表示式,準確計算出哪一個函式被呼叫:
上圖這些步驟隱藏在C++標準的背後,每一個C++編譯器都要遵循這些規則,並且這一系列的函式呼叫所涉及的程式表示式計算,都發生在編譯時。這也是C++能夠支援上面種種特性的原因。
我個人猜想,上圖整個演算法的意圖就是----實施程式設計師所希望的操作,並且在某種程度上,它是成功的。作為程式設計師,在大部分時間和開發場景上,是完全可以忽略這些背後的演算法;但是,如果涉及開發一個庫,你最好了解這些規則。
所以,讓我們從入門到放棄(開玩笑)的瞭解這些背後的演算法機制,對於很多有經驗的C++程式設計師,本文聊到的內容都是相當熟悉的東西。此外,我也希望拋磚引玉,給大家帶來一些新穎的C++子話題,例如:引數獨立查詢和SFINAE,但是我們不會特別的深入探討這些字話題。因此,本文的定位,是給大家帶來C++函式呼叫在編譯時的一些列操作策略。
命名查詢
我們的旅途始於一個函式呼叫表示式,例如,採用這個表示式blast(ast, 100)
,這個表示式很明顯是呼叫一個函式叫做blast
,但是實際是哪一個呢?
namespace galaxy {
struct Asteroid {
float radius = 12;
};
void blast(Asteroid* ast, float force);
}
struct Target {
galaxy::Asteroid* ast;
Target(galaxy::Asteroid* ast) : ast{ast} {}
operator galaxy::Asteroid*() const { return ast; }
};
bool blast(Target target);
template <typename T> void blast(T* obj, float force);
void play(galaxy::Asteroid* ast) {
blast(ast, 100);
}
回答這個問題的第一步叫做:命名查詢。在這一步,編譯器在編譯當下此時此刻,查詢出所有具有所給定查詢名稱的函式、函式模版和其他可被引用的識別符號,如下圖。
如上述流圖所示,有三個主要被查詢的名稱型別,每一個都有各自的規則。
- 成員名稱查詢:發生在使用
.
或者->
的情況下,例如:foo->bar
。這一類的查詢發生在類內區域性成員中。 - 有修飾的名稱查詢:發生在一個名稱有
::
符號進行修飾,例如:std::sort
。這一類的名稱是確定的,只要到::
符號的左邊範圍內去查詢右邊的名稱成員。 - 無修飾的名稱查詢:當編譯器看到一個沒有修飾的名稱,例如:
blast
。編譯器在此時的上下文中查詢各種範圍內的匹配名稱,具體也有詳細的查詢規則。
我們的例子中,給出一個沒有修飾的名稱。那麼,當運算一個函式呼叫表示式,從而查詢一個名稱操作時,編譯器就可能找到多個宣告,我們把這些多個宣告都叫做候選。上述事例中,編譯器可以找到三個候選:
上圖中,圈出的第一個候選需要額外關注,因為它表明一個簡單的C++特性,也就是:引數依賴查詢----ADL。正常情況下,你不希望這個函式作為候選,因為它是宣告在galaxy
名稱空間,而實際所呼叫的函式來自於galaxy
外部的名稱空間。並且,程式中沒有``using namespace galaxy```指令使得此名稱空間內的函式可見,所以,為什麼這樣的候選成立?
原因就是, 任何時刻,當你使用一個沒有修飾符的名稱在一個函式呼叫過程中,並且這個名稱不是引用一個類成員,此時ADL引入,可以更加廣泛的查詢符合的候選。特別的,在一般使用情況下,編譯器會在引數型別的名稱空間中查詢合適的候選函式,也就符合“引數依賴查詢”的意思。
完整的ADL規則,有著更加詳細的差異描述,但是,可以確定的是,ADL只適用於無修飾的名稱。對於有修飾的名稱,也就是在單個範圍內查詢,那麼使用ADL規則是沒有意義的。ADL同樣適用於過載內建操作符,例如:+
和==
。有趣的是,很多情況下,成員名稱查詢可以找很多未修飾的名稱候選,詳細看這篇博文。
函式模版的特殊具柄
通過名稱查詢的一些候選是函式,另外則是函式模版。對於函式模版存在一個問題:我們無法呼叫它們,我們只能呼叫函式。因此,在名稱查詢後,編譯器遍歷每一個候選,並試圖將每一個函式模版轉為函式。
上面,我們給出的示例中,存在一個候選就是函式模版:
template <typename T> void blast(T* obj, float force);
這個函式模版僅有一個模版引數T
,因此,當呼叫者blast(ast, 100)
沒有指定任何模版引數,但是,編譯器又必須將函式模版轉為函式,所以需要搞清楚型別T
,這也就是模版引數推理。在這一步,編譯器將由呼叫者傳入的函式引數和函式模版所期望的函式引數所對比;如果任何未指定的模版引數被引用了,例如:T
,那麼,編譯器就嘗試使用左邊的資訊去推理它。
上圖中,編譯器將T
推理為galaxy::Asteroid
,因為只有這樣做,可以將多一個函式引數T*
匹配到引數ast
。模版引數推理規則總結,詳細的介紹了相關內容。但是,在一些情況下,如果模版推理不能有效進行,也就是編譯器找不到合適的模版引數匹配到呼叫者引數型別,那麼函式模版就被從候選者列表中移除。
在候選列表中的所有函式模版生命週期到這一步結束:模版引數替換。在這一步,編譯器接受函式模版宣告,並用對應的模版引數替換掉每個模版引數出現的地方。在我們的例子中,模版引數T
被它所推理的galaxy::Asteroid
型別替換掉,如果這一步成功實施,我們最終可以獲得能夠被呼叫的函式簽名----而不在是函式模版。
當然,存在一些情況下,模版引數替換失敗。假設下面的情況,相同的函式模版接受一個第三個引數:
template <typename T> void blast(T* obj, float force, typename T::units mass=5000);
那麼,編譯器會使用galaxy::Asteroid
來替換T::units
中的T
。而結果表示式就是,galaxy::Asteroid::Units
,這樣是無效的,因為galaxy::Asteroid
沒有一個叫做Units
的成員,因此,此次的模版引數替換失敗。
當模版引數替換失敗,那麼函式模版就被移除出候選列表;在C++歷史中的某些時刻,人們意識到這樣的特性是可以挖掘利用的,這樣的發現導致了整個模版超程式設計技術的出現,常被稱作SFINAE(substitution failure is not an error)。
過載解析
在這個階段,通過名稱查詢的所有函式模版都已經消失,我們獲得了一組乾淨、漂亮的候選函式,它們也同樣被稱為過載組。下面就是我們例子中的過載組:
接下來兩步就是縮小這個集合,通過決定哪個候選函式可以保留,也就是說,哪一個候選函式可以處理函式呼叫。
或許,最顯而易見的要求就是,引數必須匹配;這也就是說,一個保留函式,應該能夠接受呼叫者的引數。如果,呼叫者的引數型別和實際的函式引數型別不匹配,它至少是可以實現隱式轉換每個引數到與之要對應的引數型別。讓我們看看給出的例子中的候選函式,是否有與之匹配的引數:
候選1
呼叫者第一個引數型別是galaxy::Asteroid*
,可以實現完全匹配。呼叫者第二個引數型別是int
,可以通過隱式轉化實現到float
型別的轉化,因此,候選1的引數是匹配的。
候選2
呼叫者第一個引數型別是galaxy::Asteroid*
,可以隱式的轉為函式引數型別Taeget
,因為Target
有一個轉化建構函式,可以接受galaxy::Asteroid*
型別的引數。然而,呼叫者傳入了兩個引數,候選2只可以接受一個引數,可以候選2被剔除。
候選3
候選3的引數型別等同於候選1,所以,它也是匹配的。
最後的決策
在這一步,我們的例子只剩下最後兩個保留的函式:
實際上,如果上圖兩個候選中,任意一個被保留下來,那麼它就是最後執行函式呼叫的具柄。但是由於最後還有兩個候選,編譯器必須在多個候選中進一步進行操作:它必須決定哪一個是更好的候選函式。為了成為最好的候選函式,它們當中必須有一個更加的匹配,這就是由決勝者規則序列決定的:
下面給出三條決勝者規則:
首要決勝者:引數最匹配者勝出
C++強調了呼叫者引數型別和函式引數型別匹配程度的重要性,寬泛來說,編譯器傾向選擇函式需要較少隱式型別轉換的的候選函式。這條規則決定了我們在使用std::vector
中的const
和non-const
版本的選擇。
在我們的例子中,由於兩個候選函式的引數型別都一致,所以,第一條規則都滿足。
第二決勝者:非模版引數勝出
如果第一條規則沒有決出勝負,那麼C++傾向於呼叫非模版函式。在我們的例子中,由於候選1是非模版函式,而候選2是模版函式,因此,我們的最優函式就是:
void galaxy::blast(galaxy::Asteroid* ast, float force)
值得重申的是,如果有一個模版函式在引數型別上更加匹配,那麼該模版函式勝出,也就是說,決勝者規則的優先順序是按順序遞降的。
第三決勝者:更加特定的模版勝出
我們的例子中,最優的候選函式已經獲得,但是如果沒有得的話,那麼我們就要參考第三決勝者規則。這條規則中,C++傾向於呼叫“更加特定”的模版函式,例如,考慮下面兩個函式模版:
template <typename T> void blast(T obj, float force);
template <typename T> void blast(T* obj, float force);
在進行模版引數推理步驟時,第一個函式模版接受任意型別作為它的第一個引數,而第二個函式模版僅僅接受指標型別。因此,第二個函式模版被稱為更加特定。因此,編譯器傾向於選擇第二個候選函式作為最優函式。
函式呼叫解析之後
在此時,編譯器已經準確知道了哪一個函式應該作為表示式blast(ast, 100)
的控制程式碼了。在許多例子中,雖然,編譯器在解析函式呼叫後還有很多工作要完成:
- 如果被呼叫的函式是一個類成員,編譯器必須檢查類成員的訪問修飾符,來判斷是否有權訪問呼叫者。
- 如果被呼叫函式是一個模版函式,編譯器必須例項化這個模版函式。
- 如果被呼叫的函式是虛擬函式,編譯器需要生成特殊的機器指令,實現在執行時保證過載準確呼叫。