學懂現代C++——《Effective Modern C++》之型別推導和auto

吳尼瑪發表於2023-04-10

前言

之前分享過Scott Meyers的兩本書《Effective C++》和《More Effective C++》。這兩本書對我深入學習C++有著很大的幫助,建議所有想進階C++技術的同學都可以看看。但是,這兩本書是大神Scott在C++11之前出的,而C++11對於C++社群來說是一次重大的變革,被稱為現代C++,用以區分C++11之前的傳統C++。

好在Scott在之後也帶來了全新的《Effective Modern C++》,其中也是延續了作者一貫的風格和質量。帶來了42個獨家技巧,助你改善C++11和C++14的高效用法(封面語)。

本文首先就帶同學們一起看看這本書的前兩章——型別推導和auto。

首先申明本文只是做知識點的總結,書中有更詳細的推導和講解過程,感興趣的同學還是強烈建議大家去讀原書。

型別推導

條款1:理解模板型別推導

模板型別推導是C++11應用最廣泛的特性之一——auto的基礎。所以,理解auto的推導規則和正確使用方式的基礎就是理解模板型別推導的規則。

先來看看模板和其呼叫的一般形式。

template<typename T>
void f(ParamType param);

f(expr);

這裡需要T的型別推導結果,依賴於expr的型別和ParamType的形式。其中,ParamType的形式需要分三種情況討論。

  • 情況1:ParamType是個指標或者引用,但不是萬能引用(形式如T&&)。

在這種情況下,模板型別推導具有以下規則:

  1. expr的引用屬性會被忽略。
  2. 忽略expr的引用性後,expr的型別和ParamType的型別進行模式匹配,由此決定T的型別。

舉個例子:

// 宣告模板
template<typename T>
void f(T& param);

// 宣告變數
int a = 1;
const int ca = a;
const int& cra = a;

//呼叫模板
f(a);   //a的型別是int,T的型別是int,param的型別是int&。
f(ca);  //ca的型別是const int,T的型別是const int,param的型別是const int&。
f(cra); //cra的型別是const int&,T的型別是const int,param的型別是const int&。
要點1:在模板型別推導過程中,具有引用型別的實參會被當成非引用型別來處理。
  • 情況2:ParamType是個萬能引用。

在這種情況下,模板型別推導規則如下:

  1. 如果expr是個左值,則T和ParamType都會被推到為左值引用。
  2. 如果expr是個右值,則和情況1中的推導規則相同。

舉個同情況1類似的例子:

// 宣告模板
template<typename T>
void f(T&& param);

// 宣告變數
int a = 1;
const int ca = a;
const int& cra = a;

//呼叫模板
f(a);   //a是左值,型別是int,T的型別是int&,param的型別是int&。
f(ca);  //ca是左值,型別是const int,T的型別是const int&,param的型別是const int&。
f(cra); //cra是左值,型別是const int&,T的型別是const int&,param的型別是const int&。
f(1);   //1是右值,型別是int,T的型別是int,param的型別是int&&。
要點2:對萬能引用形參進行推導時,左值實參會進行特殊處理。
  • 情況3:ParamType即不是指標也不是引用。

這種情況就是按值傳遞,其目標推導規則如下:

  1. expr的引用屬性會被忽略。
  2. 忽略expr的引用性後,如果expr還具有const或volatile屬性,也會被忽略。

還是看一下例子:

// 宣告模板
template<typename T>
void f(T param);

// 宣告變數
int a = 1;
const int ca = a;
const int& cra = a;

//呼叫模板
f(a);   //a的型別是int,T和param的型別都是int。
f(ca);  //ca的型別是const int,T和param的型別都是int。
f(cra); //cra的型別是const int&,T和param的型別都是int。
要點3:對按值傳遞的形參進行推導時,實參中的const或volatile屬性,也會被忽略。
  • 有一個特殊情況需要注意的就是陣列或函式做模板的實參的情況。
要點4:陣列或函式型別的實參在模板推導過程中會退化為對應的指標。除非形參param是按引用傳遞的,這時就會被推導為陣列或函式的引用。

條款2:理解auto型別推導

如果你已經熟練掌握了前面模板型別推導的規則,那麼恭喜你也基本掌握了auto型別的推導了。因為除了一個特殊情況外,auto型別推導和模板型別推導的規則是一樣的。

先看和模板型別推導一樣規則的示例:

auto a = 1;         //a的型別是int
const auto ca = a;  //ca的型別是const int
const auto& cra = a;//cra的型別是const int&
auto&& ra1 = a;     //ra1的型別是int&
auto&& ra2 = ca;    //ra2的型別是const int&
auto&& ra3 = 1;     //ra3的型別是int&&

唯一的特殊情況就是在使用了C++11引入的統一初始化——大括號初始化表示式時。如果向模板傳入一個大括號初始化表示式,則無法編譯透過。而auto會將其推導為std::initializer\_list。

