隱式類型轉換能夠說是咱們的老朋友了,在代碼裏咱們或多或少都會依賴c++的隱式類型轉換。c++
然而不幸的是隱式類型轉換也是c++的一大坑點,稍不注意很容易寫出各類奇妙的bug。golang
所以我想借着本文來梳理一遍c++的隱式類型轉換,複習的同時也避免其餘人踩到相似的坑。面試
本文索引
借用標準裏的話來講,就是當你只有一個類型T1,可是當前表達式須要類型爲T2的值,若是這時候T1自動轉換爲了T2那麼這就是隱式類型轉換。express
若是你以爲太抽象的話能夠看兩個例子,首先是最多見的混用數值類型:數組
int a = 0; long b = a + 1; // int 轉換爲 long if (a == b) { // 默認的operator==須要a的類型和b相同,所以也發生轉換 }
int轉成long是向上轉換,一般不會有太大問題,而long到int則極可能致使數據丟失,所以要儘可能避免後者。安全
第二個例子是自定義類型到標量類型的轉換:app
std::shared_ptr<int> ptr = func(); if (ptr) { // 這裏會從shared_ptr轉換成bool // 處理數據 }
由於提供了用戶自定義的隱式類型轉換規則,因此咱們能夠很簡單地去判斷智能指針是否爲空。在這裏if表達式裏須要bool,所以ptr轉換爲了bool,這又被叫作語境轉換。ide
理解了什麼是隱式類型轉換轉換以後咱們再來看看那些不容許進行隱式轉換的語言,好比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 *
string
類型const char *
到string
的轉換規則,所以把它轉換成合適的類型string
的臨時量,它會做爲參數調用複製構造函數在這裏咱們暫且忽略了string的寫時複製等黑科技,整個過程建立了s和一個臨時量,一共兩個string。
很快c++11就出現了,同時還帶來了移動語義,然而結果並無改變:
移動語義減小了沒必要要的內部數據的複製,可是臨時量仍是會被建立的。
有進搗鼓編譯器的朋友可能要說了,編譯器是不生成這個臨時量的。是這樣的,編譯器會用複製省略(copy elision)優化這段代碼。
是的,複製省略在c++11裏就已經被提到了,不過那時候它是可選的,並不強制編譯器支持這一優化。所以你在GCC和clang上觀察到的不必定能表明所有的c++編譯器的狀況,因此咱們仍以標準爲基礎推演了理論上的行爲。
到目前爲止答案都是2,然而很快有意思的事情發生了——複製省略在c++17裏成爲了被標準化的行爲。
在c++17裏除非必要,不然臨時量(如今叫作右值的結果對象,一個右值只有在實際須要存在一個臨時變量的狀況下才會建立一個臨時變量,這個過程叫作實質化,建立出來的那個臨時量就是該右值的結果對象)不會被建立,換而言之,T obj = expr
這樣的形式會以expr產生結果直接調用合適的構造函數,而不會進行臨時量的建立和複製構造函數的調用,不過爲了保證語義的完整性,複製構造函數仍然被要求是可訪問的,畢竟類自己不容許複製構造的話複製初始化自己就是不正確的,不能由於複製省略而致使錯誤的代碼被編譯經過。
因此如今過程變成了下面這樣子:
string::string(const char *)
,因而直接調用所以,在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,隱式轉換序列裏是這樣的:
所以你的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