1. 程式人生 > 其它 >08 | 易用性改進 I:自動型別推斷和初始化

08 | 易用性改進 I:自動型別推斷和初始化

如果要挑選 C++11 帶來的最重大改變的話,自動型別推斷肯定排名前三。如果只看易用性或表達能力的改進的話,那它就是“捨我其誰”的第一了。

auto


自動型別推斷,顧名思義,就是編譯器能夠根據表示式注意是“表示式”!!接下來會和decltype對比) 的型別,自動決定變數的型別(從 C++14 開始,還有函式的返回型別),不再需要程式設計師手工宣告。但需要說明的是,auto 並沒有改變 C++ 是靜態型別語言 這一事實——使用 auto 的變數(或函式返回值)的型別仍然是編譯時編譯時多型:可以用模板;執行時多型:可以用虛擬函式機制)就確定了,只不過編譯器能自動幫你填充而已。

c++是靜態語言,如果你想 在函式的引數

裡 用auto那是不可以的,但是你可以用函式模板實現你的目的

不用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 的建構函式即可使用。從效率的角度,至少在動態物件的情況下,容器和陣列也並無二致,都是通過拷貝(構造)進行初始化。
這裡要注意的是你的類要包含一個接受接受 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_ 仍由預設初始化完成。第三個建構函式則完全不使用預設初始化