摘要訊息 : 深入地了解 autodecltype.

0. 前言

C++ 11 引入的 auto 也許是 C++ 11 最知名的特性之一, C++ 14 對 autodecltype 進行了強化, C++ 17 之後 auto 被允許用在更多地方.

更新紀錄 :

  • 2022 年 5 月 2 日進行第一次更新和修正.

1. auto 與初始化列表

對於宣告 auto value {0};, 很簡單, value 的型別是 int. 但是情況卻不總是這樣. 在 C++ 11 和 C++ 14 中, value 的型別是 std::initializer_list<int>. 另外, 宣告 auto value = {0};, 如果閣下熟悉初始化列表的話, 很快就能判斷 value 的型別在 C++ 11 之後一直都是 std::initializer_list<int>. 我比較習慣於使用 auto v {} 這樣的初始化方法, 所以這樣的宣告在我的文章中大量出現. auto 在不同 C++ 版本上針對初始化列表的不同行為是我要提醒大家的.

2. decltype(auto)

如果 arr 是一個 T 型別的陣列, 那麼我們知道, arr[n] 的型別應該是 T &, 而不是 T. 在 C++ 11 中, 如果我們要求編碼器從函式參數中的陣列推導回傳型別, 我們可以這樣寫 :

template <typename T>
auto index(T *arr, int n) -> decltype(arr[n]) {
    return arr[n];
}

在 C++ 14 中, 可以直接省略尾置回傳型別 (《【C++ 14】函式回傳型別推導》) :

template <typename T>
auto index(T *arr, int n) {
    return arr[n];
}

但是 Code 1-1Code 1-2 中的函式回傳型別是不一樣的. 這是因為 auto 在推導的時候, 可能會忽略變數本身所帶有的 const, volatile 和參考標識. 為了解決這個問題, C++ 14 引入了 decltype(auto) :

template <typename T>
decltype(auto) index(T *arr, int n) {
    return arr[n];
}

這樣就沒什麼問題了. decltype(auto) 告訴編碼器此處雖然使用了 auto 要求推導, 但是編碼器在這個地方需要使用 decltype 的推導規則, 而不是 auto 的推導規則.

有些容器也多載了陣列注標運算子, 因此我們可能希望上述函式同樣可用於容器 :

template <typename Container>
decltype(auto) index(Container &arr, int n) {
    return arr[n];
}

我並沒有為第一個參數添加 const 限定, 這是因為我還想讓函式支援 index(c, n) = value; 這樣的行為. 但是有些容器的右值同樣支援陣列注標運算子, 所以我們希望函式 index 可以支援某些右值容器. 但是右值是不可以繫結到不帶 const 限定的左值參考上的, 所以我們使用 C++ 11 的通用參考, 以參考折疊的方式實作函式 :

template <typename Container>
decltype(auto) index(Container &&arr, int n) {
    return arr[n];
}

但是這個函式的行為對於右值來說, 不正確. 這是因為即使是右值, 在函式 index 內部, arr 被視為左值, 其內部多載的陣列注標運算子回傳的型別是一個左值參考, 此處按照 decltype 的推導規則, 最終回傳型別就是某一個型別的左值參考. 當我們真正進行指派的時候, 那個右值很可能已經不存在了, 此時就會發生未定行為. 因此我們要借助 std::forward 才能保證程式正確 :

#include <utility>

template <typename Container>
decltype(auto) index(Container &&arr, int n) {
    return std::forward<Container>(arr)[n];
}

我們使用 std::forward 向編碼器保證 arr 的型別被完美轉遞, 使用一個右值容器的陣列注標運算子所得到的回傳值應該是一個右值參考, 從而保證 index(std::vector<int> {1, 2, 3, 4, 5}, 2) = value; 類似於這樣的行為和左值一樣可以正常運作, 雖然它沒有任何意義. 另外, 這裡有個補充. 有些人可能會疑惑當函式 index 結束的時候, std::vector<int> {1, 2, 3, 4, 5} 不是會被銷毀嗎? 為什麼這裡可以指派. 特別指出, 函式呼叫的時候產生的臨時物件, 是等整條陳述式運作完畢之後才被銷毀. 也就是說 std::vector<int> {1, 2, 3, 4, 5} 這個臨時物件的生命週期一直到 (std::vector<int> {1, 2, 3, 4, 5}).operator[](2) = value 這條表達式運作完畢才開始銷毀.

除此之外, 有一個例外需要閣下注意, delctype(x)delctype((x)) 所推導的結果是不同的. 在回傳型別使用 decltype(auto) 的函式中, 如果 return 陳述式中加了一個括號, 那麼最終的型別可能是參考 :

