Stories

Detail Return Return

深入解析C++的auto自動類型推導 - Stories Detail

關鍵字auto在C++98中的語義是定義一個自動生命週期的變量,但因為定義的變量默認就是自動變量,因此這個關鍵字幾乎沒有人使用。於是C++標準委員會在C++11標準中改變了auto關鍵字的語義,使它變成一個類型佔位符,允許在定義變量時不必明確寫出確切的類型,讓編譯器在編譯期間根據初始值自動推導出它的類型。這篇文章我們來解析auto自動類型推導的推導規則,以及使用auto有哪些優點,還有羅列出自C++11重新定義了auto的含義以後,在之後發佈的C++14、C++17、C++20標準對auto的更新、增強的功能,以及auto有哪些使用限制。

推導規則

我們將以下面的形式來討論:

auto var = expr;

這時auto代表了變量var的類型,除此形式之外還可以再加上一些類型修飾詞,如:

const auto var = expr;
// 或者
const auto& var = expr;

這時變量var的類型是const auto或者const auto&,const也可以換成volatile修飾詞,這兩個稱為CV修飾詞,引用&也可以換成指針,如const auto,這時明確指出定義的是指針類型。

根據上面定義的形式,根據“=”左邊auto的修飾情況分為三種情形:

  • 規則一:只有auto的情況,既非引用也非指針,表示按值初始化

如下的定義:

auto i = 1;        // i為int
auto d = 1.0;    // d為double

變量i將被推導為int類型,變量d將被推導為double類型,這時是根據“=”右邊的表達式的值來推導出auto的類型,並將它們的值複製到左邊的變量i和d中,因為是將右邊expr表達式的值複製到左邊變量中,所以右邊表達式的CV(const和volatile)屬性將會被忽略掉,如下的代碼:

const int ci = 1;
auto i = ci;        // i為int

儘管ci是有const修飾的常量,但是變量i的類型是int類型,而非const int,因為此時i拷貝了ci的值,i和ci是兩個不相關的變量,分別有不同的存儲空間,變量ci不可修改的屬性不代表變量i也不可修改。

當使用auto在同一條語句中定義多個變量時,變量的初始值的類型必須要統一,否則將無法推導出類型而導致編譯錯誤:

auto i = 1, j = 2;        // i和j都為int
auto i = 1, j = 2.0;    // 編譯錯誤,i為int,j為double
  • 規則二:形式如auto&或auto*,表示定義引用或者指針

當定義變量時使用如auto&或auto*的類型修飾,表示定義的是一個引用類型或者指針類型,這時右邊的expr的CV屬性將不能被忽略,如下的定義:

int x = 1;
const int cx = x;
const int& rx = x;
auto& i = x;    // (1) i為int&
auto& ci = cx;    // (2) ci為const int&
auto* pi = ℞    // (3) pi為const int*

(1)語句中auto被推導為int,因此i的類型為int&。(2)語句中auto被推導為const int,ci的類型為const int &,因為ci是對cx的引用,而cx是一個const修飾的常量,因此對它的引用也必須是常量引用。(3)語句中的auto被推導為const int,pi的類型為const int*,rx的const屬性將得到保留。

除了下面即將要講到的第三種情況外,auto都不會推導出結果是引用的類型,如果要定義為引用類型,就要像上面那樣明確地寫出來,但是auto可以推導出來是指針類型,也就是説就算沒有明確寫出auto*,如果expr的類型是指針類型的話,auto則會被推導為指針類型,這時expr的const屬性也會得到保留,如下的例子:

int i = 1;
auto pi = &i;    // pi為int*
const char word[] = "Hello world!";
auto str = word;    // str為const char*

pi被推導出來的類型為int,而str被推導出來的類型為const char

  • 規則三:形式如auto&&,表示萬能引用

當以auto&&的形式出現時,它表示的是萬能引用而非右值引用,這時將視expr的類型分為兩種情況,如果expr是個左值,那麼它推導出來的結果是一個左值引用,這也是auto被推導為引用類型的唯一情形。而如果expr是個右值,那麼將依據上面的第一種情形的規則。如下的例子:

int x = 1;
const int cx = x;
auto&& ref1 = x;    // (1) ref1為int&
auto&& ref2 = cx;    // (2) ref2為const int&
auto&& ref3 = 2;    // (3) ref3為int&&

(1)語句中x的類型是int且是左值,所以ref1的類型被推導為int&。(2)語句中的cx類型是const int且是左值,因此ref2的類型被推導為const int&。(3)語句中右側的2是一個右值且類型為int,所以ref3的類型被推導為int&&。

上面根據“=”左側的auto的形式歸納討論了三種情形下的推導規則,接下來根據“=”右側的expr的不同情況來討論推導規則:

  • expr是一個引用

