和明天说你好!OvO
C++ 学习指南

目录

  1. 1. 简介
  2. 2. 目录:视频对照目录
  3. 3. 目录:C++ 11 新特性**
  4. 4. 目录:C++ 17 新特性**
  5. 5. C++ 的工作流程
  6. 6.
  7. 7. 预编译头文件
  8. 8. 数据类型**
    1. 8.1. 字符类型
  9. 9. 类型转换
    1. 9.1. 隐式类型转换
    2. 9.2. 显式类型转换
  10. 10. 变量
    1. 10.1. 概念
    2. 10.2. 创建(和初始化)
    3. 10.3. 常见变量创建语法
    4. 10.4. 作用域、生命周期和内存位置
  11. 11. 常量和常量表达式
    1. 11.1. 概念
    2. 11.2. 创建和初始化
    3. 11.3. 常见常量创建语法
    4. 11.4. 作用域、生命周期和内存位置
  12. 12. 函数
    1. 12.1. 概念
    2. 12.2. 创建
    3. 12.3. 参数传递
    4. 12.4. 内联函数
  13. 13. 指针
    1. 13.1. 概念
    2. 13.2. 创建(和初始化)
    3. 13.3. 函数指针
    4. 13.4. 智能指针
  14. 14. 引用
    1. 14.1. 概念
    2. 14.2. 创建和初始化
    3. 14.3. 引用和指针
    4. 14.4. 引用和函数
  15. 15. 枚举和枚举类
  16. 16. 类和结构体
    1. 16.1. 概念
    2. 16.2. 创建
    3. 16.3. 实例化
    4. 16.4. 构造函数
    5. 16.5. 初始化列表
    6. 16.6. 析构函数
    7. 16.7. 拷贝构造函数
    8. 16.8. 移动构造函数
    9. 16.9. 三/五法则
    10. 16.10. 继承
    11. 16.11. 虚函数
    12. 16.12. 纯虚函数
  17. 17. Lambda 函数
  18. 18. 模板
    1. 18.1. 概念
    2. 18.2. 模板函数
    3. 18.3. 模板类
  19. 19. STL 标准模板库
    1. 19.1. 概念
    2. 19.2. 容器**
    3. 19.3. 常见容器介绍
      1. 19.3.1. 数组 array
      2. 19.3.2. 向量 vector
        1. 19.3.2.1. 概念
        2. 19.3.2.2. 向量使用优化
      3. 19.3.3. 对组 pair 和元组 tuple
    4. 19.4. 迭代器**
    5. 19.5. 算法**
    6. 19.6. 内存分配器**
    7. 19.7. 仿函数**
  20. 20. 命名空间
  21. 21. 常见关键字
    1. 21.1. static 关键字
      1. 21.1.1. 作用
      2. 21.1.2. 静态全局变量/静态函数
      3. 21.1.3. 静态局部变量
      4. 21.1.4. 静态成员变量/静态成员函数
    2. 21.2. const 关键字
      1. 21.2.1. 作用
      2. 21.2.2. 常量
      3. 21.2.3. 只读成员函数
    3. 21.3. extern 关键字
      1. 21.3.1. 声明外部变量/函数
      2. 21.3.2. C 与 C++ 兼容
    4. 21.4. mutable 关键字
      1. 21.4.1. 作用
      2. 21.4.2. 可改变成员变量
      3. 21.4.3. 可改变的 Lambda 函数捕获变量
    5. 21.5. explicit 关键字
      1. 21.5.1. 作用
      2. 21.5.2. 禁止隐式构造函数类型转换
  22. 22. 外部库
    1. 22.1. 概念
    2. 22.2. 静态链接库
    3. 22.3. 动态链接库
    4. 22.4. 静态链接与动态链接的比较
  23. 23. 栈与堆内存的比较
  24. 24. 类型双关(原始内存操作)
  25. 25. 联合体
  26. 26. 结构化绑定
  27. 27. 线程
    1. 27.1. 基本使用
    2. 27.2. 多线程
  28. 28. 左值右值
  29. 29. 移动语义
    1. 29.1. 概念
  30. 30. 内存对齐

简介

本指南遵循视频教程 Cherno C++ 教程【中字】,相当于一份学习笔记,但有做额外补充。更清晰的 C++ 语法介绍,见 CPP Reference 官网

指南中,标有 ** 符号的表示待完成。

搜索“原则”可以快速查找 C++ 编程中应遵守的各种规范。

目录:视频对照目录

目录:C++ 11 新特性**

目录:C++ 17 新特性**

C++ 的工作流程

C++ 是一门编程语言,作为一个工具性质的产物,它的目的是生产出适用于各个平台的可执行程序或库文件。