template <typename Container>
decltype(auto) index(Container &&arr, int n) {
    auto value {42};
    // ...
    return (value);     // reference to local variable value, dangerous!!!
}

對於 auto, 我的建議和 Scott Meyers 的建議一樣 : 在可以使用 auto 的情況下, 儘量採用 auto; 只有 auto 推導可能有誤或著無法使用 auto 的時候, 才明確寫出其型別.

3. auto 與 Lambda 表達式

在 C++ 11 引入 Lambda 表達式之後, 其宣告方式只能使用 auto, 因為只有編碼器才知道 Lambda 的具體型別. 但是除了 auto 之外, std::function 也有能力接收一個 Lambda 表達式. 你可能為了同一行為, 所以把所有的 Lambda 表達式都使用 std::function 包裝起來. 但是事實上, 這確實會影響程式的運作效率和程式大小. 首先, std::function 物件有著固定的尺寸, 其 sizeof 值為 48. 但是對於有些可呼叫物件, 48 個位元組的大小可能無法存下它, 於是 std::function 便使用 new 來配置額外的記憶體用於儲存. 另外, 編碼器有時候會限制 std::function 的內嵌函式請求, 從而導致函式呼叫上面的效率損失. 因此, 直接使用 auto 宣告一個 Lambda 表達式而得到的可呼叫物件幾乎比使用 std::function 得到的可呼叫物件成本要小, 而且運作效率更高.

4. auto 與宣告

對於宣告 int size {std::vector<int> {}.size()}; 是無法通過編碼, 因為 C++ 標準規定了 std::vector 的成員函式 size 必須回傳一個無號數型別, 也就是說這個宣告在初始化列表中存在隱含型別轉換 (而不是型別提升). 你認為修改這個程式碼非常簡單, 改成 unsigned size {std::vector<int> {}.size()}; 不就行了嗎. 然而 C++ 標準只規定了 std::vector 的成員函式 size 的回傳值必須是一個無號數型別. 除了 unsigned int 之外, 無號數型別至少有 unsigned char, unsigned short, unsigned int, unsigned longunsigned long long. 如果 size 回傳值的型別是 unsigned long, 那麼這無法通過編碼. 因為 unsigned longunsigned int 的型別轉換需要 static_cast 的幫助. 你再次認為這個程式碼的修改非常簡單, unsigned long long size {std::vector<int> {}.size()};, 我乾脆用最大的無號數型別去接受不就可以了. 但是, 很多編碼器擁有一個更大的內建型別 : __uint128_t. 在 Apple Clang 下, sizeof(unsigned long long) 的回傳值為 8, 而 sizeof(__uint128_t) 的回傳值為 16. 你能保證程式庫的作者不使用 __uint128_t 或著乾脆自己實作一個整無號數整型型別來替換預設的 typename std::vector<T>::size_type 嗎? 除了編碼錯誤, 有時候還會出現運作效率的損失甚至未定行為. 所以, 正確的宣告應該是 typename std::vector<T>::size_type size {std::vector<int> {}.size()};. 為了簡便, 直接用 auto 就可以了.

5. autostd::map

先來看一段程式碼 :

#include <map>
#include <string>

int main(int argc, char *argv[]) {
    std::map<std::string, int> m;
    for(const std::pair<std::string, int> &value : m) {
        //...
    }
}

這個程式碼有問題嗎? 如果沒有經歷過, 你幾乎不可能看出問題, 即使你是 C++ 老手. 但是上述程式碼確實存在問題. 雖然我們給定 std::mapkey_typestd::string, 但是由於 C++ 標準規定 std::map 預設 key_type 不可以發生變更, 所以到了 std::map 裡面, std::string 就變成了 const std::string. 也就是說, std::map 中的每一個物件, 其真正的型別是 std::pair<const string, int>. 那麼上述程式碼就會出現每進行一次迴圈, 就會多運作一次 std::pair<const std::string, int>std::pair<std::string, int> 的複製, 在每一次迴圈結束的時候, 又會去解構剛剛所複製的物件. 你可能覺得奇怪, 按道理來說 std::pair<const std::string, int>std::pair<std::string, int> 的型別轉換並不成立, 至少針對這個 pair_t :

#include <string>

template <typename T, typename U = T>
struct pair_t {
    T first;
    U second;
};

pair_t<std::string, int> p1;
pair_t<const std::string, int> p2;
p1 = p2;        // error

