🌟《C++从零开始》 系列,开始更新中…

四、复合类型及转换

什么是复合类型?这听起来好像是有点新奇的概念

从基本数据类型(比如intchar)或其他复合数据类型构造出来的数据类型,就称为复合类型。

在前面我们已经接触过所谓的复合类型:

  • 数组(std::stringstd::array 等)、指针类型(函数指针或者说函数,指向对象的指针等)、引用类型。

  • 举个例子,下面函数func 类型是void()(int, double) ,它由基本类型组成,使其成为复合类型:

    1
    2
    3
    void func(int x, double y)
    {
    }

本章主要介绍的复合类型是结构体(注意结构体大小计算)和枚举(注意枚举作用范围问题),以及各种类型之间的转换:

  • 用户隐式转换及发生的情况;
  • 用户四种显示转换,包含C风格和C++风格;
  • t特别补充,string类型和其它类型之间的转换。

4.1 结构体

4.1.1 从C谈起

在C语言中定义一个典型结构体struct如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <stdio.h>

// 1. 定义
struct People
{
char name[10];
char gender[5];
int age;
}; // *这里有个分号,易遗漏

int main()
{
// 2.声明&初始化,结合{}
struct People people = {"royhuang","male",25};
// 3.访问,C/C++中结构体成员默认都是public可以直接访问
printf("your name: %s \n",people.name); // royhuang
return 0;
}

当时还是新手C玩家的我,很难说出struct到底带来什么好处。只能隐隐约约感觉到,struct组合多个且有逻辑关联的数据增强了程序逻辑性和可读性

那么struct 除了组织有逻辑关联的数据提高代码可读性和一致性,在实际编码中还有其它应用吗

在第三章函数,我们提到过:结构体还可用于函数传递多个参数或者函数返回多个值

  • 传递多个参数

    传递一个结构体作为参数:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    void printPeople(const struct People& people)
    {
    printf("your name: %s \n",people.name);
    printf("your gender: %s \n",people.gender);
    printf("your age: %d \n",people.age);
    }

    int main()
    {
    struct People people = {"royhuang","male",25};
    printPeople(people);
    return 0;
    }

    显然这相比传递多个参数要清爽很多(且不易出错):

    1
    void printPeople(const char* name, const char* gender, const int age)
  • 返回多个值

    函数只能返回一个参数,除了使用元组,结构体是不二的选择:

    1
    2
    3
    4
    5
    6
    7
    struct People& cleanPeople(People& people)
    {
    people.name="";
    people.gender="";
    people.age=-1;
    return people;
    }

4.1.2 C++中struct

C++中struct兼容了C,在此基础上还进行了扩展:

C C++
成员 只有数据 数据,函数等都可以
访问权限 public 默认public,有private
是否可以继承
  1. 在C中结构体声明必须带上struct 关键字,而C++中可以直接使用。

    1
    2
    struct People people1; // C
    People people1; // C++
  2. C++中,struct增加了private访问权限,且可以和类一样有成员函数,成员默认访问说明符为public(为了兼容C)。

    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
    #include <iostream>
    #include <cstring>

    struct People
    {

    char name[10];
    char gender[5];
    int age;
    // 构造函数
    People(const char* peole_name,const char* peole_gender,int peole_age)
    {
    strncpy(name,peole_name,10);
    strncpy(gender,peole_gender,5);
    age = peole_age;
    }
    // 成员函数
    void printPeople(const People& people)
    {
    std::cout<<"your name:" <<people.name<<std::endl;
    std::cout<<"your gender: "<<people.gender<<std::endl;
    std::cout<<"your age: "<<people.age<<std::endl;
    }
    // 私有变量
    private:
    char girl_friend[10];
    }; // 分号

    int main()
    {
    People people = {"royhuang","male",25};
    people.printPeople(people);
    return 0;
    }
  3. 可以继承,实现了多态。

显然,C++中struct和class区别已经不大。

4.1.3 结构体大小和比较

参考:c++中的sizeof()运算符C/C++中 sizeof 的用法总结

从基本数据类型说起

对于short、int、long简单内置数据类型,可采用sizeof 关键字计算大小:

1
2
3
4
5
6
7
8
int i;  
sizeof(int); // 值为4
sizeof(i); // 值为4,等价于sizeof(int)
sizeof i; // 值为4
sizeof(2); // 值为4,等价于sizeof(int),因为2的类型为int
sizeof(2 + 3.14); // 值为8,等价于sizeof(double),因为此表达式的结果的类型为double

char ary[sizeof(int) * 10]; // OK,编译无误

题外话:sizeof和strlen的区别

  • 运算符与函数:sizeof是运算符,并不是函数,结果在编译时得到而非运行中获得;strlen是字符处理的库函数。
  • 参数类型:sizeof参数可以是任何数据的类型或者数据(sizeof参数不退化);strlen的参数只能是字符指针且结尾是’\0’的字符串
  • 值确定时机:sizeof值在编译时确定,所以不能用来得到动态分配(运行时分配)存储空间的大小。
1
2
3
4
5
6
7
8
int main()
{
const char* str = "name";

sizeof(str); // 取的是指针str的长度,是8
strlen(str); // 取的是这个字符串的长度,不包含结尾的 \0。大小是4
return 0;
}

注意,基本数据类型的内存大小是和系统相关的,所以在不同的系统下取值可能不同。比如,

  • long 类型与指针类型在 32 位机器上只占 4 字节,在 64 位机器上占 8 字节;
  • int在32位/64位都占4字节。

本节均假设按64位机器考虑

sizeof 计算结构体大小

结构体的sizeof为了提高存取效率,涉及到字节对齐问题。

