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. 两个操作数 signedunsigned 的,则向优先级高的一侧转换。
  2. 两个操作数一个是 signed 一个是 unsignedunsigned 的级别高于 signed ,则向 unsigned 一侧转换。
  3. signed 级别高于 unsigned ,且 signed 能够容纳 unsigned 所能表示的所有数字,则向 signed 一侧转换。
  4. 否则 将两个类型转换为 有符号类型(默认有符号的类型)的无符号版本。

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

浮点转换

floatdouble 相关类型的转换。

从宽度上看,只有 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 时会出现 intshort 转换的情况;在进行 res = a + b 时,a + b 会作为 int 进行计算,最终向 res 赋值时也会进行 intshort 转换的情况。

const 相关的转换

只允许从 non-constconst 的转换。

函数调用时的转换

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

实参 形参
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种 强制转换运算符 ,介绍了它们的使用方法以及要点,对于强制类型转换的工作,使用它们是最为妥当的选择。

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

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