沒錯, 這樣簡單的 pair_t 是不成立的, 但是 C++ 標準樣板程式庫中的 std::pair 可不是這個樣子, 它遠比上面的 pair_t 複雜得多. 現在你只需要知道, 對於 std::pair 來說, 從 std::pair<const std::string, int>std::pair<std::string, int> 的轉型是成立的. 那麼你應該明白, Code 3 中的 value 是個復件, 對它的所有操作都不對原來的 std::map 中的物件生效. 因為這是一次右值繫結到被 const 標識的參考上, 所以去掉 Code 3 for 迴圈中的 const, 編碼器就會擲出編碼錯誤. 因此, 這個時候我們應該使用 auto, 寫成 for(auto &value : m). 如果不需要操作 value, 那麼加上 const 標識即可.

6. 正確使用 auto

上面提到的所有容易忽略的地方, 都令你有足夠的理由優先選用 auto, 而不是明確地宣告其型別. 而且這三個實例中的 auto 有一個共通的點, 就是打字更少. 最後給出兩個實例說明並不是每一個地方都適合使用 auto. 一個比較簡單的實例, 函式 size :

#include <stdexcept>

template <typename Container>
typename Container::size_type size(const Container &c) {
    auto sz {0};
    for(auto i {0};;) {
        try {
            c[i++];
        }catch(std::out_of_range &) {
            break;
        }
    }
    return sz;
}

某一個容器的作者對陣列注標的引數進行了檢查, 但是這個容器卻沒有提供應該提供的成員函式 size, 用於回傳容器中目前有多少元素, 於是我們使用上述程式碼來回傳一個容器中的元素數量. 一般來說, 這個函式運作地比較好, 它的回傳型別取決於容器 Container 中的 size_type 別名. 函式中的局域變數 sz 被編碼器推導為 int, 假如 typename Container::size_type 並不支援從 int 到它的任何一種可能的轉換 (包括複製建構、複製指派和可能的多載型別轉換函式), 那麼上述函式就會出現編碼錯誤. 這個時候, 就不能直接使用 auto, 而是應該明確標識它的型別 : typename Container::size_type sz {0}; 或者 auto sz {static_cast<typename Container::size_type>(0)};.

另外一個實例是關於代理類別的. 所謂代理類別就是 C++ 中, 我們會實作出一些類別, 使得它的行為像某一些類別或著內建型別一樣. 比如疊代器和智慧指標就是用於模擬內建指標的. 這個實例特別使用 C++ 標準樣板程式庫中大家都不推薦使用的 std::vector<bool> (C++ 標準樣板程式庫中的 std::vector 針對 bool 型別進行特製化, 使得它更節省記憶體, 使用一個位元來表示 bool) :

#include <vector>
#include <random>

std::vector<bool> random_boolean_list(size_t n) {
    std::random_device d {};
    std::default_random_engine e(d());
    std::bernoulli_distribution b;
    std::vector<bool> list {};
    while(n--) {
        list.push_back(b(e));
    }
    return list;
}
void process(int n, bool &status) {
    // ...
}
int main(int argc, char *argv[]) {
    auto boolean {random_boolean_list(100)};
    int n {};
    // do something on n such as std::cin >> n
    // ...
    if(n >= 100) {
        throw std::out_of_range("n is out of range!");
    }
    auto bit {boolean[n]};
    process(n, bit);        // error : no matching function for call to 'process'
}

上述程式碼首先隨機產生一個布林動態陣列, 然後取其中一個進行處理. 看著沒有什麼問題的程式碼實際上出現了編碼錯誤. 我們從編碼錯誤中, 大致可以知道主函式 main 中的局域變數 bit 並不是 bool 型別或 bool & 型別, 更不是 bool && 型別. 此處我們通過一個小技巧, 讓編碼器在編碼錯誤的同時告訴我們 bit 是什麼型別 : ++bit; (或者其它不存在的操作). Apple Clang 在 libc++ 上編碼的時候, 是這樣告訴我的 : cannot increment value of type 'std::__1::__bit_reference<std::__1::vector<bool, std::__1::allocator<bool> >, true>'. 也就是說, bit並不是 bool 型別, 而是一個自訂類別. 通過 std::vector<bool> 的一些行為, 我們也知道這個類別在模擬 bool 型別的行為, 也就是我們上面所說的代理類別. 因此, 我們知道了為什麼函式沒有辦法匹配了. 你可能會說, 這個回傳的型別不是在模擬 bool 型別的行為嗎? 那麼它必定可以向 bool 型別發生隱含型別轉化. 確實可以, 我們將 bit 的宣告改為 bool bit {boolean[n]}; 就可以了. 但是 processbit 的任何處理都不能作用於 boolean[n], 只能作用於局域變數 bit. 這裡還有一個危險的地方, 就是如果使用 auto 宣告了一個臨時的物件 auto bit {random_boolean_list(100)[n]};, 那麼接下來對 bit 的使用就會導致未定行為. 如果你想要知道具體的原因, 那麼就必須了解 std::vector<bool> 是如何設計的.

