08 | 易用性改進 I:自動型別推斷和初始化
如果要挑選 C++11 帶來的最重大改變的話,自動型別推斷肯定排名前三。如果只看易用性或表達能力的改進的話,那它就是“捨我其誰”的第一了。
auto
自動型別推斷,顧名思義,就是編譯器能夠根據表示式 (注意是“表示式”!!接下來會和decltype對比) 的型別,自動決定變數的型別(從 C++14 開始,還有函式的返回型別),不再需要程式設計師手工宣告。但需要說明的是,auto 並沒有改變 C++ 是靜態型別語言 這一事實——使用 auto 的變數(或函式返回值)的型別仍然是編譯時 (編譯時多型:可以用模板;執行時多型:可以用虛擬函式機制)就確定了,只不過編譯器能自動幫你填充而已。
c++是靜態語言,如果你想 在函式的引數
不用auto的一個例子
如果我們的遍歷函式要求支援 C 陣列的話,不用自動型別推斷的話,就只能使用兩個不同的過載:
template <typename T, std::size_t N> void foo(const T (&a)[N]) { typedef const T* ptr_t; for (ptr_t it = a, end = a + N; it != end; ++it) { // 迴圈體 } } template <typename T> void foo(const T& c) { for (typename T::const_iterator it = c.begin(), end = c.end(); it != end; ++it) { // 迴圈體 } }
使用auto的一個例子
template <typename T>
void foo(const T& c)
{
using std::begin;
using std::end;
for (auto it = begin(c),
ite = end(c);
it != ite; ++it) {
// 迴圈體
}
}
從這個例子可見,自動型別推斷不僅降低了程式碼的囉嗦程度,也提高了程式碼的抽象性,使我們可以用更少的程式碼寫出通用的功能。
總結auto
auto 實際使用的規則類似於函式模板引數的推導規則。當你寫了一個含 auto 的表示式時,相當於把 auto 替換為模板引數的結果。舉具體的例子:
- auto a = expr; 意味著用 expr 去匹配一個假想的 template
f(T) 函式模板,結果為值型別。 - const auto& a = expr; 意味著用 expr 去匹配一個假想的 template
f(const T&) 函式模板,結果為常左值引用型別。 - auto&& a = expr; 意味著用 expr 去匹配一個假想的 template
f(T&&) 函式模板,根據轉發引用和引用坍縮規則,結果是一個跟 expr 值類別相同的引用型別。
decltype
兩個基本用法
- decltype(變數名) 可以獲得變數的精確型別。
- decltype(表示式) (表示式不是變數名,但包括 decltype((變數名)) 的情況)可以獲得表示式的引用型別;除非表示式的結果是個純右值(prvalue),此時結果仍然是值型別。
如果上述規則看不懂,那麼你可以移步到:https://subingwen.cn/cpp/autotype/#2-decltype
如果我們有 int a;,那麼:
- decltype(a) 會獲得 int(因為 a 是 int)。 即a 是一個變數
- decltype((a)) 會獲得 int&(因為 a 是 lvalue)。 即**(a)可以看作是一個表示式
- decltype(a + a) 會獲得 int(因為 a + a 是 prvalue)。 雖然 a+a 是表示式但是為純右值,同第一個例子一樣
- decltype(a=a)
下列編譯通過
int main() {
int a = 0;
decltype(a) b;
decltype((a)) c=a; //erro decltype((a)) c=10;
decltype(a + a) d = 10; //純右值同b
decltype(a = a) e = a; //表示式:且不為純右值
return 0;
}
decltype(auto)
通常情況下,能寫 auto 來宣告變數肯定是件比較輕鬆的事。
但這兒有個限制,你需要在寫下 auto 時就決定你寫下的是個引用型別還是值型別。
根據型別推導規則,auto 是值型別,auto& 是左值引用型別,auto&& 是轉發引用(可以是左值引用,也可以是右值引用)。
使用 auto 不能通用地根據表示式型別來決定返回值的型別。不過,decltype(expr) 既可以是值型別,也可以是引用型別。因此,我們可以這麼寫:
decltype(expr) a = expr;
這種寫法明顯不能讓人滿意,特別是表示式很長的情況(而且,任何程式碼重複都是潛在的問題)。為此,C++14 引入了 decltype(auto) 語法。對於上面的情況,我們只需要像下面這樣寫就行了。
decltype(auto) a = expr;
這種程式碼主要用在通用的轉發函式模板中:你可能根本不知道你呼叫的函式是不是會返回一個引用。這時使用這種語法就會方便很多。
函式返回值型別推斷
從 C++14 開始,函式的返回值也可以用 auto 或 decltype(auto) 來聲明瞭。同樣的,用 auto 可以得到值型別,用 auto& 或 auto&& 可以得到引用型別;而用 decltype(auto) 可以根據返回表示式通用地決定返回的是值型別還是引用型別。
和這個形式相關的有另外一個語法,後置返回值型別宣告。嚴格來說,這不算“型別推斷”,不過我們也放在一起講吧。它的形式是這個樣子:
auto foo(引數) -> 返回值型別宣告
{
// 函式體
}
通常,在返回型別比較複雜、特別是返回型別跟引數型別有某種推導關係時會使用這種語法。
類模板的模板引數推導
如果你用過 pair 的話,一般都不會使用下面這種形式:
pair<int, int> pr{1, 42};
使用 make_pair 顯然更容易一些:
auto pr = make_pair(1, 42);
這是因為函式模板有模板引數推導,使得呼叫者不必手工指定引數型別;但 C++17 之前的類模板卻沒有這個功能,也因而催生了像 make_pair 這樣的工具函式。
知其然知其所以然
在進入了 C++17 的世界後,這類函式變得不必要了。現在我們可以直接寫:
pair pr{1, 42};
在初次見到 array 時,我覺得它的主要缺點就是不能像 C 陣列一樣自動從初始化列表來推斷陣列的大小了:
int a1[] = {1, 2, 3};
array<int, 3> a2{1, 2, 3}; // 囉嗦
// array<int> a3{1, 2, 3}; 不行
這個問題在 C++17 裡也是基本不存在的。雖然不能只提供一個模板引數,但你可以兩個引數全都不寫
array a{1, 2, 3};
// 得到 array<int, 3>
這種自動推導機制,可以是編譯器根據建構函式來自動生成:
template <typename T>
struct MyObj {
MyObj(T value);
…
};
MyObj obj1{string("hello")};
// 得到 MyObj<string>
MyObj obj2{"hello"};
// 得到 MyObj<const char*>
也可以是手工提供一個推導向導,達到自己需要的效果:
template <typename T>
struct MyObj {
MyObj(T value);
…
};
MyObj(const char*) -> MyObj<string>;
MyObj obj{"hello"};
// 得到 MyObj<string>
結構化繫結
在講關聯容器的時候我們有過這樣一個例子:
multimap<string, int>::iterator
lower, upper;
std::tie(lower, upper) =
mmp.equal_range("four");
這個例子裡,返回值是個 pair,我們希望用兩個變數來接收數值,就不得不聲明瞭兩個變數,然後使用 tie 來接收結果。在 C++11/14 裡,這裡是沒法使用 auto 的。好在 C++17 引入了一個新語法,解決了這個問題。目前,我們可以把上面的程式碼簡化為:
auto [lower, upper] =
mmp.equal_range("four");
這個語法使得我們可以用 auto 宣告變數來分別獲取 pair 或 tuple 返回值裡各個子項,可以讓程式碼的可讀性更好。
推薦大家閱讀:https://mp.weixin.qq.com/s/jS2NjcmzTHJwrPutnLlOXw
列表初始化
在 C++98 裡,標準容器比起 C 風格陣列至少有一個明顯的劣勢:不能在程式碼裡方便地初始化容器的內容。比如,對於陣列你可以寫:
int a[] = {1, 2, 3, 4, 5};
而對於 vector 你卻得寫:
vector<int> v;
v.push(1);
v.push(2);
v.push(3);
v.push(4);
v.push(5);
這樣真是又囉嗦,效能又差,顯然無法讓人滿意。於是,C++ 標準委員會引入了列表初始化,允許以更簡單的方式來初始化物件。現在我們初始化容器也可以和初始化陣列一樣簡單了:
vector<int> v{1, 2, 3, 4, 5};
同樣重要的是,這不是對標準庫容器的特殊魔法,而是一個通用的、可以用於各種類的方法。從技術角度,編譯器的魔法只是對 {1, 2, 3} 這樣的表示式自動生成一個初始化列表,在這個例子裡其型別是 initializer_list
這裡要注意的是你的類要包含一個接受接受 initializer_list 的建構函式,而這個容器引數中的元素型別相同!
統一初始化
你可能已經注意到了,我在程式碼裡使用了大括號 {} 來進行物件的初始化。這當然也是 C++11 引入的新語法,能夠代替很多小括號 () 在變數初始化時使用。這被稱為統一初始化(uniform initialization)。
大括號對於構造一個物件而言,最大的好處是避免了 C++ 裡“最令人惱火的語法分析”(the most vexing parse)。
介紹一個坑
假設你有一個類,原型如下:
class utf8_to_wstring {
public:
utf8_to_wstring(const char*);
operator wchar_t*();
};
然後你在 Windows 下想使用這個類來幫助轉換檔名,開啟檔案:
ifstream ifs(
utf8_to_wstring(filename));
你隨後就會發現,ifs 的行為無論如何都不正常。最後,要麼你自己查到,要麼有人告訴你,上面這個寫法會被編譯器認為是和下面的寫法等價的:
ifstream ifs(
utf8_to_wstring filename);
換句話說,編譯器認為你是聲明瞭一個叫 ifs 的函式,而不是物件!
如果你把任何一對小括號替換成大括號(或者都替換,如下),則可以避免此類問題:
ifstream ifs{
utf8_to_wstring{filename}};
推而廣之,你幾乎可以在所有初始化物件的地方使用大括號而不是小括號。它還有一個附帶的特點:當一個建構函式沒有標成 explicit 時,你可以使用大括號不寫類名來進行構造,如果呼叫上下文要求那類物件的話。如:
Obj getObj()
{
return {1.0};
}
如果 Obj 類可以使用浮點數進行構造的話,上面的寫法就是合法的。如果有無引數、多引數的建構函式,也可以使用這個形式。除了形式上的區別,它跟 Obj(1.0) 的主要區別是,後者可以用來呼叫 Obj(int),而使用大括號時編譯器會拒絕“窄”轉換,不接受以 {1.0} 或 Obj{1.0} 的形式呼叫建構函式 Obj(int)。
換句話說用{}來呼叫建構函式,對引數的要求更加苛刻
舉個小例子
#include <utility>
#include <iostream>
#include <initializer_list>
using namespace std;
class A {
private:
int x;
int y;
public:
A(int a,int b) :x(a),y(b){}
void pp() {
cout << x << endl;
cout << y << endl;
}
};
int main() {
//A a{1.0,2.0}; //error
A a( 1.0,2.0 ); //ok
a.pp();
system("pause");
return 0;
}
這個語法主要的限制是,如果一個類既有使用初始化列表的建構函式,又有不使用初始化列表的建構函式,那編譯器會千方百計地試圖呼叫使用初始化列表的建構函式,導致各種意外。所以,如果給一個推薦的話,那就是:
- 如果一個類沒有使用初始化列表的建構函式時,初始化該類物件可全部使用統一初始化語法。
- 如果一個類有使用初始化列表的建構函式時,則只應用在初始化列表構造的情況。
類資料成員的預設初始化
按照 C++98 的語法,資料成員可以在建構函式裡進行初始化。這本身不是問題,但實踐中,如果資料成員比較多、建構函式又有多個的話,逐個去初始化是個累贅,並且很容易在增加資料成員時漏掉在某個建構函式中進行初始化。為此,C++11 增加了一個語法,允許在宣告資料成員時直接給予一個初始化表示式。這樣,當且僅當建構函式的初始化列表中不包含該資料成員時(即如果你忘記了的話),這個資料成員就會自動使用初始化表示式進行初始化。
class Complex {
public:
Complex()
: re_(0) , im_(0) {}
Complex(float re)
: re_(re), im_(0) {}
Complex(float re, float im)
: re_(re) , im_(im) {}
…
private:
float re_;
float im_;
};
使用資料成員的預設初始化的話,我們就可以這麼寫:
class Complex {
public:
Complex() {}
Complex(float re) : re_(re) {}
Complex(float re, float im)
: re_(re) , im_(im) {}
private:
float re_{0};
float im_{0};
};
第一個建構函式沒有任何初始化列表,所以類資料成員的初始化全部由預設初始化完成,re_ 和 im_ 都是 0。第二個建構函式提供了 re_ 的初始化,im_ 仍由預設初始化完成。第三個建構函式則完全不使用預設初始化