C++ 类型之间的关系和转换

631 阅读5分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第10天,点击查看活动详情

从C++类型之间的关系讲起

C++基础类型

C++基础类型有这么几个大类:

  • 整型数据类
  • 浮点数据类
  • Null Pointer
  • Void

每个大类里又包含几个具体的类型, 用整型数据类浮点数据类来说明一下:

整型数据类

比如说:

  • char
  • short
  • int
  • long
  • bool
  • 等等

浮点数据类

比如说:

  • float
  • double

基础类型所占字节数

C++标准里对每一种基础类型都规定了相应的size,我们在代码里可以使用sizeof来获得相应的size,单位是字节:

#include <iostream>

int main()
{
    std::cout << "sizeof(int) == " << sizeof(int) << std::endl;
    std::cout << "sizeof(char) == " << sizeof(char) << std::endl;
    std::cout << "sizeof(bool) == " << sizeof(bool) << std::endl;
    std::cout << "sizeof(double) == " << sizeof(double) << std::endl;
    std::cout << "sizeof(float) == " << sizeof(float) << std::endl;
    return 0;
}

在我的机器上, 打印如下:

sizeof(int) == 4
sizeof(char) == 1
sizeof(bool) == 1
sizeof(double) == 8
sizeof(float) == 4

如果你运行的结果跟我不一样,不重要,这跟具体的编译器有关系。

基础类型之间的关系

如果两个基础类型A和B,他们能不能互相转换呢?

能转换,但是还分下面两种:

  • 类型提升
  • 类型转换

基础类型的类型提升

这又分两种:

  • 整型数据的类型提升
  • 浮点数据的类型提升

整型数据的类型提升

  • char 转 int
  • short 转 int
  • bool 转 int
  • 等等

浮点数据的类型提升

  • float 转 double

这里给一个反例,比如说 char 转 short,叫不叫类型提升?

答案是:不是类型提升。

究其原因是因为类型提升,并非是仅仅用一个占内存大的去接收一个占内存小的,而是为了让操作系统更快的处理这个数据。

类型转换

再来说说类型转换,除去类型提升之外的所有转换都称之为:'类型转换'。

提升和转换的区别

  • 类型转换可能包含了,变窄(接收者所能表达的数据范围小),进而引起数据的丢失。
  • 类型提升是绝对不可能引起数据丢失的。

再来看看在函数重载的时候,优先级问题:

void foo(int input){}
void foo(short input){}
void test()
{
    char a{'1'};
    foo(a); // 问,这个foo 是哪个foo?
}

上面的问题,就说明了,类型提升的优先级要高于类型转换

  • char -> int 是类型提升
  • char -> short 是类型转换

所以上面的foo调用,是选取了 foo(int)。

类型转换的过程

猜测:字节拷贝?

比如说:

float f{1.0f};
int a{f};

我们知道,

  • float 占四个字节
  • int 也占四个字节

那么上述代码能不能通过编译呢?

如果按照字节拷贝的说法,那是可以的,就直接拷贝,不会造成内存上的问题。

但是,这个是编译不过的,因为float所能表达的数据范围要比int大, 所以这里属于变窄转换

所以要加上强制转换:

    float f{1.0f};
    int a{static_cast<int>(f)};
    std::cout << a << std::endl;

这回能通过编译,并且能打印: 1。

我们知道,int类型和float类型在编码的时候,是不一样的,所以f所占的四个字节和a所占的四个字节,肯定是不一样的。

具体的编码算法要研究float编码和int编码。

我们这里写一个函数,打印各自的四个字节到底是什么, 来确定他们确实存储的字节不一样:

#include <iostream>

template <typename T>
void print_byte(T input)
{
    unsigned char *p{reinterpret_cast<unsigned char *>(&input)};
    for (int index = 0; index < 4; ++index)
    {
        std::cout << static_cast<unsigned int>(p[index]) << " ";
    }
    std::cout << std::endl;
}

int main()
{
    float f{1.0f};
    int a{static_cast<int>(f)};
    print_byte(f);
    print_byte(a);
    return 0;
}

在我的机器上:

0 0 128 63 
1 0 0 0

一目了然了,所以结论就是,基础类型转换的过程,并不是字节拷贝,而是根据各自的编码重新计算。

C++中的class