std::vector<bool> 和用於模擬 bool 型別的類別都是使用了特殊的實作方式. 首先我們知道, 任何一個 C++ 標準程式庫的容器或著陣列使用它們的 operator[], 回傳的必定是一個型別的參考, 針對 std::vector<bool> 來說本來應該是 bool &. 但是由於 std::vector<bool> 為了節省記憶體空間, C++ 內建型別中也不包含類似於 bit 這樣的位元流型別, 所以將一個八個位元大小的 unsigned char (一般都是它) 分成八個位元使用, 因此需要使用自訂型別來模擬位元流. 一種比較簡單的實作方式如下 :

//bit stream implement
template <bool IsConst>
class bit_reference {
private:
    unsigned char *arr;
    size_t mask;
public:
    bit_reference(char *, size_t);
public:
    operator bool() const noexcept {
        return static_cast<bool>(*this->arr & this->mask);
    }
    //...
};
template <>
class bit_reference<true> {
    //...
};

//vector implement
template <typename T> class vector;
template <>
class vector<bool> {
public:
    using reference = bit_reference<false>;
    using const_reference = bit_reference<true>;
private:
    unsigned char *start;
    unsigned char *end;
    size_t offset;
public:
    reference operator[](int n) const noexcept {
        return reference(start, n);
    }
};

Code 8 中, 我們可以看到, vector<bool> 回傳的是 bit_reference, 而不是 bool &, 儘管 bit_reference 可以隱含地向 bool 型別轉化. 但是你要清楚, 當我們呼叫函式 random_boolean_list 的時候, 獲得的是一個型別為 vector<bool> 的臨時物件 (不妨記為 temp), 然後取其第 n 個. 這條陳述式運作完畢之後, temp 就會被回收, 也就是其內部配置的記憶體的 unsigned char *startunsigned char *end 都會被收回. 但是 temp[n] 所對應的 bit_reference 物件 (不妨記為 bit_temp) 中儲存的是 [\mathrm {start}, \mathrm {end}) 範圍中的地址, 由於回傳的 temp 已經被回收了, 因此對 bit_temp 的任何操作都會導致對已收回而未重新配置的記憶體位址進行訪問, 從而導致未定行為.

通過上面的論述, 我們幾乎可以明白, 任何對代理類別使用 auto 進行推導很可能會導致一些未定行為甚至嚴重錯誤. 那麼我們是否應該放棄 auto 呢? 完全不是, 你可以將 auto bit {random_boolean_list(100)[n]}; 改為 auto bit {static_cast<bool>(random_boolean_list(size)[n])};, 或著採用之前直接明確宣告 bool 的方式, 就完全沒有問題了. 那麼對於每一個變數都這麼處理和不使用 auto 有什麼區別? 唯一的區別就是你需要多多閱讀程式庫作者所撰寫的文檔, 至少對要用到的函式或著類別有一個基本的印象, 知道哪些地方可能會產生這樣的錯誤. 當遇到這樣的地方, 我們就使用明確宣告型別的方式替代使用 auto 進行推導的方式, 而你肯定也有經驗, 這種情況並不多. 另外, 多了解程式庫的評價. 比如 C++ 標準樣板程式庫中的 std::vector<bool> 被稱為 C++ 標準樣板程式庫中最大的坑. 幾乎知道的人都會建議新人不要使用它, 而且有不少激進的人直接建議 C++ 標準委員會廢除 std::vector<bool> 這樣的偏特製化 (實際上, 它還有另外一個樣板參數用於記憶體配置). 但是由於歷史原因, C++ 並沒有廢除 std::vector<bool> 導致現在它還留在 C++ 標準樣板程式庫中. 但是經過我們基本的了解之後, 你會發現這是一個比較好的節省記憶體的設計, 只不過在用的時候有些時候會有陷阱, 這對於任何容器的疊代器而言都會有不是嗎? 小心地使用它們, 並不會導致任何問題. 說到底只是水平問題罷了. 當然, 由於 std::vector<bool> 的行為和其它 std::vector<T> 不一致, 所以推薦使用 std::bitset 來代替它.