关于C++的类型转换的整理笔记,主要关注C++相比于C语言多的部分,而且讨论的范围比较简单,不关注涉及到右值引用的类型转换。
概述 C++基本继承了C语言的类型转换规则,但是对类型转换的处理有很多区别,例如:
对于C语言,隐式转换和显式转换并没有太多区别,仅仅是危险的隐式转换可能会发出警告,使用显式转换会消除警告。
对于C++,安全的隐式转换可以通过编译,但是危险的隐式转换无法通过编译,必须使用显式转换才能顺利编译,C语言风格或C++风格的显式转换均可。
此外,C++相对于C语言还需要考虑很多很多额外的内容,我们将主要关注面向对象的部分,即自定义类型之间的类型转换,以及新加入的四个显式的强制类型转换关键词的使用
const_cast
reinterpret_cast
static_cast
dynamic_cast
现代 C++ 不建议这么做:
避免使用C语言中常用的void *类型,C++提供了更好的工具;
避免使用C语言风格的显式类型转换,可读性太差,不利于后期检查。
现代 C++ 建议这么做:
优先考虑使用static_cast,用它进行一些数值类型的转换还是基本安全的,但是涉及指针的转换仍然存在风险。
涉及虚函数,多态等情况,考虑使用dynamic_cast,虽然有运行时的开销,但是好在安全可靠;
谨慎使用const_cast,虽然这可以解开const的束缚,但是同时也带来了未知的风险;
避免使用reinterpret_cast,它非常危险,除非你知道自己在干什么。
按照 static_cast、dynamic_cast、const_cast、reinterpret_cast 的顺序,使用频率逐渐降低,危险性则逐渐增高。
基础篇 这里主要讨论如何用C++提供的几个类型转换关键字的基本使用,包括如何替代C语言风格的显式类型转换,并且不涉及到自定义类型的转换。
const_cast 这是最简单直观的转换关键字,只能用于指针或引用类型的底层const或volatile属性的添加或移除(即指针指向的内容是否是const或volatile的),这些属性只是在编译期有意义,完全不涉及到数据的修改或重新解释。
需要说明的是:
对于C语言来说,给指针或引用类型添加或移除const限制都可以隐式完成,只是移除const限制的转换会产生警告,使用显式转换可以消除警告。
对于C++来说,添加const限制也可以隐式完成,但是移除const限制的转换不允许隐式进行,会导致编译报错,必须使用显式类型转换。
例如
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 #include <iostream> void modify_value (const int *ptr) { int *non_const_ptr = const_cast <int *>(ptr); *non_const_ptr = 42 ; } void print_value (const int * ptr) { std::cout << "Value: " << *ptr << '\n' ; } int main () { int x = 10 ; print_value (&x); modify_value (&x); print_value (&x); return 0 ; }
对引用类型也同理
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 #include <iostream> void modify_value (const int &ptr) { int &non_const_ptr = const_cast <int &>(ptr); non_const_ptr = 42 ; } void print_value (const int &ptr) { std::cout << "Value: " << ptr << '\n' ; }int main () { int x = 10 ; print_value (x); modify_value (x); print_value (x); return 0 ; }
除了上面这种刻意的例子,其实这种转换也是有实际意义的:在const修饰的成员函数中,可以使用const_cast移除this指针的const限制,从而修改对象自身。(使用mutable指定可修改的成员变量是一个更好的选择)
1 2 3 4 5 6 7 8 struct Demo { int m; void func (int n) const { const_cast <Demo *>(this )->m = n; } };
需要注意的是,由于编译器可能基于const的语义对代码进行优化,这可能与const_cast移除const属性的行为产生冲突,导致修改不会生效,例如
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 #include <iostream> int main (int argc, char *argv[]) { const int a = 10 ; std::cout << "a = " << a << '\n' ; int *p = const_cast <int *>(&a); *p = 100 ; std::cout << "a = " << *p << '\n' ; std::cout << "a = " << a << '\n' ; return 0 ; }
除此之外,尝试对一个真正的常量进行修改也是非常危险的,例如
1 2 3 4 5 6 7 8 9 10 11 #include <iostream> int main () { const char *str = "Hello, world!" ; char *mod_str = const_cast <char *>(str); mod_str[0 ] = 'h' ; std::cout << str << '\n' ; return 0 ; }
这段代码可以顺利编译,但是运行可能产生如下两种结果:
程序崩溃;(Debug 模式下可能出现)
正常输出:Hello, world!。(Release 模式下可能出现)
具体表现因编译器、操作系统等不同而异。
reinterpret_cast reinterpret_cast是相对最危险的一个转换关键字,它的语义是将指向对象的内存表示重新解释为另一种类型的内存表示。 这种转换不进行任何检查,也不会导致任何数据的改动,但是转换仍然是充满风险的,因为无法保证内存中的数据解释为新的类型是有意义的,
主要用法包括:
用于进行低级别的指针或引用类型转换,允许将一种指针或引用类型转换为另一种完全不相关的指针或引用类型。
但是如果前后的类型大小不一样,以转换后类型进行的读写还会影响附近的数据。
将指针类型与整数类型进行转换,语义为:
指针转换为整数:获取指针指向的内存地址
整数转换为指针:将整数值视作一个内存地址,转换后的指针可以被用来访问对应内存地址中的对象。
指针之间转换的例子如下
1 2 3 4 5 6 7 8 9 10 11 12 13 #include <iostream> int main () { int a = -1 ; unsigned int *p1 = reinterpret_cast <unsigned int *>(&a); std::cout << *p1 << '\n' ; int *p2 = reinterpret_cast <int *>(p1); std::cout << *p2 << '\n' ; return 0 ; }
指针和整数之间转换的例子如下
1 2 3 4 5 6 7 8 9 10 11 12 13 #include <cstdint> int main () { uintptr_t addr = 0x7ffee3b2b0a0 ; int *ptr1 = reinterpret_cast <int *>(addr); int a = 42 ; int *ptr2 = &a; uintptr_t addr2 = reinterpret_cast <uintptr_t >(ptr2); return 0 ; }
引用之间转换的例子如下
1 2 3 4 5 6 7 8 9 10 11 12 13 #include <iostream> int main () { int a = -1 ; unsigned int &p1 = reinterpret_cast <unsigned int &>(a); std::cout << p1 << '\n' ; int &p2 = reinterpret_cast <int &>(p1); std::cout << p2 << '\n' ; return 0 ; }
static_cast static_cast是一个安全、编译时检查 的类型转换,通常用于数值类型转换和枚举类型的转换,还可以进行一些低风险的指针类型转换、自定义类型之间的转换。
使用例如
1 2 3 4 5 int i1 = 42 ;float f1 = static_cast <float >(i1);double d1 = 3.25 ;float f2 = static_cast <float >(d1);
这个做法的代码比C语言的显式类型转换语法可读性更高,在代码中更加突兀,便于后期检查。
static_cast可以用于指针类型之间的转换,但是它对安全性的要求较高(安全性检查局限于编译期), 对于一些存在风险的指针类型转换会直接编译报错(例如尝试int * -> double *会报错),除了下文中将要讨论的自定义类型的指针转换,实际上它只允许很少的几种情况:
任意指针类型转换到void *;(C++允许隐式进行)
void *转换到任意指针类型。(必须显式进行,隐式转换无法通过编译)
例如
1 2 3 4 5 6 7 8 9 10 #include <cstdio> int main () { int a = 10 ; auto *pb3 = static_cast <void *>(&a); return 0 ; }
由于reinterpret_cast非常危险,在很多代码检查中都禁止使用,但是我们仍然可以通过一些技巧绕过,例如使用两次static_cast:第一次转换到void *,第二次转换为目标类型指针,这可以达到一样的效果。
1 2 3 4 5 6 7 8 9 10 11 12 13 #include <iostream> int main () { int a = -1 ; unsigned int *p1 = static_cast <unsigned int *>(static_cast <void *>(&a)); std::cout << *p1 << '\n' ; int *p2 = static_cast <int *>(static_cast <void *>(p1)); std::cout << *p2 << '\n' ; return 0 ; }
static_cast在指针类型转换时,加上const限制是很自然且允许的,对底层const限制的移除则必须通过const_cast进行。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 #include <cstdio> int main () { int a = 10 ; int *p = &a; const int *cp = &a; void *pb = static_cast <void *>(p); const void *cpb1 = static_cast <const void *>(p); const void *cpb2 = static_cast <const void *>(cp); void *pb2 = const_cast <void *>(static_cast <const void *>(cp)); return 0 ; }
面向对象篇 我们首先考虑最简单的单个自定义类型与其它类型的双向转换,然后再考虑涉及到继承的简单情况,即基类与派生类的转换关系, 最后简单讨论涉及到多继承和虚函数的复杂情况。(多继承和虚函数的内容细究起来太复杂了,我也完全不会去写涉及多继承和虚继承的代码,只是用两个例子来展示几种类型转换的区别)
类型转换方法和构造函数 自定义类型需要提供特殊的类型转换方法 来支持向其它类型进行转换,语法形式为
1 2 3 operator new_type () { ... }
例如 operator int()、operator double() 分别代表向int和double进行的单向类型转换。
反过来,我们也需要通过其它类型向自定义类型的转换,此时需要提供单参数的构造函数(又称为隐式转换构造函数 )。
考虑下面的自定义有理数类型的例子
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 #include <cstdio> #include <format> #include <string> class Fraction {private : int m_a; int m_b; public : Fraction (int a, int b) : m_a (a), m_b (b) {} Fraction (int s) : Fraction (s, 1 ) {} operator int () const { return m_a / m_b; } operator double () const { return (m_a * 1.0 ) / m_b; } operator std::string () const { return std::format("{}/{}" , m_a, m_b); } }; int main () { Fraction f1 (5 , 2 ) ; int i = f1; double d = f1; std::string s = f1; printf ("%s, i = %d, d = %f" , s.c_str (), i, d); Fraction f2 (2 ) ; Fraction f3 = 3 ; 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 29 30 31 32 33 34 35 36 37 38 39 40 41 #include <cstdio> #include <format> #include <string> class Fraction {private : int m_a; int m_b; public : Fraction (int a, int b) : m_a (a), m_b (b) {} explicit Fraction (int s) : Fraction(s, 1 ) { } explicit operator int () const { return m_a / m_b; } explicit operator double () const { return (m_a * 1.0 ) / m_b; } explicit operator std::string () const { return std::format("{}/{}" , m_a, m_b); } }; int main () { Fraction f1 (5 , 2 ) ; int i = static_cast <int >(f1); double d = static_cast <double >(f1); std::string s = static_cast <std::string>(f1); printf ("%s, i = %d, d = %f" , s.c_str (), i, d); Fraction f2 (2 ) ; Fraction f3 = static_cast <Fraction>(3 ); return 0 ; }
注意这里对两个构造语句的影响是不同的,通过初始化或赋值语句的构造必须要加上显式转换,否则编译不通过。
值类型转换 对于基类和派生类之间的值类型转换:
由于派生类通常包含了基类的完整数据(一个派生类对象同时也是一个合法的基类对象),派生类向基类的值类型转换通常是安全的,C++允许派生类向基类的类型转换(允许隐式转换);
由于派生类可能含有基类没有的数据(一个基类对象通常不是一个合法的派生类对象),因此基类向派生类的转换通常是不安全的,C++不允许从基类向派生类的隐式类型转换,但是可以通过static_cast进行显式转换。
这里只需要考虑简单的单继承情况,并且不需要考虑虚函数等因素,例如
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 struct Base { int m; }; struct Derived : public Base { int n; }; int main () { Base b1; Derived d1; Base b2 = d1; Base b3 = static_cast <Base>(d1); Derived d3 = static_cast <Derived>(b1); return 0 ; }
测试代码表明,在默认情况下,基类向派生类的隐式转换被禁止了,必须进行显式转换。 但是我们也可以通过提供类型转换方法,来绕过这个限制(这里必须使用前置声明,方法的实现也必须放在后面)
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 struct Derived ;struct Base { int m; operator Derived () ; }; struct Derived : public Base { int n; }; Base::operator Derived () { return {0 , 0 }; }int main () { Base b1; Derived d1; Base b2 = d1; Derived d2 = b1; Derived d3 = static_cast <Derived>(b1); return 0 ; }
指针/引用类型转换 这里我们考虑基类和派生类之间的指针/引用类型转换,我们将基类向派生类指针/引用的转换称为向下转换,将派生类向基类的指针/引用转换称为向上转换。下面主要以指针类型举例,对于引用类型几乎没什么区别。
static_cast 在不涉及虚函数的情况下,主要通过static_cast实现基类和派生类之间的指针/引用类型转换:
向上转换(派生类指针转换为基类指针)都是非常安全的,C++允许进行,并且可以隐式转换;
向下转换(基类指针转换为派生类指针)则是充满危险的,C++不允许隐式转换,但是可以通过static_cast进行显式转换。(当然也可以通过reinterpret_cast进行更暴力直接的转换,它不要求两个类型之间存在任何联系)
示例代码如下(注释行代表转换失败,编译报错)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 #include <cassert> struct A {};struct B : public A {};int main () { A *a1 = new A{}; B *b2 = static_cast <B *>(a1); B *b3 = new B{}; A *a2 = b3; A *a3 = static_cast <A *>(b3); A *a4 = new B{}; B *b5 = static_cast <B *>(a4); return 0 ; }
依靠static_cast将基类指针显式转换为派生类指针是非常危险的:
如果实际指向的对象就是派生类对象(C++允许基类指针指向派生类对象),那么一切正常;
否则就可能发生不可控的运行时错误。
导致这种危险的根本原因是我们在编译期仅仅可以拿到指针类型,无法拿到指针指向的实际类型。 只有明确指针指向对象的实际类型是什么,才可能做到安全的转换,而这样的做法必然需要利用运行时的类型信息,需要引入运行时的检查,这种需求自然导致了dynamic_cast的产生。
dynamic_cast 由于C++通过虚函数机制实现动态多态,这套机制在提供虚表和虚表指针的同时,还提供了额外的运行时类型信息,在这一前提下,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 #include <cassert> struct A { virtual ~A () = default ; }; struct B1 : public A {};struct B2 : public A {};int main () { A *a1 = new A{}; assert (dynamic_cast <B1 *>(a1) == nullptr ); assert (dynamic_cast <B2 *>(a1) == nullptr ); A *a2 = new B1{}; assert (dynamic_cast <B1 *>(a2) != nullptr ); assert (dynamic_cast <B2 *>(a2) == nullptr ); B1 *b1 = new B1{}; assert (dynamic_cast <A *>(b1) != nullptr ); assert (dynamic_cast <B2 *>(b1) == nullptr ); A *a3 = new B2{}; assert (dynamic_cast <B1 *>(a3) == nullptr ); assert (dynamic_cast <B2 *>(a3) != nullptr ); B2 *b2 = new B2{}; assert (dynamic_cast <A *>(b2) != nullptr ); assert (dynamic_cast <B1 *>(b2) == nullptr ); return 0 ; }
程序正常运行,!= nullptr的行代表对应的转换成功,== nullptr的行代表对应的转换失败。 上述结果表明dynamic_cast转换的成功与否,只取决于实际类型和目标类型的关系,与指针类型无关。
使用static_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 #include <cassert> struct A { virtual ~A () = default ; }; struct B1 : public A {};struct B2 : public A {};int main () { A *a1 = new A{}; assert (static_cast <B1 *>(a1) != nullptr ); assert (static_cast <B2 *>(a1) != nullptr ); A *a2 = new B1{}; assert (static_cast <B1 *>(a2) != nullptr ); assert (static_cast <B2 *>(a2) != nullptr ); B1 *b1 = new B1{}; assert (static_cast <A *>(b1) != nullptr ); A *a3 = new B2{}; assert (static_cast <B1 *>(a3) != nullptr ); assert (static_cast <B2 *>(a3) != nullptr ); B2 *b2 = new B2{}; assert (static_cast <A *>(b2) != nullptr ); return 0 ; }
简单分析一下结果:
对于两个没有直接继承关系的转换(B1* -> B2*,B2* -> B1*),static_cast转换失败是非常合理的;
但是对于指向实际对象B1的A*指针,static_cast仍然可以成功转换为B2 *,这显然会导致不可预料的错误。
对比小结 可以对dynamic_cast或static_cast进行简单的对比整理:
转换时机和转换效率不同:
static_cast的转换发生在编译期,效率高
dynamic_cast的转换发生在运行时,转换之前会进行运行时检查,效率较低
转换要求不同:
static_cast要求指针指向的类型 和目标类型 是否存在继承或被继承关系
dynamic_cast要求基类必须有虚函数,要求实际类型 是目标类型 或者其派生类型,并不关系指针指向的类型
安全性不同:
static_cast只能利用编译期信息进行检查,安全性较低,转换失败会导致编译报错,即使转换成功也存在风险,可能导致未知的运行时错误
dynamic_cast利用了运行期信息进行检查,安全性高,检查失败会返回空指针(指针转换)或抛出异常(引用转换)
除此之外,reinterpret_cast也可以用于自定义类型的转换,但是它比这两个主要的强制类型转换更危险, 因为它不能妥善处理多重继承和虚继承等导致的偏移问题。(见下文)
多继承示例 考虑一个简单的多重继承例子,这里不涉及到虚函数,因此我们只比较static_cast和reinterpret_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 #include <cstdio> struct A {public : int m_a; }; struct B { int m_b; }; struct C : public A, public B {};int main () { C c; printf ("%p, %p, %p\n" , &c, reinterpret_cast <A *>(&c), static_cast <A *>(&c)); printf ("%p, %p, %p\n" , &c, reinterpret_cast <B *>(&c), static_cast <B *>(&c)); A *p1 = static_cast <A *>(&c); printf ("%p, %p\n" , &c, static_cast <C *>(p1)); B *p2 = static_cast <B *>(&c); printf ("%p, %p\n" , &c, static_cast <C *>(p2)); return 0 ; }
运行结果如下
1 2 3 4 0x7ffd71612020, 0x7ffd71612020, 0x7ffd71612020 0x7ffd71612020, 0x7ffd71612020, 0x7ffd71612024 0x7ffd71612020, 0x7ffd71612020 0x7ffd71612020, 0x7ffd71612020
这表明对于static_cast,它在处理多继承情形下的指针类型转换时,会自动考虑基类和继承类之间因为多继承产生的偏移量,但是reinterpret_cast完全不会考虑这种处理,只是忠实的进行重新解释。
虚继承示例 考虑一个典型的菱形虚继承关系,进行类型转换实验。虚继承可以解决菱形继承的很多问题,但是代价是对部分数据成员的重排:在派生类中可能不存在连续的一部分对应一个完整的基类。
首先使用static_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 #include <cassert> struct A {public : virtual ~A () = default ; }; struct B1 : public virtual A {};struct B2 : public virtual A {};struct C : public B1, public B2 {};int main () { A *a1 = new A{}; B1 *b1 = new B1{}; assert (static_cast <A *>(b1) != nullptr ); assert (static_cast <C *>(b1) != nullptr ); B2 *b2 = new B2{}; assert (static_cast <A *>(b2) != nullptr ); assert (static_cast <C *>(b2) != nullptr ); C *c = new C{}; assert (static_cast <A *>(c) != nullptr ); assert (static_cast <B1 *>(c) != nullptr ); assert (static_cast <B2 *>(c) != nullptr ); return 0 ; }
实验结果表明:
由于在菱形继承关系中使用了虚继承,从指针类型A*出发的static_cast转换全部编译失败
B1和B2完全没有继承或被继承的关系,因此B1* -> B2*和B2* -> B1*的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 #include <cassert> struct A {public : virtual ~A () = default ; }; struct B1 : public virtual A {};struct B2 : public virtual A {};struct C : public B1, public B2 {};int main () { A *a1 = new A{}; assert (dynamic_cast <B1 *>(a1) == nullptr ); assert (dynamic_cast <B2 *>(a1) == nullptr ); assert (dynamic_cast <C *>(a1) == nullptr ); B1 *b1 = new B1{}; assert (dynamic_cast <A *>(b1) != nullptr ); assert (dynamic_cast <B2 *>(b1) == nullptr ); assert (dynamic_cast <C *>(b1) == nullptr ); B2 *b2 = new B2{}; assert (dynamic_cast <A *>(b2) != nullptr ); assert (dynamic_cast <B1 *>(b2) == nullptr ); assert (dynamic_cast <C *>(b2) == nullptr ); C *c = new C{}; assert (dynamic_cast <A *>(c) != nullptr ); assert (dynamic_cast <B1 *>(c) != nullptr ); assert (dynamic_cast <B2 *>(c) != nullptr ); return 0 ; }
程序正常运行,!= nullptr的行代表对应的转换成功,== nullptr的行代表对应的转换失败。 实验结果与之前的结论一致,只有实际类型是目标类型或其派生类,才会成功进行dynamic_cast转换。