其对齐规则如下:

  1. 顺序存储:分配内存的顺序是按照声明的顺序进行顺序存储
  2. 偏移量:每个变量相对于起始位置的偏移量,必须是该变量类型大小的整数倍,不是整数倍空出内存,直到偏移量是整数倍为止;
  3. 整体大小:最后整个结构体的大小必须是,变量类型最大值的整数倍

以下实例说明。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <iostream>
using namespace std;

struct A
{
char a;
double b;
int c;
};

int main()
{
cout << sizeof(A) << endl;
}

输出为:24,而不是1+8+4=13。这是因为 :

image-20211223175820728

  1. char a 的偏移量为 0,占用 1Byte;
  2. double b 指的下一个可用的地址的偏移量为 1,不是 sizeof(double)=8 的整数倍,需要补足 7Byte 才能是偏移量为 8;
  3. int c 指的下一个可用的地址的偏移量为 16,是 sizeof(int)=4 的整数倍,满足 int 的对齐方式;
  4. 结构体大小必须是最大成员大小的整数倍,(即结构中占用最大空间的类型所占用的字节数 sizeof(double)=8)的倍数,所以最后还需填充4byte。

嵌套结构体和unio共用体对齐规则又有所不同

  • 嵌套结构体

    对于嵌套的结构体,需要将其展开。对嵌套结构体求 sizeof 时,上述原则变为:

    1. 展开后的结构体的第一个成员的偏移量应当是被展开的结构体中最大(非嵌套不要求最大)的成员的整数倍。
    2. 结构体大小必须是最大成员大小的整数倍,这里所有成员计算的是展开后的成员,而不是将嵌套的结构体当做一个整体。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    #include <iostream>
    using namespace std;

    struct B
    {
    char a;
    struct
    {
    char b;
    int c;
    } ss;
    short d;
    };

    int main()
    {
    cout << sizeof(B) << endl;
    }

    该代码输出:16。分析过程同前,这里只给出图示。

    image-20211223180812242

  • Unio共用体

    union 中变量共用内存,原则如下:

    1. 内存大小应以最长的为准;
    2. 满足上述条件下,还应是最长成员的整数倍大小。

    例如,下面例子输出共用体大小为:24

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    #include <iostream>
    using namespace std;

    union C
    {
    int a[5]; // 数组大小=成员*长度=5*4=20
    char b;
    double c;
    };

    int main()
    {
    // 最后输出补足4byte,满足是double(8字节)整数倍
    cout << sizeof(C) << endl;
    }

    特别的,请思考:

    • 将共用体内的 int a[5] 修改成 int a[6] 后,结果仍然不变;但如果将 int a[5] 修改成 int a[7],结果就变成 32

    你可在评论区写下你的见解。

扩展:sizeof计算类对象大小

关于类占用的内存空间,有以下几点需要注意:

  1. 虚函数:编译器需要为类构建虚函数表,类中需要存储一个指针指向这个虚函数表的首地址。

    注意不管有几个虚函数,都只建立一张表,所有的虚函数地址都存在这张表里,类中只需要一个指针指向虚函数表首地址即可。

  2. 静态成员:被类所有实例所共享的,它不计入sizeof计算的空间。

  3. 普通函数或静态函数:都存储在栈中,不计入sizeof计算的空间。

  4. 类成员:采用字节对齐的方式分配空间;

以下为实例说明:

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
#include <iostream>
using namespace std;

class D
{
public:
// 虚函数:需要一个指针指向虚函数表
// 32位系统指针大小为 4Byte,64位系统指针大小为 8Byte
virtual void funa();
virtual void funb();
// 普通函数或静态函数或静态成员:不计入
void func();
static void fund();
static int si;

private:
// char占1字节
char c;
// int首先要偏移3字节,是sizeof(int)整数倍
// int本身占4字节
int i;
};

int main()
{
// 最后总大小为:指针大小+1+3+4 = 12 OR 16
cout << sizeof(D) << endl;
}

以上输出为:12(32位系统)或者16(64位系统)。具体占用请查看代码中注释。

类成员对齐方式和结构体有所不同,未尽细节将在未来补充。

空类/结构体大小

思考源自于:空类的大小为什么是1?

经过实践,无论是空类还是空结构体,其大小均为1而不是0

看了一下比较信服的解答是:

  • 如果长度是0,那么把他塞给一个指针,指针指到哪里呢?不考虑指针,这个类自己存在哪里呢?如果我一下子申明100万个实例,都不占用内存吗?

  • c++ 中规定不同的对象必须拥有不同的地址,如果为0会导致两个空类的地址一样。

    但是为啥空类一定要有不同的地址来去区分不同的对象?

结构体比较

C++结构体直接进行比较会出错:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include<iostream>

struct A
{
int a;
float b;
char* c;
};


int main()
{
A a{1,2,new char[4]{'h','w','h'}};
A b{1,2,new char[4]{'h','w','h'}};

if(a==b)
std::cout<<"equal"<<std::endl;
else
std::cout<<"not equal"<<std::endl;
}

输出:

1
2
3
4
[root@roy-cpp test]# g++ -std=c++11 test.cpp -o test.out
test.cpp: In function ‘int main()’:
test.cpp:21:9: error: no match for ‘operator==’ (operand types are ‘A’ and ‘A’)
if(a==b)

