深入解析C++的auto自動型別推導

iShare_爱分享發表於2024-04-11

關鍵字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型別,這時無論花括號內是單個元素還是多個元素,都是推導為initializer_list型別,而且如果是多個元素,每個元素的型別都必須要相同,否則將編譯錯誤,如下例子:

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

x1的型別為initializer_list,這裡將經過兩次型別推導,第一次是將x1推導為initializer_list型別,第二次利用花括號內的元素推匯出元素的型別T為int型別。x2的定義將會引起編譯錯誤,因為x2雖然推導為initializer_list型別,但是在推導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型別,因此下面的程式碼將編譯不透過:

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

編譯錯誤資訊:error: cannot deduce return type from initializer list。

  • lambda式引數無法使用initializer_list型別

同樣地,在lambda式使用auto來宣告形參時,也不能給它傳遞initializer_list型別的引數,如下程式碼:

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

上面的程式碼會編譯錯誤,無法使用引數{1, 2, 3}來推匯出newV的型別。


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

相關文章