C++ auto(型別推導)精講

2020-07-16 10:04:49
C++11 引入了 auto 和 decltype 關鍵字實現型別推導,通過這兩個關鍵字不僅能方便地獲取複雜的型別,而且還能簡化書寫,提高編碼效率。本節我們先講解 auto 關鍵字,下節再講解 decltype 關鍵字。

auto 關鍵字的新意義

用過 C# 的讀者可能知道,從 Visual C#3.0 開始,在方法範圍中宣告的變數可以具有隱式型別 var。例如,下面這樣的寫法(C#程式碼):

var i = 10;  // 隱式(implicitly)型別定義
int i = 10;  // 顯式(explicitly)型別定義

其中,隱式的型別定義也是強型別定義,前一行的隱式型別定義寫法和後一行的顯式寫法是等價的。

不同於 Python 等動態型別語言的執行時變數型別推導,隱式型別定義的型別推導發生在編譯期。它的作用是讓編譯器自動推斷出這個變數的型別,而不需要顯式指定型別。

現在,C++11中 也擁有了類似的功能:auto 型別推導。其寫法與上述 C# 程式碼等價:

auto i = 10;

是不是和 C# 的隱式型別定義很像呢?

下面看下 auto 的一些基本用法:
auto x = 5;                 // OK: x是int型別
auto pi = new auto(1);      // OK: pi被推導為int*
const auto *v = &x, u = 6;  // OK: v是const int*型別,u是const int型別
static auto y = 0.0;        // OK: y是double型別
auto int r;                 // error: auto不再表示儲存型別指示符
auto s;                     // error: auto無法推匯出s的型別
在上面的程式碼範例中:
  • 字面量 5 是一個 const int 型別,變數 x 將被推導為 int 型別(const被丟棄,後面說明),並被初始化為 5。
  • pi 的推導說明 auto 還可以用於 new 操作符。在例子中,new 操作符後面的 auto(1) 被推導為 int(1),因此 pi 的型別是 int*。
  • 接著,由 &x 的型別為 int*,推匯出 const auto* 中的 auto 應該是 int,於是 v 被推導為 const int*,而 u 則被推導為 const int。
  • 最後 y、r、s 的推導過程比較簡單,就不展開講解了。讀者可自行在支援 C++11 的編譯器上實驗。

v 和 u 的推導需要注意兩點:
  • 雖然經過前面 const auto*v=&x 的推導,auto 的型別可以確定為 int 了,但是 u 仍然必須要寫後面的=6,否則編譯器不予通過。
  • u 的初始化不能使編譯器推導產生二義性。例如,把 u 的初始化改成u=6.0,編譯器將會報錯:

    const auto *v = &x, u = 6.0;
    error: inconsistent deduction for 'const auto': 'int' and then 'double'


由上面的例子可以看出來,auto 並不能代表一個實際的型別宣告(如 s 的編譯錯誤),只是一個型別宣告的“預留位置”。使用 auto 宣告的變數必須馬上初始化,以讓編譯器推斷出它的實際型別,並在編譯時將 auto 預留位置替換為真正的型別。

細心的讀者可能會發現,auto 關鍵字其實並不是一個全新的關鍵字。在舊標準中,它代表“具有自動儲存期的區域性變數”,不過其實它在這方面的作用不大,比如:

auto int i = 0;  // C++98/03,可以預設寫成 int i = 0;
static int j = 0;

上述程式碼中的 auto int 是舊標準中 auto 的使用方法。與之相對的是下面的 static int,它代表了靜態型別的定義方法。

實際上,我們很少有機會這樣直接使用 auto,因為非 static 的區域性變數預設就是“具有自動儲存期的”。

考慮到 auto 在 C++ 中使用的較少,在 C++11 標準中,auto 關鍵字不再表示儲存型別指示符(storage-class-specifiers,例如 static、register、mutable 等),而是改成了一個型別指示符(type-specifier),用來提示編譯器對此型別的變數做型別的自動推導。

auto 的推導規則

從上面的範例中可以看到 auto 的一些使用方法。它可以同指標、參照結合起來使用,還可以帶上 cv 限定符(cv-qualifier,const 和 volatile 限定符的統稱)。

再來看一組例子:
int x = 0;
auto * a = &x;      // a -> int*,auto被推導為int
auto   b = &x;      // b -> int*,auto被推導為int*
auto & c = x;       // c -> int&,auto被推導為int
auto   d = c;       // d -> int ,auto被推導為int
const auto e = x;   // e -> const int
auto f = e;         // f -> int
const auto& g = x;  // e -> const int&
auto& h = g;        // f -> const int&
由上面的例子可以看出:
  • a 和 c 的推導結果是很顯然的,auto 在編譯時被替換為 int,因此 a 和 c 分別被推導為 int* 和 int&。
  • b 的推導結果說明,其實 auto 不宣告為指標,也可以推匯出指標型別。
  • d 的推導結果說明當表示式是一個參照型別時,auto 會把參照型別拋棄,直接推導成原始型別 int。
  • e 的推導結果說明,const auto 會在編譯時被替換為 const int。
  • f 的推導結果說明,當表示式帶有 const(實際上 volatile 也會得到同樣的結果)屬性時,auto 會把 const 屬性拋棄掉,推導成 non-const 型別 int。
  • g、h 的推導說明,當 auto 和參照(換成指標在這裡也將得到同樣的結果)結合時,auto 的推導將保留表示式的 const 屬性。