有两种办法解决这个问题:

  1. 结构体 ab 每个元素逐个比较,指针比较它们的地址。

  2. 自己重载操作符==

    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<iostream>

    struct A
    {
    int a;
    float b;
    char* c;
    bool operator== (const A& other) const
    {
    bool a_is_equal = (a==other.a);
    bool b_is_equal = (b==other.b);
    bool c_is_equal = true;
    for(int i=0 ; i<3 ; i++)
    {
    if(c[i] != other.c[i])
    {
    c_is_equal =false;
    break;
    }
    }
    return a_is_equal && b_is_equal && c_is_equal;
    }
    };

    int main()
    {
    A a{1,2,new char[4]{'h','w','h'}};
    A b{1,2,new char[4]{'h','w','h'}};
    if(a==b)
    std::cout<<"equal"<<std::endl;
    else
    std::cout<<"not equal"<<std::endl;
    }

    输出:

    1
    2
    [root@roy-cpp test]# ./test.out 
    equal

4.1.4 扩展阅读:为什么C++还保留struct?

在C++中,除了默认访问控制符、模板参数,struct和class基本完全一致,struct存在的意义是什么,全用class不好吗?

struct class
继承默认权限 struct默认是public class默认是private
模板参数 不可以 可以

这里面的原因主要是:

  1. 历史包袱。给 C 语言程序开发人员有一个归属感;
  2. 兼容 。让 C++ 编译器兼容以前用 C 语言开发出来的项目,比如系统库stdlib.h 等。

4.2 枚举

4.2.1 枚举初识

和结构体类似,枚举也是组织了有逻辑关系的数据,不过枚举:

  • 数据只能是同类型(我们定义的枚举类型);
  • 相比struct/class,枚举enum更像是一种弱组织类型,如果你有一组相关的常量最好使用枚举。

举个例子吧,用枚举组合三元色(常量)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <iostream>

enum Color // 最佳实践:首字母大写
{
red,
blue,
green,
};

int main()
{
Color paint = red;
return 0;
}

相比用0、1、2分别定义三元色,代码可读性提高了很多。

枚举是整数符号常量

枚举到底什么

好吧,标题已经出卖了答案:枚举其实就是整数符号常量(默认是int)。

char 情况有点类似:

1
char c = 'A';

char 实际上存储的是一个 1 字节的整数值,即字符'A'被转换为整数值(在本例中为65)并存储。

只不过打印cout类对<<进行了重载,直接打印c 出来的是'A'

1
std::cout<<c;  // A

回到枚举enum

  • 枚举会自动分配一个整数值。默认情况下,第一个枚举器被分配整数值0,每个后续枚举器的值都比前一个+1
  • 我们也可以自定义枚举值。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <iostream>

enum Animal
{
cat = -3,
dog, // -2
pig, // -1
horse = 5,
giraffe, // 6
chicken // 7
};
int main()
{
Animal animal = dog;
std::cout << "你的宠物是:" << animal;
return 0;
}

输出:

1
你的宠物是:-2

好吧你的宠物是-2,编译器隐式将枚举转换为了整数(并没有像char一样打印字符,std::cout没有对枚举类型Animal进行重载)。

不过,编译器不会将整数隐式转换为枚举数。

1
Animal animal = -2;  // error

需要显式转换:

1
Animal animal = static_cast<Animal>(-2);

上面我们验证了enum的主要用法和特性,但可以更完美一点优雅打印枚举数吗,就像char一样?

优雅打印枚举数

怎么优雅打印枚举数?

C++中并没有提供相关函数。这只有我们自己来实现,if-elseswitch 逐个判断是最容易想到的:

1
2
3
4
5
6
7
8
9
10
11
const std::string printAnimal(Animal animal)
{
switch (animal)
{
case cat: return "cat";
case dog: return "dog";
case pig: return "pig";
// 省略其它的判断
default: return "???";
}
}

但这需要显式调用printAnimal 方法,看起来有点笨拙。

1
2
Animal animal = dog;
std::cout << "你的宠物是:" << printAnimal(animal);

直接重载std::cout 类运算符<<是更好的做法

1
2
3
4
5
6
7
8
9
10
11
std::ostream& operator<<(std::ostream& out, Animal animal)
{
switch (animal)
{
case cat: return "cat";
case dog: return "dog";
case pig: return "pig";
// 省略其它的判断
default: return "???";
}
}

现在,我们可以打印字符串看起来聪明了很多:

1
2
Animal animal = dog;
std::cout << "你的宠物是:" << animal;

输出:

1
你的宠物是:dog
认识枚举作用域

枚举内部数据和枚举具有同样的作用范围,也就是枚举对内部数据作用范围无限制

回到最开始的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
enum Color
{
red,
green,
blue,
};

int main()
{
// 直接就使用了red
// 也可以通过前缀Color::red
Color paint = red;
return 0;
}

可以看到,redenum Color 作用域是一致的,都是全局类型。

这种设置,调用起来很方便,但是在两个不同枚举类定义相同的数据,会使得命令空间污染,导致编译错误

1
2
3
4
5
6
7
8
9
10
11
12
13
enum Color
{
red,
green,
blue, // blue 表示颜色
};

enum Feeling
{
happy,
tired,
blue, // blue表示心情
};

如果你再尝试调用,编译无法通过,两个blue 冲突:

1
Color paint = blue; //  error: redeclaration of ‘blue’

虽然可以通过显式指定前缀避免错误:

1
Color paint = Color::blue; 

但终归是埋下了隐患。

更好的做法是使用命名空间namespace 限定范围,这样我们可以在不同枚举类定义相同的数据

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
#include <iostream>

namespace color
{
enum Color
{
red,
green,
blue,
};
}

namespace feeling
{
enum Feeling
{
happy,
tired,
blue,
};
}

int main()
{
// 现在必须加上作用域区域的名称
color::Color paint = color::blue ;
feeling::Feeling me = feeling::blue ;
return 0;
}
最佳实践

使用枚举请显式指明枚举类型,比如color::blue 而非直接使用blue

4.2.2 范围枚举

为什么需要范围枚举?

