當auto會推斷出不合理的類型時使用顯式類型初始化語法
條款5中我們說明了使用auto聲明類型會比顯式聲明類型更好,但是有時候auto類型推斷會和我們想象中有點差別。例如,我有一個函數以Widget
爲參數,返回std::vector<bool>
,容器中每個bool
表示Widget
是否提供特別的性質:
std::vector<bool> features(const Widget &w);
然後,如果第5個元素代表Widget
的高屬性的話,我們可以寫出下面的代碼:
Widget w;
...
bool highPriority = features(w)[5]; // w是否具有高屬性?
...
processWidget(w, highPriority); // 根據w的高屬性來處理它
這代碼沒有錯誤,它可以運行得很好。但是如果我們做一個看似無關緊要的改變,用auto聲明代替顯式類型聲明:
auto highPriority = features(w)[5];
現在情況改變了,代碼編譯可以通過,但是代碼不會像預期那樣運行了:
processWidget(w,highPriority);
// 未定義行爲
就像註釋所說的那樣,processWidget
函數現在的行爲是未定義的。爲什麼呢?答案可能會讓你感到驚訝,在auto的那代碼中,highPriority 變量的類型不再是bool類型了。儘管std::vector<bool>
概念上是持有bool類型,但是std::vector<bool>
的operator[]函數返回的並不是容器中元素的引用(除了std::vector<bool>
,std::vector::operator[]
都會返回容器內元素的引用)。與之代替的是,它返回std::vector<bool>::reference
類型(std::vector<bool>
中的嵌套類)。
std::vector<bool>::reference
的存在是因爲std::vector<bool>
容器是以位(bit)的方式來存儲bool變量。這就引起了operator[]函數出現問題,因爲std::vector<T>
的operator[]函數應該是返回T&類型,但是C++無法引用位bit。因此std::vector<bool>
的operator[]函數無法返回bool&,只能返回一個行爲類似(acks like)bool&的對象,這個對象必須可以用於所有需要bool&的地方。在這些情況下,std::vector<bool>::reference
會隱式轉換爲bool類型(不是bool&)。
請記住這個特性,然後重新看看我們一開始的代碼:
bool highPriority = features(w)[5];
features 函數返回一個std::vector<bool>
對象,然後該對象調用了operator[]函數,這個函數返回一個std::vector<bool>::reference
對象,接着該對象隱式轉換爲一個bool類型變量來初始化highPriority。最終highPriority 的值爲features 函數返回的std::vector<bool>
對象的第五個元素的值,就是代碼看上去那樣。
對比用auto聲明的highPriority:
auto highPriority = features(w)[5];
// 類型推斷
同樣地,features函數返回一個std::vector<bool>
對象,然後該對象調用了operator[]函數,這個函數返回一個std::vector<bool>::reference
對象,但這次不一樣,因爲highPriority 的類型是通過auto推斷的,highPriority 的值不再是features 函數返回的std::vector<bool>
對象的第五個元素的值了。
highPriority 的值取決於std::vector<bool>::reference
的實現。一種實現是該對象內有一個指向容器內字(word)數據結構的偏移量特定個位(bit)數的指針。
feature 函數返回的是個臨時的std::vector<bool>
對象,這個對象沒有名字,爲了方便討論我們稱它爲temp。temp調用了它的operator[]函數,返回的std::vector<bool>::reference
對象包含了一個指向字(word)的偏移量爲5的指針。所以highPriority 是這個std::vector<bool>::reference
對象的拷貝,那麼highPriority內也有一個指針,指向temp中的字數據(word)偏移量爲5的位。當聲明定義highPriority結束後,temp被析構,那麼highPriority中的指針就變成了空懸指針(dangling pointer),這就造成了調用processWidget 時產生未定義行爲:
processWidget(w, highPriority);
// 未定義行爲
// highPriority 內含有空懸指針
std::vector<bool>::reference
是代理類(proxy class)的一個例子:一個類的存在是爲了模仿和增強另一個類的行爲。代理類在很多情況下被使用,例如,std::vector<bool>::reference
的存在是爲了給你一個假象,讓你以爲std::vector<bool>
的operator[]函數返回的是位bit的引用;再比如說標準庫中的智能指針接收原生指針的內存管理。代理類的使用是根深蒂固的,事實上,代理模式也是一個長時間被褒獎的設計模式。
一些代理類的設計是被用戶使用的,例如std::shared_ptr
和std::unique_ptr
,而另一些代理類的設計是默默工作的,例如std::vector<bool>::reference
,它的同胞是std::bitset::reference
。
與它們同一陣營的還有C++庫中一些使用表達式模板(expression templates)的類。開發這些庫文件是爲了寫出高效數字化的代碼。例如,一個矩陣類Matrix 以及4個Matrix 對象m1, m2, m3, m4,給定下面的表達式:
Matrix sum = m1 + m2 + m3 + m4;
如果Matrix 對象的operator+函數返回的是一個代理類,而不是返回Matrix,那麼計算的效率可以高很多,比如operator+函數返回一個像Sum<Matrix,Matrix>
的代理類來替代Matrix。就像std::vector<bool>::reference
和bool那樣,代理類也可以隱式轉換爲Matrix,也可以通過“=”來初始化sum(本人不懂模板元編程)。
總結一條規則,auto應付不好那些不想被用戶知道的代理類。這裏代理類的對象通常都是聲明表達式結束後就析構(作爲臨時變量),所以創建這種類型的變量是背離了這些庫設計的初衷。例如std::vector<bool>::reference
這個例子就違反了這個初衷,從而引發了未定義行爲。
所以你應該避免這樣的代碼:
auto someVar = expression of "invisible" proxy class type;
但是你怎麼知道哪些是代理類呢,程序是不會顯示它們的實體的,他們應該是不可見的,至少在概念上。就算你真的發現了他們,那麼你真的會放棄條款5所說的auto的優勢嗎?
我們先來解決如何發現它們這個問題。儘管代理類在程序設計中是不可見的,但是庫文件會有文檔告知。你對你使用的庫文件的設計越熟悉,你使用庫中代理類的遇到的坑就越少。
如果庫文檔很讓人失望,那麼看頭文件來彌補。源代碼完全遮掩代理類是很少可能發生的,代理類通常是因爲客戶調用某個函數而返回的,所以調用函數的簽名會反應出它們的實體。例如,這是std::vector<bool>::operator[]
的說明:
namespace std {
template <class Allocator>
class vector<bool, Allocator> {
public:
...
class reference { ... };
reference operator[](size_type n);
...
};
}
假定你已經知道std::vector<T>
的operator[]函數正常會返回T&,那麼這種非常規的返回一個代理類會是一種警告。仔細留意你使用的接口,你會經常發現代理類的實體。
在實踐中,大部分開發者只會在追查模糊的編譯錯誤或者修改單元測試的bug時纔會發現代理類型的使用。不管你是怎麼發現它們的,我想說的只要auto推斷出的類型是代理類,而不是代理類所代理的類,那麼解決辦法不一定需要放棄使用auto。auto本身是沒有問題的,問題只是auto沒有推斷出我們想要的類型。解決辦法是強迫進行不一樣的類型推斷,這種方法我稱爲顯式類型初始化語法(explicitly typed initializer idiom)
顯式類型初始化語法用auto聲明變量,但是初始化表達式顯式說明你想要auto推斷的類型。下面這個例子說明它如何強迫highPriority 推斷爲bool類型:
auto highPriority = static_cast<bool>(features(w)[5]);
雖然features(w)[5]
還是會返回std::vector<bool>::reference
類型,但是類型轉換使得表達式的類型轉換爲bool,然後auto把highPriority 的類型推斷爲bool。
在矩陣Matrix 的那個例子中,我們可以這樣寫:
auto sum = static_cast<Matrix>(m1 + m2 + m3 + m4);
這種語法的應用不限於初始值爲代理類這種情況。這語法還可以用於你故意創建一個與初始表達式類型不一樣的變量。例如,你有個函數計算可容忍誤差值:
double calcEpsilon();
這個函數明確返回double,但是你知道你的應用程序只需要float精確度就足夠了,可是值得擔心double和float的所佔字節大小不同。你可以聲明一個float變量來存儲calcEpsilon函數的結果,
float ep = calcEpsilon();
// 隱式轉換
但是這樣是很難告訴他人:”我是故意減少函數返回結果的精度的“。但是用顯示類型初始化語法可以做到這個意思:
auto ep = static_cast<float>(calcEpsilon());
同樣地,你也可以使用它當你故意把float的值附給整形數。再比如,一個double值,範圍是0.0~1.0,代表需要的元素與容器首元素的距離(0.5爲容器中間的元素),也就是說你需要計算具有隨機訪問迭代器的容器(例如,std::vector,std::deque)中的元素的索引。再進一步說,你需要的索引結構類型是int,那麼假如容器爲c, double值爲d,那麼你可以計算索引:
int index = d * c.size();
但是你故意將右邊的double轉換爲int這個意圖是模糊的,使用顯示類型初始化語義可以清楚地表達這個意圖:
auto index = static_cast<int>(d * c.size());
總結
需要記住的2點:
- 不可見的代理類初始表達式可能會使auto推斷出錯誤的結果
- 顯示類型初始化語法迫使auto推斷你想要的類型