深入理解C++方法重載、內聯與高級用法
方法重載
你可能已經注意到,你可以在一個類中寫多個構造函數,所有這些構造函數都有相同的名字。這些構造函數只在參數的數量和/或類型上有所不同。你可以對C++中的任何方法或函數做同樣的事情。具體來說,你可以通過為具有不同數量和/或類型的參數的多個函數使用同一個名稱來重載一個函數或方法。例如,在SpreadsheetCell類中,你可以將setString()和setValue()都重命名為set()。類定義現在看起來像這樣:
export class SpreadsheetCell {
public:
void set(double value);
void set(std::string_view value);
// 省略了一些內容以保持簡潔
};
set()方法的實現保持不變。當你編寫代碼調用set()時,編譯器會根據你傳遞的參數來確定調用哪個實例:如果你傳遞一個string_view,編譯器會調用string_view實例;如果你傳遞一個double,編譯器會調用double實例。這被稱為重載解析。
你可能會試圖對getValue()和getString()做同樣的事情:將它們都重命名為get()。然而,這樣做是不行的。C++不允許你僅基于方法的返回類型來重載一個方法名,因為在許多情況下,編譯器無法確定你試圖調用的是哪個方法實例。例如,如果方法的返回值沒有被捕獲在任何地方,編譯器就沒有辦法知道你試圖調用的是哪個方法實例。
基于const的重載
你可以基于const來重載一個方法。也就是說,你可以寫兩個具有相同名稱和相同參數的方法,一個聲明為const,另一個則不是。如果你有一個const對象,編譯器會調用const方法;如果你有一個非const對象,它會調用非const重載。通常,const重載和非const重載的實現是相同的。為了避免代碼重復,你可以使用Scott Meyer的const_cast()模式。
例如,Spreadsheet類有一個名為getCellAt()的方法,返回對非const SpreadsheetCell的引用。你可以添加一個const重載,返回對const SpreadsheetCell的引用,如下所示:
export class Spreadsheet {
public:
SpreadsheetCell& getCellAt(size_t x, size_t y);
const SpreadsheetCell& getCellAt(size_t x, size_t y) const;
// 代碼省
Scott Meyer的const_cast()模式將const重載實現為你通常會做的那樣,并通過適當的轉換將非const重載的調用轉發給const重載,如下所示:
const SpreadsheetCell& Spreadsheet::getCellAt(size_t x, size_t y) const {
verifyCoordinate(x, y);
return m_cells[x][y];
}
SpreadsheetCell& Spreadsheet::getCellAt(size_t x, size_t y) {
return const_cast<SpreadsheetCell&>(as_const(*this).getCellAt(x, y));
}
基本上,你首先使用std::as_const()(定義在<utility>中)將*this(一個Spreadsheet&)轉換為const Spreadsheet&。接下來,你調用getCellAt()的const重載,它返回一個const SpreadsheetCell&。然后你用const_cast()將這個轉換為非const SpreadsheetCell&。
有了這兩個getCellAt()的重載,你現在可以在const和非const Spreadsheet對象上調用getCellAt():
Spreadsheet sheet1 { 5, 6 };
SpreadsheetCell& cell1 { sheet1.getCellAt(1, 1) };
const Spreadsheet sheet2 { 5, 6 };
const SpreadsheetCell& cell2 { sheet2.getCellAt(1, 1) };
在這種情況下,const重載的getCellAt()并沒有做太多的事情,所以你通過使用const_cast()模式并沒有贏得太多。然而,想象一下,如果const重載的getCellAt()做了更多的工作;那么將非const重載轉發給const重載可以避免重復那些代碼。
顯式刪除重載
重載的方法可以被顯式刪除,這使你能夠禁止使用特定參數調用某個方法。例如,SpreadsheetCell類有一個setValue(double)方法,可以這樣調用:
SpreadsheetCell cell;
cell.setValue(1.23);
cell.setValue(123);
對于第三行,編譯器將整數值(123)轉換為double,然后調用setValue(double)。如果由于某種原因,你不希望setValue()使用整數調用,你可以顯式刪除setValue()的整數重載:
export class SpreadsheetCell {
public:
void setValue(double value);
void setValue(int) = delete;
};
有了這個改變,嘗試使用整數調用setValue()的操作將被編譯器標記為錯誤。
Ref-Qualified方法
普通類方法可以在非臨時和臨時類實例上調用。假設你有以下類:
class TextHolder {
public:
TextHolder(string text) : m_text { move(text) } {}
const string& getText() const { return m_text; }
private:
string m_text;
};
當然,毫無疑問,你可以在非臨時實例的TextHolder上調用getText()方法。這里有一個例子:
TextHolder textHolder { "Hello world!" };
cout << textHolder.getText() << endl;
然而,getText()也可以在臨時實例上調用:
cout << TextHolder{ "Hello world!" }.getText() << endl;
cout << move(textHolder).getText() << endl;
你可以通過添加所謂的ref-qualifier來明確指定可以在哪種類型的實例上調用某個方法,無論是臨時的還是非臨時的。如果一個方法只應該在非臨時實例上調用,在方法頭后加上&限定符。類似地,如果一個方法只應該在臨時實例上調用,在方法頭后加上&&限定符。
下面修改后的TextHolder類實現了帶有&限定符的getText(),通過返回對m_text的引用。而帶有&&限定符的getText()返回m_text的右值引用,這樣m_text就可以從TextHolder中移動出來。如果你想從臨時TextHolder實例中檢索文本,這可能會更有效率。
class TextHolder {
public:
TextHolder(string text) : m_text { move(text) } {}
const string& getText() const & { return m_text; }
string&& getText() && { return move(m_text); }
private:
string m_text;
};
假設你有以下調用:
TextHolder textHolder { "Hello world!" };
cout << textHolder.getText() << endl;
cout << TextHolder{ "Hello world!" }.getText() << endl;
cout << move(textHolder).getText() << endl;
那么第一次調用getText()會調用帶有&限定符的重載,而第二次和第三次調用則會調用帶有&&限定符的重載。
內聯方法
C++允許你建議調用一個方法(或函數)時,不應該在生成的代碼中實際實現為調用一個單獨的代碼塊。相反,編譯器應該將方法的主體直接插入到調用該方法的代碼中。這個過程被稱為內聯,希望這種行為的方法被稱為內聯方法。
你可以通過在方法定義中的名字前放置inline關鍵字來指定一個內聯方法。例如,你可能想讓SpreadsheetCell類的訪問器方法成為內聯的,這種情況下,你會這樣定義它們:
inline double SpreadsheetCell::getValue() const {
m_numAccesses++;
return m_value;
}
inline std::string SpreadsheetCell::getString() const {
m_numAccesses++;
return doubleToString(m_value);
}
這向編譯器提供了一個提示,用實際的方法體替換對getValue()和getString()的調用,而不是生成代碼來進行函數調用。請注意,inline關鍵字只是一個提示給編譯器。如果編譯器認為這會影響性能,它可以忽略它。
有一個注意事項:內聯方法(和函數)的定義必須在每個調用它們的源文件中都可用。如果你想一下,這是有道理的:如果編譯器看不到方法定義,它怎么能代替方法的主體呢?因此,如果你編寫內聯方法,你應該將這些方法的定義放在類定義所在的同一個文件中。
注意,高級C++編譯器不要求你將內聯方法的定義放在類定義的同一個文件中。例如,Microsoft Visual C++支持鏈接時代碼生成(LTCG),它會自動內聯小的函數體,即使它們沒有被聲明為inline,即使它們沒有定義在類定義的同一個文件中。GCC和Clang也有類似的功能。
在C++20模塊之外,如果一個方法的定義直接放在類定義中,即使沒有使用inline關鍵字,該方法也隱式地被標記為內聯。使用C++20中從模塊導出的類時,情況并非如此。如果你希望這些方法是內聯的,你需要用inline關鍵字標記它們。這里有一個例子:
export class SpreadsheetCell {
public:
inline double getValue() const {
m_numAccesses++;
return m_value;
}
inline std::string getString() const {
m_numAccesses++;
return doubleToString(m_value);
}
// 省略了一些內容以保持簡潔
}
注意,如果你在調試器中單步執行一個被內聯的函數調用,一些高級C++調試器會跳轉到內聯函數的實際源代碼,給你造成了函數調用的假象,而實際上代碼是內聯的。許多C++程序員在不理解將一個方法標記為內聯的后果時,就使用了內聯方法語法。將一個方法或函數標記為內聯只是給編譯器一個提示。編譯器只會內聯最簡單的方法和函數。如果你定義了一個編譯器不想內聯的內聯方法,它會默默地忽略這個提示。現代編譯器會在決定內聯一個方法或函數之前,考慮諸如代碼膨脹等指標,并且不會內聯任何不劃算的東西。
默認參數
在C++中,與方法重載類似的功能是默認參數。你可以在原型中為函數和方法參數指定默認值。如果用戶為這些參數提供了參數,那么默認值將被忽略。如果用戶省略了這些參數,將使用默認值。不過,有一個限制:你只能為從最右邊的參數開始的連續參數列表提供默認值。否則,編譯器將無法將缺失的參數與默認參數匹配。默認參數可用于函數、方法和構造函數。例如,你可以為Spreadsheet構造函數中的寬度和高度分配默認值,如下所示:
export class Spreadsheet {
public:
Spreadsheet(size_t width = 100, size_t height = 100);
// 省略了一些內容以保持簡潔
};
Spreadsheet構造函數的實現保持不變。請注意,你只在方法聲明中指定默認參數,而不是在定義中指定。現在,盡管只有一個非復制構造函數,你仍然可以使用零個、一個或兩個參數調用Spreadsheet構造函數:
Spreadsheet s1;
Spreadsheet s2 { 5 };
Spreadsheet s3 { 5, 6 };
一個為所有參數提供默認值的構造函數可以作為默認構造函數。也就是說,你可以在不指定任何參數的情況下構造該類的對象。如果你嘗試同時聲明一個默認構造函數和一個為所有參數提供默認值的多參數構造函數,編譯器會報錯,因為如果你不指定任何參數,它不知道該調用哪個構造函數。