C++ 归纳:类型转换

在我们平时的使用中,很少主动去变换一个量的类型,在不知不觉中(尤其在使用函数的过程中)会出现很多错综复杂的 隐式的 类型转换,导致函数获得的结果并非在我们意料之中,我认为 C++ 中的一切麻烦皆来于此。

这篇文章会探讨 C++ 中,何时发生了转换以及发生了何种类型的转换。

我们的目的是了解这些 隐式的 转换,并且尽可能的使用 类型安全 的转换。

本文讨论的基本类型

bool char short int long float
引用:& 指针:* 数组:[]
signed unsigned
const volatile

第一行是基本类型,按照所占字节大小(从1Byte开始)进行排列;第二行是可加在基本类型后面的,具有特殊意义的操作符;第三行和第四行是修饰符。行与行之间可以任意组合,可以涵盖大多数变量以及形参的定义。

何种类型的转换

整型提升(自动提升)

上表中,位于 int 左边的任意 signed unsigned 类型(unsigned short 除外)被自动转换为 int 。

unsigned short 可能会被转换为 unsigned int ,这取决于系统或编译器定义的 类型宽度 ,当然这也被称为 整型提升 。

当然还有 wchar_t ,为了保证类型安全也会进行整型提升,将 wchar_t 转换为一个 int 之后的、类型安全的、最小宽度类型。可能仍然是 int ,也可能是 unsigend int 或者宽度更宽的类型。

这些转换是安全的,因为编译器的实现保证了 int 比这些类型能容纳更大的数据(宽度更宽),在进行整型提升时不会损失精度。

这个过程是自动的,即如果满足要求,编译器会自动执行整型提升,同时也是编译器首选的转换模式,如果这个模式不满足类型安全则编译器会使用其它策略。

整型转换

整形转换是指整型在 signed 以及 unsigned 之间的转换。

这个转换在进行表达式计算时是有规则的:

  1. 两个操作数 都 是 signed 或 unsigned 的,则向优先级高的一侧转换。
  2. 两个操作数一个是 signed 一个是 unsigned ,unsigned 的级别高于 signed ,则向 unsigned 一侧转换。
  3. 若 signed 级别高于 unsigned ,且 signed 能够容纳 unsigned 所能表示的所有数字,则向 signed 一侧转换。
  4. 否则 将两个类型转换为 有符号类型(默认有符号的类型)的无符号版本。

这个转换不总是安全的,因为两种形式可表示的数据范围不同。能发生这种情况的场景也十分有限,只会发生在 signed 和 unsigned 互相赋值的场景下(平时很少使用)。

浮点转换

指 float 和 double 相关类型的转换。

从宽度上看,只有 float 转换为 double 的行为是安全的。

如果非需要,不要做从 浮点类型 向 整型 的转换,这种转换通常有 截断 和 溢出 两种结果。

截断 的结果经常被用于浮点数取整;溢出 很可能是我们小看了浮点类型能够表示的数的范围,而溢出的结果往往是我们所讨厌的。

用户定义的转换

在 class 中可以进行类型转换的定义,称为 转换函数:

operator typename() 

转换函数定义了将类转换为其它类型的行为。除非必要,否则不要使用转换函数。

何时发生类型转换

表达式计算时的转换

编译器遇到表达式计算时会按照下面的规则进行类型转换:

从最宽的 浮点类型 long double 开始检查,如果存在 long double 则将其它操作数转换为 long double ;

进行 double 的检查;进行 float 的检查;进行 整型提升 ;进行 整形转换 ;

初始化和赋值时的转换