非范围枚举看起来很好,但会带来一些问题:

  • 隐式转换的危害。非范围枚举会隐式地将枚举类型转换为整数类型,这有时候会造成一些意料之外的错误(比如不同枚举类型进行比较,下举例说明);
  • 非强制要求前缀。这显然和我们刚刚提到的枚举最佳实践不符。

范围枚举的特性很好地解决了上述问题:

  • 范围枚举是强类型的(不会隐式转换为整数)和强作用域的(必须指定前缀);
  • 其余和非范围枚举没什么区别。
范围枚举初识

范围枚举通过enum class 声明:

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <iostream>

enum class Color
{
red,
blue,
};

enum class Fruit
{
banana,
apple,
};

先认识下范围枚举的强作用域

1
Color c = red;  // error,identifier "red" is undefined

必须显式指定作用范围,契合了最佳实践:

1
Color c = Color::red; // ok

再来认识范围枚举的强类型(不会隐式转换)。

  1. 无法直接打印

    因为范围枚举不会隐式转换为intstd::cout 无法直接打印。

    1
    std::cout << Fruit::banana;

    除非你显式进行转换:

    1
    std::cout << static_cast<int>(Fruit::banana);
  2. 不能直接比较两个不同枚举类型

    下面代码编译能通过吗?

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    int main()
    {
    Color color = Color::red ;
    Fruit fruit = Fruit::banana ;

    if (color == fruit)
    std::cout << "color和fruit相等\n";
    else
    std::cout << "color和fruit不相等\n";
    return 0;
    }

    如果是无范围枚举上述代码不会编译出错,甚至会打印:

    1
    color和fruit相等

    因为redbanana 都被隐式转换为int类型,值都为0

    但如果是范围枚举,上述代码会直接报错:

    1
    compile error: the compiler doesn't know how to compare different types Color and Fruit

    范围枚举不会转换任何可以与另一种类型进行比较的类型。

    不过,比较同一类型是ok的

    1
    if (Color::red == Color::blue) // ok 
最佳实践

请尽量使用范围枚举,它更安全。

虽然在实际编码我们还是可能会使用非范围枚举,因为非范围枚举隐式转换可避免大量手动static_casting 。但是,只是偶尔的需要static_casting 便不能构成拒接范围枚举的理由。

4.X 类型转换🌟

什么是类型转换

将值从一种数据类型转换为另一种数据类型的过程,称为类型转换。

C++的类型有几种

C++的类型转换分为两种,一种为隐式转换,另一种为显式转换:

  • 隐式转换:编译器自动进行的类型转换,就是隐式转换;
  • 显式转换:程序员显式使用_cast 类型转换符进行类型转换,就是显式转换。

C++ 中的绝大多数类型转换都是隐式类型转换

所以,我们先来接触隐式类型转换吧。

4.X.1 隐式转换

隐式转换可以分为两个部分,标准转换用户自定义转换,我们来看看它们是什么。

标准转换

标准转换就是编译器里内置的一些类型转换规则:

  • 数值提升
  • 数值转换
  • 算术转换
  • 数组退化成指针、函数转换成函数指针
  • 数据类型指针到void指针的转换、nullptr_t到数据类型指针的转换
  • 特定语境下要求的转换,比如if里要求bool类型的值、枚举类型转换为整型

可以看到,这些转换基本针对基本数据类型指针、或数组这种内置的聚合类型的。