通過上面的一系列範例,可以得到下面這兩條規則:
  • 當不宣告為指標或參照時,auto 的推導結果和初始化表示式拋棄參照和 cv 限定符後型別一致。
  • 當宣告為指標或參照時,auto 的推導結果將保持初始化表示式的 cv 屬性。

看到這裡,對函數模板自動推導規則比較熟悉的讀者可能會發現,auto 的推導和函數模板引數的自動推導有相似之處。比如上面例子中的 auto,和下面的模板引數自動推匯出來的型別是一致的:
template <typename T> void func(T   x) {}        // T   -> auto
template <typename T> void func(T * x) {}        // T * -> auto *
template <typename T> void func(T & x) {}        // T & -> auto &
template <typename T> void func(const T   x) {}  // const T   -> const auto
template <typename T> void func(const T * x) {}  // const T * -> const auto *
template <typename T> void func(const T & x) {}  // const T & -> const auto &

注意:auto 是不能用於函數引數的。上面的範例程式碼只是單純比較函數模板引數推導和 auto 推導規則的相似處。

因此,在熟悉 auto 推導規則時,可以藉助函數模板的引數自動推導規則來幫助和加強理解。

auto 的限制

上面提到了 auto 是不能用於函數引數的。那麼除了這個之外,還有哪些限制呢?

我們通過下面的程式碼來演示一下 auto 的限制:
void func(auto a = 1) {}          // error: auto不能用於函數引數
struct Foo
{
    auto var1_ = 0;               // error: auto不能用於非靜態成員變數
    static const auto var2_ = 0;  // OK: var2_ -> static const int
};
template <typename T>
struct Bar {};

int main(void)
{
    int arr[10] = {0};
    auto aa     = arr;   // OK: aa -> int *
    auto rr[10] = arr;   // error: auto無法定義陣列
    Bar<int> bar;
    Bar<auto> bb = bar;  // error: auto無法推匯出模板引數
    return 0;
}
在 Foo 中,auto 僅能用於推導 static const 的整型或者列舉元(因為其他靜態型別在 C++ 標準中無法就地初始化),雖然 C++11 中可以接受非靜態成員變數的就地初始化,但卻不支援 auto 型別非靜態成員變數的初始化。

在 main 函數中,auto 定義的陣列 rr 和 Bar<auto>bb 都是無法通過編譯的。

注意 main 函數中的 aa 不會被推導為 int[10],而是被推導為 int*。這個結果可以通過 auto 與函數模板引數自動推導的對比來理解。

什麼時候用 auto

前面說了這麼多,最重要的是,應該在什麼時候使用 auto 呢?

在 C++11 之前,定義了一個 stl 容器以後,在遍歷的時候常常會寫這樣的程式碼:
#include <map>
int main(void)
{
    std::map<double, double> resultMap;
    // ...
    std::map<double,double>::iterator it = resultMap.begin();
    for(; it != resultMap.end(); ++it)
    {
        // do something
    }
    return 0;
}
觀察上面的疊代器(iterator)變數it的定義過程,總感覺有點憋屈。其實通過 resultMap.begin(),已經能夠知道 it 的具體型別了,卻非要書寫上長長的型別定義才能通過編譯。

來看看使用了 auto 以後的寫法:
#include <map>
int main(void)
{
    std::map<double, double> resultMap;
    // ...
    for(auto it = resultMap.begin(); it != resultMap.end(); ++it)
    {
        // do something
    }
    return 0;
}
再次觀察 it 的定義過程,是不是感到清爽了很多?

再看一個例子,在一個 unordered_multimap 中查詢一個範圍,程式碼如下:
#include <map>
int main(void)
{
    std::unordered_multimap<int, int> resultMap;
    // ...
    std::pair< std::unordered_multimap<int,int>::iterator, std::unordered_multimap<int, int>::iterator > range = resultMap.equal_range(key);
    return 0;
}
這個 equal_range 返回的型別宣告顯得煩瑣而冗長,而且實際上並不關心這裡的具體型別(大概知道是一個 std::pair 就夠了)。這時,通過 auto 就能極大的簡化書寫,省去推導具體型別的過程:
#include <map>
int main(void)
{
    std::unordered_multimap<int, int> map;
    // ...
    auto range = map.equal_range(key);
    return 0;
}
另外,在很多情況下我們是無法知道變數應該被定義成什麼型別的,比如下面的例子。

【範例】auto 簡化函數定義的範例。
class Foo
{
    public:
    static int get(void)
    {
        return 0;
    }
};
class Bar
{
    public:
    static const char* get(void)
    {
        return "0";
    }
};
template <class A>
void func(void)
{
    auto val = A::get();
    // ...
}
int main(void)
{
    func<Foo>();
    func<Bar>();
    return 0;
}
在這個例子裡,我們希望定義一個泛型函數 func,對所有具有靜態 get 方法的型別 A,在得到 get 的結果後做統一的後續處理。若不使用 auto,就不得不對 func 再增加一個模板引數,並在外部呼叫時手動指定 get 的返回值型別。

上面給出的各種範例僅僅只是實際應用中很少的一部分,但也足以說明 auto 關鍵字的各種常規使用方法。更多的適用場景,希望讀者能夠在實際的程式設計中親身體驗。

注意 auto 是一個很強大的工具,但任何工具都有它的兩面性。不加選擇地隨意使用 auto,會帶來程式碼可讀性和維護性的嚴重下降。因此,在使用 auto 的時候,一定要權衡好它帶來的“價值”和相應的“損失”。