隱式型別轉換可以說是我們的老朋友了,在程式碼裡我們或多或少都會依賴c++的隱式型別轉換。
然而不幸的是隱式型別轉換也是c++的一大坑點,稍不注意很容易寫出各種奇妙的bug。
因此我想借著本文來梳理一遍c++的隱式型別轉換,複習的同時也避免其他人踩到類似的坑。
本文索引
什麼是隱式型別轉換
借用標準裡的話來說,就是當你只有一個型別T1,但是當前表示式需要型別為T2的值,如果這時候T1自動轉換為了T2那麼這就是隱式型別轉換。
如果你覺得太抽象的話可以看兩個例子,首先是最常見的混用數值型別:
int a = 0;
long b = a + 1; // int 轉換為 long
if (a == b) {
// 預設的operator==需要a的型別和b相同,因此也發生轉換
}
int轉成long是向上轉換,通常不會有太大問題,而long到int則很可能導致資料丟失,因此要儘量避免後者。
第二個例子是自定義型別到標量型別的轉換:
std::shared_ptr<int> ptr = func();
if (ptr) { // 這裡會從shared_ptr轉換成bool
// 處理資料
}
因為提供了使用者自定義的隱式型別轉換規則,所以我們可以很簡單地去判斷智慧指標是否為空。在這裡if表示式裡需要bool,因此ptr轉換為了bool,這又被叫做語境轉換。
理解了什麼是隱式型別轉換轉換之後我們再來看看那些不允許進行隱式轉換的語言,比如golang:
var a int32 = 0;
var b int64 = 1;
fmt.Println(a + b) // error!
fmt.Println(int64(a) + b)
編譯器會告訴你型別不同無法運算。一個更災難性的例子如下:
sleepDuration := 2.5
time.Sleep( time.Duration(float64(time.Millisecond) * ratio) ) // 休眠2.5ms
本身是非常簡單的程式碼,然而多層巢狀式的型別轉換帶來了雜音,程式碼可讀性嚴重下降。
這種形式的型別轉換被稱為顯式型別轉換,在c++裡是這樣的:
A a{1};
B b = static_cast<B>(a);
static_cast
被用於將某個型別轉換到其相關的型別,需要使用者指明待轉換到的型別,除此之外還有const_cast
等cast,它們負責了c++中的顯式型別轉換。
由此可見隱式型別轉換轉換可以簡化程式碼的書寫。不過簡化不是沒有代價的,我們細細說來。
基礎回顧
在正式介紹隱式型別轉換之前,我們先要回顧一下基礎知識,放輕鬆。
直接初始化
首先是類的直接初始化。
顧名思義,就是顯式呼叫型別的建構函式進行初始化。舉個例子:
struct A {
A() = default;
A(const A&) = default;
A(int) {}
};
// 這是預設初始化: A a; 注意區分
A a1{}; // c++11的列表初始化
// 不能寫出A a2(),因為這會被認為是函式宣告
A a2(1);
A a3(a2); // 沒錯,顯式呼叫複製建構函式也是直接初始化
auto a4 = static_cast<A>(1);
需要注意的是a4,用static_cast
轉換成型別T的這一步也是直接初始化。
這種初始化方式有什麼用呢?直接初始化會考慮全部的建構函式,而不會忽略explicit修飾的建構函式。
顯式地呼叫建構函式進行直接初始化實際上是顯式型別轉換的一種。
複製初始化
除去預設初始化和直接初始化,剩下的會導致複製的基本都是複製初始化,典型的如下:
A func() {
return A{}; // 返回值會被複制初始化
}
A a5 = 1; // 先隱式轉換,再複製初始化
void func2(A a) {} // 非引用的引數傳遞也會進行復制構造
然而類似A a6 = {1}
的表示式卻不是複製初始化,這是複製列表初始化,會直接選擇合適的非explicit建構函式進行初始化,而不用建立臨時量再進行復制。
複製初始化又起到什麼作用呢?
首先想到的是這樣可以創造某個物件的副本,沒錯,不過還有一個更重要的作用:
如果想要某個型別T1的value能進行到T2的隱式轉換,兩個型別必須滿足這個表示式的呼叫T2 v2 = value
。
而這個形式的表示式正是複製初始化表示式。至於具體的原因,我們馬上就會在下一節看到。
型別構造時的隱式轉換
在進入本節前我們看一道經典的面試題:
std::string s = "hello c++";
請問建立了幾個string呢?如果你脫口而出1個,那麼面試官八成會狡黠一笑,讓你回家等通知去了。
那麼答案是什麼呢?是1個或者2個。什麼,你逗我呢?
先別急,我們分情況討論。首先是c++11之前。
在c++11前題目裡的表示式實際上會導致下面的行為:
- 首先
"hello c++"
是const char[N]
型別的,不過它在表示式中於是退化成const char *
- 然後因為s實際上是處於“宣告即定義”的表示式中,因此適用的只有複製建構函式,而不是過載的=
- 因此等號的右半邊必須也是
string
型別 - 因為正好有從
const char *
到string
的轉換規則,因此把它轉換成合適的型別 - 轉換完會返回一個新的
string
的臨時量,它會作為引數呼叫複製建構函式 - 複製建構函式呼叫完成後s也就建立完畢了。
在這裡我們暫且忽略了string的寫時複製等黑科技,整個過程建立了s和一個臨時量,一共兩個string。
很快c++11就出現了,同時還帶來了移動語義,然而結果並沒有改變:
- 前面步驟相同,字串字面量隱式轉換成string,建立了一個臨時量
- 臨時量是個右值,所以繫結給右值引用,因此移動建構函式被選擇
- 臨時量裡的資料移動到s裡,s建立完成
移動語義減少了不必要的內部資料的複製,但是臨時量還是會被建立的。
有進搗鼓編譯器的朋友可能要說了,編譯器是不生成這個臨時量的。是這樣的,編譯器會用複製省略(copy elision)優化這段程式碼。
是的,複製省略在c++11裡就已經被提到了,不過那時候它是可選的,並不強制編譯器支援這一優化。因此你在GCC和clang上觀察到的不一定能代表全部的c++編譯器的情況,所以我們仍以標準為基礎推演了理論上的行為。
到目前為止答案都是2,然而很快有意思的事情發生了——複製省略在c++17裡成為了被標準化的行為。
在c++17裡除非必要,否則臨時量(現在叫做右值的結果物件,一個右值只有在實際需要存在一個臨時變數的情況下才會建立一個臨時變數,這個過程叫做實質化,建立出來的那個臨時量就是該右值的結果物件)不會被建立,換而言之,T obj = expr
這樣的形式會以expr產生結果直接呼叫合適的建構函式,而不會進行臨時量的建立和複製建構函式的呼叫,不過為了保證語義的完整性,複製建構函式仍然被要求是可訪問的,畢竟類本身不允許複製構造的話複製初始化本身就是不正確的,不能因為複製省略而導致錯誤的程式碼被編譯通過。
所以現在過程變成了下面這樣子:
- 編譯器發現表示式是string的複製初始化
- 右側是表示式會隱式轉換產生一個string的純右值用於初始化同一型別的s
- 判斷複製建構函式是否可用,然後發現符合複製省略的條件
- 尋找string裡是否有符合要求的建構函式
- 找到了
string::string(const char *)
,於是直接呼叫 - s初始化完成
因此,在c++17下只會建立一個string物件,這比移動語義更加高效。這也是為什麼我說題目的答案既可以是1也可以是2的原因。
同時我們還發現,在複製構造時的型別轉換不管複製有沒有被省略都是存在的,只不過換了一個形式,這就是我們後面要講的內容。
隱式轉換是如何工作的
複習完基礎知識,我們可以進入正題了。
隱式轉換可以分為兩個部分,標準定義的轉換和使用者自定義的轉換。我們先來看看它們是什麼。
標準轉換
也就是編譯器裡內建的一些型別轉換規則,比如陣列退化成指標,函式轉換成函式指標,特定語境下要求的轉換(if裡要求bool型別的值),整數型別提升,數值轉換,資料型別指標到void指標的轉換,nullptr_t到資料型別指標的轉換等。
底層const和volatie也可以被轉換,只不過只能新增不能減少,可以把T*
轉換成const T*
,但反過來是不可以的。
這些轉換基本都是針對標量型別和陣列這種內建的聚合型別的。
如果想要指定自定義型別的轉換規則,則需要編寫使用者自定義型別轉換的介面了。
使用者自定義轉換
說了這麼多,也該看看使用者自定義轉換了。
使用者能控制的自定義轉換介面一共也就兩個,轉換建構函式和使用者定義轉換函式。
轉換建構函式就是隻類似T(T2)
這樣的建構函式,它擁有一個顯式的T2型別的引數,通過這個建構函式可以實現從T2轉換型別至T1的效果。
使用者定義轉換函式是類似operator T2()
這樣的類方法,注意不需要指定返回值。通過它可以實現從T1轉換到T2。可轉換的型別包括自身T1(還可附加cv限定符,或者引用)、T1的基類(或引用)以及void。
舉個例子:
struct A {};
struct B {
// 轉換建構函式
B(int);
B(const A&);
// 使用者定義轉換函式,不需要顯式指定返回值
operator A();
operator int();
}
上面的B自定義了轉換規則,既可以從int和A轉換成B,也可以從B轉換成int和A。
不難看出規則是這樣的:
T <---轉換建構函式--- 其他型別
T ---使用者定義轉換函式---> 其他型別
這裡的轉換建構函式是指沒有explicit
限定的,有的話就不能用於隱式型別轉換。
從c++11開始explicit
還可以用於使用者定義的轉換函式,例如:
template <typename T>
struct SmartPointer {
//...
T *ptr = nullptr;
// 方便判斷指標是否為空
explicit operator bool() {
return ptr != nullptr;
}
};
SmartPointer<int> p = func();
if (p) {
p << 1; // 這是不允許的
}
這樣的型別轉換函式只能用於顯式初始化以及特定語境要求的型別轉換(比如if裡的條件表示式要求返回bool值,這算隱式轉換的一種),因此可以避免註釋標註的那種語義錯誤。因此這類轉換函式也無法用於其他的隱式轉換。
c++11開始函式可以自動推導返回值,模板和自動推到也可以用於自定義的轉換函式:
template <typename T>
struct SmartPointer {
//...
T *ptr = nullptr;
explicit operator bool() {
return ptr != nullptr;
}
// 配合模板引數
operator T*() {
return ptr;
}
/* 自動推到返回值,與上一個同義
operator auto() {
return ptr;
}
*/
};
SmartPointer<int> p = func();
int *p1 = p;
最後使用者自定義的轉換函式還可以是虛擬函式,但是隻有從基類的引用或指標進行派發的時候才會呼叫子類實現的轉換函式:
struct D;
struct B {
virtual operator D() = 0;
};
struct D : B
{
operator D() override { return D(); }
};
int main()
{
D obj;
D obj2 = obj; // 不呼叫 D::operator D()
B& br = obj;
D obj3 = br; // 通過虛派發呼叫 D::operator D()
}
使用者定義轉換函式不能是類的靜態成員函式。
隱式轉換序列
瞭解完標準內建的轉換規則和使用者自定義的轉換規則,我們該看看隱式轉換的工作機制了。
對於需要進行隱式轉換的上下文,編譯器會生成一個隱式轉換序列:
- 零個或一個由標準轉換規則組成的標準轉換序列,叫做初始標準轉換序列
- 零個或一個由使用者自定義的轉換規則構成的使用者定義轉換序列
- 零個或一個由標準轉換規則組成的標準轉換序列,叫做第二標準轉換序列
對於隱式轉換髮生在建構函式的引數上時,第二標準轉換序列不存在。
初始標準轉換序列很好理解,在呼叫使用者自定義轉換前先把值的型別處理好,比如加上cv限定符:
struct A {};
struct B {
operator A() const;
};
const B b;
const A &a = b;
初始標準轉換序列會把值先轉換成適當的形式以供使用者轉換序列使用,在這裡operator A() const
希望傳進來的this是const B*
型別的,而對b直接取地址只能得到B*
,正好標準轉換規則裡有新增底層const的規則,所以適用。
如果值的型別正好,不需要任何預處理,那麼初始標準轉換序列不會做任何多餘的操作。
如果第一步還不能轉換出合適的型別,那麼就會進入使用者定義轉換序列。
如果型別是直接初始化,那麼只會呼叫轉換建構函式;如果是複製初始化或者引用繫結,那麼轉換建構函式和使用者定義轉換函式會根據過載決議確定使用誰。另外如果轉換函式不是const限定的,那麼在兩者都是可行函式時優先選擇轉換函式,比如operator A();
這樣的,否則會報錯有歧義(GCC 10.2上測試顯示有歧義的時候會選擇轉換建構函式,clang++11.0和標準描述一致)。這也是我們複習了幾種初始化有什麼區別的原因,因為類的構造形式不同結果也可能會不同。
選擇好一個規則後就可以進入下一步了。
如果是在建構函式的引數上,那麼隱式轉換到此就結束了。除此之外我們需要進行第三部。
第三部是針對使用者轉換序列處理後的值的型別做一些善後工作。之所以不允許在建構函式的引數上執行這一步是因為防止過度轉換後和使用者轉換規則產生迴圈。
舉個例子:
struct A
{
operator int() const;
};
A a;
bool b = a;
在這裡a只能轉換成int,而為了偷懶我們直接把a隱式轉換成bool,問題來了,初始標準轉換序列把A*
轉換成了const A*
(作為this,類方法的隱式引數),使用者轉換序列把const A*
轉換為了int,int和bool是完全不同的型別,怎麼辦呢?
這就用上第二標準轉換序列了,這裡是數值轉換,int轉成bool。
不過上面只是個例子,請不要這麼寫,因為在實際程式碼中會出現問題:
template <typename T>
struct SmartPointer {
//...
T *ptr = nullptr;
operator bool() {
return ptr != nullptr;
}
T& operator*() {
return *ptr;
}
};
auto ptr = get_smart_pointer();
if (ptr) {
// ptr 是int*的包裝,現在我們想取得ptr指向的值
int value = p;
// ...
}
上面的程式碼不會有任何編譯錯誤,然而它將引發嚴重的執行時錯誤。
為什麼呢?因為如註釋所說我們想取得指標指向的值,然而我們忘記解引用了!實際上因為要轉換成int,隱式轉換序列裡是這樣的:
- 初始標準轉換序列 -----> 當前型別已經呼叫使用者轉換序列的要求了,什麼都不做
- 使用者定義轉換序列 -----> 和int最接近的有轉換關係的型別只有bool了,呼叫這個
- 第二標準轉換序列 -----> 得到了bool,目標的int,正好有規則可用,進行轉換
因此你的value只會有兩種值,0和1。這就是隱式轉換帶來的第一個大坑,而上面程式碼反應出的問題叫做“安全bool(safe bool)”問題。
好在我們可以用explicit
把它踢出轉換序列:
template <typename T>
struct SmartPointer {
//...
T *ptr = nullptr;
explicit operator bool() {
return ptr != nullptr;
}
};
這樣當再寫出int value = p
的時候編譯器就能及時發現並報錯啦。
第二標準轉換序列的本意是幫我們善後,畢竟類的編寫者很難面面俱到,然而也正是如此帶來了一些坑點。
還有另外一點要注意,標準規定了如果使用者轉換序列轉換出了一個左值(比如一個左值引用),而最終轉換目標的右值引用,那麼標準轉換中的左值轉換為右值的規則不可用,程式是無法通過編譯的,比如:
struct A
{
operator int&();
};
int&& b = A();
編譯上面的程式碼,g++會獎勵你一句cannot bind rvalue reference of type ‘int&&’ to lvalue of type ‘int’
。
如果隱式轉換序列裡一個可行的轉換都沒有呢?那很遺憾,只能編譯報錯了。
隱式轉換引發的問題
現在我們已經知道隱式轉換的工作方式了。而且我們也看到了隱式型別轉換是如何闖禍的。
下面將要介紹隱式型別轉換闖了禍怎麼善後,以及怎麼防患於未然。
是時候和實際應用碰撞出點火花了。
引用繫結
第一個問題是和引用相關的。不過與其說是隱式轉換惹的禍倒不如說是引用繫結自身的坑。
我們知道對於一個型別T,可以有這幾種引用型別:
T&
,T的引用,只能繫結到T型別的左值const T&
,const T的引用,可以繫結到T的左值和右值,以及const T的左值和右值T&&
,T的右值引用,只能繫結到T型別的右值const T&&
,一般來說見不到,然而當你對一個const T&
使用std::move
就能得到這東西了
引用必須在宣告的同時進行初始化,所以下面這樣的程式碼應該是大家再熟悉不過的了:
int num = 0;
const int &a = num;
int &b = num;
int &&c = 100;
const int &d = 100;
新的問題出現了,考慮一下如下程式碼的執行結果:
int a = 10;
long &b = a;
std::cout << b << std::endl;
不是10嗎?還真不是:
c.cpp: In function ‘int main()’:
c.cpp:6:11: error: cannot bind non-const lvalue reference of type ‘long int&’ to an rvalue of type ‘long int’
6 | long &b = a;
|
報錯說得很清楚了,一個普通的左值引用不能繫結到一個右值上。因為a是int,b是long,所以a想賦值給b就必須先隱式轉換成long。
隱式轉換除非是轉成引用型別,否則一般都是右值,所以這裡報錯了。解決辦法也很簡單:
long b1 = a;
const long &b2 = a;
要麼直接複製構造一個新的long型別變數,值型別的變數可以從右值初始化;要麼使用const左值引用,因為它能繫結到右值。
擴充套件一下,函式的引數傳遞也是如此:
void func(unsigned int &)
{
std::cout << "lvalue reference" << std::endl;
}
void func(const unsigned int &)
{
std::cout << "const lvalue reference" << std::endl;
}
int main()
{
int a = 1;
func(a);
}
結果是“const lvalue reference”,這也是為什麼很多教程會叫你儘量多使用const lvalue引用的原因,因為除了本身的型別T,這樣的函式還可以通過隱式型別轉換接受其他能轉換成T的資料做引數,並且相比建立一個物件並複製初始化成引數,應用的開銷更小。當然右值最優先匹配的是右值引用,所以如果有形如void func(unsigned int &&)
的過載存在,那麼這個過載會被呼叫。
最典型的應用非下面的例子莫屬了:
template <typename... Args>
void format_and_print(const std::string &s, Args&&... args)
{
// 實現格式化並列印出結果
}
std::string info = "%d + %d = %d\n";
format_and_print(info, 2, 2, 4);
format_and_print("%d * %d = %d\n", 2, 2, 4);
只要能隱式轉換成string,就能直接呼叫我們的函式。
最重要的一點,隱式型別轉換產生的通常是右值。(當然顯式型別轉換也一樣,不過在隱式轉換的時候更容易忘了這點)
陣列退化
同樣是隱式轉換帶來的經典問題:陣列在求值表示式中退化成指標。
你能給出下面程式碼的輸出嗎:
void func(int arr[])
{
std::cout << (sizeof arr) << std::endl;
}
int main()
{
int a[100] = {0};
std::cout << (sizeof a) << std::endl;
func(a);
}
在我的amd64 Linux上使用GCC 10.2編譯執行的結果是400和8,後者其實是該系統上int*
的大小。因為sizeof不求值而函式引數傳遞是求值的,所以陣列退化成了指標。
這樣的隱式轉換帶來的壞處是什麼呢?答案是陣列的長度丟失了。假如你不知道這一點,在函式中仍然用sizeof去求陣列的大小,那麼難免不會出問題。
解決辦法有很多,比如最簡單的藉助模板:
template <std::size_t N>
void func(int (&arr)[N])
{
std::cout << (sizeof arr) << std::endl; // 400
std::cout << N << std::endl; // 100
}
現在N是100,而sizeof會返回400,因為sizeof一個引用會返回引用指向的型別的大小,這裡是int [100]
。
一個更簡單也更為現代c++推崇的做法是放棄原始陣列,把它當做沉重的歷史包袱丟棄掉,轉而使用std::array
和即將到來的std::span
。這些更現代化的陣列替代品可以更好得代替原始陣列而不會發生諸如隱式轉換成指標等問題。
兩步轉換
還有不少教程會告訴你在隱式轉換的時候超過一次的型別轉換是不可以的,我習慣把這種問題叫做“兩步轉換”。
為什麼叫兩步轉換呢?假如我們有ABC三個型別,A可以轉B,B可以轉C,他們是單步的轉換,而如果我們需要把A轉成C,就需要先把A轉成B,因為A不能直接轉換成C,因此形成了一個轉換鏈:A -> B -> C
,其中進行了兩次型別轉換,我稱其為兩步轉換。
下面是一個典型的“兩步轉換”:
struct A{
A(const std::string &s): _s{s} {}
std::string _s;
};
void func(const A &s)
{
std::cout << s._s << std::endl;
}
int main()
{
func("two-steps-implicit-conversion");
}
我們知道const char*
能隱式轉換到string,然後string又可以隱式轉換成A:const char* -> string -> A
,而且函式引數是個常量左值引用,應該能繫結到隱式轉換產生的右值。然而用g++編譯程式碼會是下面的結果:
test.cpp: In function 'int main()':
test.cpp:15:10: error: invalid initialization of reference of type 'const A&' from expression of type 'const char [30]'
15 | func("two-steps-implicit-conversion");
| ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
test.cpp:8:20: note: in passing argument 1 of 'void func(const A&)'
8 | void func(const A &s)
| ~~~~~~~~~^
果然報錯了。可是這真的是因為兩步轉換帶來的結果嗎?我們稍微改一改程式碼:
struct A{
A(bool b)
{
_s = b ? "received true" : "received false";
}
std::string _s;
};
void func(const A &s)
{
std::cout << s._s << std::endl;
}
int main()
{
int num = 0;
func(num); // received false
unsigned long num2 = 100;
func(num2); // received true
}
這次不僅編譯通過,而且指定-Wall -Wextra
也不會有任何警告,輸出也是正常的。
那就怪了,這裡的兩次呼叫分別是int -> bool -> A
和unsigned long -> bool -> A
,很明星的兩步轉換,怎麼就是合法的正常程式碼呢?
其實答案早在隱式轉換序列那節就告訴過你了:
一個隱式型別轉換序列包括一個初始標準轉換序列、一個使用者定義轉換序列、一個第二標準轉換序列
也就是說不存在什麼兩步轉換問題,本身轉換序列最少可以轉換1次,最多可以三次。兩次轉換當然沒問題了。
唯一會觸發問題的是出現了兩次使用者定義轉換,因為隱式轉換序列裡只允許一次使用者定義轉換,語言標準也規定了不允許出現多餘一次的使用者定義轉換:
At most one user-defined conversion (constructor or conversion function) is implicitly applied to a single value. -- 12.3 Conversions [class.conv]
所以這條轉換鏈:const char* -> string -> A
是有問題的,因為從字串字面量到string和string到A都是使用者定義轉換。
而int -> bool -> A
和unsigned long -> bool -> A
這兩條是沒問題的,第一次轉換是初始標準轉換序列完成的,第二次是使用者定義轉換,整個過程合情合理。
由此看來教程們只說對了一半,“兩步轉換”的癥結在於一次隱式轉換中不能出現兩次使用者定義的型別轉換,這個問題叫做“兩步自定義轉換”更恰當。
使用者定義的型別轉換隻能出現在自定義型別中,這其中包括了標準庫。所以換句話說,當你有一條A -> B -> C
這樣的隱式轉換鏈的時候,如果其中有兩個都是自定義型別,那麼這個隱式轉換是錯誤的。
唯一的解決辦法就是把第一次發生的使用者自定義轉換改成顯式型別轉換:
struct A{
A(const std::string &s): _s{s} {}
std::string _s;
};
void func(const A &s)
{
std::cout << s._s << std::endl;
}
int main()
{
func(std::string{"two-steps-implicit-conversion"});
}
現在隱式轉換序列裡只有一次自定義轉換了,問題也就不會發生了。
總結
相信現在你已經徹底理解c++的隱式型別轉換了,常見的坑應該也能繞過了。
但我還是得給你提個醒,儘量不要去依賴隱式型別轉換,多用explicit
和各種顯式轉換,少想當然。
Keep It Simple and Stupid.
參考資料
https://zh.cppreference.com/w/cpp/language/copy_elision
http://www.cplusplus.com/doc/tutorial/typecasting/
https://en.cppreference.com/w/cpp/language/implicit_conversion
https://zh.cppreference.com/w/cpp/language/cast_operator
https://www.nextptr.com/tutorial/ta1211389378/beware-of-using-stdmove-on-a-const-lvalue
https://en.cppreference.com/w/cpp/language/reference_initialization