C++ 进阶篇

 
 
C++
 11k
 

在观看了 Cherno 的 C++ 系列视频后的一些记录——进阶篇,涉及 C++11^+^ 语言新特性

2024-07-19 00:22:05

目录:

编译器、链接器是如何工作的

【44】C++ 的智能指针

【47】C++ 的动态数组(std::vector)

【58】C++ 的函数指针

【68】C++ 的虚析构函数

【75】C++ 的结构化绑定

【76】C++ 如何处理 OPTIONAL 数据

单一变量存放多种类型的数据

如何存储任意数据的数据

C++ 的单例模式

C++ 的左值和右值

C++ 的参数计算顺序

C++ 移动语义


编译器、链接器是如何工作的

C++ 不关心文件,文件是不存在于 C++ 中的东西

一个程序包含一个或多个翻译单元。一个翻译单元由一个实现文件(.cpp.cxx)及其直接或间接包含的所有标头(.h.hpp)组成。每个翻译单元由编译器独立编译。编译完成后,链接器会将编译后的翻译单元合并到单个程序中。ODR[^1] 规则(单一定义规则)的冲突通常显示为链接器错误。在多个翻译单元中定义同一名称时,将发生链接器错误

[^1]: 在 C++ 程序中,符号(例如变量或函数名称)可以在其范围[^2]内进行任意次数的声明。但是,一个符号只能被定义一次
[^2]: 范围是由一组封闭的大括号指定的,例如在函数或类的定义中

默认情况下,非常量全局变量和 Free 函数具有外部链接,它们在程序中的任何翻译单元内可见。具有内部链接或无链接的符号仅在声明它的翻译单元内可见。类定义或函数中声明的变量没有链接。如果要强制一个全局名称具有内部链接,可以将它显示声明为static,此关键字将它的可见性限制在声明它的同一翻译单元内。

默认情况下,以下对象具有内部链接:

  • const对象
  • constexpr对象
  • typedef对象
  • 命名空间范围中static对象

若要为const对象提供外部链接,请将其声明为extern并为其赋值:

1
extern const int value = 42;

【44】C++ 的智能指针

unique_ptr是作用域指针,超出作用域时会被销毁。它是唯一的,不能复制unique_ptr,因为多个unique_ptr会指向同一个内存块,当你销毁了其中一个unique_ptr时该内存块就会被释放,剩余的unique_ptr会指向被释放的内存块,这将会导致毁灭性的错误

1
2
3
4
5
#include <memory>

std::unique_ptr<Entity> entity(new Entity()); // 不建议

std::unique_ptr<Entity> entity3 = std::make_unique<Entity>();

如果你喜欢分享,共享指针shared_ptr会是不错的选择。shared_ptr的工作方式是通过引用计数,引用计数基本上是一种方法,可以跟踪你的指针有多少个引用,一旦引用计数为0,它就被删除了

1
2
3
4
5
#include <memory>

std::shared_ptr<Entity> entity(new Entity()); // 不建议

std::shared_ptr<Entity> entity3 = std::make_shared<Entity>();

unique_ptr中,不直接调用new的原因是因为异常安全

shared_ptr中则有所不同,因为shared_ptr需要分配另一块内存,叫做控制块,用来存储引用计数。如果你首先创建一个new Entity,然后将其传递给shared_ptr构造函数,那么它必须做2次内存分配,先做new Entity的分配,然后是shared_ptr控制块的分配。make_shared可以组合起来更有效率,而且有些讨厌newdelete的人显然会从你的代码中删除这些关键字

最后有一个东西你可以和shared_ptr一起使用,叫做弱指针weak_ptr。当你把一个shared_ptr赋值给另一个shared_ptr,引用计数会增加,但你把一个shared_ptr赋值给另一个weak_ptr,它不会增加引用计数,从而解决循环引用导致的内存泄露问题

1
2
3
#include <memory>

std::weak_ptr<Entity> entity3 = std::make_weak<Entity>();

【47】C++ 的动态数组(std::vector)