先从第一个数值提升说起吧。

  • 数值提升

    数值提升是什么

    顾名思义,数值提升就是将更小的数据类型(比如char) 转换为更大的数据类型(通常是int或者double)。显然这种提升是安全的,它不会丢失精度。

    数值提升也分为两个子类别:浮点提升和整数提升。

    • 浮点提升

      浮点提升规则很简单,就是float 可以隐式提升为double 。一个例子就能明白:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      #include <iostream>

      void printDouble(double d)
      {
      std::cout << d;
      }
      int main()
      {
      printDouble(4.0f); // 4.0f是float被隐式提升double
      return 0;
      }
    • 整型提升

    charboolshortintlong 都是整型。

    整型提升说白了,就是比int 小的数据类型,尽量往intunsigned int上靠。它的规则大致总结如下:

    1. 无符号或者有符号char、short 优先隐式转换为int,如果int不够容纳,则转换为 unsigned int;
    2. bool也转换为int,false变成0,true变成1。

    举一个小例子。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    #include <iostream>

    void printInt(int x)
    {
    std::cout << x;
    }

    int main()
    {
    short s = 3 ; // short
    printInt(s);
    printInt('a'); // char
    printInt(true); // bool

    return 0;
    }

    数值之间的转换就结束了

    看到这儿或许你会说作者是不是遗漏了,int→float 之类的整型提升介绍呢?

    其实,int→float 、还有宽类型→窄类型的转换都被归类为数值转换。当然这只是学术上的区别,你不必太过纠结这点。

  • 数值转换

    什么是数值转换

    数值提升未提到的转换,都是数值转换。

    回答这么敷衍?好吧,我具体一点。

    数值转换可分为以下五种规则:

    1. 整型可转换为任何其它整型(不包括整型提升)

      1
      2
      3
      short s = 3; 
      long l = 3;
      char ch = s;
    2. 浮点类型转换为任何其他浮点类型(不包括浮点提升)

      1
      2
      float f = 3.0; 
      long double ld = 3.0;
    3. 整数类型转换为任何浮点类型

      1
      double d = 3;
    4. 将浮点类型转换为任何整数类型

      1
      int i = 3.5;
    5. 将整数类型或浮点类型转换为 bool

      1
      bool b1 = 3.0;

    可以看到:数值转换数值提升的规则综合起来就是一句话:C++中基本任意整型之间都可以进行隐式转换

    但数值转换会带来一些危险

    由于数值转换可以将:宽类型→窄类型、浮点类型→整数类型,这会导致精度丢失。

    1
    2
    3
    4
    5
    6
    7
    // 浮点类型→整数类型
    float a = 1.5;
    int b = a ; // a = 1.0 , 小数部分丢失

    // 宽类型→窄类型
    int a = 1000000;
    char d = a; // d = 64('@') , a被截断

    另一点比较隐蔽,隐式转换总是转换为右值(显式转换也是)。

    听起来好像没什么毛病,但是和引用结合起来就坏事了,我们知道引用只能用左值初始化。

    1
    2
    3
    int a = 10;
    long &b = a; // error,无法使用右值初始化引用
    long &b = static_cast<long>(a); // error,显式转换也是右值

    解决办法也很简单:

    1
    2
    3
    4
    5
    6
    7
    8
    // 1.和引用类型保持一致
    int a = 10;
    long tmp = a;
    long &b = tmp;

    // 2.使用const修饰,这样可以接受右值初始化
    int a = 10;
    const long &b = a;

    扩展到函数也是一样:

    1
    2
    3
    4
    void func(const long& value);

    int a = 10;
    func(a);

    这也是为什么很多教程说尽量用const修饰引用:这样可以使得函数参数可以隐满足式转换规则

  • 算术转换

    算术转换就是当操作数不是同一类型时,会隐式转换为同一类型进行再进行操作。

    算术转换优先级列表:

    • long double > double > float > unsigned long long > long long > long > unsigned int > int

    注意到最低优先级是int。

    算术转换只有两条规则:

    • 如果至少有一个操作数的类型在优先级列表中,则将具有较低优先级的操作数转换为具有较高优先级的操作数的类型;
    • 否则(两个操作数的类型都不在列表中),两个操作数都进行类型提升。

    一个简单例子。

    1
    2
    3
    int i = 2;
    double d = 3.5;
    typeid(i + d).name(); // 最终类型为d,double优先级更高,所以i提升为double
  • 指针相关转换

    这些我们之前其实都基本见过:

    • 数组退化成指针、函数转换成函数指针
    • 数据类型指针到void指针的转换、nullptr_t到数据类型指针的转换

    举个小例子加深下印象就好。

    1
    2
    3
    4
    5
    6
    int* i = new int();
    void* ptr = i; // 所以之前说void类型接受任何类型的指针

    char* pc = 0; // int 转换为 Null 指针再转换为char*指针
    char* pc = nullptr; // nullptr转换为char*指针
    dog* pd = new yellowdog(); // 指针类型转换,子类 yellowdog 指针转换为父类 dog 指针
  • 特殊语境下转换

    以if为例,if接受bool类型。

    1
    2
    3
    4
    if(3)  // 3转换为bool类型,这里是true(非0值都转换为true)
    {
    std::cout<<"fine"<<std::endl;
    }

    bool也是整型,其实也就是前面说别的,C++中整型几乎都可以进行隐式转换。

别踩坑:char和int的转换?

前面我们说到,char和int可以隐式转换。但这可能会带来一些意外的错误:

1
2
char c = '1';
int a = c; // a=49

此时是将c 的ASCII码49(数字的ASCII码从48开始,即'0'开始) 赋值给变量a

如果需要获取c存储值'1'

1
2
int a1 = c-48;  // 方法1
int a2 = c-'0'; // 方法2
用户自定义转换🌟

这部分内容有点超前,适合有一定面对对象基础的同学。

怎么进行用户定义转换

用户自定义的隐式转换是隐式转换的重头戏,一般指两方面内容:

  1. 转换构造函数,利用接受单个参数第一个参数其余参数都提供了默认值 的构造函数,将其他类型对象→转换为本对象

    C++ 会将任何构造函数视为隐式转换运算符。

  2. 类型转换重载重载指定类型,将本类的对象→转换为指定类型对象

两者合起来可以构成一个双向转换关系,下面我们看一个例子:

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
#include <iostream>
using namespace std;

class dog
{
public:
// 1.转换构造函数
dog(string name) {m_name = name;}
// 2.类型转换重载
operator string(){ return m_name;}
private:
string m_name;
};
void s2dog(cosnt dog&)
{
// nothing;
}

int main()
{
string dogname = "dog";
// 1.转换构造函数
dog d = dogname; // ok
s2dog("dog"); // ok
// 2.类型转换
std::cout << "my name is " << string(d) << "\n";
return 0;
}

输出:

1
my name is dog

第一种隐式转换方式看起来有点让人费解:

1
dog d = dogname;

它也没有重载 = ,怎么就stringdog转换了?

这是因为dog(string name) {m_name = name;} 有两层含义,除了构造函数外,它还可以作为隐式转换函数,将 string 对象转换为 dog 对象。

具体过程还涉及到复制初始化(5.2.2节详解):

  1. 表达式从右到左,构造函数dog(string)作为转换构造函数 ,编译器先创建dog临时匿名对象,使得=两边操作类型一致;
  2. 然后构造函数dog(string) ,初始化匿名对象;
  3. 编译器创建对象d
  4. 调用复制构造函数,匿名对象作为复制构造函数参数,初始化对象d

用户自定义转换有什么危险

隐式转换总是或多或少有点危险,用户自定义隐式转换也不例外。

  • 比如,我们只是想声明一个构造函数,但是会自动被识别为转换构造函数——将stringdog也许并不是我们的本意 。
  • 通常编码需避免双向隐式转换,我们开头的例子是不好的编程实践。

如果不想构造函数进行隐式转换,可以用 explicit 进行声明:

1
explicit dog(string) {m_name = name;}

此时进行隐式转换会出错:

1
string dogname = "dog";  // error

