cpp / 2023.02.27

C++ の 类型转换(Type&Cast)

当表达式期望为 T 类型,而我们只能产生 S 类型的表达式时,就需要利用类型转换功能来满足需求。

当表达式期望为 T 类型,而我们只能产生 S 类型的表达式时,就需要利用类型转换功能来满足需求。

传统的转换总结来说分为隐式显式两种。

隐式转换

凡是在语境中使用了某种表达式类型 T1,但语境不接受该类型,而接受另一类型 T2 的时候,会进行隐式转换

1int a = 1;
2long long b = a + 1; // int -> long long
3if (a) {             // int -> bool
4  char c = a;        // int -> char
5}
6const int d = a;     // int -> const int

事实上,C++ 的任何的隐式转换都是使用 static_cast 来实现,所以讲 static_cast 的时候已经提了一部分内容。

这里有个比较有有意思的例子:生成了几个 string?

1std::string s = "hi";

首先,"hi" 会被编译器认为是 const char* 型字面量。

  • 在 C++11 之前,会首先调用 std::string::string(const char *) 的初始化构造函数,进行隐式转换生成一个临时变量,再调用拷贝构造函数生成 s
  • 引入移动语义的 C++11 之后,依然会隐式转换生成临时变量,但此时由于该变量为右值,于是调用了移动构造函数将临时变量保有的资源 "hi" 移至 s 中;
  • 而到了 C++17,引入了一个叫复制消除的规则,要求在满足一定的条件下避免对象的复制,于是这里的临时变量不会生成,直接调用 std::string::string(const char *) 构造对象。这可比移动构造高效多了;

综上所述,在不同编译器标准下,答案分别为 2 2 1。

显式转换

显式转换就是在表达式之前加上想要转换的目标类型。

Cast in C

C 中的类型转换语法非常简单粗暴,直接在表达式前加上 (Target_Type) 即可,如:

1double pi = 3.14;
2int p = (int)pi;
3std::cout << p; // output: 3

但这种粗暴的强转会带来许多难以察觉的安全性问题。于是,C++ 提供了许多应用场景更广泛的转换算子,用于删除 C 语言转换中的一些多义性和危险继承。

Cast in C++

static_cast

1static_cast<new_type>(expr);

该算子实现的效果与 C 中的强转差不多,但由于没有在运行时进行类型检查来确保安全性,故跟 Cast in C 一样存在隐患,用于非多态对象的转换。

所谓多态对象,就是声明或继承了至少一个虚函数的类类型的对象。每个多态对象中,实现都会储存额外的信息,它被用于进行虚函数的调用,RTTI 功能特性也用它在运行时确定对象创建时所用的类型,而不管使用它的表达式是什么类型。

对于非多态对象,值的解释方式由使用对象的表达式所确定,这在编译期就已经决定了。

通常用于数值类型的相互转换,比如浮点型到整型(如 doubleint),整型到字符型(如 int -> char)等。

得到的 char 可能没有足够的位来保存整个 int 值,故需要程序员来验证转换的结果是否安全。

但在类的层次结构之间进行转换时,比如将基类指针向下转换为派生类指针这一操作,由于派生类可能有自己新定义的字段或信息,故向下转换是不安全的。但向上转换一定是安全的,因为派生类一定包含基类的所有信息。

 1class B {};
 2class D: public B {
 3 public:
 4  int val;
 5};
 6
 7void Foo(B* pb, D* pd) {
 8  D* pd1 = static_cast<D*>(pb); // not safe! D::val 不在 B 中。
 9                                // 如果此时 pb 指向一个非 D 类对象,则调用 pd1->val 出错。
10  B* pb1 = static_cast<B*>(pd); // safe! 基类指针一定能指向所有派生类
11}

dynamic_cast

1dynamic_cast<new_type>(expr); // 其中 new_type/expr 必须为指针或引用。若 new_type 为指针,则 expr 必须为指针;如为引用,则 expr 为左值