在使用文本编辑器或集成开发环境编写完源代码后,我们会将源代码交给编译器生成可执行文件。生成可执行文件的过程实际分为三步:

  • 预处理:在正式开始编译之前,预处理器会根据预处理语句(以 # 开头的语句)对每个源文件 .cpp 做文本处理。预处理完成后的源文件将会交给编译器,真正开始编译。
  • 编译:编译器将每一个预处理后的源文件当做一个翻译单元,把其中的代码编译为目标平台的机器码,同时把静态变量和全局变量提取到数据段,存储在单独的目标文件 .obj.o 中,交由链接器进行链接。
  • 链接:一个目标文件或库文件可能使用了另一个目标文件或库文件的符号(Symbol,即标识符)定义,然而每一个文件在编译完成后都是单独的,它们无法直接互相访问。因此,链接器在解析目标文件和库文件时,如果发现目标文件或库文件中使用了或可能使用另一个目标文件或库文件的符号定义,它就会把这个符号的声明和定义链接在一起,使得符号能够正常使用。通过链接,链接器将所有的目标文件以及库文件合并成一个完整的可执行程序。

备注:

  1. 如果没有设置预编译头文件,头文件不会被直接编译,而是加入源文件中,作为源文件的一部分被编译。

  2. 对于函数来说,符号指的是整个函数签名;对于全局变量来说,符号指的是变量名称和类型;对于类和结构体来说,符号指的是它们的名称……当然,命名空间是另一个话题了。要注意的是,符号不能重复定义,这也是变量、函数等不能重复定义的根本原因

  3. 要注意的是,即使我们的项目只有一个目标文件,C++ 运行时库也需要使用程序的入口点函数,因此生成可执行文件的过程中必然存在链接过程。

预处理阶段,我们可以使用宏来对我们源文件中的代码进行文本替换。宏在调试场景(如日志打印系统)中使用的比较多。

定义在头文件中的宏可以对所有导入它的源文件生效,过度使用宏会使得代码的可读性很差。因此,我们应该遵守一个原则:不要在代码中过度依赖宏。

宏定义中可以使用转义字符,我们可以使用转义换行符的方式实现多行的宏定义。

有关宏操作的几个预处理语句:

  • #define:宏定义语句。定义一个宏,预处理器将跟据宏的定义进行文本替换。

  • #if、#elif、#else:分支语句。若条件为真,保留其下的所有文本;若条件为假,移除这些文本。

  • #ifdef:分支语句,#if defined 的简化版。如果一个宏定义过,保留其下的所有文本;如果这个宏未定义过,移除这些文本。

  • #ifndef:#ifdef 相反。

  • #endif:用来结束分支语句,帮助划分文本范围。

备注:

  1. “其下的所有文本”指从该分支语句的下一行起,至下一个分支语句的前一行结束。

  2. #if#elif 语句可以配合参数 defined 来实现判断一个宏是否被定义。

一个简单的例子:

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
35
36
37
38
39
40
41
42
#include <iostream>

// 多行宏定义,反斜杠后面必须紧跟着回车
#define MAIN int main()\
{\
LOG("Hello!");\
LOGX("Hello!");\
\
std::cin.get();\
}

#define HW_DEBUG 2

// 较好的定义方式
#if HW_DEBUG == 1 // 如果条件 HW_DEBUG == 1 满足,则
#define LOGX(x) std::cout << x << std::endl
#elif HW_DEBUG == 2 // 否则如果条件 HW_DEBUG == 2 满足,则
#define LOGX(x) std::cout << "value: " << x << std::endl;
#else // 否则
#define LOGX(x)
#endif // 用来结束分支语句 #else

#define HW_DEBUG

// 常见的定义方式
#ifdef HW_DEBUG // 如果定义了宏 HW_DEBUG,则
#define LOG(x) std::cout << x << std::endl
#elif defined HW_DEBUGX //或者定义了宏 HW_DEBUGX,则
#define LOG(x) std::cout << "value: " << x << std::endl;
#else // 否则
#define LOG(x)
#endif // 用来结束分支语句 #else

// 使用多行宏定义 MAIN,等价于:
//int main()
//{
// LOG("Hello!");
// LOGX("Hello!");
//
// std::cin.get();
//}
MAIN

预编译头文件

在大型的 C++ 项目中,每个源文件通常会包含许多公共的头文件(如标准库、第三方库头文件),这些头文件很少改变,我们希望它们在没有改变时不编译,从而提升编译过程的速度。这就需要对头文件进行预编译。

同时,在预处理阶段,虽然我们通过预处理语句能够保证最终结果是一个头文件只执行一次包含,但对预处理器而言,它依然需要一次一次地重复读取和解析这些头文件,判断它们是否需要被包含。这会花费很长的时间,拖慢编译的速度。通过预编译头文件,我们能够跳过重复预处理的步骤,提升编译过程的速度。

通过预编译头文件的方式,我们能够实现项目编译的提速

当然使用预编译头文件可能导致我们不清楚一个源文件到底使用了哪些具体的头文件,因此我们应该遵守一个原则:只把那些很多源文件都会使用的头文件加入预编译头文件中,例如 iostreamstringWindows.h 等。

使用 Visual Studio 构建项目时实现预编译头文件:

  1. 创建一个预编译头文件 pch.h,将需要预编译的头文件包含语句写在其中。
  2. 创建对应的源文件 pch.cpp,并包含这个预编译头文件。
  3. 右键 pch.cpp,将“属性 -> C/C++ -> 预编译头 -> 预编译头”设置为“创建”。
  4. 右键项目,将“属性 -> C/C++ -> 预编译头 -> 预编译头”设置为“使用”,将同界面中的“预编译头文件”设置成 pch.h

备注:

  1. 不要将经常改变的头文件加入预编译,否则每次编译项目时,预编译的头文件都需要重新编译,这就失去了预编译的意义。
  2. 预编译头文件的名称不必须是 pch.h,这只是约定俗成的名称。

数据类型**

字符类型

字符类型有如下几种:

  • char:存储 1 字节的字符(UTF-8)。

  • wchar_t:存储宽字符,通常是 2~4 字节,取决于平台和编译器。

  • char16_t:存储 2 字节的字符(UFTF-16)。

  • char32_t:存储4字节的字符(UTF-32)。

类型转换

隐式类型转换

C++ 允许对值进行一次隐式类型转换,通常发生在赋值、函数参数、运算符运算。

隐式类型转换具体包括:基本数据类型的类型提升数组与指针函数与指针使用构造函数使用类型转换运算符

  • 基本数据类型的类型提升:略。
  • 数组与指针:数组变量可以隐式转换为一个指向数组第一个元素的指针变量。
  • 函数与指针:函数可以隐式转换为一个指向函数的函数指针,详见下文
  • 使用构造函数:构造函数所接受类型的变量可以通过隐式使用构造函数,隐式转换为一个对象,详见下文
  • 使用类型转换运算符:一个对象可以通过隐式使用类型转换运算符,隐式转换为目标类型。

显式类型转换

显示类型转换分为 C 风格C++ 风格两种。

C 风格的显式类型转换语法同 Java 中的显式类型转换语法,为 (目标类型)值

C++ 风格的显示类型转换需要使用四种类型转换函数:

  • static_cast:静态(编译时)类型转换,并增加编译时的检查,通常用在基本数据类型间等简单的类型转换。
  • reinterpret_cast:使用类型双关的做法实现类型转换。
  • const_cast:移除或添加上变量的 const 修饰符。
  • dynamic_cast:动态(运行时)类型转换,如果转换失败会返回 NULLnullptr,通常用在多态场合下,父类向子类的类型转换。

备注:

  1. dynamic_cast 要求被转换的类型是一个多态类型,即拥有虚函数的类型。
  2. dynamic_cast 需要 RTTI(Runtime Type Information,运行时类型信息)的支持,它会增加一定的开销。

变量

概念

变量是用于存储数据的可访问内存位置(使用符号或指针访问),且数据可以改变

变量依据创建语法分为声明定义的变量在堆上创建的变量。其中,声明定义的变量又分为全局变量局部变量成员变量,取决于在全局作用域中、在局部作用域中,还是在类和结构体中定义。

备注:

全局作用域指源文件顶层;局部作用域指局部代码块。

创建(和初始化)

变量的创建,本质上是为变量分配一块内存,并获得访问它的方法。

变量是用于存储数据的可访问内存位置,且数据可以改变,因此变量在创建时可以不初始化。但变量在使用前必须初始化,因为变量在创建时只会进行内存分配,内存中依然保存着原来的值(不同于 Java,Java 会将变量初始化为默认值)。

成员变量的创建由实例的创建决定,非成员变量创建方式依据语法分为两种:

  • 声明定义变量(在数据段或栈上创建变量):使用声明并定义变量的语法,创建的是一个有明确符号的变量

  • 在堆上创建变量:使用 new 关键字,运行期间在堆上分配内存(即动态分配内存),并创建一个指针变量用来保存分配的堆内存地址,创建的是一个没有明确符号、用指针访问的变量

备注:

  1. 变量的声明,本质上是告诉编译器这个变量的名称和类型,同时告诉编译器变量的定义存在于某个翻译单元中,将来由链接器完成定义的链接,现在请允许我使用这个变量
  2. 变量的定义,本质上是让编译器为变量分配内存,让它在运行时能够真正使用。
  3. 变量的声明定义分离需要使用 extern 关键字,详见下文
  4. 类或结构体类型的变量创建时,可能对它的成员变量进行初始化,取决于构造函数中的代码实现。
  5. 用声明定义变量的语法创建一个类或结构体类型的变量时,会调用它的默认构造函数。当然,也可以显示指定要调用的构造函数。
  6. 堆内存需要使用 deletedelete[] 关键字手动释放。二者的区别在于,delete[] 会为删除的每个元素调用它们的析构函数(如果有)。

一个简单的例子:

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

// 声明定义的方式,在数据段上创建变量(和初始化)
int var1;
int var2 = 1;

// 在堆上创建变量(和初始化)
int* var3 = new int;
int* var4 = new int(1);

class Class
{
public:
int value;
// 构造函数
Class()
{
// 对成员变量 value 初始化
value = 1;
std::cout << "Value is " << value << std::endl;
}
};

int main()
{
// 声明定义的方式,在堆上创建变量(和初始化)
int var3;
int var4 = 1;

// 类或结构体类型的变量创建时,会调用它的构造函数,可能对成员变量进行初始化
Class class1; // 实例创建时,成员变量创建,随后调用构造函数 >> Value is 1
Class* class2 = new Class(); // >> value is 1

std::cin.get();
}

常见变量创建语法

声明定义变量

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
// 基本类型
int intValue = 0;

// 指针
// 关于指针,需要注意 “* 其实是和 intPointer 放在一起的”
// 例如:int* a, b 得到的其实是指向 int 类型变量的指针 a 和 int 类型的变量 b
// 因此编写代码时,强烈建议将 “多个变量的定义分行书写”
int* intPointer = &intValue;

// 数组,一维数组 intArray、二维数组 intArray2D
int intArray[5]{ 1, 2, 3, 4, 5 }; // 列表初始化(C++ 11)
int intArray2D[5][5]{{ 1, 2, 3, 4, 5 }, { 1, 2, 3, 4, 5 }, { 1, 2, 3, 4, 5 }, { 1, 2, 3, 4, 5 }, { 1, 2, 3, 4, 5 }};

// 字符串
const char* str1 = "Hello"; // 原始字符串
std::string str2 = "Hello"; // 其实是一个对象

// 类和结构体
class Circle {
public:
double radius;
Circle(double r) : radius(r) {}
};
Circle circle1(5.0); // 构造函数
Circle circle2{ 5.0 }; // 也是列表初始化(C++ 11)

在堆上创建变量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 基本类型
int* intValue = new int(0);

// 数组
// 数组,一维数组 intArray、二维数组 intArray2D
// 对于数组,要注意它类型转换为的是 “指向数组首个元素的指针”
// 因此一维数组使用 int*,二维数组使用 int(*)[n]
int* intArray = new int[5]{ 1, 2, 3, 4, 5 }; // 列表初始化(C++ 11)
int (*intArray2D)[5] = new int[5][5]{{ 1, 2, 3, 4, 5 }, { 1, 2, 3, 4, 5 }, { 1, 2, 3, 4, 5 }, { 1, 2, 3, 4, 5 }, { 1, 2, 3, 4, 5 }};

// 字符串
const char* str1 = new char[6]{'H', 'e', 'l', 'l', 'o'};
std::string* str2 = new std::string("Hello"); // 其实是一个对象

// 类和结构体
class Circle {
public:
double radius;
Circle(double r) : radius(r) {}
};
Circle* circle = new Circle(5.0); // 构造函数
Circle* circle2 = new Circle{5.0}; // 也是列表初始化(C++ 11)

作用域、生命周期和内存位置

  • 非静态全局变量:作用域为整个程序,生命周期为整个程序运行周期,存储在数据段上。

  • 非静态局部变量:作用域为整个代码块,生命周期随代码块执行结束而结束,存储在栈上。

  • 非静态成员变量:作用域为实例,生命周期同实例的生命周期,存储位置也取决于实例。

  • 静态全局变量:作用域为整个翻译单元,生命周期为整个程序运行周期,存储在数据段上。

  • 静态局部变量:作用域为整个代码块,但生命周期为整个程序运行周期,存储在数据段上。

  • 静态成员变量:作用域为类或结构体,生命周期为整个程序运行周期,存储在数据段上。

  • 在堆上创建的变量:没有固定的作用域,生命周期由我们控制,存储在堆上。

以三个概念分别做总结,如下:

作用域

  • 程序:非静态全局变量
  • 翻译单元:静态全局变量
  • 代码块:局部变量
  • 实例:非静态成员变量
  • 类或结构体:静态成员变量
  • 不固定:在堆上创建的变量

生命周期:

  • 程序:全局变量、静态变量
  • 代码块:非静态局部变量
  • 实例:非静态局部变量
  • 不固定:在堆上创建的变量

内存位置:

  • 数据段:全局变量、静态变量
  • 取决于实例:成员变量
  • 栈:非静态局部变量
  • 堆:在堆上创建的变量

常量和常量表达式

概念

常量是存储着不可修改数据的可访问内存运行时确定不变常量表达式在编译时期会被替换为字面量编译时确定不变

创建和初始化

常量是存储着不可修改数据的可访问内存,因此常量在创建时必须初始化;常量表达式在编译时期会被替换为字面量,因此常量表达式的值必须是编译时期就可以计算出来并确定的值

常量和常量表达式的创建方式如下:

  • 创建常量:使用 const 关键字修饰变量,值在运行时确定(例如从函数的返回值中获得),并在初始化后不可更改。

  • 创建常量表达式:使用 constexpr 关键字修饰变量,值必须在编译时能够计算和确定,使用它的地方会被编译器直接替换为字面量,能够提升编译期优化的效果,通常配合 constexpr 函数使用。

一个简单的例子:

1
2
3
4
5
6
7
8
9
10
11
12
int add1(int a, int b)
{
return a + b;
}
const int sum1 = add1(5, 10); // 创建常量变量,运行时,sum1 赋值为 15 后,不允许修改

// 通常配合 constexpr 函数使用
constexpr add2(int a, int b)
{
return a + b;
}
constexpr int sum2 = add2(5, 10); // 创建常量表达式,编译时求值优化为 constexpr int sum2 = 15

常见常量创建语法

创建常量:

1
2
3
4
5
6
7
8
9
10
int variableInt = 1;

// 基本类型、数组、结构体
const int CONST_INT = 1;

// 指针
// const 在 * 前表示指针指向常量;const 在 * 后表示这是常量指针。eg:
const int* pointerToConstInt = &CONST_INT; // 一个指向 int 常量的指针
int* const CONST_POINTER_TO_INT = &variableInt; // 一个指向 int 变量的常量指针
const int* const CONST_POINTER_TO_CONST_INT = &CONST_INT; // 一个指向 int 常量的常量指针

创建常量表达式:**。

作用域、生命周期和内存位置

  • 常量:与变量一致。

  • 常量表达式:不存在作用域、生命周期和内存位置的概念,因为它在编译时期就被替换成了字面量

函数

概念

函数本质上是一组代码语句的集合,或者说 CPU 指令的集合。

函数分为函数成员函数(即方法),取决于定义在全局作用域还是定义在类和结构体中

备注:

相较于变量来说,函数没有局部的概念。除成员函数外,函数都是全局的。

创建

创建一个函数,我们可以通过声明并定义的方式,也可以使用声明定义分离的方式。声明定义分离时,我们通常将声明写在头文件中,定义写在源文件中

备注:

  1. 函数的声明,本质上是告诉编译器这个函数的签名,同时告诉编译器函数的定义存在于某个翻译单元中,将来由链接器完成定义的链接,现在请允许我使用这个函数
  2. 函数的定义,本质是为一个函数符号提供函数的具体实现,即函数体
  3. 函数本身支持声明定义分离,也可以使用 extern 关键字,详见下文

两个简单的例子:

声明并定义

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

// 声明并定义
void print(const char* message)
{
std::cout << message << std::endl;
}

int main()
{
print("Hello"); // >> Hello

std::cin.get();
}

声明定义分离

–Main.cpp

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

// 声明函数 globalPrint 存在于某个翻译单元,请允许使用
void globalPrint(const char* message);

int main()
{
// 声明函数 localPrint 存在于某个翻译单元,请允许使用
void localPrint(const char* message);

globalPrint("Global"); // >> Global
localPrint("Local"); // >> Local

std::cin.get();
}

–Extern.cpp

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

void globalPrint(const char* message)
{
std::cout << message << std::endl;
}

void localPrint(const char* message)
{
std::cout << message << std::endl;
}

参数传递

C++ 中函数的参数传递犹如是创建一个形参变量,并使用实参变量对它初始化,例如 int parameter = argument

由于参数传递的这个特点,不使用引用作为形参时,会为实参变量创建一个副本;使用引用作为形参是,相当于对实参变量起了一个别名,不会创建实参变量的副本。

由于引用本身不是一种独立的数据类型,引用和所引用的类型可以相互替换。所以,我们可以认为:使用除引用外的类型作为形参时,函数都在使用值传递方式;使用引用类型作为形参时,函数在使用引用传递方式。

一个简单的例子:

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

// 犹如为变量 a 起了别名 int& target = a,以及创建了变量 b 的副本 int source = b
void addTo(int& target, int source)
{
source++;
target += source;
std::cout << target << std::endl;
}

int main()
{
int a = 10;
int b = 5;
addTo(a, b); // >> 16
std::cout << a << std::endl; // 使用引用,所以 a 变了 >> 16
std::cout << b << std::endl; // 创建副本,所以 b 没变 >> 5
}

内联函数

使用 inline 关键字修饰函数,可以建议编译器将该函数的调用代码内联,即用函数体替换函数调用,以避免函数调用带来的开销。

内联适合短小的、频繁调用的函数。

备注:

说是建议,是因为编译器可能不会内联较复杂的函数。

指针

概念

指针是一个特殊的变量,存储着整型数据,它的值表示内存中某个字节的地址

指针的类型其实是用来告诉编译器,它指向的地址所属变量的类型。

指针类型是一种独立的数据类型,指向各种数据类型的指针相互间可以转换。这也是实现原始类型双关的基础。

有关指针的常用运算符:

  • & 取地址运算符:实质上是在获取一个变量或函数的指针(获取指针)
  • *** 解引用运算符:**实质上是在获取指针指向变量的值(获取值)
  • -> 箭头运算符:实质上是对指针操作的语法糖,ptr->value 等价于 (*ptr).value

创建(和初始化)

指针变量本身只能通过定义的方式创建,遵循声明定义变量的语法。

数组指针的类型语法比较复杂,其格式是:元素类型 (*数组指针名称)[元素个数]

函数指针的类型语法也比较复杂,其格式是:返回值类型 (*函数指针名称)(参数列表)

指针有两种主要的初始化方式:对变量或函数做取地址运算使用动态分配的地址

备注:

使用 typedefusing 为函数指针的类型起别名时要注意,别名的位置同变量名称的位置,即:typedef 返回值类型(*新类型名称)(参数列表)。数组指针同理。

一个简单的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
int add(int a, int b)
{
return a + b;
}

int main()
{
// 起类型别名
typedef int(*AddFunction)(int, int);

int(*function1)(int, int) = add; // function1 函数指针
AddFunction function2 = add; // function2 函数指针

int a = 1;
int* ptr1 = &a; // 对变量做取地址运算
int* ptr2 = new int; // 使用动态分配的地址
}

函数指针

函数本质上是一组 CPU 指令的集合,它也被存储在内存中,拥有自己的地址,我们可以认为函数名称也是一种变相的指针,因此,函数也可以使用指针来指向。函数指针就是传递函数的方法。

C++ 保留了 C 语言中函数指针的写法,称为原始函数指针

除函数指针外,还可以使用闭包函数对象仿函数等方法传递函数。

C++ 11 提供了 std::function 来统一描述所有函数传递方法。

函数指针是一种传递函数的方法,它可以实现回调函数,也使得结构体中可以间接拥有函数,它的使用方式与函数调用的语法一致。

备注:

  1. 在 C++ 中,原始数组、原始字符串等与“原始”有关的概念来自于 C 语言,有时也称作“C 风格”。
  2. 函数在赋值给指针时,会隐式使用取地址操作符进行类型转换。

一个简单的例子:

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

int add(int a, int b)
{
return a + b;
}

int main()
{
int(*function)(int, int) = add; // 隐式类型转换,原始函数指针

std::cout << function(1, 5) << std::endl; // 像函数一样使用 >> 6

std::cin.get();
}

智能指针

智能指针指的是 C++ 标准库中提供的 unique_ptrshared_ptrweak_ptr

  • unique_ptr独占变量所有权的指针,适合用于局部作用域内的资源管理,自动释放,不能复制。它的实现利用了堆上创建的变量离开作用域时自动销毁,以及对象销毁时会执行析构函数的特点,在离开作用域时,unique_ptr 就会执行在析构函数中设置好的 delete 操作。unique_ptr 的实现逻辑很简单,使用开销很小,但它不能共享,共享可能导致争夺数据和野指针等问题。正因如此,它的拷贝构造函数和赋值操作符都被设置为了 delete

  • shared_ptr支持共享变量所有权的指针,内部维护一个引用计数。每当有shared_ptr指向同一个对象时,引用计数增加;当指针失效时,引用计数减少到 0,自动调用delete释放资源。shared_ptr 的实现和设计复杂,因此,它的使用开销也比较大。

  • weak_ptr 一般配合shared_ptr使用,解决循环引用问题,它不会让引用计数增加。

我们应该遵守一个原则:使用智能指针时,优先选择开销小的 unique_ptr,除非需要共享所有权。

备注:

  1. “变量的所有权”主要指的是某个变量在程序运行中由谁负责管理其生命周期,即由谁负责创建、使用和销毁它。
  2. 我们一般只对堆上的变量谈所有权。
  3. 循环引用指的是两个 shared_ptr 互相持有对方,它们都因为对方的持有导致自身不能销毁,造成内存泄漏。

一个简单的例子:

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
35
36
37
#include <iostream>
#include <memory>

class Entity {
public:
std::shared_ptr<Entity> ptr;
Entity() { std::cout << "Entity created" << std::endl; }
~Entity() { std::cout << "Entity destroyed" << std::endl; }
};

int main() {
{
// 使用 unique_ptr
std::unique_ptr<Entity> uniquePtr = std::make_unique<Entity>();

// 使用 shared_ptr
std::shared_ptr<Entity> sharedPtr1 = std::make_shared<Entity>();
{
std::shared_ptr<Entity> sharedPtr2 = sharedPtr1; // 共享变量所有权,引用计数加 1
std::cout << "sharedPtr1 count: "
<< sharedPtr1.use_count() << std::endl; // >> 2

std::weak_ptr<Entity> weakPtr = sharedPtr1; // 共享变量所有权,但引用计数不变
}
// sharedPtr2、weakPtr 离开作用域,引用计数只减 1
std::cout << "sharedPtr1 count: " << sharedPtr1.use_count() << std::endl; // >> 1
} // 离开作用域,所有智能指针释放内存

// 循环引用,导致内存泄漏
std::shared_ptr entity1 = std::make_shared<Entity>();
std::shared_ptr entity2 = std::make_shared<Entity>();
entity1->ptr = entity2;
entity2->ptr = entity1;

std::cin.get();
return 0;
}

引用

概念

引用不是一个变量,是为一个变量起的别名,对某类型变量的引用等价于这个变量自身。

引用不是一种独立的数据类型,引用被引用的数据类型可以相互转换。

引用分为左值引用和右值引用,详见下文

备注:

指针是变量,因此指向一种数据类型的指针属于另一种独立的数据类型;引用不是变量,因此对一种数据类型的引用就等同这种数据类型,可以相互替换,区别只在于值传递方式。

创建和初始化

引用只能使用同类型的变量做初始化,我们可以将引用的创建和初始化理解为为变量起了一个别名。因此,引用在创建时必须初始化

一个简单的例子:

1
2
3
4
5
6
7
int main()
{
int a = 1;
int& ref1 = a; // 为变量 a 起了一个别名 ref1

std::cin.get();
}

引用和指针

在功能上,可以说引用是对指针的功能封装,一个变量的引用在使用上等价于指向这个变量的指针解引用运算的结果。

引用和指针的区别在于:

  • 引用创建时必须引用已有变量,相比指针更安全;指针创建时可以指向未知地址,即指针的值可以不初始化或初始化为 0NULLnullptr 等不存在的内存地址,容易产生悬空指针等问题。
  • 引用引用一个变量,相当于告诉编译器这两个标识符都代表内存中的同一块区域,不会分配新的内存空间,因此不额外占用内存;指针指向一个变量,需要分配新的内存空间,并将值设置成另一个变量的地址,会额外占用内存
  • 引用一旦绑定就无法更改;指针可以重新赋值指向其他变量
  • 引用更适用于数据传递,写出来的代码安全性和可读性更强;指针更适用于动态内存管理

一图分清:

![指针与引用](images/CPP RUSH PLAN.drawio.png)

一个简单的例子:

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

int main()
{
int a = 1;

int& refToA = a;
int* ptrToA = &a;

// refToA 使用上等价于 *ptrToA
std::cout << refToA << std::endl; // >> 1
std::cout << *ptrToA << std::endl; // >> 1
}

引用和函数

由于引用不是变量,因此引用不是一种独立的数据类型,引用被引用的数据类型可以相互转换。将它们替换使用,使得在函数的形参和返回值可以实现值传递方式和引用传递方式,详见前文

需要注意的是,不要使用局部变量的引用,因为局部变量会在作用域结束后立即销毁,此时的引用将会引用向已被释放的内存区域,产生悬空引用。返回对象成员的引用也要确保引用的成员不会在对象销毁前被销毁。

一个简单的例子:

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
35
36
37
38
39
40
#include <iostream>
#include <string>
#include <memory>

class String {
private:
char* m_Buffer;
int m_Size;
public:
String(const char* string)
{
m_Size = strlen(string);
m_Buffer = new char[m_Size + 1];
memcpy(m_Buffer, string, m_Size);
m_Buffer[m_Size] = 0;
}

~String()
{
delete[] m_Buffer;
}

// 希望使用引用传递返回值,且可以确保返回的引用不会在对象销毁前销毁
// 因此使用 char& 替代 char
char& operator[](unsigned int index)
{
return m_Buffer[index];
}
};

int main() {

String string = "Hello!";

// 使用引用,犹如为 string.m_Buffer[1] 起了个别名 string[1]:
// char& string[1] = string.m_Buffer[1]
string[1] = 'a';

std::cin.get();
}

枚举和枚举类

枚举 enum 和枚举类 enum class 的作用是为难以理解含义的常量整数值,赋予具体的名称和意义

枚举和枚举类的值默认使用 int 类型,也可以使用类似继承的语法来设置他们为其他任意的整数类型

在未对枚举和枚举类的值赋值时,第一个值默认为 0,其他的值默认为前一个值增加 1

为了兼容 C,C++ 中的枚举保留了 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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
#include <iostream>

enum EnumInt
{
A, B, C // A,B,C 的值分别是 0,1,2,作用域为全局
};

struct Struct
{
enum EnumUChar : unsigned char // 使用继承的语法,设置值的类型为 unsigned char
{
A = 1, B = 5, C // A,B,C 的值分别是 1,5,6,作用域为 Struct
};
};

enum class EnumClassInt
{
A = 1, B, C // A, B, C 的值分别为 1, 2, 3,作用域被限制在 EnumClassInt 中
};

int main()
{
// 枚举的值,其作用域在枚举所在的整个作用域,因此 EnumInt 中值 B 的作用域为全局
EnumInt value1 = B;
if (value1 == B)
{
std::cout << "value1 is B!" << std::endl;
}

// 枚举的值,其作用域在枚举所在的整个作用域,因此 EnumUChar 中值 B 的作用域为 Struct
Struct::EnumUChar value2 = Struct::B;
if (value2 == Struct::B)
{
std::cout << "value2 is Struct::B!" << std::endl;
}

// 枚举类的值,其作用域为枚举类自身,因此 EnumClassInt 中值 B 的作用域为 EnumClassInt
EnumClassInt value3 = EnumClassInt::B;
if (value3 == EnumClassInt::B)
{
std::cout << "value3 is EnumClassInt::B!" << std::endl;
}

int anInt1 = EnumInt::B; // 能隐式转换
// int anInt2 = EnumClassInt::B; // 不能隐式转换

std::cin.get();
}

类和结构体

概念

类和结构体是一个组织数据和操作数据的函数的方式,是一种自定义的数据类型。

C++ 中的结构体保留了 C 的写法,这也导致了除成员可见性外,C++ 中的类和结构体完全等价。具体区别在于:

  • 为了符合面向对象编程的原则,类中的成员默认是 private 的;为了兼容 C 语言,结构体的成员默认是 public 的。
  • 人们在更倾向于使用结构体表达只包含数据的事物(如顶点),使用类表示包含操作的事物(如播放器)。

备注:

在后文将使用类和实例(对象)指代类和结构体,以及它们的实例。

一个简单的例子:

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
#include <iostream>

class Log // struct Log 与之完全等价
{
// public 成员变量
public:
const int LogLevelError = 0;
const int LogLevelWarning = 1;
const int LogLevelInfo = 2;
// private 成员变量
private:
int m_LogLevel = LogLevelInfo;

// public 成员函数
public:
void setLevel(int level)
{
m_LogLevel = level;
}

void error(const char* message)
{
if (m_LogLevel >= LogLevelError)
{
std::cout << "[ERROR]:" << message << std::endl;
}
}

void warn(const char* message)
{
if (m_LogLevel >= LogLevelWarning)
{
std::cout << "[WARNING]:" << message << std::endl;
}
}

void info(const char* message)
{
if (m_LogLevel >= LogLevelInfo)
{
std::cout << "[INFO]:" << message << std::endl;
}
}
};

int main()
{
// 实例化
Log log;
log.setLevel(log.LogLevelWarning);
log.error("FBI OPEN THE DOOR!");
log.warn("FBI OPEN THE DOOR!");
log.info("FBI OPEN THE DOOR!");

std::cin.get();
}

创建

创建一个类,我们可以使用声明并定义的方式,也可以使用声明定义分离的方式。声明定义分离时,我们通常将声明写在头文件中,定义写在源文件中

类的声明和定义的区别在于成员是否定义。注意,静态成员变量只能将声明和定义分离。

备注:

  1. 类的声明本质上是告诉编译器,我创建了一个新的类型,它里面包含了哪些成员,现在请允许我使用这个新的类型
  2. 类的定义是在类外完成成员函数的定义静态成员变量的定义
  3. 类可以在全局作用域、局部作用域、类和结构体中声明和定义。
  4. 编译器不允许对成员变量进行多次声明,并认为这是重定义错误,因为类在实例化时会跟据成员变量的声明自动完成他们的定义。

一个简单的例子:

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

// 声明类
class Class
{
public:
// 同时向编译器声明了它的成员变量 value、count
int value;
static int count;
// int count; // 不允许再次声明,提示重定义错误
};
int Class::count;

// 用作变量创建
Class aClass;

实例化

实例的创建称为实例化。

类实例化时,会在构造函数执行前完成非静态成员变量的定义,随后构造函数才被调用,以便我们为成员变量赋值。注意,静态成员变量只能将声明和定义分离,需要在类外单独完成定义才能使用。

常见的实例化方式有调用构造函数列表初始化(C++ 11),当然也区分声明定义变量在堆上创建变量两种方式。前文已有,在此不做赘述。

一个简单的例子:

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
#include <iostream>
#include <string>

class Person {
public:
std::string name;
int age;
static int count;

// 构造函数
Person()
: name("Unknown"), age(0)
{
count++;
}

Person(const std::string& name, int age)
: name(name), age(age)
{
count++;
}

void printName()
{
std::cout << name << std::endl;
}
};
// 在类先完成创建
int Person::count;

int main() {
// 默认构造函数实例化对象
Person person1; // 或 Person person1 = Person()
person1.printName();

// 有参构造函数实例化对象
Person person2("Alice", 30); // 或 Person person2 = Person("Alice", 30)
person2.printName();

// 默认构造函数列表初始化(C++ 11)
Person person3{}; // 或 Person person3 = {},或 Person person3Too = Person{};
person3.printName();

// 有参构造函数列表初始化(C++ 11)
Person person4{ "Bob", 25 }; // 或 Person person4 = { "Bob", 25 },或 Person person4 = Person{ "Bob", 25 }
person4.printName();

// 使用栈内存省略

std::cin.get();
}

构造函数

构造函数在实例化时成员变量创建后调用,我们会在构造函数中对对象做初始化操作。

和 Java 一样,构造函数是一个没有返回值、和类同名的函数,如果类中没有提供任何构造函数,编译器会为其添加一个空的默认构造函数。

在调用默认构造函数时,声明定义变量的方式不能带有方法圆括号,在堆上创建变量的方式可以带有也可以不带有

一个简单的例子:

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
35
36
37
38
39
40
41
42
43
#include <iostream>

class Entity
{
private:
float m_X, m_Y;
public:
// 默认构造函数
Entity()
{
m_X = 0.0f;
m_Y = 0.0f;
};

// 有参构造函数
Entity(float x, float y)
{
m_X = x;
m_Y = y;
};

void print()
{
std::cout << m_X << ", " << m_Y << std::endl;
}
};

int main()
{
// 调用默认构造函数
Entity e1; // 不可以带有小括号“()”
e1.print(); // >> 0, 0
Entity* e2 = new Entity(); // 可以带有也可以不带有小括号“()”
e2->print(); // >> 0, 0

// 调用有参构造函数
Entity e3(5.0f, 5.0f);
e3.print(); // >> 5, 5
Entity* e4 = new Entity(5.0f, 5.0f);
e4->print(); // >> 5, 5

std::cin.get();
}

初始化列表

在构造函数中,如果只是简单的为成员变量赋值,可以使用初始化列表。

非静态常量成员和引用类型成员必须使用初始化列表进行初始化。

类实例化时,在构造函数执行前就会完成成员变量的定义,以便我们为成员变量赋值。然而对于类类型的成员变量来说,他们在创建时会执行默认构造方法,创建一个空实例,如果我们在构造方法中再次创建一个新的实例并赋值给该成员变量,就会造成二次实例化,拖慢性能。使用初始化列表可以控制成员变量创建时所接受的值/所调用的构造函数,避免二次实例化。

要注意的是,初始化列表的初始化顺序并不是按声明顺序写的,而是按成员在类中声明的顺序,当成员变量之间存在依赖关系时,要注意初始化的先后顺序。

一个简单的例子:

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
#include <iostream>

class Wheel
{
public:
float m_Length;
float m_Width;

Wheel()
: m_Length(0.0f), m_Width(0.0f)
{
std::cout << "Wheel created default!" << std::endl;
}

Wheel(float length, float width)
: m_Length(length), m_Width(width)
{
std::cout << "Wheel created!" << std::endl;
}

void print()
{
std::cout << m_Length << " * " << m_Width << std::endl;
}
};

// 使用初始化列表
class Car1
{
public:
Wheel m_Wheel;

Car1()
: m_Wheel(10.0f, 5.0f) // 使用初始化列表
{
m_Wheel.print();
}

};

// 不使用初始化列表
class Car2
{
public:
Wheel m_Wheel;

Car2()
{
m_Wheel = Wheel(10.0f, 5.0f); // 不使用初始化列表,在构造函数中赋值
m_Wheel.print();
}

};

class Entity {
int x;
int y;
public:
Entity(int a, int b) : y(b), x(y + a) {} // 这里 x 将先于 y 初始化,可能导致未定义行为
};

int main()
{
Car1 car1;
// 避免了二次实例化
// >> Wheel created!
// >> 10 * 5
Car2 car2;
// 二次实例化!
// >> Wheel created default!
// >> Wheel created!
// >> 10 * 5
}

析构函数

析构函数与构造函数相反,在实例销毁时调用。我们会在析构函数中释放实例中的资源,如删除指针等等。

析构函数是一个没有返回值、名为 ~类名 的函数。

不同于 Java,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
#include <iostream>

class Entity
{
private:
const char* m_Name;
public:

Entity(const char* name)
: m_Name(name) {}

~Entity()
{
std::cout << "Entity " << m_Name << " destroyed!" << std::endl;
}
};

void createEntity()
{
Entity e1("e1");
}

int main()
{
createEntity(); // >> Entity e1 destroyed! (函数结束后)

Entity* e2 = new Entity("e2");
delete e2; // >> Entity e2 destroyed!

std::cin.get();
}

拷贝构造函数

拷贝构造函数是一个参数为类本身左值引用的特殊构造函数。我们会在拷贝构造函数中实现对象的拷贝逻辑。

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
#include <iostream>
#include <string>
#include <memory>

class String {
private:
char* m_Buffer;
int m_Size;
public:
String(const char* string)
{
m_Size = strlen(string);
m_Buffer = new char[m_Size + 1];
memcpy(m_Buffer, string, m_Size);
m_Buffer[m_Size] = 0;
}

// 实现拷贝构造函数(深拷贝)
String(const String& other)
:m_Size(other.m_Size)
{
m_Buffer = new char[m_Size + 1];
memcpy(m_Buffer, other.m_Buffer, m_Size + 1);
}

~String()
{
delete[] m_Buffer; // 浅拷贝将导致二次释放内存错误
}

char& operator[](unsigned int index)
{
return m_Buffer[index];
}

friend std::ostream& operator<<(std::ostream& stream, const String& string);
};

std::ostream& operator<<(std::ostream& stream, const String& string)
{
stream << string.m_Buffer;
return stream;
}

void printString(const String& string) // 总是使用常量引用来传递对象
{
// 即使是要创建拷贝,也应该在函数中手动创建
// Stirn copy = string;
std::cout << string << std::endl;
}

int main() {

String string = "Hello!";
String second = string; // 使用拷贝构造函数,也是一种隐式转换

second[1] = 'a';

std::cout << string << std::endl; // >> Hello!(浅拷贝将导致 string 也变为 Hallo!)
std::cout << second << std::endl; // >> Hallo!

std::cin.get();
// 程序结束,销毁两个对象时,浅拷贝将导致二次释放内存错误
}

移动构造函数

移动构造函数是一个参数为类本身右值引用的特殊构造函数。我们会在移动构造函数中实现对象的移动逻辑,即将另一个对象中的指针、变量等都赋值给本对象,最后设置为空。

一个类类型的右值对象赋值给这个类类型的变量时,会使用隐式构造函数类型转换的方式调用移动构造函数。

备注:

  1. 右值引用本身是一个左值,我们一般使用 std::move 函数将它转换为右值。
  2. 实现了移动构造函数后,会删除默认的移动赋值运算符函数 operator=,要求我们提供实现(详见 三/五法则)。
  3. 赋值运算符函数只在重新赋值时调用,不在初始化赋值时调用。

一个简单的例子:

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
#include <iostream>

class String {
private:
char* m_Data;
size_t m_Size;
public:
// 构造函数
String(const char* string) {
printf("Created!\n");
m_Size = strlen(string);
m_Data = new char[m_Size];
memcpy(m_Data, string, m_Size);
}

// 移动构造函数
String(String&& other) noexcept {
printf("Moved!\n");
m_Size = other.m_Size;
m_Data = other.m_Data;
// 避免 other 销毁时释放数据
other.m_Size = 0;
other.m_Data = nullptr;
}

// 赋值运算符函数
String& operator=(String&& other) noexcept {
printf("Move Assgined!\n");

// 赋值自己则不执行操作
if (this != &other) {
// 释放自己的数据
delete[] m_Data;
// 移来 other 中的数据
m_Size = other.m_Size;
m_Data = other.m_Data;
// 避免 other 销毁时释放数据
other.m_Size = 0;
other.m_Data = nullptr;
}

return *this;
}

~String() {
printf("Destroyed!\n");
delete m_Data;
}
};

int main() {
// >> Created!
// >> Moved!
String string = "Name is Entity";
// 右值引用本身是一个左值,因此需要将它转换为右值后,才能触发移动构造函数的隐式类型转换
String another = std::move(string);
// 使用赋值运算符函数
another = "Name is Entity";

std::cin.get();
};

三/五法则

C++ 三法则指的是:如果需要析构函数,则一定需要拷贝构造函数拷贝赋值运算符函数。五法则在三法则的基础上加上了移动构造函数移动赋值运算符函数

原因很简单,当我们需要析构函数时,表明这个类中有堆上分配的变量,我们需要拷贝构造函数和拷贝赋值运算符函数实现深拷贝,需要移动构造函数和移动赋值运算符函数将拷贝操作简化为数据移动。

继承

多态指的是一个类可以拥有多种类型,通过继承来实现,使用虚函数来保证

在 C++ 中,当一个类继承自另一个类时,创建子类对象时会在子类对象的一部分内存中创建一个父类对象。这部分内存存储了父类的所有成员变量和虚表指针。

由于访问控制的限制,即便父类对象存在于子类对象中,子类也无法直接访问父类对象中的 private 成员。

继承时,我们可以使用可见性修饰符来限定继承过来的成员的最高可见性

有关对象切割问题:

  • 声明定义变量:定义父类类型变量时,将子类对象赋值给父类类型的变量将产生对象切割问题。因为编译器会依据变量类型确定使用的数据段或栈内存大小,运行时,创建的子类对象才被放入定好大小的内存,子类对象中包含的父类子对象填满了空间,导致所有属于子类对象的那部分被丢弃。因此,最终内存中存储的其实是一个父类对象

  • 在堆上创建变量:不会产生对象切割问题。使用 new 关键字,将在运行时调用子类的构造函数创建子类对象,随后才为整个对象分配堆内存,随后将内存地址赋值给指针。因此,最终内存中存储的就是完整的子类型对象

由于 C++ 支持多重继承,可能出现菱形继承问题,即一个子类继承自两个父类,这两个父类又继承自同一个顶层父类,导致子类对象中存有两个顶层父类的子对象。菱形继承问题会导致内存的浪费二义性

  • 内存的浪费:子类对象中存有两个顶层父类的子对象,通常这是没有必要的,既导致数据不一致,又浪费了内存。
  • 二义性:子类从顶层父类中继承而来的成员都有两份,我们没法确定和指定使用的是哪一个。

解决菱形继承问题的办法是:

  • 虚继承,编译器会确保在继承链中每个子类只包含一份该基类的实例。
  • 使用组合代替继承,在这种情况下,使用继承不如使用组合好(当然,使用组合并不能减少内存的使用)。

备注:

  1. 和 Java 类似,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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
#include <iostream>

class Base
{
private:
int m_Value;
public:

Base()
: m_Value(0) {}

Base(int value)
: m_Value(value) {}

int getValue()
{
return m_Value;
}

void basePrint()
{
std::cout << "[Base]: Value is " << getValue() << std::endl;
}

// 同名函数
void who()
{
std::cout << "Base" << std::endl;
}
};

class AnotherBase
{
public:
void where()
{
std::cout << "Here" << std::endl;
}
};

class Derived : public Base, public AnotherBase // 多重继承
{
private:
int m_Key;
public:
Derived()
: m_Key(0) {}

Derived(int key)
: m_Key(key) {}

int getKey()
{
return m_Key;
}

void derivedPrint()
{
// std::cout << "[Derived]: Dict is { " << m_Value << ", " << getKey() << " }" << std::endl; // 直接访问 private 成员,出现不可访问错误
std::cout << "[Derived]: Dict is { " << getValue() << ", " << getKey() << " }" << std::endl; // 通过 getValue 函数间接访问
}

// 同名函数
void who()
{
std::cout << "Derived" << std::endl;
}
};

int main()
{
// 多重继承
Derived derived;
derived.where(); // >> Here

// 限定作用域来指定调用子类还是父类的函数
derived.Derived::who(); // >> Derived
derived.Base::who(); // >> Base

// 对象切割问题
Base derived1 = Derived();
Base* derived2 = new Derived();

std::cin.get();
}

内存中 derived1、derived2 的数据如下:

derived1

derived1

derived2

derived2

虚函数

我们将编译时编译器根据对象的静态类型(即声明时的类型)决定函数的调用称之为静态绑定,而动态绑定则允许程序在运行时通过对象的实际类型来确定函数的调用。正因如此,动态绑定技术能够实现成员函数的重写

虚函数使用虚函数表虚表指针来实现动态绑定。当父类拥有虚函数时,编译器会为子类和父类创建各自的虚函数表,其中存储指向虚函数的指针。运行期间,父类对象和子类对象中各自会保存一个指向自己虚表的虚表指针。函数调用时,会通过虚表指针查找虚表中对应的函数指针,如果是子类,找到的就会是指向子类重写方法的函数指针,从而实现动态绑定。

在 Java 中,只有在调用静态方法私有方法构造方法时才使用静态绑定;在 C++ 中,只有在调用虚函数时才使用动态绑定。因此,在 C++ 中,类的成员函数默认是不可被重写的,只有使用 vitural 修饰的虚函数才可以被子类重写。

使用 override (C++ 11)修饰子类中用于重写父类的函数,可以为这个函数提供一些语法等层面的检查,提升代码规范。

我们应遵守一个原则:必须将父类的析构函数定义为虚函数。这样可以避免声明为父类类型的子类对象,在销毁时未调用子类的析构函数,导致内存泄漏。

备注:

  1. C++ 中,拥有虚函数的类称为多态类
  2. 引入虚函数映射表,会不可避免地产生少量的内存和运行开销。
  3. 虽然说是“重写”,其实没有真的覆盖父类的函数,是基于函数指针实现的重定向。

一个简单的例子:

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
#include <iostream>

class Base
{
private:
int m_Value;
public:

Base()
: m_Value(0) {}

Base(int value)
: m_Value(value) {}

int getValue()
{
return m_Value;
}

// 声明为虚函数,允许重写
virtual void print()
{
std::cout << "[Base]: Value is " << getValue() << std::endl;
}

// 声明为虚函数,保证安全
virtual ~Base() { }
};

class Derived : public Base
{
public:
// 声明重写,主要是提供代码检查
void print() override
{
std::cout << "[Derived]: Value is " << getValue() << std::endl;
}
};

int main()
{
Derived* derived = new Derived();
derived->print(); // >> [Derived]: Value is 0

Base* base = new Base();
base->print(); // >> [Base]: Value is 0

Base* derivedToo = new Derived();
derivedToo->print(); // >> [Derived]: Value is 0

std::cin.get();
}

纯虚函数

纯虚函数没有函数实现

一个类如果拥有纯虚函数,编译器就会认为它是抽象类,无法用来实例化,直到你重写纯虚函数。因此,纯虚函数可以用来要求子类必须重写它

我们可以使用纯虚函数来设计抽象类和接口,实现面向对象编程。

一个简单的例子:

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
35
36
37
38
39
40
41
42
43
44
45
#include <iostream>

class Base
{
private:
int m_Value;
public:

Base()
: m_Value(0) {}

Base(int value)
: m_Value(value) {}

int getValue()
{
return m_Value;
}

// 声明为纯虚函数
virtual void print() = 0;
};

class Derived : public Base
{
public:
// 重写父类的纯虚函数
void print() override
{
std::cout << "[Derived]: Value is " << getValue() << std::endl;
}
};

int main()
{
Derived* derived = new Derived();
derived->print(); // >> [Derived]: Value is 0

// Base* base = new Base(); // 无法再实例化

Base* derivedToo = new Derived();
derivedToo->print(); // >> [Derived]: Value is 0

std::cin.get();
}

Lambda 函数

Lambda 匿名函数(C++ 11)是一种用来创建一个无名、一次性函数的方法,它实际上是一个闭包对象,只不过我们将其当作函数来使用。

Lambda 函数的创建需要上下文环境,它在运行时生成,作用于运行时,因此不能在全局作用域中创建。

Lambda 函数的创建语法是: [捕获列表](参数列表) { 函数体 }

Lambda 函数捕获列表接受的值为:

  • =按值传递所有使用到的变量
  • &按引用传递所有使用到的变量
  • 指明使用的变量:按值传递
  • 指明使用的变量并在前使用 & 操作符:按引用传递

备注:

原始函数指针只能接受无捕获列表的 Lambda 函数,对于有捕获列表的 Lambda 函数,我们应该使用 std::function 接收。

模板

概念

模板实质上是制定了一套规则,让编译器跟据规则生成代码

模板的逻辑类似于宏定义,但它更加安全,也更晚发生。编译器会用每一个模板参数值替换模板参数,生成对应的结果,它可以使用在类、结构体和函数上

模板本身并不会编译进可执行程序中,只有使用了模板,编译器才会跟据模板参数的值,生成符合的结果,例如函数定义、类声明等。MSVC 编译器甚至不会对模板中的代码进行语法检查。也由于这个特点,模板参数接受的值只能是编译时就确定的值,即常量表达式或类型名称

模板参数的类型表示模板参数接受什么样的值,由于模板参数只接受编译时就确定的值,模板参数的类型就必须是任意在编译时可以确定值的数据类型和类型名称

我们应该遵守一个原则:不要编写过于复杂的模板代码,这会导致严重的代码阅读可读性问题。

模板函数

一个简单的例子:

源代码

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

// 类型模板参数 T,即模板参数类型为类型名称(它接受的值是各种类型名称)
template<typename T>
void print(T value)
{
std::cout << value << std::endl;
}

int main() {
print<int>(1); // 出现 int 类型的函数调用,模板参数值为 int,编译器将生成一个接收 int 类型参数的 print 函数
print<const char*>("Hello!"); // const char* 也是如此

// 当编译器可以推断模板参数时,可以省略不写
print<>(1.0f);
print(2l);

std::cin.get();
}

编译结果(模拟)

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

// 编译器自动生成的四个 print 函数的定义
void print(int value)
{
std::cout << value << std::endl;
}

void print(const char* value)
{
std::cout << value << std::endl;
}

void print(float value)
{
std::cout << value << std::endl;
}

void print(long value)
{
std::cout << value << std::endl;
}

int main() {
print(1);
print("Hello!");
print(1.0f);
print(2l);

std::cin.get();
}

模板类

一个简单的例子:

源代码

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

// 非类型模板参数 N,模板参数类型为 int(它接受的值是 int 常量表达式)
template<int N>
class Array
{
private:
int m_Array[N];
public:
int getSize()
{
return N;
}
};

int main() {
Array<5> array;

std::cin.get();
}

编译结果

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

// 编译器生成的类声明
class Array
{
private:
int m_Array[5];
public:
int getSize()
{
return 5;
}
};

int main() {
Array array;

std::cin.get();
}

STL 标准模板库

概念

STL 标准模板库(Standard Template Library)是 C++ 标准库中提供的基于模板实现的工具库,其内容涵盖容器、算法、迭代器、内存分配器、仿函数五个方面。

容器**

常见容器介绍

数组 array

标准库中的数组 std::array 即是一个静态数组

相比于原始数组:

  • 二者的数据都存储在栈上

  • 标准库中的数组可以记录数组的大小

  • 标准库中的数组拥有迭代器,支持 foreach 循环,同样支持标准库中提供的很多集合操作函数,如 std::sort

  • 标准库中的数组支持宏,比如设置 ITERATOR_DEBUG_LEVEL 可以实现边界检查

我们应该遵守一个原则:尽可能使用标准库种的数组,代替原始数组。

向量 vector

概念

标准库中的向量 std::vector 即是一个动态数组

向量为了支持动态扩容,它的数据存储在堆上

向量使用优化

向量的默认容量是 0,每当我们放入一个新元素,它就会扩容一次,容量增加 1

向量扩容的方法是在堆上创建一个更大的数组,并把旧的数组复制给新数组,随着元素数量增多,扩容一次的开销也在变大。因此,在默认容量的情况下,往向量中不断放入新元素会导致越来越多的复制行为。所以,在使用向量时,我们应该遵守一个原则:提前设置足够大的容量,避免频繁扩容。

当我们使用向量存放对象时,push_back 函数接收我们在容器外构造的对象,把它拷贝或移动到容器的末尾;emplace_back 函数接收构造函数参数,在容器的末尾直接构造对象。前者的步骤更多,因此相对而言,后者的性能更好。如果已经有一个对象需要添加到容器中,我们应该使用 push_back;如果希望在容器内直接构造对象,避免多余的构造和拷贝,我们应该使用 emplace_back

我们应该遵守一个原则:尽量使用 emplace_back,除非我们要将已有的对象添加到容器中。

一个简单的例子:

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
35
36
37
38
39
40
41
42
43
44
45
46
#include <iostream>
#include <vector>

class Entity
{
private:
int _x;
public:

Entity(int x)
: _x(x)
{
std::cout << "Constructed!" << std::endl;
}

Entity(const Entity& other)
: _x(other._x)
{
std::cout << "Copied!" << std::endl;
}

~Entity()
{
std::cout << "Destroyed!" << std::endl;
}
};

int main() {

std::vector<Entity> vector;
vector.reserve(10); // 增加容量到 10,避免扩容和复制

vector.push_back({ 1 }); // >> Constructed! >> Copied! >> Destroyed!
vector.push_back({ 2 });
vector.push_back({ 3 });
vector.push_back({ 4 });
vector.push_back({ 5 });

vector.emplace_back(1); // >> Constructed!
vector.emplace_back(2);
vector.emplace_back(3);
vector.emplace_back(4);
vector.emplace_back(5);

std::cin.get();
}

对组 pair 和元组 tuple

对组通常用于处理一组简单的二元数据,比如键值对。因为它的结构很简单,只包含两个成员(firstsecond),所以在要快速创建一个二元数据容器时非常方便。

元组是一个通用的多元容器,可以包含任意多个不同类型的数据项,比对组更灵活。元组只能通过 std::get<index> 函数来访问元素,语法不常见可读性低。

对组和元组都缺少明确且有意义的字段名称。在实际应用中,我们更应该使用类或结构体来代替元组和对组,保证代码的安全性和可读性。

迭代器**

算法**

内存分配器**

仿函数**

命名空间

作用域可以减少符号的命名冲突问题,然而对于需要提供给其它翻译单元使用的变量和函数来说,它们只能定义在全局作用域中。因此,当我们的项目使用了很多依赖库的时候,依赖库所提供的符号就很容易产生命名冲突。

C 语言的解决方案是为所有的符号增加一个唯一的前缀,如 GLFW 库中提供的函数 glfwInit 等就使用了库名称 glfw 作为前缀。这是一个不够直接的解决办法,因此 C++ 引入了命名空间。

命名空间是一种专门用来解决全局作用域中符号命名冲突问题的作用域。命名空间将原先定义在全局作用域下的符号转移到其中,使用这些符号时要通过它来访问。

命名空间使用作用域解析运算符来解析指定命名空间下的符号,如 std::cout

命名空间支持嵌套别名内联

我们应该遵守一个原则:不要滥用 using namespace 具体命名空间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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
#include <iostream>
#include <string>
#include <algorithm>

namespace apple {
void print(const char* message) {
std::cout << message << std::endl;
}
}

namespace orange {
void print(const char* message) {
std::string copy = message;
std::reverse(copy.begin(), copy.end());
std::cout << copy << std::endl;
}
// 嵌套的命名空间
namespace correct {
void print(const char* message) {
std::cout << message << std::endl;
}
}
// 内联的命名空间中的符号可以直接访问,相当于没有命名空间(雾)
inline namespace justhello {
void just_hello() {
std::cout << "Hello?" << std::endl;
}
}
}

int main() {
apple::print("Apple!");
orange::print("Orange!");

// 访问嵌套的命名空间
orange::correct::print("Orange Correct!");
// 为命名空间起别名
namespace orangecrt = orange::correct;
orangecrt::print("Orangecrt!");

// 内联的命名空间中的符号可以直接访问,相当于没有命名空间(雾)
orange::just_hello();

std::cin.get();
}

常见关键字

static 关键字

作用

static 关键字有三种作用,取决于用在全局变量或函数上,在局部变量上,还是在成员变量或函数上

静态变量有几个共通点:

  • 无论哪种静态变量,都存储在数据段上,而不是栈上。
  • 静态变量只初始化一次,即使是局部静态变量,再局部代码块再次执行时也不会再初始化。
  • 静态变量的生命周期为整个程序运行周期。

静态变量的具体作用域、生命周期和内存位置前文已有,在此不做赘叙。

静态全局变量/静态函数

在全局变量或函数上使用 static,被修饰的全局变量或函数将具有内部链接性,即在链接时将只对同一个目标文件的成员可见

我们应该遵守一个原则:一个全局变量或函数除非需要跨翻译单元链接,否则应该使用 static 修饰,避免方式不必要的错误。

一个简单的例子:

Main.cpp

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>

#define Log(x) std::cout << x << std::endl

// -- 定义静态全局变量,在 Static.cpp 中也定义有同名静态全局变量,不会出现多重定义错误 --
static int staticVar = 11;
// -- 定义非静态全局变量,在 Static.cpp 中也定义有同名非静态全局变量,出现多重定义错误 --
// int commonVar = 33;

// -- 静态全局变量不允许使用 extern 修饰,不能单独声明 --
// -- 声明非静态全局变量,定义在 Static.cpp 中 --
extern int commonVar;

// -- 声明静态函数,定义在 Static.cpp 中 --
static void staticFunction();
// -- 声明非静态函数,同样定义在 Static.cpp 中 --
void commonFunction();

int main()
{
// -- 表明静态全局变量的定义只对同一个翻译单元的成员可见 --
// 输出的是 Main.cpp 自己定义的 staticVar,值为 11
Log(staticVar);
// 输出的是 Static.cpp 中定义的 commonVar,值为 44
Log(commonVar);

// -- 表明静态函数的定义只对同一个翻译单元的成员可见 --
// 出现未定义错误
// staticFunction();
// 正常链接到 Static.cpp 中函数 commonFunction 的定义
commonFunction();

std::cin.get();
}

Static.cpp

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

#define Log(x) std::cout << x << std::endl

static int staticVar = 22;
int commonVar = 44;

static void staticFunction()
{
Log("This is a static function outside class and struct.");
}

void commonFunction()
{
Log("This is a common function outside class and struct.");
}

静态局部变量

在局部变量上使用 static,被修饰的局部变量的生命周期将会延长至整个程序运行周期,但作用域不变

一个简单的例子:

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

void print()
{
static int i = 0; // 存储在静态存储区
std::cout << i << " " << std::endl;
i++;
}

int main()
{
print(); // 第一次调用,i 初始化为 0,后续不再初始化
print();
print();
print();
print();
// 输出结果为:0 1 2 3 4

// 找不到 i 的定义
// std::cout << i << " " << std::endl;

std::cin.get();
}

静态成员变量/静态成员函数

在成员变量或成员函数上使用 static,其行为与 Java 中在成员变量或成员函数上使用 static 一致,被修饰的成员变量或成员函数将被类或结构体的所有实例共享

静态成员变量必须在类或结构体外单独定义,详见前文

一个简单的例子:

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
35
36
37
38
39
40
41
#include <iostream>

class StaticClass
{
public:
// 声明类存在静态成员变量 staticVar,这个变量将被类的所有实例共享
static int staticVar;
// 声明类存在非静态成员变量 commonVar
int commonVar;
// 定义类的静态成员函数 staticPrint,这个函数将被类的所有实例共享
static void staticPrint()
{
std::cout << staticVar << std::endl;
};
// 定义类的非静态成员函数 commonPrint
void commonPrint()
{
std::cout << commonVar << std::endl;
};
};
// 必须在类外定义静态成员变量 staticVar
int StaticClass::staticVar;

int main()
{
// 创建实例,同时创建成员变量 commonVar
StaticClass staticClass1;
staticClass1.staticVar = 11;
staticClass1.commonVar = 22;

StaticClass staticClass2;
staticClass2.staticVar = 33;
staticClass2.commonVar = 44;

staticClass1.staticPrint(); // 输出结果为 33;
staticClass1.commonPrint(); // 输出结果为 22;
staticClass2.staticPrint(); // 输出结果为 33;
staticClass2.commonPrint(); // 输出结果为 44;

std::cin.get();
}

const 关键字

作用

const 关键字有两种作用,取决于用在变量上,还是在成员函数声明后

常量

在变量上使用 const,被修饰的变量将成为一个常量变量,在初始化后不允许修改,用于运行时确定的常量。

特别地,常量对象除不能再赋值外,也只允许调用他们的只读函数

我们应该遵守一个原则:对于一个不需要被修改的变量,我们设置让它为常量,避免意外修改,提升代码安全性。

一个简单的例子:

1
2
3
4
5
int add1(int a, int b)
{
return a + b;
}
const int sum1 = add1(5, 10); // sum1 赋值为 15 后,不允许修改

只读成员函数

在成员函数声明后使用 const,被修饰的函数将不允许对成员变量进行修改(除非这个成员变量使用 mutable 修饰为可修改),成为一个只读函数

这个用法可以提升代码的安全性和规范性。

在使用常量对象或常量对象的引用时,编译器会要求我们只能使用它的只读函数。

一个简单的例子:

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

class Entity
{
public:
int m_X, m_Y;

int getX() const
{
// m_X = 10; // 不允许
return m_X;
}
};

int main()
{
const Entity entity;
entity.getX(); // 如果没有使用 const 修饰 getX 函数,则不允许调用 getX 函数

std::cin.get();
}

extern 关键字

声明外部变量/函数

声明外部变量/函数,告诉编译器这个变量/函数的定义存在于某个翻译单元中,将来由链接器完成定义的链接,现在请允许我使用这个变量/函数

备注:

extern 关键字的目的是声明一个在某个翻译单元中开放出来允许别的翻译单元使用的全局变量/函数,static 关键字的目的是让一个全局变量/函数不允许被其它翻译单元访问。它们在目的上就是冲突的,因此静态全局变量/函数不能使用 extern 关键字修饰。

一个简单的例子:

Main.cpp

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

// 声明 externInt 变量存在于其它翻译单元,允许对它的使用
extern int externInt;

// 声明 externFunction 函数存在于其它翻译单元,允许对它的使用
extern int externFunction(int a, int b);

int main()
{
// 使用
externInt++;
std::cout << externInt << std::endl; // >> 11
std::cout << externFunction(1, 5) << std::endl; // >> 6

std::cin.get();
}

Extern.cpp

1
2
3
4
5
6
7
// externInt 全局变量的定义
int externInt = 10;

// externFunction 函数的定义
int externFunction(int a, int b) {
return a + b;
}

C 与 C++ 兼容

使用 extern "C" 来告诉编译器使用 C 语言的链接方式,防止 C++ 编译器对函数名进行名称修饰,使得 C 代码能够正确链接到这些函数。

mutable 关键字

作用

mutable 关键字有两种作用,取决于用在成员变量上,还是在 Lambda 函数上

可改变成员变量

在成员变量上使用 mutable,主要用于调用一个只读成员函数时,需要对调用次数进行计数的调试场合,它允许被修饰的成员变量在只读成员函数中改变值。除非必要,不建议这样使用。

一个简单的例子:

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

class Entity
{
public:
int m_X, m_Y;
mutable int m_DebugCount = 0; // 允许在只读函数中修改

int getX() const
{
m_DebugCount++; // 自增计数
return m_X;
}
};

int main()
{
const Entity entity;
entity.getX();

std::cin.get();
}

可改变的 Lambda 函数捕获变量

在 Lambda 函数上使用 mutable,主要用于让 Lambda 函数中按值传递方式捕获的变量变得可以修改。这种使用场景相对更多。

一个简单的例子:

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

int main()
{
int x = 8;
int y = 10;

auto fun = [=]() mutable
{
x++; // 使 x 可以自增
std::cout << x << std::endl;
};

fun();

std::cout << x << std::endl; // 不会修改外部的 x,因为按值传递
}

explicit 关键字

作用

explicit 关键字用在构造函数上,用于禁止编译器进行隐式构造函数转换

禁止隐式构造函数类型转换

C++ 编译器允许代码隐式地进行一次类型转换。隐式构造函数类型转换是指类或结构体类型的变量可以直接使用构造函数所接受的参数赋值,这会隐式的调用它的构造函数,实现类型转换。

这种转换可能造成代码阅读困难等问题。因此,在必要时可以使用 explicit 来禁止。

一个简单的例子:

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

class Person {
public:
std::string name;
int age;

Person(const std::string& name)
: name(name), age(-1) {}

// 禁止隐式构造函数转换
explicit Person(int age)
: name("Unknown"), age(age) {}

void printName()
{
std::cout << name << std::endl;
}
};

int main() {
short aShort = 1;
int anInt = aShort; // 隐式类型转换

Person person1 = std::string("Miya"); // 隐式构造函数转换,使用了 Person 类的构造函数 Person(const std::string& name)
// Person person2 = 18; // 隐式构造函数转换失败,没有参数匹配的构造函数

std::cin.get();
}

外部库

概念

外部库文件分为静态链接库 .lib.a动态链接库 .dll.so.dylib

静态链接库

静态链接库文件在编译时发生,即在编译过程中将库文件链接到可执行文件(或一个动态链接库),在程序运行时作为一个整体一起被装入内存使用。

静态链接所使用的静态链接库文件中包含了所有的符号和定义

使用 Visual Studio 构建项目时,加载预编的译静态链接库的设置比较麻烦,基本步骤如下:

  1. 下载库文件:下载官方提供的预编译的库文件,放入解决方案的 Dependencies/库名称 目录下(为了项目的规范性),库文件必须与项目的目标平台/使用的工具链一致/兼容。
  2. 添加附加包含目录:项目属性设置“C/C++ -> 常规 -> 附加包含目录”添加上头文件所在目录。
  3. 添加附加库目录:项目属性设置“链接器 -> 常规 -> 附加库目录”添加上库文件所在目录。
  4. 添加附加依赖项:项目属性设置“链接器 -> 输入 -> 附加依赖项”添加上要加载的静态库文件。

使用 Visual Studio 构建项目时,加载拥有源码可以自己编译的静态链接库的基本步骤如下:

  1. 添加库项目:取得库项目源码,并将库项目添加到解决方案中。
  2. 添加附加包含目录:项目属性设置“C/C++ -> 常规 -> 附加包含目录”添加上库项目的头文件所在目录。
  3. 添加引用:右键项目“添加 -> 引用”,选择库项目并确定。

动态链接库

动态链接库文件在运行时发生,即在程序运行过程中动态地查找和链接库文件,将库文件装入内存使用。

动态链接所使用的动态链接库文件中包含了所有的符号和定义

使用 Visual Studio 构建项目时,链接预编译的动态链接库的基本步骤如下:

  1. 加载导入库:采用与静态链接相同的步骤(步骤 1~4),加载对应的导入库。
  2. 添加动态链接库文件:将动态链接库文件放置在可执行程序的同级目录下,这是动态链接库的默认搜索路径,程序运行时会在这个路径下自动搜索需要的动态链接库并将其装入内存。若库文件位于不同路径,可以设置系统环境变量 PATH 或者在代码中指定动态链接库的路径。

备注:

在 Windows 平台上,动态链接库有对应的导入库(静态链接库)文件,其中包含了符号信息,链接器使用这个导入库解析符号,以确定运行时需要加载哪个动态链接库,同时把可执行文件可以链接到导入库中的符号,运行时由系统的加载器再去加载符号定义。

静态链接与动态链接的比较

  • 使用静态链接的方式,库文件将和可执行文件打包在一起,编译器和链接器知道程序实际需要链接的符号定义,因此可以对链接过程做优化,但可执行文件的体积相对更大。
  • 使用动态链接的方式,库文件单独存在于系统中,可执行文件的体积相对较小,支持不同程序共享同一份库,在运行时只需要装入一份库文件,可以节省内存空间,但在链接的过程中,有很多优化策略无法执行。

栈与堆内存的比较

C++ 有两种可以由编程人员主动申请的常用内存类型,堆和栈,他们的核心区别如下:

  • 内存大小不同:栈内存的大小通常是固定的,取决于操作系统和编译器的配置;堆内存虽然也有默认大小,但它可以动态变化,随程序运行而改变。

  • 内存申请语法不同:栈内存的申请语法为声明定义变量,在编译时就确定了申请的内存大小;堆内存的申请方式为使用 new 关键字在运行时动态申请内存空间

  • 内存分配方式不同:栈内存是连续的,通过栈顶指针的移动来分配和释放内存,分配简单且快速;堆内存不是连续的,散布在内存中各个地方,分配时会调用 allocate 函数,然后调用系统底层函数,执行空闲列表检查、内存请求、分配情况记录等操作,过程复杂且慢,是堆内存使用起来慢的第一因素

  • 内存释放方式不同:栈内存在其作用域结束时会自动弹出栈释放;堆内存需要使用 delete 关键字手动释放,释放时同样需要做复杂的操作,是堆内存使用起来慢的第二因素

  • 性能不同:栈内存的分配和释放都比堆内存快速,且栈内存因为其连续性,操作系统能够更有效地使用缓存。

我们应该遵守一个原则:尽可能使用栈内存。使用堆内存的唯一原因是我们无法使用栈内存实现,例如我们需要生命周期比作用域大的变量,或是我们需要存储一个特别大的数据,例如一个超过 50 MB 的纹理图案。

备注:

栈内存和堆内存实际都位于计算机的 RAM 中,只不过栈内存因为其连续性,可以一起放入 CPU 缓存线上,能减少缓存未命中的情况,适用于高频使用的变量、函数等;堆内存因为其不连续性,更容易产生缓存未命中。当然,少量的缓存未命中不会在宏观层面拖慢程序的运行速度。

类型双关(原始内存操作)

使用 C++ 我们可以实现对内存的直接操作。操作栈内存,我们只需要将一个栈上的变量转换为指针,此后转换成我们想要的类型的指针,即可把一种类型的变量当作另一种类型对待,绕过类型系统。

把一种类型的变量当作另一种类型对待,这就是类型双关

需要注意,这样的原始内存操作非常危险,同时要注意内存的大小是否相等,避免访问不属于自己的内存。

一个简单的例子:

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

int main() {
// 二者指向内存中的同一块地方,且把这块内存当作不同类型对待
long long aLong = 10L; // >> 0a 00 00 00 00 00 00 00
double& aDouble = *((double*)&aLong); // >> 0a 00 00 00 00 00 00 00

// >> aDouble:00 00 00 00 00 00 f0 3f,即 1.0
// >> aLong:00 00 00 00 00 00 f0 3f,即 460 7182 4188 0001 7408
aDouble = 1.0;

std::cin.get();
}

联合体

联合体和结构体一样,唯一区别是它的所有成员变量共享一块内存

由于它的特点,我们可以很方便地用它来实现类型双关。相较于原始内存操作,这种方式实现的类型双关更加安全。

当一个联合体中有多个成员变量时,联合体只占用所有成员变量中最大的内存大小。

备注:

在类中,匿名的类、结构体和联合体,只承载了组织成员的功能,不会限定成员的作用域。

一个简单的例子:

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
35
36
37
38
39
40
41
42
43
44
45
#include <iostream>

// 假设我们只有一个处理 Vector2 的函数
// 但我们想要用它处理 Vector4
// 我们可以将 Vector4 当作两个 Vector2
// 使用匿名联合体实现这种类型双关
struct Vector2 {
float x, y;
};

struct Vector4 {
// 匿名联合体,让其内成员变量共享一块内存
union {
// 使用匿名结构体来组织数据,把 x,y,z,w 打包成匿名联合体的一个匿名成员变量
struct {
float x, y, z, w;
};
// 第二个匿名成员变量,存储两个 Vector2 a,b
// 此后 Vector2 a、b 和 x、y、z、w 共享同一块 16 字节的内存
// 使得 a.x == Vector4.x,b.x == Vector4.z ……
struct {
Vector2 a, b;
};
};
};

void print_vector2(const Vector2& vector2) {
std::cout << "x: " << vector2.x << ", y: " << vector2.y << std::endl;
}

int main() {
Vector4 vector4 = { 1.0f, 2.0f, 3.0f, 4.0f };
print_vector2(vector4.a); // >> x: 1, y: 2
print_vector2(vector4.b); // >> x: 3, y: 4

vector4.y = 5.0f; // 我们可以直接修改成员变量 x、y、z、w
vector4.b = { 9.0f, 12.0f }; // 也可以直接修改 Vector2 类型的成员变量 a、b

std::cout << "==============================================" << std::endl;

print_vector2(vector4.a); // >> x: 1, y: 5
print_vector2(vector4.b); // >> x: 9, y: 12

std::cin.get();
}

结构化绑定

结构化绑定(C++ 17)用于将对组、元组转换为有名称的变量

在创建一个需要返回多个结果的函数时,我们通常有两种选择:使用自定义的结构体使用元组、对组作为返回值类型。

对于函数调用者来说,操作返回的结构体更方便,因为结构体可以为每个结果设置名称,而元组、对组只能使用 firstsecond 甚至是索引来访问结果。我们可以使用 std::tie 函数把元组、对组的结果转换为有名称的变量,但它的步骤仍然比较复杂。

使用结构化绑定,可以在创建变量的时候顺道完成转换,使得对组和元组的使用变得简单有效。

一个简单的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include "pch.h"

std::pair<std::string, int> create_person() {
return { "Cherno", 10 };
}

int main() {
auto person = create_person();
// 使用 std::get<索引>(集合)
std::string& name1 = std::get<0>(person);
int age1 = std::get<1>(person);
std::cout << "name1: " << name1 << ", age1: " << age1 << std::endl;
// 使用 std::tie,可读性更好
std::string name2;
int age2;
std::tie(name2, age2) = create_person();
std::cout << "name2: " << name2 << ", age2: " << age2 << std::endl;
// 使用结构化绑定,比使用 std::tie 更简洁
auto[name3, age3] = create_person();
std::cout << "name3: " << name3 << ", age3: " << age3 << std::endl;

std::cin.get();
};

线程

基本使用

C++ 中线程的基本使用于 Java 差别不大。

一个简单的例子:

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

static bool s_finished = false;

void do_work() {
// 结束标志不为 true 时循环执行
while (!s_finished) {
using namespace std::literals::chrono_literals;
std::cout << "Working...\n";
std::this_thread::sleep_for(1s); // 线程休眠一秒
}
}

int main() {
std::thread worker(do_work);

// 当用户输入内容,结束标志设为 true
std::cin.get();
s_finished = true;

worker.join(); // 让当前线程阻塞,等待 worker 线程执行完毕
std::cout << "Work finished!" << std::endl;

std::cin.get();
};

多线程

当一个任务可以被分为大量不相干的子任务时,就适合使用多线程

我们可以使用 std::async 设置异步任务实现多线程。

备注:

  1. 线程创建和销毁的开销大约为 200ms 级别,对于小于这个级别的小型任务,不适合使用多线程。
  2. 线程函数的参数按值移动或复制,如果需要传递引用,实参必须使用 std::ref 包裹。

一个简单的例子:

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
#include <iostream>
#include <string>
#include <chrono>
#include <array>
#include <algorithm>
#include <future>
#include <random>

class Timer {
private:
std::chrono::steady_clock::time_point _Start, _End;
std::string _Function_Name;
public:
Timer(std::string FunctionName)
: _Function_Name(FunctionName){
_Start = std::chrono::high_resolution_clock::now();
}

~Timer() {
_End = std::chrono::high_resolution_clock::now();
std::chrono::milliseconds duration = std::chrono::duration_cast<std::chrono::milliseconds>(_End - _Start);
std::cout << "[" << _Function_Name << "]: cost " << duration.count() << "ms" << std::endl;
}
};

void heavily_task() {
using namespace std::literals::chrono_literals;
std::this_thread::sleep_for(100ms);
}

void dosth() {
Timer timer("dosth");

// 模拟单线程操作耗时任务
for (size_t i = 0; i < 10; i++) {
heavily_task();
}
}

void dosth_async() {
Timer timer("dosth_async");

// 模拟多线程异步操作耗时任务
// 必须接受返回值,否则返回的临时对象 future 的析构会导致线程等待,变成同步执行
std::vector<std::future<void>> futures;
for (size_t i = 0; i < 10; i++) {
futures.push_back(std::async(std::launch::async, heavily_task));
}
}

int main() {
// 执行 10 次 100ms 的任务
dosth(); // >> 1088ms
dosth_async(); // >> 322ms(线程创建销毁开销 200ms)

std::cin.get();
}

左值右值

左值是指有存储空间的值,比如变量中的值;右值是指没有存储空间临时值,比如字面量、函数返回值。

非常量左值引用只能引用左值常量左值引用能够引用左值和右值右值引用(C++ 11)只能引用右值

我们使用右值引用的主要原因是,这样我们就能知道我们得到的数据是临时的,我们可以独占它,使用它时不像左值一样有所有权冲突、浅拷贝等问题。

一个简单的例子:

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
#include <iostream>

int get_value() {
return 10;
}

int set_value(int value) {
// ...
}

void set_lref(int& ref) {
// ...
}

void set_const_lref(const int& ref) {
// ...
}

void set_rref(int&& ref) {
// ...
}

int main() {
// a:左值,10:右值
int a = 10;

// b:左值,get_value 函数的返回值:右值
int b = get_value();

// c:左值,b:左值
int c = b;

// 实参 a 是左值,形参 value 是左值
set_value(a);
// 实参 10 是右值,用来创建形参 value,是左值
set_value(10);

// == 左值引用 ==
// 非 const 左值引用只能引用左值
set_lref(a);
// set_lref(10); // 非 const 左值引用只能绑定左值
// const 左值引用允许引用左值和右值(编译器会为右值创建一个临时变量,让它引用,这样引用就能同时接受左值和右值)
set_const_lref(a);
set_const_lref(10);

// == 右值引用 ==
// 右值引用只能引用右值
// set_rref(a); // 右值引用只能绑定右值
set_rref(10);

// 字符串
std::string firstName = "Yan";
std::string lastName = "Chernikov";
// firstName + lastName 实际上也是一个函数调用的返回值,是右值
std::string fullName = firstName + lastName;

std::cin.get();
};

移动语义

概念

在使用左值引用时,当一个函数需要得到参数对象的所有权,我们不得不去复制这个对象,因为这个对象在函数外构造,我们无法保证能够安全拿到这个对象的所有权。这会导致重复构造对象

在使用右值引用时,我们可以保证能够安全拿到这个对象的所有权,我们可以在移动构造函数中取走这个对象的数据,而不需要复制

一个简单的例子:

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
#include <iostream>

class String {
public:
String() = default;

// 构造函数
String(const char* string) {
printf("Created!\n");
m_Size = strlen(string);
m_Data = new char[m_Size];
memcpy(m_Data, string, m_Size);
}

// 拷贝构造函数
String(const String& other) {
printf("Copied!\n");
m_Size = other.m_Size;
m_Data = new char[m_Size];
memcpy(m_Data, other.m_Data, m_Size);
}

// 移动构造函数
String(String&& other) noexcept {
printf("Moved!\n");
m_Size = other.m_Size;
m_Data = other.m_Data;
// 避免 other 销毁时释放数据
other.m_Size = 0;
other.m_Data = nullptr;
}

~String() {
printf("Destroyed!\n");
delete m_Data;
}

void print() const {
for (uint32_t i = 0; i < m_Size; i++) {
printf("%c", m_Data[i]);
}
printf("\n");
}

private:
char* m_Data;
uint32_t m_Size;
};

class Entity {
public:
// 使用左值引用时,为了拿到 name 的所有权,必须使用拷贝构造函数(隐式转换)
// 因为 name 本身可能在其它地方被修改或销毁
Entity(const String& name)
: m_Name(name) {}

// 使用右值引用时,我们可以确保安全拿到所有权
// 右值引用本身是一个左值,因此需要将它转换为右值后,才能触发移动构造函数的隐式类型转换
Entity(String&& name)
//: m_Name((String&&)name) {}// 强制转换
: m_Name(std::move(name)) {} // 使用 std::move 转换,更好

void print_name() {
m_Name.print();
}

private:
String m_Name;
};

int main() {
// 传递的是左值字符串对象 name
// 生命周期为整个 main 函数
String name = String("Name is Entity");
Entity entity1(name);
entity1.print_name();

printf("================================\n");

// 传递的是右值字符串,经过 String 的构造函数隐式转换为右值字符串对象
// 生命周期为 Entity 的构造函数
Entity entity2("Name is Entity");
entity2.print_name();

std::cin.get();
};

内存对齐

内存对齐指的是占用 n 字节的成员变量,相对于实例首地址的偏移量要是 n 的倍数,同时类和结构体占用的内存要是占用最大成员的倍数

内存对齐是多数处理器的默认规则,原因是处理器通常以特定的字节(如4字节、8字节)为单位访问内存,有些平台不支持访问未对齐的内存,其更底层的原因是内存的 IO 是以 8 个字节为单位进行的。

一条内存是由若干个内存颗粒(chip) 组成,每个 chip 内部是由8个 bank 组成的。由于内存编址的原因,平时看起来连续的地址,如 0x00000x0007 ,实际上分别位于 8 个 bank 中,这样读取连续的 8 个字节时,可以让 8 个 bank 并行工作,每个 bank 只需要工作一次,效率极高。当我们读取没有对齐的内存,如 0x00010x0008,每个 bank 都需要工作两次。通过内存对齐,可以牺牲一定的空间,换取更快的效率

我们可以使用预处理语句 #pragma pack(字节数) 来指定以几个字节对齐内存。编译器会用默认对齐方式和我们指定的方式中较小的字节对齐内存。因为内存对齐本身就是牺牲空间换效率,对于可以使用较小字节对齐的类型,使用更大的字节对齐也不会提升处理器的访问效率。

一个简单的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 结构体
struct A {
char x; // 偏移量 0,1 字节对齐
int y; // 偏移量 1(+ 3),4 字节对齐,在前面填充 3 字节
double z; // 偏移量 8,8 字节对齐
char w; // 偏移量 16,1 字节对齐
} // 结构体占用 17(+ 7),是占用最大成员的倍数,在最后填充 7 字节

// 结构体嵌套
struct B {
char x; // 偏移量 0,1 字节对齐
A a; // 偏移量 1(+ 7),8 字节对齐(占用最大成员),在前面填充 7 字节
int y; // 偏移量 16,4 字节对齐
} // 结构体占用 20(+ 4),是占用最大成员的倍数,在最后填充 4 字节
C++
HTML 学习指南
© 2024 Lyana-nullptr
Powered by hexo | Theme is blank