C++_类型转换笔记

这两天碰到了关于类型转换的问题,这才想起来,从来没有总结过这部分基础内容。趁此机会,总结一下,当作复习了。

这部分内容会可能会牵扯到一些左值、右值的概念,暂且不表。

我们直接进入主题...

在 C/C++ 中,类型转换一般有两种,隐式类型转换显式类型转换,下面我们逐个简单分析一下,原理只在必要时阐述。

注意,这里使用的语言标准大部分是 C++11!

隐式类型转换

算术转换

这里的算术转换其实就是在计算过程中发生的转换,比如:

1
2
3
4
5
6
7
8
9
10
#include <iostream>
int main() {
int a = 1;
double d = 2.2;
std::cout << a + d << std::endl;
}
/*d
output:
3.2
*/

在上面的代码中,a + f在计算时,就发生了隐式类型转换,a的类型转换成了与f类型一致的double类型,最终输出的结果也是double类型。
总而言之,算术转换的规则比较简单,就是窄的类型向宽的类型转换,但要时刻注意转换带来的精度损失可能会造成的影响。

数组与指针的转换

这种情况,可能会有一些同学产生误区,实际上数组和指针是两种完全不同的东西,这里不再过多解释。

1
2
3
4
5
6
7
8
9
10
11
#include <iostream>
int main() {
int arr[5] = {1, 2, 3, 4, 5};
int *pa = arr;
std::cout << sizeof(arr) << " " << sizeof(pa) << std::endl;
return 0;
}
/*
output:
20 8
*/

指针的转换

这种情况比较常见的例子就是其他类型指针向void*的转换,比如qsort函数的原型:

1
void __cdecl qsort(void *_Base,size_t _NumOfElements,size_t _SizeOfElements,int (__cdecl *_PtFuncCompare)(const void *,const void *));

第一个参数就是void*的指针,目的是为了能接收各种类型的数组;第四个参数是一个返回值为int,参数为两个const void*的函数指针,这是由用户提供的排序函数。

类的隐式转换

由于 C++ 引入了类,所以还需要格外注意类类型的隐式转换,比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <iostream>

class myInt {
public:
myInt() { }
myInt(int i) {
std::cout << i << std::endl;
}
};

int main() {
myInt mi;
int i = 10;
mi = i;

return 0;
}

在上述代码中,我们自定义了myInt类,并为它提供了一个带参的构造函数,所以语句mi = i;在执行时会自动调用这个构造函数,完成隐式类型转换。
另外,C++ 允许内置类型转换多次,但只允许类类型转换一次。比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#include <iostream>

class myInt {
public:
myInt() { }
myInt(int i) {
std::cout << i << std::endl;
}
};

class myString {
public:
myString() { }
myString(std::string s) {
std::cout << s << std::endl;
}
};

int main() {
myInt mi;
double d = 3.14;
mi = d; // 合法

std::string s;
myString ms;
ms = s; // 合法
ms = "xxx" // 非法
return 0;
}

此时,若我们想避免自定义类型的隐式转换,可以使用explicit关键字来禁止:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <iostream>

class myInt {
public:
myInt() { }
explicit myInt(int i) {
std::cout << i << std::endl;
}
};

int main() {
myInt mi;
int i = 10;
mi = i; // 此时非法

return 0;
}

这里,我们还需要注意explicit只对一个实参的构造函数起作用,而带多个参数的构造函数不能用于隐式转换,所以也就没有必要加上explicit了。
但是,如果我们使用显式类型转换的话,这个构造函数依然会被调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <iostream>

class myInt {
public:
myInt() { }
explicit myInt(int i) {
std::cout << i << std::endl;
}
};

int main() {
myInt mi;
int i = 10;
mi = (myInt)i; // 合法
mi = myInt(i); // 合法
mi = static_cast<myInt>(i); // 合法
return 0;
}

最后,还需要指出的是 C++ 也允许类类型向其他类型转换(包括内置类型和其他类类型),但需要为被转换的类提供类型转换运算符,比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <iostream>

class myInt {
public:
myInt() { }
explicit myInt(int i): m(i) {
std::cout << i << std::endl;
}
operator int() const {
return m;
}
int m;
};

int main() {
myInt mi;
int i = 10;
mi = (myInt)i; // 合法
mi = myInt(i); // 合法
mi = static_cast<myInt>(i); // 合法

int j = mi; // 合法
return 0;
}

同样,explicit也与适用于类的类型转换运算符:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include <iostream>

class myInt {
public:
myInt() { }
myInt(int i): m(i) {
std::cout << i << std::endl;
}
operator int() const {
std::cout << m << std::endl;
return m;
}
int m;
};

int main() {
myInt mi;
int i = 10;
mi = (myInt)i; // 合法
mi = myInt(i); // 合法
mi = static_cast<myInt>(i); // 合法

// int j = mi; // 此时非法
int M = (int)(mi); // 合法
int L = int(mi); // 合法
int k = static_cast<int>(mi);
return 0;
}

