徹底理解c++的隱式類型轉換
隱式類型轉換可以說是我們的老朋友了,在代碼里我們或多或少都會依賴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