只能进行显示转换:

1
string dogname = static_cast<dog>("dog");

用户自定义转换有什么好处

隐式转换并不是一无是处,它仍然有存在的意义。

下面声明一个Rational 有理数类,处理数字类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Rational 
{
public:
int num;
int den;
// 1.构造函数隐式转换
Rational(int numerator = 0, int denominator = 1)
: num(numberator), den(denominator) {}
// 2.重载运算符*
Rational operator*(const Rational& lhs, const Rational& rhs)
{
return Rational(lhs.num*rhs.num, lhs.den*rhs.den);
}
};

int main()
{
Rational r1 = 23;
Rational r2 = r1 * 2;
Rational r3 = 3 * r1;
}
  • 上面代码定义了一个有理数类 Rational,它的构造函数接受 2 个默认参数,分别代表分子和分母,给该构造函数传递一个参数时,Rational 具有隐式转换的特性,所以我们可以直接将数字赋值给 Rational 对象,如:Rational r1 = 23;
  • 为了避免双向转换,这里并没有定义将 Rational 转换为 int 的转换函数,而当我们想实现 Rational 对象和 int 之间自由的算术运算时,我们需要定义全局的操作符重载,如上面的 operator* 定义了有理数的乘法云算符。
最佳实践

类型转换是危险的操作,使用更长的操作符(后文将提到的显式转换)提醒同事和将来的自己注意安全。

4.X.2 显式转换

显式转换主要有两个作用:

  1. 完成C++隐式转换无法完成的工作,比如范围枚举中将eunm 类型→int
  2. 尽可能的替代隐式转换,在程序显式的声明这是个转换操作——这很专业。

C++/C总是难以分开的,先从C中显式转换说起吧。

C风格显式转换

在标准 C 编程中,转换是通过 () 运算符完成的,类型的名称要转换的值放在括号内。

1
2
double x = 1.5;
int y = (double)x;

C风格的转换格式看起来很简单,但其实有不少缺点:

  1. 转换太过随意,可以在任意类型之间转换。可以把一个指向const对象的指针转换成指向非const对象的指针,把一个指向基类对象的指针转换成一个派生类对象的指针。这些转换之间的差距是非常巨大的,但是传统的C语言风格的类型转换没有区分这些。
  2. C风格的转换没有统一的关键字和标示符。对于大型系统,做代码排查时容易遗漏和忽略。

为此,C++ 引入了_cast强制转换运算符。

C++显式转换

C++转换风格完美的解决了C风格两个问题:

  1. 对类型转换做了细分,提供了四种不同类型转换,以支持不同需求的转换;
  2. 类型转换有了统一的标示符,利于代码排查和审查。

四种不同类型的转换分别是:

  • static_cast,命名上理解是静态类型转换,如int转换→char,转换失败不返回NULL。

  • dynamic_cast,命名上理解是动态类型转换,如子类和父类之间的多态类型转换,转换失败返回NULL

    ⚠️ 所以上行转换(子类→父类)这种安全转换用static_cast是可以的,但是下行转换(父类→子类)这种不安全的转换使用dynamic_cast,失败会返回NULL,会运行时检查。

  • const_cast,去除const属性,如常量指针/引用转换→非常量指针/引用。

  • reinterpret_cast,仅仅重新解释类型,没有进行二进制的转换。

static_cast

什么时候使用static_cast

  • 基本数据类型之间的转换,如把int转换为char,带来安全性问题由程序员来保证;
  • 把空指针转换成目标类型的空指针
  • 把任何类型的表达式转为void类型
  • (不推荐)类层次结构中基类和派生类之间指针或引用的转换:上行转换(子类→父类)是安全的;下行转换(父类→子类)由于没有动态类型检查,所以是不安全的。

隐式转换都建议使用static_cast进行标明和替换

例如,下面隐式转换都可替换为显式的static_cast转换。

1
2
3
4
5
6
int n = 6;

double d = static_cast<double>(n); // 基本类型转换
int *pn = &n;
double *d = static_cast<double *>(&n) // 无关类型指针转换,编译错误,应该使用reinterpret_cast
void *p = static_cast<void *>(pn); // 任意类型转换成void类型
dynamic_cast

什么时候使用dynamic_cast

只有在派生类之间转换时才使用dynamic_cast,type-id必须是类指针,类引用或者void*

  • 使用时基类必须要有虚函数,因为dynamic_cast是运行时类型检查,需要运行时类型信息,而这个信息是存储在类的虚函数表中,只有一个类定义了虚函数才会有虚函数表(如果一个类没有虚函数,那么一般意义上,这个类的设计者也不想它成为一个基类)。

和static_cast对比有什么区别

  • 下行转换,dynamic_cast是安全的(当类型失败时,转换过来的是空指针),而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
class BaseClass 
{
public:
int m_iNum;
// 基类必须有虚函数,保持多态性才能使用dynamic_cast
virtual void foo(){};
};

class DerivedClass: public BaseClass
{
public:
char *m_szName[100];
void bar(){};
};
  
int main()
{
BaseClass* pb = new DerivedClass();
// 子类->父类,静态类型转换,正确但不推荐
DerivedClass *pd1 = static_cast<DerivedClass*>(pb);
// 子类->父类,动态类型转换,正确
DerivedClass *pd2 = dynamic_cast<DerivedClass*>(pb);

BaseClass* pb2 = new BaseClass();
// 父类->子类,静态类型转换,危险!访问子类m_szName成员越界
DerivedClass *pd21 = static_cast<DerivedClass*>(pb2);

// 父类->子类,动态类型转换,安全的。结果是NULL
DerivedClass *pd22 = dynamic_cast<DerivedClass*>(pb2);
return 0;
}
const_cast

