隐式类型转换可以说是我们的老朋友了,在代码里我们或多或少都会依赖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)优化这段代码。