进行宽度提升的转换总是安全的,在进行初始化和赋值时通常也遵循该原则。

  1. 以常量(字面量)进行的初始化

    如果没有其它理由,编译器总会将 数字 作为 int 类型对待;如果有后缀(比如浮点类型的 f 、无符号长整形的 ul)则转换为后缀标识的类型;如果 int 的能够表示的范围不够大,则向更宽的整型转换。

  2. 不允许缩窄的初始化

    C++ 11 的新特性 列表初始化 ,使用列表初始化进行初始化操作时,不允许将列表中的数据类型 缩窄 使用。

    char c1 {31325};  // narrowing, not allowed
    

    31325 超过了 char 能够表示的数据范围,如果不使用 列表初始化 则 c1 将进行溢出表示;如果使用了 列表初始化 则会出现编译错误。

我们在进行赋值时常常也会使用常量,有时也会用到表达式,这两种情况的规则已经在上文介绍过了,要注意的是:在表达式赋值时,转换或许不仅发生一次。

short res;
short a = 10;
short b = 20;
res = a + b;

在这个代码中,在定义 a b 时会出现 int 向 short 转换的情况;在进行 res = a + b 时,a + b 会作为 int 进行计算,最终向 res 赋值时也会进行 int 向 short 转换的情况。

const 相关的转换

只允许从 non-const 向 const 的转换。

函数调用时的转换

若实参与形参类型不一致,则编译器会将 实参 类型向 形参 转换,存在一些无关紧要的转换,这在编译器匹配合适的函数时会十分有用。

实参 形参
type type &
type const type
type volatile type
type * const type
type * volatile type
type & type
type [] * type
type() type(*)()

type * 指得是类型指针;* type 指得是数组的指针形式;type(*)() 指得是函数指针。

如果实参和形参满足上述列表的话,编译器无需(不会)进行转换,因为这些转换“无关紧要”。这也意味着如果存在两个函数:

type func(type);
type func(type &);

将会被视为重复的函数声明,产生二义性(ambiguous)。

或者你进行了如下的声明:

int func(int &a);
int func(const int a);

仍然会出现二义性,这是可能会出现的错误信息:

error: call of overloaded ‘func(int&)’ is ambiguous

使用函数时还会出现的一种转换:若函数返回非 void 类型,在计算 return 表达式时也会进行转换,表达式计算时的转换也适用于return 表达式。

强制类型转换

强制类型转换是显式的 explicit。

我们熟知的语法是 (typename) value ,C++中可以使用新的方式 typename(value) ,就像是调用了 “类的构造函数” 或者说定义了一个“类的对象”,记住这种感觉,因为我们稍后会用到。

这种语法用来将 value 强制的转换为 typename 。

这种强制的类型转换是在告诉编译器“我知道自己在做什么,所以不需要警告或者错误信息”,这似乎没有问题,我却认为我们需要安全的,最好可以在编译期间检查出来的类型转换的错误。

于是我们有4个 强制类型转换运算符 :

  1. static_cast<typename> (value) ,依然是将 value 转换为 typename,可用于基本类型,也可以作用于指针和引用。编译器会对这种转换进行检查,当两者无法兼容时会出现编译期间的错误,相对于传统形式的强制转换要安全一些。

  2. const_cast<typename> (value) ,用于取消 const 以及 volatile 特性。这不能用于转换基本类型,只可以作用于指针和引用。

  3. dynamic_cast<typename> (value),作用于指向类的指针,是 RTTI 的重要组件,用于判断两个类的指针能否进行转换。比如基类的指针可以指向派生类,则通过基类的指针可以调用到派生类当中的成员,但这依赖于析构函数的定义。如果试图调用派生类中独自定义的成员,那是不允许也是不可能的。

    为达到此目的,我们或许还需要再将基类指针转换为派生类指针,这就需要 dynamic_cast<typename> (value) 。typename 是一个指向派生类的指针,value 是指向基类的指针。如果允许转换,那么转换返回的结果会是 typename ,若不允许,则返回一个 nullptr 。

    总之这个运算符会让你安全的进行转换。

  4. reinterpret_cast<typename> (value),这个单词的含义是 重新解释 。它允许将任何指针转换为任何指针,也可以将整型转换为指针所指向的地址,当然也可以反向进行转换。这也存在限制,比如不能将指针转换为浮点型,不能用于函数指针和数据指针之间的转换。

    这种转换依赖于底层的实现,不同环境下地址的表示方式不同,这是不可移植的。但是如果需要将指针固定到某一个位置时,应该可以考虑到这种方法。