由此我们可以得出以下结论:

  1. C++ 允许不同类对象隐式或显式互相转换。
  2. 编译器会使用带一个参数的构造函数来将其他类型对象转换为本类对象,同时也会使用类的类型转换运算符将其本类对象转换为其他类型对象。
  3. 使用explicit关键字可以禁止隐式类型转换。

继承关系中的类型转换

将基类指针或引用绑定到派生类对象中的基类部分上,这个过程,我们叫做派生类到基类的类型转换。
比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <iostream>

class Base {
};

class Derived: public Base {
};

// 函数传参也支持派生类到基类的类型转换,前提是使用指针或引用
void func(Base& b) {

}

int main() {
Derived d;
Base* pb = &d; // 合法
Base& rb = d; // 合法

return 0;
}

此时,若在基类中创建一个虚函数,并分别在基类和派生类中实现,然后使用绑定派生类对象的基类指针或引用调用这个虚函数,就会构成动态绑定,在运行时会调用派生类的函数,这就是 C++ 多态的实现过程。
需要注意的是,不存在基类向派生类的隐式转换,因为派生类指针或引用指向基类对象时,派生类指针或引用可能会访问派生类成员,而这些成员在基类对象中并不存在。
同时还需要强调的是,对象之间也不存在类型转换。尽管我们可以将派生类对象拷贝、赋值或移动给一个基类对象,但这也只是因为我们自己定义(或者编译器帮我们合成)了这些拷贝控制成员。因为编译器在赋值时,会自动调用这些成员,由于 C++ 支持派生类向基类的类型转换,所以这个赋值行为是合法的。然而,由于基类对象并不包含派生类的成员,所以派生类的独有成员在这个赋值过程中,就被忽略了,只有派生类的基类部分被赋值给了基类对象,这个过程就叫做对象切片(Object Slicing)。

函数调用时类型转换

比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <iostream>

int add(int i, int j) {
return i + j;
}

int main() {
int i = 1;
double d = 3.14;
std::cout << add(i, d) << std::endl;
return 0;
}
/*
4
*/

上述代码中的情况,在调用函数时发生的隐式类型转换与算术转换的情况是类似的。
而且,上述情况对于自定义类也是存在的。但前提是,这个自定义类需要定义好了转换函数,或者重载了类型转换运算符,比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include <iostream>

int add(int i, int j) {
return i + j;
}

class myInt {
public:
myInt() { }
myInt(int i): m(i) {
std::cout << i << std::endl;
}
// 与前面的例子一样,可以使用 explicit 来禁止隐式类型转换
operator int() const {
std::cout << m << std::endl;
return m;
}
int m;
};

int main() {
int i = 1;
myInt mi;
add(i, mi); // 合法
return 0;
}

在上述代码中,因为我们重载了类型转换运算符,所以在调用add函数时,编译器会将mi隐式转换为int类型,若我们没有定义类型转换运算符或者将其声明为explicit的,编译器就无法执行隐式转换了。

其他情况

比如:

1
2
3
if(x) {
...
}

其中x可以是算术类型,也可以是指针类型,且都会被转换为bool类型。
再比如:

1
2
3
4
5
int i;
const int& ri = i;
const int* pi = &i;
int& ri2 = ri; // error
int* pi2 = pi; // error

在上述代码中,常量类型的指针或引用是无法转化为非常量的指针或引用的。因为在使用常量的指针或引用时,一定是不希望修改目标值的,如果转化为非常量的指针或引用,就可以修改目标值了,这显然与预期不符。这里只需要记住:严格无法向宽松转换。

显式类型转换

显式类型转换转换其实也叫做强制类型转换,从名字上来理解即可。

C 风格显式类型转换

比如下面的代码:

1
2
3
4
5
6
7
8
9
10
11
#include <iostream>
int main() {
int a = 1;
double d = 2.2;
int c = a + (int)d;
std::cout << c << std::endl;
}
/*
output:
3
*/

还有前面提到的qsort函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <stdio.h>
#include <stdlib.h>

int cmp(const void *a, const void *b) {
return *(int*)a > *(int*)b;
}

int main() {
int arr[] = {3, 2, 1, 6, 8};
qsort(arr, sizeof(arr) / sizeof(*arr), sizeof(*arr), cmp);
for(int i = 0; i < 5; ++i) {
printf("%d ", arr[i]);
}
return 0;
}

我们在cmp这个函数中,把两个const void*转换为int*,并解引用比较大小。

C++ 风格显式类型转换

C++11 提供了四个新的关键字来满足这部分需要,分别是static_castconst_castreinterpret_castdynamic_cast

static_cast

这个关键字常用于具有明确定义的类型转换,比如上面例子中的:

1
2
3
int a = 1;
double d = 2.2;
int c = a + static_cast<int>(d);

或者是:

1
2
3
int i = 1;
void *p = &i;
int *pi = static_cast<int*>(p);

static_cast的使用比较简单直观,但要注意转换过程发生在编译阶段,这点需要与dynamic_cast区分开。

const_cast

const_cast的用法也比较直观,就是改变运算对象的底层const,比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <iostream>

