在代码里我们或多或少都会依赖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,这又被叫做语境转换。
由此可见隐式类型转换转换可以简化代码的书写。不过简化不是没有代价的,我们细细说来。
二、基础回顾
在正式介绍隐式类型转换之前,我们先要回顾一下基础知识,放轻松。
直接初始化
首先是类的直接初始化。
顾名思义,就是显式调用类型的构造函数进行初始化。举个例子:
structA{
A() = default;
A(constA&) = default;
A(int) {}
};
// 这是默认初始化: A a; 注意区分
A a1{}; // c++11的列表初始化
// 不能写出A a2(),因为这会被认为是函数声明
A a2(1);
A a3(a2); // 没错,显式调用复制构造函数也是直接初始化
autoa4 = static_cast<A>(1);
需要注意的是a4,用static_cast转换成类型T的这一步也是直接初始化。
这种初始化方式有什么用呢?直接初始化会考虑全部的构造函数,而不会忽略explicit修饰的构造函数。
显式地调用构造函数进行直接初始化实际上是显式类型转换的一种。
复制初始化
除去默认初始化和直接初始化,剩下的会导致复制的基本都是复制初始化,典型的如下:
A func(){
returnA{}; // 返回值会被复制初始化
}
A a5 = 1; // 先隐式转换,再复制初始化
voidfunc2(A a){} // 非引用的参数传递也会进行复制构造
然而类似A a6 = {1}的表达式却不是复制初始化,这是复制列表初始化,会直接选择合适的非explicit构造函数进行初始化,而不用创建临时量再进行复制。
复制初始化又起到什么作用呢?
首先想到的是这样可以创造某个对象的副本,没错,不过还有一个更重要的作用:
如果想要某个类型T1的value能进行到T2的隐式转换,两个类型必须满足这个表达式的调用T2 v2 = value。
而这个形式的表达式正是复制初始化表达式。至于具体的原因,我们马上就会在下一节看到。
类型构造时的隐式转换
我们看一道经典的面试题:
std::strings = "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的原因。
同时我们还发现,在复制构造时的类型转换不管复制有没有被省略都是存在的,只不过换了一个形式,这就是我们后面要讲的内容。
总结:尽量不要去依赖隐式类型转换,多用explicit和各种显式转换,少想当然。