把指向堆分配的类对象的指针存储在vector中,亦或者是存储栈(一条线上)分配的类或者结构体对象,主要考虑的是,存储对象比存储指针在技术上更优,这样内存分配是在一条线上的,在某种意义上它们都在同一条高速缓存线上,唯一的问题是要调整vector的大小,需要重新分配和复制所有的东西,这可能是一个缓慢的操作

【58】C++ 的函数指针

  1. 原始风格的函数指针,来自 C 语言

  2. C++ 的方式使用函数指针

  3. lambda匿名函数

函数指针,是将一个函数赋值给一个变量的方法

1
2
3
4
//! \1 得到一个指向该函数的函数指针,此处存在一个隐式转换`HelloWorld`->`&HelloWorld`
void(*function)() = HelloWorld;

function();
1
2
3
typedef void(*HelloWorldFunction)();

HelloWorldFunction function = HelloWorld;
1
2
3
4
5
6
7
void HelloWorld(int x);

void Output(const std::vector<int> &values, void(*haven)(int));

Output(values, HelloWorld);
//! \2
Output(values, [](int value) -> void {std::cout << x << " ,Hello World?" << std::endl;});

【68】C++ 的虚析构函数

你如果允许一个类有子类,一定得确保析构函数为virtual,否则没人能安全的扩展这个类。标记virtual,意味着 C++ 编译器知道可能会有一个在层次结构下的重写方法,虚析构函数有些特殊,它不是覆写函数,而是加上一个析构函数。理论有些难以理解,举个例子,你需要根据类的基类类型来处理该类,扩展一点就是你把一个派生类的对象指针传递给一个函数(只接受基类指针),该函数做了删除基类指针之类的操作,这个时候就会出现问题了,会导致内存泄露

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Base {
public:
Base() { std::cout << "Base Constructor\n"; }
~Base() { std::cout << "Base Destructor\n"; }
//! \correct: virtual ~Base() { std::cout << "Base Destructor\n"; }
}

class Derived : public Base {
public:
Derived() { std::cout << "Derived Constructor\n"; }
~Derived() { std::cout << "Derived Destructor\n"; }
//! \wrong: 派生类的析构函数不会被调用,内存没有释放干净
}

int main() {
Base* ptr = new Derived();
delete ptr;
}

C++ 的类型转换

C++ 是强类型语言,意味着在 C++ 中,每个变量都有一个明确的数据类型,并且该类型在编译时就已经确定,但它的类型系统具有一定的灵活性和宽容性——类型转换和类型推导。我们执行类型转换的方法:隐式类型转换和显示类型转换,显示类型转换有 C 语言风格和 C++ 语言风格的

C 语言风格类型转换,在圆括号中制定了要强制转换的类型,然后是我们要强制转换的变量,例如(int)a或者(int)(a) + 3