static_cast 相对,dynamic_cast 在运行时执行类型检查,故用于多态对象向上转换,且更加安全。具体表现为:

  • 如果转型成功,那么 dynamic_cast 就会正确返回转换后的值;
  • 如果转型失败且 new_type 是指针类型,那么它会返回 nullptr
  • 如果转型失败且 new_type 是引用类型,那么它会抛出 std::bad_cast 异常。
 1#include <iostream>
 2
 3class A {
 4 public:
 5  virtual void foo() { std::cout << "A foo()\n"; }
 6  virtual ~A() = default;
 7};
 8
 9class B : public A {
10 public:
11  virtual void foo() override { std::cout << "B foo()\n"; }
12  void bar() { std::cout << "B bar()\n"; }
13  virtual ~B() = default;
14};
15
16class C : public B {
17 public:
18  virtual void foo() { std::cout << "C foo()\n"; }
19  void bar() { std::cout << "C bar()\n"; }
20  virtual ~C() = default;
21};
22
23void Foo(A& a) {
24  try {
25    [[__maybe_unused__]] C &c = dynamic_cast<C&>(a);
26    std::cout << "Cast to C SUCCESS!\n";
27  } catch(std::bad_cast) {
28    std::cout << "Cast to C ERROR!\n";
29  }
30}
31
32int main() {
33  A* pa1 = new C;
34  A* pa2 = new B;
35
36  pa1->foo();
37
38  B* pb = dynamic_cast<B*>(pa1);
39  std::cout << "Try to cast A* pa1 to B* pb ...\n";
40  if (pb) {
41    std::cout << "Cast success\n";
42    pb->foo();
43    pb->bar();
44  } else {
45    std::cout << "Cast failed\n";
46  }
47
48  C* pc = dynamic_cast<C*>(pa2);
49  std::cout << "Try to cast A* pa2 to C* pc ...\n";
50  if (pc) {
51    std::cout << "Cast success\n";
52    pc->foo();
53    pc->bar();
54  } else {
55    std::cout << "Cast failed\n";
56  }
57
58  C c;
59  Foo(c);
60
61  B b;
62  Foo(b);
63}
64// output:
65// C foo()
66
67// Try to cast A* pa1 to B* pb ...
68// Cast success
69// C foo()
70// B bar()
71
72// Try to cast A* pa2 to C* pc ...
73// cast failed
74
75// Cast to C SUCCESS!
76
77// Cast to C ERROR!

整体类层次结构为 A -> B -> C。从上述结果中不难发现:

  • A* pa1 指向 C 类对象,调用虚函数 foo() 时实现了多态的效果;
  • A* pa1 指向 C 类对象,转换为 B* 指针时成功,因为 CB 的派生类,拥有基类的所有信息;
  • A* pa2 指向 B 类对象,转换为 C* 指针时失败,并返回空指针;
  • A& a 绑定到 C 类对象时,能够转换到 C& 型;
  • A& a 绑定到 B 类对象时,无法转换到 C& 型,并抛出异常;

总结出,dynamic_cast<new_type>(expr) 转换成功与否,关键看指针/引用 expr 指向/绑定的对象是否为 new_type 的相同类型或派生类,而不用关心指针/引用类型本身。

再来看另一种情况。

 1class A { virtual f(); };
 2class B : virtual public A { virtual f(); };
 3class C : virtual public A {};
 4class D { virtual f(); };
 5class E : public B, public C, public D { virtual f(); };
 6
 7/********
 8   A  
 9  / \
10 B   C   D
11 |___|___|
12     |
13     E
14********/

如果我们拥有一个 D* pd = new E; 的指针,希望将其转换为 A*,一种可行的思路是先转到 E* 再转到 B* 最后转到 A*,就像这样进行"crabbing":

1E* pe = dynamic_cast<E*>(pd);
2B* pb = pe;
3A* pa = pb;

事实上,如果 new_typeexpr 所指代的指针/引用类型处于同一层次(比如这里的 DB),并且 expr 所指向/绑定的对象为它俩的共同派生类,则可以进行横向转换,比如:

1B* pb = dynamic_cast<B*>(pd);

在一些更复杂的类层次结构中,横向转换可以极大地提高 coding 效率。

reinterpret_cast

1reinterpret_cast<new_type>(expr);

reinterpret_cast 通过对底层比特位重新解读来进行类型间的转换。当然,这也是不安全的,需要程序员手动检查。

1int main() {
2  int a;
3  std::cout << &a << '\n'
4            << reinterpret_cast<unsigned long long>(&a); // 将 64 位指针类型的 &a 重新解读为 64 位 unsigned long long 类型。
5}
6// output:
7// 0000000169AFFD5C
8// 6068108636

const_cast

1const_cast<new_type>(expr);

const_cast 最大特点就在于它可以移除 expr 的 cv 限定,这是其余几个算子都做不到的。

 1class A {
 2  int val;
 3 public:
 4  A(int val_): val(val_) {}
 5  void show() { std::cout << val << '\n'; }
 6  void foo(int i) const {
 7    // val = i;                    // ERROR! A::foo() is const
 8    const_cast<A*>(this)->val = i; // A::foo() const 中,this 为 const A* 型,使用 const_cast 去限定后允许修改成员变量
 9  }
10};
11
12int main() {
13  A a(3);
14  a.show();
15  a.foo(6);
16  a.show();
17}
18// output: 6

const_cast 使得到非 const / 非 volatile 类型的指针/引用能够实际指向/绑定到 const / volatile 对象。通过非 const 访问路径修改 const 对象和通过非 volatile 泛左值指代 volatile 对象是 UB。

 1int main() {
 2  const int a = 1;
 3  int* pa = const_cast<int*>(&a);
 4  int& ra = const_cast<int&>(a);
 5
 6  *pa = 2;
 7  std::cout << "a = " << a << " *pa = " << *pa << " ra = " << ra << '\n';
 8
 9  ra = 3;
10  std::cout << "a = " << a << " *pa = " << *pa << " ra = " << ra << '\n';
11}
12// output:
13// a = 1 *pa = 2 ra = 2
14// a = 1 *pa = 3 ra = 3

用户定义转换

在现代 C++ 中,传统转换已然无法轻易满足日益增长的应用需求,需要用户自己制定转换规则,来提高代码效率。