cosnt_cast是四种类型转换符中唯一可以对常量进行操作的转换符,用来去除常量性,程序员对这个操作负责。

  • 常量指针转换为非常量指针,并且仍然指向原来的对象
  • 常量引用被转换为非常量引用,并且仍然指向原来的对象

注意转换类型只能是指针或引用。

1
2
3
4
5
6
7
8
9
10
11
12
13
struct SA 
{
  int i;
};

int main()
{
const SA ra;
ra.i = 10; // error,直接修改const类型
SA &rb = const_cast<SA&>(ra);
rb.i = 10; // ok,去除了常量性
return 0;
}
reinterpret_cast

非常危险的操作符,谨慎使用

  • reinterpret_cast可以在指针和引用里进行肆无忌惮的转换;
  • reinterpret_cast可以将整型转换为指针,也可以把指针转换为数组;
  • reinterpret_cast是从底层对数据进行重新解释,依赖具体的平台,可移植性差。

一般用来不同类型的指针或引用之间转换。

1
2
3
4
int a = 1;
int* p_a = &a;
bool* p_c = reinterpret_cast<bool*>(p_a);
std::cout << *p_c<< "\n"; // 1
最佳实践
  • 什么时候使用显式转换

    在所有需要转换的地方都应该尽量使用显式转换。

  • 四种显式转换该怎么选择

    ⚠️ 除了static_cast可用于非指针、引用类型,其它转换符都必须是指针或引用

    • static_cast:基本类型转换、类下行转换、空指针和其它指针的转换;
    • reinterpret_cast:不同类型的指针类型转换;
    • const_cast:将常量指针/引用转换为非常量指针/引用;
    • daynamic_cast:多态类之间的类型转换。

4.X.3 类型转换补充:string

这里主要总结下上面没提到,平时又经常用到的转换:

  1. string和char等基本类型的转换;
  2. string和char[]之间转换;
  3. string和其它类型数组(比如int[])之间的转换。

1和2在1.8.3 & 1.8.4 节都已介绍过,为了完整性这里再次进行简单总结。

string↔基本类型

string不是内置类型,使用static_cast是不行的,比如int↔string。

string↔基本类型有两种方式:

  1. 使用输入、输出流作为媒介实现;
  2. 使用C++11std::string内置的相关函数实现。

为了通用性,我们先介绍 std::istringstreamstd::ostringstream 作为媒介如何实现转换。

  • std::istringstream ,即输入流:构造函数接受数据的输入,然后使用>> 提取流中数据;
  • std::ostringstream ,即输出流:使用<< 接受数据的输入,使用成员方法如str() 提取流中数据。

下面是具体实例。

  • string→基本类型

    基本思路:用string对象初始化输入流 → 输入流写入>>其它类型中(string此时会被自动转换)。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    #include <sstream>
    #include <string>
    #include <iostream>

    int main()
    {
    std::string str = "233";
    int a;
    std::istringstream iStream(str);
    iStream>>a; // 输出流自动转换
    return 0;
    }
  • string←基本类型

    基本思路:用<<初始化输出流 → 输出流对象调用str()方法。

    1
    2
    3
    4
    int b = 233;
    std::ostringstream oStream;
    oStream<<b;
    oStream.str();

好消息,C++11给广大程序员带来了福音,提供了大量已经定义好的方法

更多介绍,可参考:c++ string和其他类型互转

但注意,char↔string的转换方法未实现,我们可使用以下方式实现转换:

1
2
3
4
char c = 'a';
std::string str = c; // char → string

c = str[0]; // string → char

下面对常用的转换方法进行介绍。

  • string→基本类型

    常用方法原型:

    1
    2
    3
    4
    5
    6
    // string→int
    int stoi( const std::string& str, std::size_t* pos = 0, int base = 10 );
    // string→long
    long stol( const std::string& str, std::size_t* pos = 0, int base = 10 );
    // string→float
    float stof( const std::string& str, std::size_t* pos = 0 );

    使用实例:

    1
    2
    3
    4
    5
    6
    7
    8
    # include<string>
    int main()
    {
    std::string str = "123";
    int a = std::stoi(str);
    int float = std::stof(str);
    return 0;
    }
  • string←基本类型

    常用方法原型:

    1
    2
    3
    4
    5
    // 函数重载
    std::string to_string( int value );
    std::string to_string( long value );
    std::string to_string( float value );
    std::string to_string( double value );

    使用实例:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    # include<string>
    int main()
    {
    int a = 1;
    std::string str1 = std::to_string(a);
    float b = 1.2;
    std::string str1 = std::to_string(b);
    return 0;
    }
string↔char[]

string和char[]数组之间的转换比较简单,因为string内置了相关方法。

  • char[]← string

    如果是字符串常量,C++可以隐式转换:

    1
    2
    char c_arr[] = "royhuang";  // "royhuang"是string类型的字符串常量,可以隐式转换
    char* p_c_arr = "royhuang"; // 这里没发生隐式转换,但p_c_arr指向了字符串常量

    对于非字符串常量,std::string同样提供了c_str()data() 等方法,不过要注意:

    • 返回类型只能是const char* ,也就是必须为常量,且是指针
    1
    2
    3
    4
    5
    std::string str = "royhuang";

    const char* c_arr1 = str.c_str();
    const char* c_arr2 = str.data();
    // const char c_arr3[] = str.data(); // 非法
  • char[] → string

    string构造函数接受字符串数组:string::string(const char* szCString)

    1
    2
    3
    char c_arr[] = "royhuang";  

    std::string str{ c_arr }; // 构造函数

但是如果涉及到字符串分隔符,就比较麻烦点,参考下文处理。

string→其它类型数组