如果expr是一個引用,那麼它的引用屬性將被忽略,因為我們使用的是它引用的對象,而非這個引用本身,然後再根據上面的三種推導規則來推導,如下的定義:

int x = 1;
int &rx = x;
const int &crx = x;
auto i = rx;    // (1) i為int
auto j = crx;    // (2) j為int
auto& ri = crx;    // (3) ri為const int&

(1)語句中rx雖然是個引用,但是這裏是使用它引用的對象的值,所以根據上面的第一條規則,這裏i被推導為int類型。(2)語句中的crx是個常量引用,它和(1)語句的情況一樣,這裏只是複製它所引用的對象的值,它的const屬性跟變量j沒有關係,所以變量j的類型為int。(3)語句裏的ri的類型修飾是auto&,所以應用上面的第二條規則,它是一個引用類型,而且crx的const屬性將得到保留,因此ri的類型推導為const int&。

  • expr是初始化列表

當expr是一個初始化列表時,分為兩種情況而定:

auto var = {};    // (1)
// 或者
auto var{};    // (2)

當使用第一種方式時,var將被推導為initializer_list<T>類型,這時無論花括號內是單個元素還是多個元素,都是推導為initializer_list<T>類型,而且如果是多個元素,每個元素的類型都必須要相同,否則將編譯錯誤,如下例子:

auto x1 = {1, 2, 3, 4};    // x1為initializer_list<int>
auto x2 = {1, 2, 3, 4.0};    // 編譯錯誤

x1的類型為initializer_list<int>,這裏將經過兩次類型推導,第一次是將x1推導為initializer_list<T>類型,第二次利用花括號內的元素推導出元素的類型T為int類型。x2的定義將會引起編譯錯誤,因為x2雖然推導為initializer_list<T>類型,但是在推導T的類型時,裏面的元素的類型不統一,導致無法推導出T的類型,引起編譯錯誤。

當使用第二種方式時,var的類型被推導為花括號內元素的類型,花括號內必須為單元素,如下:

auto x1{1};        // x1為int
auto x2{1.0};    // x2為double

x1的類型推導為int,x2的類型推導為double。這種形式下花括號內必須為單元素,如果有多個元素將會編譯錯誤,如:

auto x3{1, 2};    // 編譯錯誤

這個將導致編譯錯誤:error: initializer for variable 'x3' with type 'auto' contains multiple expressions。

  • expr是數組或者函數

數組在某些情況會退化成一個指向數組首元素的指針,但其實數組類型和指針類型並不相同,如下的定義:

const char name[] = "My Name";
const char* str = name;

數組name的類型是const char[8],而str的類型為const char*,在某些語義下它們可以互換,如在第一種規則下,expr是數組時,數組將退化為指針類型,如下:

const char name[] = "My Name";
auto str = name;    // str為const char*

str被推導為const char*類型,儘管name的類型為const char[8]。

但如果定義變量的形式是引用的話,根據上面的第二種規則,它將被推導為數組原本的類型:

const char name[] = "My Name";
auto& str = name;    // str為const char (&)[8]

這時auto被推導為const char [8],str是一個指向數組的引用,類型為const char (&)[8]。

當expr是函數時,它的規則和數組的情況類似,按值初始化時將退化為函數指針,如為引用時將為函數的引用,如下例子:

void func(int, double) {}
auto f1 = func;    // f1為void (*)(int, double)
auto& f2 = func;    // f2為void (&)(int, double)

f1的類型推導出來為void (*)(int, double),f2的類型推導出來為void (&)(int, double)。

  • expr是條件表達式語句

當expr是一個條件表達式語句時,條件表達式根據條件可能返回不同類型的值,這時編譯器將會使用更大範圍的類型來作為推導結果的類型,如:

auto i =  condition ? 1 : 2.0;    // i為double

無論condition的結果是true還是false,i的類型都將被推導為double類型。

使用auto的好處

  • 強制初始化的作用

當你定義一個變量時,可以這樣寫:

int i;

這樣寫編譯是能夠通過的,但是卻有安全隱患,比如在局部代碼中定義了這個變量,然後又接着使用它了,可能面臨未初始化的風險。但如果你這樣寫:

auto i;

這樣是編譯不通過的,因為變量i缺少初始值,你必須給i指定初始值,如下:

auto i = 0;

必須給變量i初始值才能編譯通過,這就避免了使用未初始化變量的風險。

  • 定義小範圍內的局部變量時

在小範圍的局部代碼中定義一個臨時變量,對理解整體代碼不會造成困擾的,比如:

for (auto i = 1; i < size(); ++i) {}