两个class类型之间的关系,有哪几种呢?

    • A 是 B,且 B 也是 A
    • A 是 B, 但 B 不是 A
  • 不是但可转换

  • 不是但不能转换

类型别名用于class

class Animal{};
using Dongwu = Animal;

上面有两个类型,Animal,Dongwu,他们在编译器看来,是完全相同的两个类型,这种就是:
Animal 就是 Dongwu,Dongwu就是Animal, 他们可以任意互换。

这种就是 A是B,且B也是A的关系。

这种完全等价的两个类型,不存在转换的概念。

子类与父类

class Animal{};
class Fish: public Animal{};

Fish是Animal,但Animal不是Fish,这就是所谓
A是B,但B不是A

如果B不是A,在通常情况下,无法进行转换,也就是说:

    Animal a;
    static_cast<Fish>(a); // 无法转换,因为 Animal 不是 Fish

反过来:

    Fish f;
    static_cast<Animal>(f); // 这句可以编译, 因为 Fish 是 Animal

既然可以编译,我们来分析一下,这种是如何转换的。

首先从子类对象与父类对象的内存构成来看,一个子类对象里包含了完整的父类对象。

所以父类对象的size肯定是要比子类对象的size要小的。

上面的static_cast<Animal>(f);, 无疑于将一个大size的类型转换到一个小size的类型。

我们可以非常自然的想到,这种转换,肯定是只保留了子类对象里的父类部分,而丢失了子类部分。

一般的,这种转换称之为object slicing, 对象裁剪

看下面的例子:

Fish f1{};
Fish f2{};

Animal& a{f1};

a = f2; // 这里出现了 Fish -> Animal ,也就是裁剪

我们画两个图,来解释上面的问题:

  • 未裁剪之前, 注意紫色的部分,是f2中的Animal部分

image.png

  • 裁剪之后, a和f1本身就是一个,这一块内存成了缝合体。

image.png

那这里能不能不裁剪,让行为正常一点呢:

重载Animal的 operator= , 并将这个操作符变成virtual, 这里就不提了。

不同的class类型

上面说了子类与父类这种特殊的关系。

再来说说一般情况下,类与类的关系。

class A{};
class B{};

void foo(A a){
}

void test()
{
    B b;
    foo(b); // 这里能行吗
}

显然是不行的。A和B完全八竿子打不着。

但是:

C++有时候烦就烦在这个地方,这两个八竿子打不着的,也能产生关系:

这里说两种,能让上述代码编译通过的办法:

  • A 有一个 constructor,接受 B 类型做为参数
  • B 重载了 operator A, 让自己可以转换成 A

先说第一种:

class B;

class A
{
public:
    A(const B &b) {}
};
class B
{
};

这种能行,是因为编译发现foo函数需要的类型是A,但是传了B,它就琢磨:

  • 我能不能找找,能不能就地构造一个A出来,然后就去看A的constructor,果然发现能行!!

再说第二种:

class A
{
};
class B
{
public:
    operator A()
    {
        return A{};
    }
};

这个operator A是一个重载的操作符,它的作用就是,调用这个操作符的时候,能返回一个A。

编译器的想法:

  • B有没有重载operator A呢,我去瞅瞅,果然重载了,就这样调用这个函数吧,生成一个A,出去然后传给foo。

这两种,如果你的代码里都实现了,那么 operator A 这种优先级更高。

explicit 关键字可以防止隐式构建对象

class B;
class A
{
    public:
    explicit A(const B& b) {} // 注意,前面加了explicit关键字
}
class B{};

void foo(A a)
{
}

void test()
{
    B b;
    foo(b); // 编译器无权调用上面的explicit的constructor,来就地构建一个A
}

此时此刻,上面的代码是编译不了的,但是这仅仅是说,编译器没有这种隐式构建的权限,如果我们非要编译器就地构建,它还是得老实照办:

void test()
{
    B b;
    foo(static_cast<A>(b));
}

上面,我们加了这种static_cast,就是相当于给了编译器这种就地构建的权利!

所以就又能编译通过了!

总结

本文总结了C++类型之间的关系,这包含基本类型与class类型。

基本类型之间可以互相转换。

class之间也可以互相转换。

class之间的转换又可以通过我们的自定义代码进行控制。

如果能熟练掌握,那么我们的代码就更上一层楼。