类的自动转换

这一点可以说明在定义构造函数时 explicit 的意义。

类的自动转换指得是:基本类型自动转换为类类型,其原因源自于构造函数。

还记得C++中得强制类型转换 typename(value) 么,如果 typename 是一个类的名字的话,就相当于调用了类的构造函数。

class Stonewt
{
private:
    enum {Lbs_per_stn = 14};      // pounds per stone
    int stone;                    // whole stones
    double pds_left;              // fractional pounds
    double pounds;                // entire weight in pounds
public:
    Stonewt(double lbs);          // constructor for double pounds
    Stonewt(int stn, double lbs); // constructor for stone, lbs
    Stonewt();                    // default constructor
    ~Stonewt();
    void show_lbs() const;        // show weight in pounds format
    void show_stn() const;        // show weight in stone format
};

我们来关注这个类的构造函数 Stonewt(double lbs) 。

如果我们这样使用 Stonewt s = 16.6 那么这个语句是成立的,编译器会调用 Stonewt(double) 将 16.6 构造为一个匿名的 Stonewt 对象,并将其赋值给 s 。

对于任意一个类来说,能否发生这样的转换取决于其构造函数的定义:是否允许将某个基本类型构造为该类对象。这种转换也意味着提供了一种从基本类型到类的映射,在函数调用以及表达式赋值时可以做这样的转换。但是这样的转换或许并非是我们想要的,从语义上会造成误解,于是将 explicit 标识在构造函数上以消除这种隐式的转换,从而明确构造函数的语义,而不是将构造函数理解为 “强制类型转换”。

在隐式的条件下,通过合适的构造函数可以实现 基本类型 向 类 的转换。同样的,也可以实现 类 向 基本类型 的转换,这需要在类的成员中定义 转换函数。

转换函数通过 operator typename() 定义为类成员,拥有函数体,没有返回值 声明,不能拥有参数,可以被 const 修饰。

定义之后就可以实现从类向基本类型的转换,可以将两个类对象进行四则运算,实际上是将类转换为相应的类型后进行计算。

在C++11中,转换函数也可被 explicit 修饰,表示不可通过该方式进行隐式转换,需要进行强制类型转换。

总之要谨慎的使用隐式的转换。

总结

本文讨论了何时发生类型转换,以及发生了何种类型的转换。讨论的重点在于何时发生了类型转换,同时说明了类型转换的基本规则:在基本类型中的隐式转换往往是进行“宽度提升”的转换,这类转换是安全的,即不会丢失精度。同时也描述了稍微复杂的规则,例如表达式计算时的转换。

在“何时发生的类型转换”的介绍当中,初始化、赋值以及表达式计算时的隐式类型转换,是我们较容易分辨和处理的。在介绍完基本规则后,将最容易出现问题的 函数调用时的转换 以及 类的自动转换 进行说明,能够掌握 无关紧要的转换 以防止在进行函数重载时出现 二义性 ;能够清楚的认识 explicit 在构造函数以及转换函数中的意义。

介绍了C++中的4种 强制转换运算符 ,介绍了它们的使用方法以及要点,对于强制类型转换的工作,使用它们是最为妥当的选择。

认识以及清楚在编写代码的过程中产生的 隐式的 类型转换,以及 安全的 进行强制类型转换,是我们理解 函数重载 的规则,以及 模板类型匹配 规则的重要基础。

认识这些转换并在编写代码时尽可能地少使用 显式的 以及 隐式的 ,或者说 各种类型的 类型转换,非必要不使用,会让我们的程序更加健壮。