int main() {
int i = 10;
const int *pi = &i;
int *pi2 = const_cast<int*>(pi);
*pi2 = 20;
std::cout << i << std::endl;
return 0;
}
/*
output:
20
*/

或者是:

1
2
3
4
5
6
7
8
9
10
#include <iostream>

int main() {
const int i = 10; // 注意这里
const int *pi = &i;
int *pi2 = const_cast<int*>(pi);
*pi2 = 20;
std::cout << i << std::endl;
return 0;
}

上面两个例子,都是在去除const,但第二个例子中,原对象本身就是const修饰的,所以后面通过指针pi2强行修改其值,是未定义行为(尽管语法上没有任何问题),要避免。而第一个例子中,原对象本身不是const修饰的,通过指针pi2修改其值,是合法的。

同样,const_cast的转换过程也发生在编译阶段。

reinterpret_cast

reinterpret_cast关键字比较强大,也很危险,容易导致未定义行为。因为,它的使用场景很多,包括:指针和数之间转换、函数指针和对象指针转换、任意类型之间的相互转换、类无关类型的相互转换等。
使用这个关键字的前提,是需要对转换的类型和被转换的类型二者的内存布局十分了解,这里暂时不做深究。

dynamic_cast

dynamic_cast是 C++ 中处理多态类型安全转换的运算符,完全依赖运行时类型检查(RTTI,run-time type identification)。也就是说,这个运算符专门用于转换指针和引用,而且使用这个运算符进行多态的操作时,编译器会检查类型安全,避免可能产生的未定义行为,代价就是需要要一些性能和内存消耗,当然也可以通过-fno-rtti编译指令禁用。
这里我们看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#include <iostream>
using namespace std;

class Base {
public:
virtual void f() { cout << "Base" << endl; }
};
B
class Derived: public Base {
public:
void f() override { cout << "Derived" << endl; }
int data;
};

int main() {
Base base;
Derived derived;

Base *pb;
Derived *pd;
Derived *pd2;

/* 向上转换 */
pd = &derived;
pb = dynamic_cast<Base*>(pd);
pb->f();

/* 向下转换,需要 RTTI */
pd2 = dynamic_cast<Derived*>(pb);
pd2->f();
return 0;
}
/*
output:
Derived
Derived
*/

上述代码中,两种dynamic_cast的用法都是合法的,第二种需要RTTI的支持,如果编译指令禁用了RTTI,编译器会报错。
实际上,我们完全可以使用static_cast来替换掉dynamic_cast,比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#include <iostream>
using namespace std;

class Base {
public:
virtual void f() { cout << "base" << endl; }
};

class Derived: public Base {
public:
void f() override { cout << "Derived" << endl; }
int data;
};

int main() {
Base base;
Derived derived;

Base *pb;
Derived *pd;
Derived *pd2;

/* 向上转换 */
pd = &derived;
pb = static_cast<Base*>(pd);
pb->f();

/* 向下转换,需要 RTTI */
pd2 = static_cast<Derived*>(pb);
pd2->f();
return 0;
}
/*
output:
Derived
Derived
*/

这依然是合法的,且此时需要的代价也会更小。但如果pd指向父类对象,再通过dynamic_cast转换为子类指针时,会返回nullptr,这就是编译器所作的检查,也即:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
#include <iostream>
using namespace std;

class Base {
public:
virtual void f() { cout << "base" << endl; }
};

class Derived: public Base {
public:
void f() override { cout << "Derived" << endl; }
int data;
};

int main() {
Base base;
Derived derived;

Base *pb;
Derived *pd;
Derived *pd2;
Derived *pd3;

/* 向上转换 */
pd = &derived; // 指向子类
pb = static_cast<Base*>(pd);
pb->f();

/* 向下转换,需要 RTTI */
pb = &derived; // 指向子类
pd2 = static_cast<Derived*>(pb);
pd2->f();

/* 向下转换,RTTI 返回 nullptr*/
pb = &base; // 指向父类
pd3 = dynamic_cast<Derived*>(pb);
if(pd3 == nullptr) {
std::cout << "cast error" << endl;
}
return 0;
}
/*
output:
Derived
Derived
cast error
*/

此时,我们仍然可以把dynamic_cast替换为static_cast,替换之后不会报错,也不会进行RTTIpd3的值是一个指向子类的地址而不是nullptr,而若使用pd3访问子类成员,就会产生未定义行为。

总结

  1. C 风格的显示类型转换也可以完成 C++11 提供的四个新的关键字的部分功能。在编码时,应该结合整个工程的编码规范进行选择。
  2. 只有dynamic_cast发生在运行期,其他三个都发生在编译期,并且dynamic_cast有时会被编译器优化到编译期。
  3. 只有dynamic_cast会运行RTTI,其他三个则没有。运行RTTI带来了更安全的环境,但会造成更多消耗,可以使用-fno-rtti编译指令禁用。
  4. 注意类类型的隐式转换,都可以使用explicit进行禁止。
  5. 对于内置类型的隐式转换,是编译器自动完成的,要注意这些操作可能带来的意外结果。

Buy me a coffee ? :)
0%