隐式类型转换能够说是咱们的老朋友了,在代码里咱们或多或少都会依赖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