或者是基於範圍的for循環的代碼,只是想要遍歷容器中的元素,對於元素的類型不關心,如:

std::vector<int> v = {};
for (const auto& i : v) {}
  • 減少冗餘代碼

當變量的類型非常長時,明確寫出它的類型會使代碼變得又臃腫又難懂,而實際上我們並不關心它的具體類型,如:

std::map<std::string, int> m;
for (std::map<std::string, int>::iterator it = m.begin(); it != m.end(); ++it) {}

上面的代碼非常長,造成閲讀代碼的不便,對增加理解代碼的邏輯也沒有什麼好處,實際上我們並不關心it的實際類型,這時使用auto就使代碼變得簡潔:

for (auto it = m.begin(); it != m.end(); ++it) {}

再比如下面的例子:

std::unordered_multimap<int, int> m;
std::pair<std::unordered_multimap<int, int>::iterator,
          std::unordered_multimap<int ,int>::iterator>
    range = m.equal_range(k);

對於上面的代碼簡直難懂,第一遍看還看不出來想代表的意思是什麼,如果改為auto來寫,則一目瞭然,一看就知道是在定義一個變量:

auto range = m.equal_range(k);
  • 無法寫出的類型

如果説上面的代碼雖然難懂和難寫,畢竟還可以寫出來,但有時在某些情況下卻無法寫出來,比如用一個變量來存儲lambda表達式時,我們無法寫出lambda表達式的類型是什麼,這時可以使用auto來自動推導:

auto compare = [](int p1, int p2) { return p1 < p2; }
  • 避免對類型硬編碼

除了上面提到的可以減少代碼的冗餘之外,使用auto也可以避免對類型的硬編碼,也就是説不寫死變量的類型,讓編譯器自動推導,如果我們要修改代碼,就不用去修改相應的類型,比如我們將一種容器的類型改為另一種容器,迭代器的類型不需要修改,如:

std::map<std::string, int> m = { ... };
auto it = m.begin();
// 修改為無序容器時
std::unordered_map<std::string, int> m = { ... };
auto it = m.begin();

C++標準庫裏的容器大部分的接口都是相同的,泛型算法也能應用於大部分的容器,所以對於容器的具體類型並不是很重要,當根據業務的需要更換不同的容器時,使用auto可以很方便的修改代碼。

  • 跨平台可移植性

假如你的代碼中定義了一個vector,然後想要獲取vector的元素的大小,這時你調用了成員函數size來獲取,此時應該定義一個什麼類型的變量來承接它的返回值?vector的成員函數size的原型如下:

size_type size() const noexcept;

size_type是vector內定義的類型,標準庫對它的解釋是“an unsigned integral type that can represent any non-negative value of difference_type”,於是你認為用unsigned類型就可以了,於是寫下如下代碼:

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

這樣寫可能會導致安全隱患,比如在32位的系統上,unsigned的大小是4個字節,size_type的大小也是4個字節,但是在64位的系統上,unsigned的大小是4個字節,而size_type的大小卻是8個字節。這意味着原本在32位系統上運行良好的代碼可能在64位的系統上運行異常,如果這裏用auto來定義變量,則可以避免這種問題。

  • 避免寫錯類型

還有一種似是而非的問題,就是你的代碼看起來沒有問題,編譯也沒有問題,運行也正常,但是效率可能不如預期的高,比如有以下的代碼:

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

這段代碼看起來完全沒有問題,編譯也沒有任何警告,但是卻暗藏隱患。原因是std::unordered_map容器的鍵值的類型是const的,所以std::pair的類型不是std::pair<std::string, int>而是std::pair<const std::string, int>。但是上面的代碼中定義p的類型是前者,這會導致編譯器想盡辦法來將m中的元素(類型為std::pair<const std::string, int>)轉換成std::pair<std::string, int>類型,因此編譯器會拷貝m中的所有元素到臨時對象,然後再讓p引用到這些臨時對象,每迭代一次,臨時對象就被析構一次,這就導致了無故拷貝了那麼多次對象和析構臨時對象,效率上當然會大打折扣。如果你用auto來替代上面的定義,則完全可以避免這樣的問題發生,如:

for (const auto& p : m) {}

新標準新增功能

  • 自動推導函數的返回值類型(C++14)

C++14標準支持了使用auto來推導函數的返回值類型,這樣就不必明確寫出函數返回值的類型,如下的代碼:

template<typename T1, typename T2>
auto add(T1 a, T2 b) {
    return a + b;
}

int main() {
    auto i = add(1, 2);
}

不用管傳入給add函數的參數的類型是什麼,編譯器會自動推導出返回值的類型。

  • 使用auto聲明lambda的形參(C++14)