舉例如下:

// 宣告模板
template<typename T>
void f(T param);

f({1, 2, 3}); //無法編譯透過

auto a = {1, 2, 3}; //a的型別是std::initializer_list<int>

另外,還有一個要注意的點是:在C++14中可以在函式的返回值或lambda表示式的形參中使用auto,但這裡的auto使用的是模板型別推導,而不是auto型別推導。所以如果在這種情況下使用大括號初始化表示式也是無法編譯透過的。

條款3:理解decltype

要點1:在絕大多數情況下,decltype會返回變數或表示式確切的型別。

在C++11中,decltype的主要用途就在於宣告那些返回值型別依賴於形參型別的函式模板。舉個例子,我們寫一個模板函式f,其形參是一個支援方括號下標語法(即“[]”)的容器和一個下標,並在返回下標操作結果前進行合法性驗證。函式的返回值型別需要與下標操作結果的返回值型別相同。其最終實現如下:

//C++11版
template<typename Container, typename Index>
auto f(Container&& c, Index i) -> decltype(std::forward<Container>(c)[i]) {
    checkInvalid();// 合法性驗證
    return std::forward<Container>(c)[i];
}

//C++14版
template<typename Container, typename Index>
decltype(auto) f(Container&& c, Index i) {
    checkInvalid();// 合法性驗證
    return std::forward<Container>(c)[i];
}

//使用
auto str = f(makeStringDeque(), 5);//其中makeStringDeque是一個返回std::deque<std::string>的工廠函式

條款4:掌握檢視型別推導結果的方法

要點1:利用IDE編輯器、編譯器錯誤資訊和Boost.TypeIndex庫常常能夠檢視到推導得到的型別。

要點2:有些工具得出的結果可能無用或者不準確。所以,理解C++型別推導規則是必要的。

auto

條款5:優先選用auto,而非顯式型別宣告

C++11新增的auto,最大的作用就是讓我們遠離了那些囉嗦的顯示型別宣告。比如我用std::function定義一個如下的函式:

std::function<bool(const std::unique_ptr<Widget>&, 
                    const std::unique_ptr<Widget>&)> 
    derefUPLess = [](const std::unique_ptr<Widget>& p1, 
                    const std::unique_ptr<Widget>& p2)
                    { return *p1 < * p2; };

可以看到這個定義寫起來真是囉嗦,且還容易一不小心寫錯。如果我們用auto來定義則可以寫成:

auto derefUPLess = [](const std::unique_ptr<Widget>& p1, 
                    const std::unique_ptr<Widget>& p2)
                    { return *p1 < * p2; };

更有甚者,在C++14中auto可以使用在lambda表示式的形參中,於是我們可以得到一個可以應用於任何類似指標之物指涉到的值的比較函式,如下:

auto derefLess = [](const auto& p1, 
                    const auto& p2)
                    { return *p1 < * p2; };

而且,通常情況下,std::function物件一般都會比使用auto生命的變數使用更多的記憶體。所以,在能使用auto的情況下,我們通常都應該選擇使用auto。

要點1:auto變數必須初始化,基本上對會導致相容性和效率問題的型別不匹配現象免疫,還可以簡化重構流程,通常也比顯式指定型別要少打一些字。

條款6:當auto推導的型別不符合要求時,使用帶顯式型別的初始化物習慣用法

在條款2中,我們已經見識了auto推導的一個特殊情況。同樣,還存在著一個普遍的規律是,“隱形”代理類和auto無法和平共處。

我們需要先認識一下“隱形”代理類。所謂代理類,就是指為了模擬或增強其他型別的類。代理類在C++中很常見,比如標準庫中的智慧指標就是將資源管理嫁接到裸指標上。舉個例子,通常我們會認為std::vector<T>的operator[]會返回T&。但實際上,std::vector<bool>的operator[]的返回值型別是std::vector<bool>::reference,它就是一個“隱形”的代理類。請看以下程式碼:

std::vector<bool> features(const Widget& w);// 返回的vector種每一個bool值都代表著Widget是否提供一個特定的功能

Widget w;

auto highPriority = feature(w)[1];//第一個feature表示這個Widget是否具有高優先順序

processWidget(w, highPriority);//未定義行為!!!

為什麼最後一句是一個未定義行為呢?因為這裡auto推導的型別是std::vector<bool>::reference。而這個代理類可能會導致highPriority含有一個空懸的指標(具體原因設計標準庫的實現,細節請看書)。

要點1:“隱形”的代理型別可以導致auto根據初始化表示式推匯出“錯誤的”型別。

解決這個問題的方法就是強制進行一次型別轉換。我們將上面出問題的語句替換為以下語句,就可以解決這個未定義行為:

auto highPriority = static_cast<bool>(feature(w)[1]);
要點2:auto推匯出“錯誤的”型別時可以進行強制型別轉換,讓auto強制推匯出你想要的型別。

相關文章