如果是分割每一个字符为数组元素,可以转换为:string→char[]→其它类型数组

下面展示了:string→int[] ,这里使用了容器vector。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# include<string>
int main()
{
std::string str = "123456";
std::vector<int> i_arr;
i_arr.reserve(10); // tips:预先分配一些空间

// 1.先转换为char数组
const char* c_arr = str3.c_str();
// 2.char数组再转换为其它类型数组
for(char c:str3)
{
// 注意是c-'0',不是static_cast<int>(c)
// c-'0'算术类型提升为int
i_arr.push_back(c-'0');
}
return 0;
}

string←其它类型数组,没啥意义一般很少需要

比如:

1
int a[] = {45,45,67,144};

转换为454567144 有啥用呢?一般不需要这么做。更多的反而是怎么将一个int类型转换string。

1
2
int i = 2;
string str = std::to_string(i);

其实上面介绍都是很特殊的string→数组情况:分割每一个字符为数组元素。但往往我们需要处理的是如何按指定规则分割子串为数组元素

比如,如何将:

1
string str = "12,45,56" ;

转换为:

1
int arr[] = {12,45,56};

最常见的便是按指定分隔符来分割字符串为新数组元素。

string指定分割→数组

对于指定分隔符来分割字符串,再转换为数组。一般而言有如下思路:

  1. 通过string成员函数:find+substr函数;
  2. 通过C语言中分割函数:strtok函数;
  3. 通过stringstream实现。

这里主要介绍第1、第3两种方式,完整的介绍可参考:C++字符串分割方法总结

  • find函数原型: size_t find (const string& str, size_t pos = 0) const;
  • substr函数原型: string substr (size_t pos = 0, size_t len = npos) const;

以string→int[]为例

  • 通过find+substr函数

    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
    #include <string>
    #include <vector>

    void strSplit(const std::string src_str, vector<int>& vec, const char pattern)
    {
    std::string str = src_str;
    str += std::string(pattern); // 方便处理最后一个字符串
    size_t pre_pos = 0;
    size_t pos = str.find(pattern);
    while (pos != str.npos) // 如果没找到分隔符则终止
    {
    string temp = str.substr(pre_pos,pos);
    vec.push_back(std::stoi(temp));
    // 保留剩下的字符串作为新串
    str = str.substr(pos+1,str.length());
    // 查找新字符串第一个分隔符的位置
    pos = str.find(pattern);
    }
    }

    int main()
    {
    std::string str = "12,45,56" ;
    std::vector<int> vec;

    strSplit(str,vec,',');
    return 0;
    }
  • 通过stringstream

    这种方式更为简单。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    #include <iostream>
    #include <vector>
    #include <sstream>

    void strSplit(const std::string src_str, vector<int>& vec, const char pattern)
    {
    std::stringstream input_ss(src_str);
    std::string sub_str{}; // sub_str接受返回的子串
    while (getline(input_ss, sub_str, pattern)) // getline分割字符串
    {
    vec.push_back(std::stoi(sub_str));
    }
    }

    int main()
    {
    std::string str = "12,45,56" ;
    std::vector<int> vec;

    strSplit(str,vec,',');
    return 0;
    }
leetcode实战

以经典题型为例,将下面转换为多维数组,第一行输入的是长度。

3

11 12 13

14 15 16

17 18 19

完整实现代码如下:可见关键是如何实现分割字符串的函数(面试时这种常用函数最好直接背下)。

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 <iostream>
#include <vector>
#include <sstream>

void strSplit(const std::string src_str, vector<int>& vec, const char pattern)
{
std::stringstream input_ss(src_str);
std::string sub_str{}; // sub_str接受返回的子串
while (getline(input_ss, sub_str, pattern)) // getline分割字符串
{
vec.push_back(std::stoi(sub_str));
}
}

int main()
{
int N = 0;
// std::getline(std::cin,N); // getline不能接受类型int的参数,所以这里不使用
std::cin>>N;
std::cin.ignore(); // 但是cin要处理换行符,getline不需要处理这个问题

std::vector<vector<int>> vec ; // vector比内置数组好用
for(int i=0; i<N; i++)
{
std::string input_str; // 读取一行字符串
std::getline(cin,input_str);

vector<int> vec_tmp ;
strSplit(input_str,vec_tmp,','); // 分割该行字符串
vec.push_back(vec_tmp);
}
return 0;
}
最佳实践

不涉及到数组:

  • 如果是基本类型之间的互转,隐式转换虽然也可以实现,但最好使用static_cast显式转换;

    不过需要注意char↔int之间的转换,也许你并不需要char类型的ASCII值。

  • 如果是指针、引用、多态、常量转非常量之间的转换,请使用显示转换;

  • 如果string和基本类型转换,使用std::string 内置的函数库效率最高。

涉及到数组:

  • 如果是string↔char[],不涉及到分割子串,使用std::string 内置函数可以很方便进行转换;
  • 如果是string→其它类型数组(单向,反向没多大意义),不涉及到分割子串 ,按string→char[]→其它类型数组转换即可;
  • 如果是string→其它类型数组(单向),涉及到分割子串
    • 先借助find和substr函数切割出子串数组(string[]);
    • 再将每个子串转换为其它类型(内置函数实现,如stoi)。

最后,请尽量避免使用隐式转换。

更新记录

2022-01-22 :更新笔记

  1. 第一次更新

参考资料


  1. 1.C++中已经有面向对象的概念,那struct还有啥存在的意图:https://www.zhihu.com/question/23174488
  2. 2.C++中四种强制类型转换区别详解:https://blog.csdn.net/chen134225/article/details/81305049
  3. 3.没有学不会的C++:用户自定义的隐式类型转换 :https://juejin.cn/post/6844903798100459533