但用户能修改的只有两种,初始化构造函数与用户定义转换函数。

 1#include <iostream>
 2
 3class Foo {
 4  int val;
 5 public:
 6  // 初始化构造函数
 7  // 其他类型 -> Foo
 8  Foo(int v): val(v) { std::cout << "call Foo(int)\n" }
 9
10  // 用户定义转换函数,不需要显式指定返回值
11  // Foo -> 其他类型
12  operator int() { return val; }               // 可隐式自定义转换
13  explicit operator int*() { return nullptr; } // 强制显式自定义转换
14};
15
16int main() {
17  Foo f(1);
18
19  if (f > 0) {
20    std::cout << "call operator int()\n";
21  }
22
23  if (static_cast<int*>(f) == nullptr) {
24    std::cout << "call explicit operator int* ()\n";
25  }
26  return 0;
27}
28// output:
29// call Foo(int)
30// call operator int()
31// call explicit operator int* ()

有了这一功能,我们就能轻易地使用 operator bool() 来将某些类对象直接嵌到条件判断表达式中——直接通过自定义规则隐式转换为可被接收的 bool 类型。

运行时类型信息(RTTI)

typeid 算子

定义于头文件 <typeinfo>

typeid 用于获取当前对象的类型信息,返回一个 type_info 类,可以通过调用 type_infoname() 方法来获取相应的字符串型名称。

 1class A {};
 2class B : public A {};
 3
 4int main() {
 5  bool b;
 6  char c;
 7  int i;
 8  double d;
 9
10  A* foo = new B;
11  B bar;
12
13  std::cout << typeid(b).name() << '\n'
14            << typeid(c).name() << '\n'
15            << typeid(i).name() << '\n'
16            << typeid(d).name() << '\n'
17            << typeid(foo).name() << '\n'
18            << typeid(*foo).name() << '\n'
19            << typeid(bar).name();
20}
21// output:
22// b
23// c
24// i
25// d
26// P1A
27// 1A
28// 1B

不难发现,typeid 不仅支持内置类型,还允许获取用户自定义类型的信息。但上面只描述了静态类型的情况,在编译期就能确定类型,下面看看涉及多态的 RTTI 情况。

 1class Base {
 2 public:
 3  virtual ~Base() = default;
 4};
 5
 6class Derived : public Base {
 7 public:
 8  virtual ~Derived() = default;
 9};
10
11int main() {
12  Base* pb = new Derived;
13  Derived d;
14  Base& rb = d;
15  std::cout << typeid(pb).name() << '\n'
16            << typeid(*pb).name() << '\n'
17            << typeid(rb).name();
18  return 0;
19}
20// output:
21// P4Base
22// 7Derive
23// 7Derive

发现无论是否为多态场景,指针的类型依然为其静态类型,但其解引用后的类型却存在差异,即虽然 pb 的类型为 Base*,但其指向的对象被识别为 Derived 类型,这与之前 A* foo = new B 的表现大相径庭。

这是因为这里的 Base 为多态对象,当应用于多态类型的表达式时,typeid 的求值会对表达式求值,并指代表示该表达式动态类型的对象,这涉及运行时开销(虚表查找)。这里如果表达式是通过对一个指针解引用所得,且该指针是空指针值,那么就会抛出 std::bad_typeid 异常。

而其他情况下 typeid 表达式都在编译时解决,不会对表达式求值。

了解了这一点,也就基本知道 dynamic_cast 的原理了——其实它就是利用 RTTI 去判断一个指针实际所指的对象类型,这依赖于虚函数表,故仅用于多态类型的转换,无法对非多态对象执行 dynamic_cast

type_info

上面说到 typeid 会返回一个 type_info 类,实际上它保有一个类型的实现指定信息,包括类型的名称和比较二个类型相等的方法或相对顺序。定义如下:

 1class type_info {
 2public:
 3  virtual ~type_info();
 4
 5  bool operator==(const type_info& rhs) const noexcept;
 6  bool operator!=(const type_info& rhs) const noexcept; // C++20 移除
 7
 8  bool before(const type_info& rhs) const noexcept;
 9  size_t hash_code() const noexcept;
10  const char* name() const noexcept;
11
12  type_info(const type_info& rhs) = delete;
13  type_info& operator=(const type_info& rhs) = delete;
14};

每个类型的 type_info 是独一无二的,故禁止了拷贝。除了之前提过的 name() 方法,在这里我仅关注 operator= 算子,另外两个不作深究。而 operator= 主要是检查对象是否指代相同类型,这个会经常用到,通常用于比较两个带有虚函数的类的对象是否相等。

RTTI 底层原理

在一个多态对象的内存模型中,最开始就会放一个指向虚表的虚表指针 vptr,而在虚表的 -1 索引处保有一个 type_info* 条目,其指向的 type_info 保存着该多态对象的类型信息。typeid 会判断出表达式是否为一个多态对象,若是,则不断地深入内存,进行虚表指针 -> 类型信息指针 -> 类型信息层层递进,最后找到 type_info 并返回;而非多态对象则没有虚表这一概念,直接在编译期求得静态类型即可。