C++14標準還支持了可以使用auto來聲明lambda表達式的形參,但普通函數的形參使用auto來聲明需要C++20標準才支持,下面會提到。如下面的例子:

auto sum = [](auto p1, auto p2) { return p1 + p2; };

這樣定義的lambda式有點像是模板,調用sum時會根據傳入的參數推導出類型,你可以傳入int類型參數也可以傳入double類型參數,甚至也可以傳入自定義類型,如果自定義類型支持加法運算的話。

  • 非類型模板形參的佔位符(C++17)

C++17標準再次拓展了auto的功能,使得能夠作為非類型模板形參的佔位符,如下的例子:

template<auto N>
void func() {
    std::cout << N << std::endl;
}

func<1>();        // N為int類型
func<'c'>();    // N為chat類型

但是要保證推導出來的類型是能夠作為模板形參的,比如推導出來是double類型,但模板參數不能接受是double類型時,則會導致編譯不通過。

  • 結構化綁定功能(C++17)

C++17標準中auto還支持了結構化綁定的功能,這個功能有點類似tuple類型的tie函數,它可以分解結構化類型的數據,把多個變量綁定到結構化對象內部的對象上,在沒有支持這個功能之前,要分解tuple裏的數據需要這樣寫:

tuple x{1, "hello"s, 5.0};
itn a;
std::string b;
double c;
std::tie(a, b, c) = x;    // a=1, b="hello", c=5.0

在C++17之後可以使用auto來這樣寫:

tuple x{1, "hello"s, 5.0};
auto [a, b, c] = x;    // 作用如上
std::cout << "a=" << a << ", b=" << b << ", c=" << c << std::endl;

auto的推導功能從以前對單個變量進行類型推導擴展到可以對一組變量的推導,這樣可以讓我們省略了需要先聲明變量再處理結構化對象的麻煩,特別是在for循環中遍歷容器時,如下:

std::map<std::string, int> m;
for (auto& [k, v] : m) {
    std::cout << k << " => " << v << std::endl;
}
  • 使用auto聲明函數的形參(C++20)

之前提到無法在普通函數中使用auto來聲明形參,這個功能在C++20中也得到了支持。你終於可以寫下這樣的代碼了:

auto add (auto p1, auto p2) { return p1 + p2; };
auto i = add(1, 2);
auto d = add(5.0, 6.0);
auto s = add("hello"s, "world"s);    // 必須要寫上s,表示是string類型,默認是const char*,
                                    // char*類型是不支持加法的

這個看起來是不是和模板很像?但是寫法要比模板要簡單,通過查看生成的彙編代碼,看到編譯器的處理方式跟模板的處理方式是一樣的,也就是説上面的三個函數調用分別產生出了三個函數實例:

auto add<int, int>(int, int);
auto add<double, double>(double, double);
auto add<std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > >(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >);

使用auto的限制

上面詳細列出了使用auto的好處和使用場景,但在有些地方使用auto還存在限制,下面也一併羅列出來。

  • 類內初始化成員時不能使用auto

在C++11標準中已經支持了在類內初始化數據成員,也就是説在定義類時,可以直接在類內聲明數據成員的地方直接寫上它們的初始值,但是在這個情況下不能使用auto來聲明非靜態數據成員,比如:

class Object {
    auto a = 1;    // 編譯錯誤。
};

上面的代碼會出現編譯錯誤:error: 'auto' not allowed in non-static class member。雖然不能支持聲明非靜態數據成員,但卻可以支持聲明靜態數據成員,在C++17標準之前,使用auto聲明靜態數據成員需要加上const修飾詞,這就給使用上造成了不便,因此在C++17標準中取消了這個限制:

class Object {
    static inline auto a = 1;    // 需要寫上inline修飾詞
};
  • 函數無法返回initializer_list類型

雖然在C++14中支持了自動推導函數的返回值類型,但卻不支持返回的類型是initializer_list<T>類型,因此下面的代碼將編譯不通過:

auto createList() {
    return {1, 2, 3};
}

編譯錯誤信息:error: cannot deduce return type from initializer list。

  • lambda式參數無法使用initializer_list類型

同樣地,在lambda式使用auto來聲明形參時,也不能給它傳遞initializer_list<T>類型的參數,如下代碼:

std::vector<int> v;
auto resetV = [&v](const auto& newV) { v = newV; };
resetV({1, 2, 3});

上面的代碼會編譯錯誤,無法使用參數{1, 2, 3}來推導出newV的類型。


此篇文章同步發佈於我的微信公眾號深入解析C++的auto自動類型推導,如果您感興趣這方面的內容,請在微信上搜索公眾號iShare愛分享或者微信號iTechShare並關注,以便在內容更新時直接向您推送。

Add a new Comments

Some HTML is okay.