C++ 语言风格类型转换,有四种类型转换操作符。必须要认识到的是,它们不做任何 C 风格类型转换不能做的事情,它们可能会做其它的事情(可读性、类型安全和维护性),但是实际结果也只是一个成功的类型转换而已。通过使用 C++ 语言风格类型转换,可以提高代码的安全性、可读性和维护性,你可以在代码库中直接通过英文单词搜索它们

  • static_cast:用于编译时类型检查的转换
  • dynamic_cast:主要用于运行时类型检查和多态类型的安全转换
  • const_cast:用于修改变量的常量性(即去掉或添加constvolatile
  • reinterpret_cast:进行低级别(位级操作)的重新解释类型转换(类型双关)

dynamic_cast更像是一个函数,它不像编译时进行的类型转换,而是在运行时计算,正因为如此,它确实有相关的运行成本。dynamic_cast是专门用于沿继承层次结构进行的强制类型转换,只用于多态类类型,这是因为需要有虚函数(通常是虚析构函数)以启用 RTTI (runtime type information 运行时类型信息)。RTTI 存储了运行时所有的类型信息,dynamic_cast依赖 RTTI 来检查实际类型,RTTI 引入了一定的运行时开销,因为需要维护和查询类型信息

【75】C++ 的结构化绑定

C++ 17 的新特性,可以更好的处理多返回值(这一点在之前的 P52 中讲过),如果某组多返回值只处理一次,就不必使用结构体

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

std::tuple<std::string, int> CreatePerson() {
return {"Dawn", 21};
}

int main() {
// 处理一
auto person = CreatePerson();
auto name = std::get<0>(person);
auto age = std::get<1>(person);

// 处理二
std::string name_tie;
int age_tie;
std::tie(name_tie, age_tie) = CreatePerson();

// 处理三
auto [name_auto, age_auto] = CreatePerson();

std::cin.get();
}

【76】C++ 如何处理 OPTIONAL 数据

std::optional,C++ 17 的新东西, 用于处理薛定谔的猫(一些可能存在,也可能不存在的数据)

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

std::optional<std::string> ReadFileAsString(const std::string &file_path) {
std::ifstream stream(file_path);
if (stream) {
std::string result;
stream.close();
return result;
}

return {};
}

int main() {
auto data = ReadFileAsString("./data.txt");
if (data) {
std::cout << "file open successfully\n";
} else {
std::cout << "file open failure\n";
}

std::cin.get();
}

单一变量存放多种类型的数据

std::variant,C++ 17 中的新特性,并不是一个真正的特性,更多的是一个在 C++ 17 中标准库给的类。它的作用是让我们不用担心处理的数据的确切类型

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 <variant>
#include <fstream>

enum class kErrorCode {
none = 0,
not_found = 1,
not_access = 2
};

std::variant<std::string, kErrorCode> ReadFileAsString(const std::string &file_path) {
std::ifstream file(file_path);

if (!file) {
if (!std::ifstream(file_path).good()) {
return kErrorCode::not_found;
}
return kErrorCode::not_access;
}

std::string content((std::istreambuf_iterator<char>(file)), std::istreambuf_iterator<char>());
file.close();

return content;
}

int main() {
auto data = ReadFileAsString("example.txt");
if (auto value = std::get_if<kErrorCode>(&data)) {
kErrorCode &error = *value;
switch (error) {
case kErrorCode::not_found: std::cout << "Error: File not found.\n";
break;
case kErrorCode::not_access: std::cout << "Error: No access to file.\n";
break;
}
}

std::cin.get();
}

这是一个使用 variant 的好例子,比返回一个布尔值更详细一些,而返回一个布尔值来判断是否有结果就是 optional 的方式了

std::variant有点像类型安全的联合体,不同的是,它的内存大小取决于容纳的最大类型的大小加上类型管理开销,并且考虑内存对齐

  • 类型管理开销指的是存储类型索引所需的空间,一般是一个 int
  • 在 64 位系统上,内存对齐通常是 8 字节的倍数。(在这里,cherno 的说法应该是有误,特别是有关内存计算的部分)

std::variant特别是在大类型上只会比最大类型稍大一些,并且考虑到联合体不能存储std::string,需要自己处理char *,因此通常情况下使用std::variant比联合体更为合适

如何存储任意数据的数据

可以使用 void 指针来做,然而std::any是一个更安全更好的 C++ 17 的全新处理方式

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

void *operator new(size_t size) {
std::cout << size << "byte\n";
return malloc(size);
}

int main() {
std::any data;
data = 2;
data = std::string("Dawn");
std::string &str = std::any_cast<std::string &>(data);

std::cin.get();
}

这里最大的问题是为什么要用std::any而不是std::variant

在绝大数情况下,std::variantstd::any更好,除了有一点限制性更要求类型安全(这是一件好事)之外,在处理较大数据是std::variant会执行得更快——不需要动态内存分配

对于小类型,std::any只是把它们存储为一个 Union(工作方式跟 variant 相同),如果是大类型,它会带你进入大存储空间的void *,在这种情况下,会进行动态分配(不利于性能)

因此,好像并没有用std::any的必要

C++ 的单例模式

单例模式(Singleton Pattern)是一种设计模式,确保一个类或者结构体只有一个实例,并提供一个全局访问点。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
#include <iostream>

class Random {
public:
Random(const Random &) = delete;
Random &operator=(const Random &) = delete;

static Random &Get() {
static Random instance;
return instance;
}

static float GenerateRandomFloat() { return Get().IGenerateRandomFloat(); }
private:
Random() {}

float IGenerateRandomFloat() const { return m_random_float_; }

float m_random_float_ = 0.05f;
};

int main() {
float random_num = Random::GenerateRandomFloat();

std::cout << random_num << "\n";
std::cin.get();
}

在这里提醒一点,静态方法是类级别的方法,而不是实例级别的方法,可以通过类名直接调用(允许像使用命名空间一样调用)

C++ 的左值和右值

搞清楚左值和右值对于理解更高级的 C++ 特性,比如移动语义是如何工作的,有很大的帮助。

左值是有某种存储支持的变量,右值是临时值,左值引用仅仅接受左值,如果是常量左值引用(const) ,兼容临时的右值和实际存在的左值,而右值引用仅仅接受右值。在这里提醒一点,函数重载同时存在常量左值引用和右值引用,并传递右值对象的情况下,编译器会选择走右值引用的函数

右值引用的主要优势在于优化,如果我们知道传入的是一个临时对象的话,就不用在意是否活着、是否完整、是否拷贝,我们可以简单的偷它的资源给到特定的对象,或者在其他地方使用它,因为我们知道它是暂时的,不会存在很长时间

C++ 的参数计算顺序

参数求值的顺序是什么?这个问题的答案,是“未定义行为”,C++ 实际上并没有提供 C++ 规范来真正定义在这种情况下应该发生什么,参数(形参)或实参应该按照什么顺序求值。这里的“未定义行为”,也就是说它会根据编译器的不同而变化,完全依赖于 C++ 编译器将代码转换成机器码的实际实现。但如果提到 C++17 说了后缀表达式必须在别的表达式之前被计算,不能同时计算,那就加分了,也就是说,它们必须一个接一个地完成

C++ 移动语义

移动语义本质上允许我们移动对象,但在 C++11 之前是不可能的,因为 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
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
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
#include <iostream>
#include <cstring>

class String {
public:
String() : m_data_(nullptr), m_size_(0) {};

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("Copyed\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.m_size_ = 0;
other.m_data_ = nullptr;
}

String &operator=(String &&other) noexcept {
printf("Moved\n");

if (this != &other) {
delete[] m_data_;
m_size_ = other.m_size_;
m_data_ = other.m_data_;

other.m_size_ = 0;
other.m_data_ = nullptr;
}

return *this;
}

~String() {
printf("Destroyed\n");
delete[] m_data_;
}

void Print() {
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:
Entity(const String &name) : m_name_(name) {}

Entity(String &&name) : m_name_(std::move(name)) {}

void PrintName() {
m_name_.Print();
}
private:
String m_name_;
};

int main() {
{
Entity entity("Dawn");
entity.PrintName();

String str = "hello";
String dest = std::move(str);
}

{
String apple = "Apple";
String dest;

std::cout << "Apple: ";
apple.Print();
std::cout << "Dest: ";
dest.Print();

dest = std::move(apple);

std::cout << "Apple: ";
apple.Print();
std::cout << "Dest: ";
dest.Print();
}

std::cin.get();
}

移动语义实际上只是接管了那个旧的字符串,而不是通过复制所有的数据和分配新的内存来进行深度复制(深拷贝),实际上只是做了浅拷贝,重新连接了指针

移动赋值操作符是你想要包含在类中的东西,他的作用是将一个对象移动到一个现有的对象中。这里会涉及到 C++ 三法则 和 C++ 五法则

  • 三法则和五法则是指导开发者如何正确管理资源和对象生命周期的两个重要原则

  • 三法则指出,如果一个类显示的定义析构函数、复制构造函数、复制赋值运算符中的任何一个,那么它很可能需要显示地定义所有这三个函数

  • 随着 C++11 的引入,移动语义和智能指针等新特性使得管理资源变得更加高效,三法则扩展成了五法则,新增了移动构造函数、移动赋值运算符两个新的函数

std::move是你想要将一个对象转化为临时对象时要做的,换句话说,如果你需要把一个已经存在的变量变为临时变量,确保使用std::move,这样你就可以使用移动构造函数或移动赋值运算符从那个变量中获取资源,并进行移动

Comments