Effective Modern C++ 條款5 儘量用auto代替顯式類型聲明

用auto代替顯示類型聲明

先開個小玩笑:
int x;
該死的,我忘記初始化x 了,那麼它的值是未定義的。它可能會被編譯器初始化爲0,這取決於它的聲明位置。

我們再看看另一個小玩笑,它用一個迭代器解引用得到的結構去初始化一個局部變量:

template <typename It>
void dwim(It b, It e)
{
    while (b != e) {
      typename std::iterator_traits<It>::value_type currValue
                                      = *b;
      ...
    }
}

額,"typename std::iterator_traits<It>::value_type"這個表達式可以得到指針或者迭代器所指對象的類型?真的是這樣子的嗎?我都忘記了,該死!

好吧。最後一個小玩笑,如果我們能用一個閉包類型聲明變量會有多麼愉悅啊。哦噢,閉包類型只有編譯器知道,我們根本寫不了那種類型。該死!

該死,該死,該死。用C++編程根本就不是一件開心的事情- -。


好吧,過去C++編程的確不是,但是,在C++11中,所有這些問題都被一掃而空,因爲autoauto聲明的變量通過初始值來推斷類型,所以它們必須是初始化。這意味着你可以告別未初始化變量而產生的問題了:

int x;    // 可能未初始化

auto x2; // 報錯,要求初始化

auto x3 = 0;   // 很好,x3已定義

就算用auto聲明迭代器解引用,也可以工作得很好:

template <typename It>
void dwim(It b, It e)   // 效果如前
{
    while (b != e) {
      auto currValue = *b;
      ...
    }
}

然後因爲auto用的是類型推斷(見條款2),所以它也可以用來表示只有編譯器知道的類型:

auto derefUPLess =      // 比較unique_pt指向的Widget
  [](const std::unique_ptr<Widget> &p1, 
     const std::unique_ptr<Widget> &p2) 
  {  return *p1 < *p2; };

這太爽了。在C++14中,更爽,因爲lambda表達式中的參數可以使用auto

auto derefLess =      // 比較具有指針行爲的變量指向的值
  [](const auto &p1, const auto &p2)
  { return *p1 < *p2; };

儘管這個很爽,但是你可能認爲我們並不需要auto來聲明一個閉包,因爲我們可以使用std::function對象。雖然那也是可以的,但是它的行爲並不是你想的那樣的。不過現在你更可能在想什麼是std::function對象,現在我們理清它。

std::function對象是C++11的標準庫的一個模板,它的產生源於函數指針。不過函數指針只可以指向函數,而std::function可以用於所有可調用對象。就像你聲明函數指針一樣說明函數的類型,std::function對象也要求說明你想使用的那個可調用對象類型。你需要實例化std::function來指定類型。例如,你想要聲明一個名字爲funcstd::function對象,它適用於類似以下簽名的可調用對象:
bool(const std::unique_str<Widget> &, const std::unique_str<Widget> &)
那麼你可以這樣:

std::function<bool(const std::unique_str<Widget> &,
                   const std::unique_str<Widget> &)> func; 

因爲lambda表達式是一個可調用對象,所以閉包可以存儲在std::function對象中。這意味着在C++11中,我們可以聲明一個不用auto版本的derefUPLess函數:

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

就算我們可以用類型別名來解決語法上的囉嗦,但是使用std::function對象和使用auto是不一樣的。一個auto類型推斷的變量持有閉包並且它的類型就是閉包的類型,所以它佔用的內存只是閉包需要的內存。而std::function聲明的變量通過實例化模板而持有閉包,那麼它的大小是固定的因爲一些指定的簽名(has a fixed size for any given signature)。閉包請求的內存大小可能大於std::function指定的內存大小,在這種情況下,std::function的構造函數會請求堆內存來存儲閉包。這樣的結果就是,std::function對象使用的內存總是比auto類型推斷的對象多。而且,std::function由於其實現細節而限制了內聯,並且是間接函數調用,所以幾乎可以肯定的是,通過std::function對象調用閉包會比auto類型推斷的對象中調用要慢。總的來說,std::function方法比起auto方法,消耗內存更大,速度更慢,而且std::function方法可能產生out-of-memory異常(從而請求堆內存?)。還有,你看上面的代碼,相比於std::function實例化,用auto實現可以少寫很多代碼。在std::functionauto的對比中,auto更好。


auto的好處除了可以避免爲初始化變量、冗長的類型聲明、有能力直接持有閉包之外,還有一個就是避免類型截斷這個問題,下面這段代碼你很可能見過,甚至寫過:

std::vector<int> v;
...
unsigned sz = v.size();

v.size()返回的真正類型是std::vector<int>::size_type,但是很少開發者知道。std::vector<int>::size_type被說明爲一個無符號整型數,所以很多開發者認爲unsigned合適然後寫下上面的代碼。這段代碼可能引起有趣的後果。例如在32位的Window系統中,unsignedstd::vector<int>::size_type兩個類型的位數相同,但是在64位Window系統中,unsigned是32位,而std::vector<int>::size_type是64位,這意味着在32位Window中可以正常工作,在64爲Window中結果可能不正確,而當你把應用程序從32位系統部署到64位系統時,誰想花時間修改這樣的bug?

使用auto確保不會發生上面的問題:
auto sz = v.size(); // sz的類型是std::vector<int>::size_type

如果你仍然不覺得使用auto是明智的,考慮下面的代碼:

std::unordered_map<std::string,int> m;
...
for (const std::pair<std::string, int> &p : m)
{
    ...
}

這看起來很完美,但是有個問題,你發現了嗎?

想要知道哪裏錯了,這要求記住std::unordered_mapkey部分的類型是const修飾的,所以哈希表(std::unordered_map)中的std::pair類型不是std::pair<std::string,int>,而是std::pair<const std::string, int>。所以上面的代碼是有問題的,而導致的結果是,編譯器會把std::pair<const std::string,int>對象強制轉換爲std::pair<std::string,int>對象(也就是p聲明的類型)。因此編譯器爲m 哈希表中每一個元素的拷貝生成一個臨時對象(key爲非const的pair類型),然後p 就引用了那些臨時對象。當循環結束,那些臨時對象被析構。如果你寫了這段代碼,你會對這個行爲感到驚訝,因爲你毫無疑問地想要用p 引用綁定m 中的每一個元素。

ps:本人在gcc環境下測試上面的代碼無法通過編譯

這種無心的類型錯誤總是可以用auto避免:

for (const auto &p : m)
{
    ...
}

這不僅提高效率,而且更容易類型正確。而且,這代碼中如果你取p 的地址,有個很吸引人的特點,你取得的地址肯定是指向m 中的元素,而如果代碼中不用auto,你取得的地址指向的是一個臨時對象,在循環結束後析構。

ps:本人在gcc環境下測試,就算不用auto,指針也是指向m中的元素,並非臨時變量。

回顧最後的兩個例子,當我們應該寫std::vector<int>::size_type類型時寫了unsigned類型和當我們應該寫std::pair<const std::string, int>類型時寫了std::pair<std::string,int>類型,都展示了顯示類型聲明可能會導致你想不到的隱式類型轉換。如果你用auto作爲類型用於目標變量,你就不需要擔心你的用於初始化的變量類型與聲明的變量類型不匹配。


這就是建議儘量用auto代替顯式類型聲明的原因。但是,auto並不是完美的,每個auto變量的類型都是通過初始值或者初始化表達式來推斷的,但有些是初始化表達式的類型是你意想不到的。這些例子在條款2和條款6中可以看到,所以這裏就不寫了。與之替代的是,我把注意力放在另一個概念上,它關於用auto代替顯示類型聲明,這就是源代碼的可讀性。

首先,深呼吸一口氣,慢慢放鬆。auto只是一個選擇,而不是強迫你使用它。如果基於你的專業判斷,你的代碼使用顯示類型聲明會更簡潔和更具有維護性,那麼你當然可以繼續使用顯示類型聲明。但是需要記住C++並沒有開墾新特性,而是採用其他編程語言具有的類型推理(type inference),其他靜態類型命令式語言(如C#,D,Scalar,VB等)或多或少具有該特性,剛不用說許多的靜態類型函數式語言(如ML,Haskell,OCamal等)。在某種程度上,這是由於Perl,Python和Ruby這些顯示類型很少的動態類型語言的成功。在軟件開發社區對於類型推理有非常多的經驗,這說明這種技術與創建大型的,可維護的,健壯性強的程序沒有矛盾之處。

一些開發者可能會爲使用auto感到困擾,因爲無法通過快速瀏覽源代碼知道一個對象的類型,但是IDE可以緩解這個問題(具體問題見條款4),很多情況下IDE可以爲對象的確切類型提供個大概,這在很多時候是已經足夠了。例如我們只需要知道一個對象是容器,計數器,智能指針就夠了,而無須知道他們的確切類型。


總結

事實上,顯式聲明對象很多時候會造成微妙的錯誤,不正確且沒效率。而且,如果改變了初始表達式的類型,auto類型也會自動地改變,這意味着對重構(refectoring)有很大幫助。例如你寫了個返回類型爲int的函數,後來覺得long類型更好,那麼你修改了函數後,下一次編譯後用auto接收函數返回值的變量會自動更新類型。如果你用int接收,則需要修改每一處函數調用的代碼。

需要記住的2點:

  • auto變量必須初始化,它通常不會類型不匹配,從而剛輕便和更高效,還能減少重構的工作量,一般我們儘量用auto代替顯式類型聲明。
  • auto類型變量會有條款2和條款6中的陷阱。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章