The Cherno CppSeries学习笔记
前言
这是学习油管大佬Cherno的Cpp教程的个人笔记,供参考
b站上有从YouTube上搬运过来的熟肉视频,链接:【【1】【Cherno C++】【中字】欢迎来到C++】 https://www.bilibili.com/video/BV1uy4y167h2/?share_source=copy_web&vd_source=4175708e3d0d482d0df075930a6bbbdf
C++参考文档:
中文版:cppreference.com
英文版:cppreference.com
【P19】C++类与结构体对比
区别:
作用上:class默认private,struct默认public。
使用上:引入struct是为了让C++向后兼容C。
推荐选用:
若只包含一些变量结构或POD(plain old data)时,选用struct。例如数学中的向量类。
1 | struct Vec2 { |
若要实现很多功能的类,则选用class
【P24】C++枚举
枚举量的声明
enum是enumeration的缩写。基本上它就是一个数值集合。不管怎么说,这里面的数值只能是整数。
定义枚举类型的主要目的:增加程序的可读性
枚举变的名字一般以大写字母开头(非必需)
默认情况下,编译器设置第一个 枚举变量值为 0,下一个为 1,以此类推(也可以手动给每个枚举量赋值),且未被初始化的枚举值的值默认将比其前面的枚举值大1。)
枚举量的值可以相同
枚举类型所使用的底层类型默认为int类型,也可指定其他底层类型 ,如 unsigned char,char
1
2
3
4enum example : unsigned char //将类型指定成unsigned char,枚举变量变成了8位整型,减少内存使用
{
A, B = 10, C
};对于枚举,只定义了赋值运算符,没有为枚举定义算术运算 ,但能参与其他类型变量的运算
1
2
3A++; //非法!
D = A + C //非法!
int sum = 1 + A //Ok,编译器会自动把枚举量转换为int类型。
代码案例:
1 |
|
【P25】C++构造函数
- 当创建对象的时候,构造函数被调用
- 构造函数一般是用来初始化类的对象
构造函数用来初始化类的对象,与父类的其它成员不同,它不能被子类继承(子类可以继承父类所有的成员变量和成员函数,但不继承父类的构造函数)。因此,在创建子类对象时,为了初始化从父类继承来的数据成员,系统需要调用其父类的构造函数。
构造原则如下:
- 如果子类没有定义构造方法,则调用父类的无参数的构造方法
- 如果子类定义了构造方法,不论是无参数还是带参数,在创建子类的对象的时候,首先调用父类无参数的构造方法,然后调用自己的构造方法
- 在创建子类对象时候,如果子类的构造函数没有显式调用父类的构造函数,则会自动调用父类的默认无参构造函数
- 在创建子类对象时候,如果子类的构造函数没有显示调用父类的构造函数且父类自己提供了无参构造函数,则会调用父类自己的无参构造函数
- 在创建子类对象时候,如果子类的构造函数没有显示调用父类的构造函数且父类只定义了自己的有参构造函数,则会出错(如果父类只有有参数的构造函数,则子类必须显示调用此带参构造函数)
代码案例:
1 |
|
运行结果:
Entity construct
Entity construct
Player construct
【P27】C++继承
当你创建了一个子类,它会继承父类除构造函数外的所有成员变量和成员函数
继承给我们提供了这样的一种方式:把一系列类的所有通用的代码(功能)放到基类
在定义一个新的类 B 时,如果该类与某个已有的类 A 相似(指的是 B 拥有 A 的全部特点),那么就可以把 A 作为一个基类,而把B作为基类的一个派生类(也称子类)。
派生类是通过对基类进行修改和扩充得到的,在派生类中,可以扩充新的成员变量和成员函数。
派生类拥有基类的全部成员函数和成员变量,不论是private、protected、public。需要注意的是:在派生类的各个成员函数中,不能访问基类的private成员(即任何不是私有的(private)成员,派生类都可以访问到)。
继承的格式
1
2
3class 派生类名:public 基类名
{
};
【P28】C++虚函数
- 虚函数可以让我们在子类中重写方法。
- 格式
1 | claee 父类名{ |
代码案例:
1 |
|
【P29】C++纯虚函数
含有纯虚函数的基类称为抽象类,它不能被实例化
- 纯虚函数优点
防止派生类忘记实现虚函数,纯虚函数使得派生类必须实现基类的虚函数。
在某些场景下,创建基类对象是不合理的,含有纯虚函数的类称为抽象类,它不能直接生成对象
- 声明方法: 在基类中纯虚函数的后面加 =0
1 | virtual void funtion() = 0; |
【P30】C++可见性
- 可见性是一个属于面向对象编程的概念,它指的是类的某些成员或方法实际上是否可见。可见性是指:谁能看到它们,谁能调用它们,谁能使用它们等这些内容
- 可见性是对程序实际运行方式、程序性能或类似的东西没影响。它只是单纯的是语言层面的概念,让你能够写出更好的代码或者帮助你组织代码
- C++中有三个基础的可见修饰符(访问修饰符):private、protected、public
private:只有自己的类和它的友元才能访问(继承的子类也不行,友元的意思就是可以允许你访问这个类的私有成员)
protected:这个类以及它的所有派生类都可以访问到这些成员。(但在main函数中new一个类就不可见,这其实是因为main函数不是类的函数,对main函数是不可访问的)
public:谁都可见
- 可见性是让代码更加容易维护,容易理解,不管是阅读代码还是扩展代码。这与性能无关,也不会产生完全不同的代码。可见性不是CPU需要理解的东西,这不是你的电脑需要知道的东西。它只是人类发明的东西,为了帮助其他人和自己
【P34】C++中的const
const关键字是一种修饰符。所谓“修饰符”,就是在编译器进行编译的过程中,给编译器一些“要求”或“提示”,但修饰符本身,并不产生任何实际代码。就 const 修饰符而言,它用来告诉编译器,被修饰的这些东西,具有“只读”的特点。在编译的过程中,一旦我们的代码试图去改变这些东西,编译器就应该给出错误提示
所以,const修饰符的作用主要是利用编译器帮助我们检查自己代码的正确性。我们使用const在源码中标记出“不应该改变”的地方,然后利用编译器,帮助我们检查这些地方是否真的没有被改变过。如果我们不小心去修改了这些地方,编译器就会报错,从而帮助我们纠正错误。使用const和不使用const,对于最终编译产生的代码并没有影响。
虽然const对于最终代码没有影响,但是尽可能使用const,将帮助我们避免很多错误,提高程序正确率
const指针的用法
1 | const int Age =90; |
上述代码我们可以做两件事,一可以改变这个指针的内容,就是指针指向的内存内容;二可以改变指针指向的内存地址
**const int*(同int const*) 常量指针
可以改变指针指向的地址,不能再去修改指针指向的内容
1 | const int* a = new int; |
int const* 指针常量
可以改变指针指向的内容,不能再去修改指针指向的地址
1 | int* const a = new int; |
const int* const
既不能改变指针指向的内容,也不能改变指针指向的地址
在类和方法中的const
和变量没有关系,而是用在方法名的后面( 只有类才有这样的写法 ),意味着这个方法不会修改任何实际的类
1 |
|
以上面的代码为例,有时我们会写两种GetX(),一个有const一个没有,然后上面传入const Enity&
的方法就会调用const的GetX()版本
我们把成员方法标记为const是因为如果我们在编码的过程中有一些“只读”的对象,我们需要调用const方法。如果没有const方法,那const 类名&
对象就调用不了该方法
如果一个方法在调用时,实际上没有修改或者不应该修改传入的对象,总是标记你的方法为const,否则在有常量引用或类似的情况下就用不了你的方法。
在const函数中, 如果要修改别的变量,可以用关键字mutable:
1
2
3
4
5
6
7
8
9
10
11
12
13class Entity
{
private:
int m_x,m_y;
mutable var; //把类成员标记为mutable,意味着类中的const方法可以修改这个成员
public:
int Getx() const
{
var = 2; //ok mutable var
m_x = 2; //ERROR!
return m_x;
}
};
【P35】C++mutable关键字
mutable有两种不同的用途:
- 与const一起用(最主要的用法,见上一篇)
- lambda表达式,或者同时包含这两种情况
引用传递
1
2
3
4
5
6
7
8
9
10
11
int main()
{
int x = 8;
auto f = [&]()
{
x++; //如果是值传递,则会报错。
std::cout << y << std::endl;
};
f();
}值传递
1
2
3
4
5
6
7
8
9
10
11
12
int main()
{
int x = 8;
auto f = [=]()
{
int y = x;
y++;
std::cout << y << std::endl;
};
f();
}添加mutable关键字,会干净许多(但本质是一样的)
1
2
3
4
5
6
7
8
9
10
11
int main()
{
int x = 8;
auto f = [=]() mutable
{
x++;
std::cout << x << std::endl;
};
f();
}
【P38】创建并初始化C++对象
- 基本上,当我们编写了一个类并且到了我们实际开始使用该类的时候,就需要实例化它(除非它是完全静态的类)
- 实例化类有两种选择,这两种选择的区别是内存来自哪里,我们的对象实际上会创建在哪里
- 应用程序会把内存分为两个主要部分:堆和栈。还有其他部分,比如源代码部分,此时它是机器码
栈分配
格式:
1 | // 栈中创建 |
- 什么时候栈分配?几乎任何时候,因为在C++中这是初始化对象最快的方式和最受管控的方式
- 什么时候不栈分配? 如果创建的对象太大,或是需要显示地控制对象的生存期,那就需要堆上创建
堆分配
格式:
1 | // 堆中创建 |
- 当我们调用new Entity时,实际发生的就是我们在堆上分配了内存,我们调用了构造函数,然后这个new Entity实际上会返回一个Entity指针,它返回了这个entity在堆上被分配的内存地址,这就是为什么我们要声明成Entity*类型
- 如果你使用了new关键字,那你就要用delete来进行清除
【P40】C++隐式转换与explicit关键字
隐式转换
隐式转换只能进行一次。这里涉及到编译器的一条规则,编译器每次只能执行一种类类型的隐式转换。如果同时提出多个转换请求,编译器则会报错
1 |
|
如上,int型的20就被隐式转换为一个Entity对象,这是因为Entity类中有一个Entity(int age)构造函数,因此可以调用这个构造函数,然后把23作为他的唯一参数,就可以创建一个Entity对象
同时我们也能看到,对于语句Entity e1 = "Dylan";
原因是只能进行一次隐式转换,”Dylan”是const char数组,这里需要先转换成std::string
,再由std::string
转换成Entity,语句PrintEntity("Dylan");
报错也是一样的道理
最好不写Entity test5 = 23;
这样的函数,应尽量避免隐式转换。因为Entity e(20);
更清晰
explicit 关键字
explicit会禁用隐式转换,explicit关键字放在构造函数前面
在构造函数前面加上explicit,这就意味着这个构造函数不会进行隐式转换。比如,如果想用一个整数创建一个Entity对象,就必须显式地调用这个构造函数。在实际使用时,它可以防止意外转换,导致性能问题或bug
1 |
|
【P41】C++运算符(操作符)及其重载
运算符重载概念:对已有的运算符重新进行定义,赋予其另一种功能,以适应不同的数据类型
“+”和“*”操作符重载
1 |
|
“<<”操作符的重载
1 |
|
bool操作符的重载
1 | bool operator==(const Vector2& other) const |
【P44】C++的智能指针
- 智能指针本质上是原始指针的包装。当你创建一个智能指针,它会调用new并为你分配内存,然后基于你使用的智能指针,这些内存会在某一时刻自动释放
- 优先使用unique_ptr,其次考虑shared_ptr
尽量使用unique_ptr因为它有一个较低的开销,但如果你需要在对象之间共享,不能使用unique_ptr的时候,就使用shared_ptr
作用域指针unique_ptr的使用
- 要访问所有这些智能指针,你首先要做的是包含memory头文件
- unique_ptr是作用域指针,意味着超出作用域时,它会被销毁然后调用delete
- unique_ptr是唯一的,不可复制,不可分享
- unique_ptr构造函数实际上是explicit的,没有构造函数的隐式转换,需要显式调用构造函数
- 最好使用
std::unique_ptr<Entity> entity = std::make_unique<Entity>();
因为如果构造函数碰巧抛出异常,不会得到一个没有引用的悬空指针从而造成内存泄露,它会稍微安全一些 std::make_unique<>()
是在C++14引入的,C++11不支持
1 |
|
共享指针shared_ptr的使用
shared_ptr的工作原理是引用计数,具有一定的开销
引用计数基本上是一种方法,多个指针都存储某个对象的地址,只有当所有指针都被销毁时,这个对象才被析构
例如:我创建了一个共享指针shared_ptr,并指向一个对象的地址,我又创建了另一个shared_ptr来复制它,我的引用计数是2,第一个和第二个,共2个。当第一个死的时候,我的引用计数器现在减少1,然后当最后一个shared_ptr死了,引用计数回到零,内存就会被释放
1 |
|
弱指针weak_ptr的使用
- 可以和共享指针shared_ptr一起使用
- weak_ptr可以被复制,但是同时不会增加额外的控制块来控制计数,仅仅声明这个指针还活着
当你将一个shared_ptr赋值给另外一个shared_ptr,引用计数++,而若是把一个shared_ptr赋值给一个weak_ptr时,它不会增加引用计数。有时候只是想找个指向这块内存的指针,但我不想把这块内存的生命周期与这个指针关联,这种情况下,弱指针就意味着“我指向这东西,但这东西什么时候释放不关我事儿”
1 | { |
【P47】 C++的动态数组(std::vector)
- vector本质上是一个动态数组,是内存连续的数组
- 它的使用需要包含头文件
#include <vector>
- 使用格式:类型尽量使用对象而非指针。
std::vector<T> a;
//T是一种模板类型,尽量使用对象而非指针
a.erase();
//清除指定元素,参数是迭代器类型
a.clear();
//清除数组列表
使用案例:
1 |
|
【P48】C++的std::vector使用优化
vector的优化策略:
问题1:当向vector数组中添加新元素时,为了扩充容量,当前的vector的内容会从内存中的旧位置复制到内存中的新位置(产生一次复制),然后删除旧位置的内存。 简单说,push_back时,容量不够,会自动调整大小,重新分配内存。这就是将代码拖慢的原因之一
解决办法: 使用
vertices.reserve(n)
,直接指定容量大小,避免重复分配产生的复制浪费
问题2:在非vector内存中创建对象进行初始化时,即push_back() 向容器尾部添加元素时,首先会创建一个临时容器对象(不在已经分配好内存的vector中)并对其追加元素,然后再将这个对象拷贝或者移动到我们真正想添加元素的容器中 。这其中就造成了一次复制浪费解决办法:
emplace_back()
,直接在容器尾部创建元素,即直接在已经分配好内存的那个容器中直接添加元素,不创建临时对象
简单的说:
reserve提前申请内存,避免动态申请开销,emplace_back直接在容器尾部创建元素,省略拷贝或移动过程
1 |
|
运行结果:
Copied!
Copied!
Copied!
Copied!
Copied!
Copied!
发生六次复制的原因:
第一次push_back,capacity扩容到1,临时对象拷贝到真正的vertices所占内存中,第一次Copied
第二次push_back,capacity扩容到2,vertices进行内存搬移产生的拷贝为第二次Copied,然后再是临时对象的搬移,为第三次Copied
第三次push_back,capacity扩容到3,vertices进行内存搬移产生的拷贝为第四、五次Copied,然后再是临时对象的搬移为第六次Copied
所以它3次push_back对应输出Copied的次数分别是1,3,6。由此类推,它的复制次数是这样递增的:1+2+3+…
解决:
1 | int main() |
【P53】C++的模板
模板:模板允许你定义一个可以根据你的用途进行编译的模板(有意义下)。故所谓模板,就是让编译器基于DIY的规则去为你写代码
函数模板格式:
1 | template <class 形参名,class 形参名,...> 返回类型 函数名(参数列表) { 函数体 } |
代码案例:
1 | template<typename T> void Print(T temp) |
类模板格式:
1 | template<class 形参名,class 形参名,…> class 类名 { ... }; |
代码案例:
1 | //可以传类型,也可以传数字,功能太强大了 |
【P52】C++中如何处理多返回值
在平常的代码编写中,我们可能会遇到需要一个函数返回多个值的情况,对于返回多个值的实现其实也就是需要有能够存储多个值的东西,然后返回这个东西就可以了。能实现这个功能的我们可以想到有数组、STL库中的vector、array、pair、tuple以及结构体,接下来就来粗略介绍如何使用它们来返回以及它们的区别(普通数组的使用是最基本的,就不介绍了)
1.通过array或vector返回相同类型的多值
这里用返回array作为例子,当然,这里也可以返回一个vector,同样可以达成返回多个数据的目的,不同点是array对象和数组存储在相同的内存区域(栈)中,vector对象存储在自由存储区(堆),所以从技术上说,返回array会更快
但该方法都只适用于相同类型的多种数据的返回
1 | //设置array存储的数据类型是stirng,大小是2 |
2.通过pair返回两个不同类型的值
使用std::pair可以返回两个不同类型的数据,该容器可以绑定两个异构成员。这种方式的弊端是只能返回两个值
1 |
|
3.通过tuple返回多个不同类型的值
std::tuple可以将任意个异构成员绑定在一起,所以std::tuple作为函数返回值理论上可以用来返回任意个不同类型的值
1 |
|
4.通过结构体返回多个值(推荐)
结构体是在栈上建立的,所以在技术上速度也是可以接受的,而且不像用pair的时候使用只能temp.first, temp.second
,这样不清楚前后值是什么,可读性不佳。而如果换成temp.str, temp.val
后可读性极佳,永远不会弄混
1 |
|
补充:还有一种方法,使用了C++17引入的新特性,具体见P75
【P54】C++的堆和栈内存的比较
当我们的程序开始的时候,程序被分成了一堆不同的内存区域,除了堆和栈以外,还有很多东西,但我们最关心这两个
栈通常是一个预定义大小的内存区域,通常约为2兆字节左右。堆也是一个预定义了默认值的区域,但是它可以随着应用程序的进行而改变
栈和堆内存区域的实际位置(物理位置)在ram中完全一样(并不是一个存在CPU缓存而另一个存在其他地方)
在程序中,内存是用来实际储存数据的。我们需要一个地方来储存允许程序所需要的数据(比如局部变量or从文件中读取的东西)。而栈和堆,它们就是可以储存数据的地方,但栈和堆的工作原理非常非常不同,但本质上它们做的事情是一样的
栈和堆的区别
区别一:定义格式不同
1
2
3
4
5//在栈上分配
int val = 5;
//在堆上分配
int *hval = new int; //区别是,我们需要用new关键字来在堆上分配
*hval = 5;区别二:内存分配方式不同
对栈来说:
在栈上,分配的内存都是连续的。添加一个int,则栈指针(栈顶部的指针)就移动4个字节,所以连续分配的数据在内存上都是连续的。栈分配数据是直接把数据堆在一起(所做的就是移动栈指针),所以栈分配数据会很快 。
如果离开作用域,在栈中分配的所有内存都会弹出,内存被释放。对堆来说
在堆上,分配的内存都是不连续的,
new
实际上做的是在内存块的空闲列表中找到空闲的内存块,然后把它用一个指针圈起来,然后返回这个指针。(但如果空闲列表找不到合适的内存块,则会询问操作系统索要更多内存,而这种操作是很麻烦的,潜在成本是巨大的)
离开作用域后,堆中的内存仍然存在
建议: 能在栈上分配就在栈上分配,不能够在栈上分配时或者有特殊需求时(比如需要生存周期比函数作用域更长,或者需要分配一些大的数据),才在堆上分配
【P56】C++的auto关键字
auto的使用场景:
在使用iterator 的时候,如:
1 | std::vector<std::string> strings; |
当类型名过长的时候可以使用auto:
1 |
|
除此之外类型名过长的时候也可以使用using或typedef方法:
1 | using DeviceMap = std::unordered_map<std::string, std::vector<Device*>>; |
auto使用建议:如果不是上面两种应用场景,请尽量不要使用auto,能不用,就不用
【P57】C++的静态数组(std::array)
std::array是一个实际的标准数组类,是C++标准模板库的一部分
静态的是指不增长的数组,当创建array时就要初始化其大小,不可再改变
使用格式:
1
2
3
4
5
6
7
int main() {
std::array<int, 5> data; //定义,有两个参数,一个指定类型,一个指定大小
data[0] = 1;
data[4] = 10;
return 0;
}array和原生数组都是创建在栈上的(vector是在堆上创建底层数据储存的)
原生数组越界的时候不会报错,而array会有越界检查,会报错提醒。
使用std::array的好处是可以访问它的大小(通过size()函数),它是一个类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
void PrintArray(const std::array<int, 5>& data) //显式指定了大小
{
for (int i = 0;i < data.size();i++) //遍历数组
{
std::cout << data[i] << std::endl;
}
}
int main()
{
std::array<int, 5> data;
data[0] = 0;
data[1] = 1;
data[2] = 2;
data[3] = 3;
data[4] = 4;
PrintArray(data);
return 0;
}如何传入一个数组大小和所存数据类型未知的标准数组作为参数?
方法:使用模板
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
template <typename T>
void printarray(const T &data)
{
for (int i = 0; i < data.size(); i++)
{
std::cout << data[i] << std::endl;
}
}
template <typename T, unsigned long N> // or template <typename T, size_t N>
void printarray2(const std::array<T, N> &data)
{
for (int i = 0; i < N; i++)
{
std::cout << data[i] << std::endl;
}
}
int main()
{
std::array<int, 5> data;
data[0] = 2;
data[4] = 1;
printarray(data);
printarray2(data);
return 0;
}
【P58】C++的函数指针
函数实际上是CPU的指令,本质是二进制文件,可以获取这些二进制文件的地址,即函数指针
定义格式:data_types (*func_pointer) ( data_types arg1, data_types arg2, ..., data_types argn);
代码案例:
1 |
|
也可以用typedef或者using来使用函数指针:
1 |
|
实用场景:函数指针常见的用途之一是作为参数传递到其他函数里去
1 |
|
优化lambda
lambda本质上是一个普通的函数,只是它不像普通函数这样声明,它是我们的代码在编译过程中生成的,用完即弃的函数,不算一个真正的函数,是匿名函数
格式:[] ({形参表}) {函数内容}
1 | void ForEach(const std::vector<int>& values, void(*function)(int)) |
【P59】C++的lambda
lambda本质上是一个匿名函数,用这种方式创建函数不需要实际创建一个函数,在使用函数指针的时候可以考虑使用lambda表达式
lambda表达式的写法(使用格式):
[]( {参数表} ){ 函数体 }
中括号表示的是捕获,描述了上下文中哪些数据可以被lambda使用,以及使用的方式是传值还是传引用
如果使用捕获,则:
- 添加头文件:
#include <functional>
- 修改相应的函数签名
std::function <void(int)> func
替代void(*func)(int)
- 捕获[]使用方式:
[&]
以引用隐式捕获被使用的自动变量,即将所有变量值传递到lambda中[=]
以复制隐式捕获被使用的自动变量,将所有变量引用传递到lambda中[a]
是将变量a通过值传递,如果是[&a]
就是将变量a引用传递
它可以有0个或者多个捕获,详情参考:https://zh.cppreference.com/w/cpp/language/lambda
1 |
|
有一个可选修饰符mutable,它允许lambda函数体修改通过拷贝传递捕获的参数。若我们直接在lambda中给a赋值会报错,需要写上mutable ,使用案例如下:
1 | int a = 5; |
另一个使用lambda的场景:**find_if
**
我们还可以写一个lambda接受vector的整数元素,遍历这个vector找到比3大的整数,然后返回它的迭代器,也就是满足条件的第一个元素
find_if
是一个搜索类的函数,区别于find
的是:它可以接受一个函数指针来定义搜索的规则,返回满足这个规则的第一个元素的迭代器。这个情况就很适合lambda表达式的出场了
1 |
|
【P60】为什么我不使用using namespace std
1.不容易分辨各类函数的来源
比如我在一个自己的库中定义了一个vector,而标准库里又有一个vector,那么如果用了using namespace std 后,所用的vector到底是哪里的vector呢?
1 | std::vector<int>vec1; //good |
2.一定不要在头文件内使用using namespace std
如果别人用了你的头文件,就会把这些命名空间用在了你原本没有打算用的地方,会导致莫名其妙的产生bug,如果有大型项目,追踪起来会很困难。 如果公司有自己的模板库,然后里面有很多重名的类型或者函数,就容易弄混
3.可以就在一些小作用域里用,但能不用就不用!养成良好的代码书写习惯!
【P61】C++的命名空间
介绍:大型程序往往会使用多个独立开发的库,这些库会定义大量的全局名字,如类、函数和模板等,不可避免会出现某些名字相互冲突的情况。命名空间namespace
分割了全局命名空间,其中每个命名空间是一个作用域
命名空间是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
namespace apple {
void print(const char* text) {
std::cout << text << std::endl;
}
}
namespace orange {
void print(const char* text) {
std::string temp = text;
std::reverse(temp.begin(), temp.end());
std::cout << temp << std::endl;
}
}
int main()
{
//using namespace apple::print; //单独引用apple命名空间的一个成员函数print
//using namespace apple; //导入apple命名空间的所有成员
apple::print("hello"); //输出正常text
orange::print("world"); //输出反转的text
return 0;
}
1.全局命名空间
全局作用域中定义的名字(即在所有类、函数以及命名空间之外定义的名字)也就是定义在全局命名空间global namespace
中。全局作用域是隐式的,所以它并没有名字,下面的形式表示全局命名空间中一个成员:
1 | ::member_name |
2.嵌套的命名空间
1 | namespace foo { |
3.内联命名空间
C++11新标准引入了一种新的嵌套命名空间,称为内联命名空间inline namespace
。内联命名空间可以被外层命名空间直接使用。定义内联命名空间的方式是在关键字namespace
前添加关键字inline
第一次声明命名空间时使用了inline关键字,这叫显式内联。由于第一次声明命名空间时使用了
inline
关键字,后续相同的命名空间不再需要加inline关键字
,但是,这里声明的还是内联命名空间,这种情况成为隐式内联声明的命名空间同名,它们同属一个命名空间。这是C++命名空间一直都有的特性
使用场景:假设我们队类库进行了升级,同时又希望:
- 使用者代码不受影响,除非使用者自己想改
- 可以自由使用新类库的功能
- 如果有需要仍然可以使用原来的类库
1 | //探究内联命名空间的用法 |
4.未命名的命名空间
关键字namespace
后紧跟花括号括起来的一系列声明语句是未命名的命名空间unnamed namespace
。未命名的命名空间中定义的变量具有静态生命周期:它们在第一次使用前被创建,直到程序结束时才销毁。
Tips:每个文件定义自己的未命名的命名空间,如果两个文件都含有未命名的命名空间,则这两个空间互相无关。在这两个未命名的命名空间里面可以定义相同的名字,并且这些定义表示的是不同实体。如果一个头文件定义了未命名的命名空间,则该命名空间中定义的名字将在每个包含了该头文件的文件中对应不同实体。
和其他命名空间不同,未命名的命名空间仅在特定的文件内部有效,其作用范围不会横跨多个不同的文件。未命名的命名空间中定义的名字的作用域与该命名空间所在的作用域相同,如果未命名的命名空间定义在文件的最外层作用域中,则该命名空间一定要与全局作用域中的名字有所区别:
1 | // i的全局声明 |
未命名的命名空间取代文件中的静态声明:
在标准C++引入命名空间的概念之前,程序需要将名字声明成static
的以使其对于整个文件有效。在文件中进行静态声明的做法是从C语言继承而来的。在C语言中,声明为static
的全局实体在其所在的文件外不可见。 在文件中进行静态声明的做法已经被C++标准取消了,现在的做法是使用未命名的命名空间。
【P62】C++的线程
使用多线程,首先要添加头文件
#include <thread>
。在Linux平台下编译时需要加上**”-lpthread”链接库**
创建一个线程对象:**
std::thread objName (一个函数指针以及其他可选的任何参数)
**等待一个线程完成它的工作的方法 :
worker.join()
这里的线程名字是worker,换其他的也可以,自己决定的,调用
join()
的目的是:让主线程等待 工作线程 完成它所有的操作后,再继续执行主线程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
static bool s_Finished = false;
void DoWork() {
using namespace std::literals::chrono_literals; //导入命名空间 std::literals::chrono_literals
while (!s_Finished) {
std::cout << "Working..." << std::endl;
std::this_thread::sleep_for(1s); //等待一秒
//std::this_thread可以用来给当前线程下命令
}
}
int main()
{
//DoWork即是我们想要在另一个执行线程中发生的事情
std::thread worker(DoWork); //开启多线程操作,这里传入的是函数指针
std::cin.get(); //此时worker线程会循环打印,而主线程此时被cin.get()阻塞
s_Finished = true; //让worker线程终止的条件,如果按下回车,则会修改该值,间接影响到worker线程的工作
worker.join(); //join():等待工作线程结束后,才会执行接下来的操作
//std::cout...是join语句的下一行代码,所以在DoWork函数中的所有内容完成前,它都不会运行
std::cout << "Done" << std::endl;
return 0;
}如果是正常情况,
DoWork
应该会一直循环下去,但因为这里是多线程,所以可以在另一个线程中修改工作线程的变量,来停止该线程的循环。 多线程对于加速程序是十分有用的,线程的主要目的就是优化
【P63】C++的计时
1.使用chrono库
介绍:在C++11中,是标准模板库中与时间有关的头文件。该头文件中所有函数与类模板均定义在std::chrono
命名空间中
计时的使用很重要。在逐渐开始集成更多复杂的特性时,如果想编写性能良好的代码,需要用到计时来看到差异。计时有两种选择,一种是用平台特定的API,另一种是利用chrono库计时,此处推荐后者
在使用上,一个比较好的方法是自定义一个Timer类,在其构造函数里面记下开始时刻,在其析构函数里面记下结束时刻,并打印从构造到析构所用的时间。如此就可以用这样一个类来对一个作用域进行计时
获得当前时间:
1 | std::chrono::time_point<std::chrono::steady_clock> start = std::chrono::high_resolution_clock::now(); |
获得时间差:
1 | std::chrono::duration<float> duration = end - start; |
注意:在自定义计时器类的构造函数、析构函数中,不要使用auto关键字,应该在计时器类的构造函数、析构函数前定义start、end、duration变量
1 |
|
2.关于类模板std::chrono::time_point和duration_cast函数
时间点:
1 | template <class Clock, class Duration = typename Clock::duration> class time_point; |
类模板 std::chrono::time_point
表示时间中的一个点。它被实现成如同存储一个 Duration
类型的自 Clock
的纪元起始开始的时间间隔的值
Clock
必须满足时钟 (Clock) 的要求或为 std::chrono::local_t (C++20 起)
第一个模板参数Clock用来指定所要使用的时钟,在标准库中有三种时钟,分别为:
- system_clock:当前系统范围(即对各进程都一致)的一个实时的日历时钟(wallclock)
- steady_clock:当前系统实现的一个维定时钟,该时钟的每个时间嘀嗒单位是均匀的(即长度相等)。
- high_resolution_clock:当前系统实现的一个高分辨率时钟。
第二个模板参数用来表示时间的计量单位(特化的std::chrono::duration<> )
时间点都有一个时间戳,即时间原点。chrono库中采用的是Unix的时间戳1970年1月1日 00:00。所以time_point也就是距离时间戳(epoch)的时间长度(duration)
duration_cast
duration_cast()用于将时间间隔从一种表示方式转换为另一种表示方式(即在不同的时间单位之间进行转换)
使用案例如下:
小时转换为分钟/秒/毫秒/微秒
1 |
|
参考文档:标准库头文件 - C++中文 - API参考文档 (apiref.com)
英文版:Date and time utilities - cppreference.com
【P65】C++的排序
原型:
std::sort( vec.begin(), vec.end(), cmp)
cmp参数为排序准则,可以设置排序的规则。传入的函数可以是bool类型的内置函数,也可以是lambda表达式
默认是从小到大排序
使用内置函数,添加头文件functional,使用std::greater<>()
函数,则会按照从大到小顺序排列:
1 |
|
使用 lambda 进行灵活排序:
1 | std::sort(values.begin(), values.end(), [](int a, int b) |
对于已定的传入参数的顺序
[](int a, int b)
,如果要将参数a排在b前面,则返回true,如果将参数a排在b后面则返回false
1 | //a < b 若返回true,a排在前面。此时为升序排列(如果a小于b,那么a就排在b的前面) |
【P67】C++的联合体
union { };
,注意结尾有分号。- 通常union是匿名使用的,但是匿名union不能含有成员函数
- 在可以使用类型双关的时候,通过使用union实现的可读性更强
- union的特点是共用内存 。可以像使用结构体或者类一样使用它们,也可以给它添加静态函数或者普通函数、方法等待。然而不能使用虚方法,除此之外还有其他一些限制
使用案例:
1 |
|
输出:
1,2
3,4
-———————-
1,2
500,4
引自b站评论:union里的成员会共享内存,分配的大小是按最大成员的sizeof,视频里举的例子有两个成员,也就是那两个匿名的结构体,改变其中一个结构体里的变量,在另外一个结构体里面与之共享内存的变量也会改变。如果这两个成员是结构体struct{ int a,b} ;
和int k
, 如果k=2;
相对应的a=2
,而b不变。union我觉得在这种情况下很好用,就是用不同的结构表示同样的数据 ,那么你可以按照获取和修改他们的方式来定义你的 union结构,很方便
【P68】C++的虚析构函数
如果用基类指针来引用派生类对象,那么基类的析构函数必须标记为virtual,否则 C++ 只会调用基类的析构函数,不会调用派生类的析构函数
继承时,要养成的一个好习惯就是,基类析构函数中,加上virtual
为什么要调用派生类析构函数?
若派生类有一个成员int数组要在堆上分配内存,且是在构造函数中分配,在析构函数中释放内存,那么在运行该代码时,我们调用了派生类构造函数来分配一些内存,却无法调用派生类析构函数的delete
释放该内存的话,就会导致内存泄漏
代码案例:
1 |
|
【P72】C++的预编译头文件(PCH)
预编译头文件(PCH)是一种用来加速编译过程的技术。一个项目中有多个C++文件并且每个文件又包含多个头文件,考虑到每次对C++文件进行修改,整个文件都需要重新编译,所以通常会使用预编译头文件来避免头文件的重复预处理。
1.原理
它可以先将一些经常被用到的头文件进行预处理且只进行一次,然后生成一个二进制文件。在编译其他源文件时,便可以直接引用这个二进制文件,而不需要再次解析和处理这些头文件,避免了重复的预处理过程,从而提高了编译速度
2.使用限制
预编译头文件中的内容最好都是不需要反复修改更新的东西,每修改一次,预编译头文件都要重新编译一次,会导致编译速度降低。像C++标准库、windows的API这种不会大改的文件可以放到预编译头文件中,可以节省编译时间
3.缺点
预编译头文件的使用会隐藏掉这个cpp文件的依赖。比如在一个cpp文件中#include <vector>
,就清楚地知道这个cpp文件中需要vector的依赖,而如果放到预编译头文件中,就会将该信息隐藏
4.实现
创建预编译头文件:开发者需要先创建一个预编译头文件,该文件通常包含项目中的常用头文件。这个预编译头文件可以是一个单独的文件,也可以是一个包含多个头文件的文件
例子如下:
pch.h文件
1 |
|
pch.cpp
1 |
Main.cpp
1 |
|
右键打开pch.cpp文件的属性页并如图进行修改
打开项目的属性页并如图进行修改
然后右键项目选择生成
对比:
【P73】C++的dynamic_cast
dynamic_cast可以在继承体系里面向上、向下或者平级进行类型转换,常用于将基类的指针或引用安全地转换成派生类的指针或引用
使用时需要保证是多态,即基类里面含有虚函数
由于dynamic_cast使用了RTTI(运行时类型识别),所以会对性能增加负担
如果一条dynamic_cast语句的转换目标是指针类型并且失败了,则返回NULL
Tip:支持运行时类型识别(run-time type identification,RTTI)。dynamic_cast主要还是用于执行“安全的向下转型,至于“向上转型”(即派生类指针或引用类型转换为其基类类型),本身就是安全的,尽管可以使用dynamic_cast进行转换,但这是没必要的, 普通的转换已经可以达到目的,毕竟使用dynamic_cast是需要开销的
适用情况:我们想使用基类对象的指针或引用执行某个派生类操作并且该操作不是虚函数。一般来说,只要有可能我们应该尽量使用虚函数,使用RTTI有潜在风险,程序员必须清楚知道转换的目标类型并且必须检查类型转换是否被成功执行
代码案例
注:使用dynamic_cast就必须在编译器中启动RTTI,即“运行时期类型识别”。默认情况下编译器是不启动RTTI的。凡是程序中用到了RTTI(比如typeid)都要在编译器中打开RTTI,这样才能正确编译
1 |
|
【P74】C++的基准测试
写一个计时器对代码测试性能。在release模式下去测试,这样更有实际意义
该部分内容同P63涉及的知识点一致,所以这里直接上代码案例
代码案例:
1 |
|
【P75】C++的结构化绑定
结构化绑定structured binding是C++17的新特性,能让我们更好地处理多返回值。可以在将函数返回为tuple、pair、struct等结构体且赋值给另外的变量的时候,直接得到成员,而不是结构体
在P52有涉及到如何处理多返回值,当时是用结构体去处理,而这个结构化绑定就是在这个的基础上拓展的一种新方法,特别是处理元组(tuple),对组(pair)以及返回诸如此类的结构体
注:确保在项目属性-C/C++-语言-C++语言标准里面选择C++17或者C++20
1 |
|
【P76】如何处理OPTIONAL数据
在读取文件内容的时候,往往需要判断读取是否成功,常用的方法是传入一个引用变量或者判断返回的std::string
是否为空,C++17在 STL 中引入了一个更好的方法:std::optional,它用来管理一个可选的容纳值(一个可能存在也可能不存在的数据)
注:确保在项目属性-C/C++-语言-C++语言标准里面选择C++17或者C++20
1.std::optional
std::optional
是一种“和类型(sum type)”,也就是说,std::optional
类型的变量要么是一个T
类型的变量,要么是一个表示“什么都没有”的状态
2.has_value()
我们可以通过has_value()来判断对应的optional对像是否处于已经设置值的状态,如:
1 | if(data.has_value()) {} |
3.访问optional对象中的数据
我们可以通过value(), value_or()来获取optional对象中存储的值, **value_or()**可以允许传入一个默认值, 如果optional对象为std::nullopt(无值状态), 则直接返回传入的默认值
1 |
|
【P77】单一变量存放多种类型的数据
类模板 std::variant
表示一个类型安全的联合体,是C++17的新特性,是一种可以容纳多种类型变量的结构体
1.std::variant
和optional很像,它的作用是让我们不用担心处理确切的数据类型,且只有一个变量,所以我们需要做的就是定义一个variant对象,然后列出它所有可能的数据类型。
比如在解析一个文件时,我们可能不确定读取的数据是一个字符串还是整数,又或者是在程序运行时接受一个命令行参数,而我们不确定该参数是什么数据类型,这时
std::variant
允许我们列出所有可能的数据类型,然后再由我们决定是什么数据类型
2.与union的区别
技术上讲,union更有效率,而variant更加类型安全,不会造成未定义行为,所以在可以自由使用更多内存的情况下,应当去使用它,除非做的是底层优化,非常需要性能
3.std::variant的运用
- 1)简单运用
1 |
|
- 2)index()索引
1 | //std::variant的index函数 |
- 3)get_if()
1 |
|
4)std::variant和std::optional
std::optional
能允许我们有一些不存在的数据,但当我们需要知道程序哪里出现问题,而不仅仅只是知道数据不存在的时候,我们便选择std::variant
,例子如下:
1 | //如果我们成功获取数据,则用字符串作为值,否则设置一些错误编码,以此作为返回值 |
【P80】如何让C++字符串更快
内存分配建议:能分配在栈上就别分配到堆上,因为把内存分配到堆上会降低程序的速度
string的常用优化:SSO(短字符串优化)、COW(写时复制技术优化)
1.为何优化字符串
1)
std::string
和它的很多函数都喜欢分配在堆上,这实际上并不理想 。2)一般处理字符串时,比如使用
substr()
切割字符串,这个函数会自己处理完原字符串后创建出一个全新的字符串,它可以变换并有自己的内存(new,堆上创建)。3)在数据传递中减少拷贝是提高性能的最常用办法。在C中指针是完成这一目的的标准数据结构,而在C++中引入了安全性更高的引用类型,所以在C++中若传递的数据仅仅可读,
const string&
便成了C++合适的方式。但这并非完美,从实践上来看,它至少有以下几方面问题:
字符串字面值、字符数组、字符串指针的传递依然要数据拷贝
这三类低级数据类型与string类型不同,传入时编译器要做隐式转换,即需要拷贝这些数据生成string临时对象。
const string&
指向的实际上是这个临时对象。通常字符串字面值较小,性能损失可以忽略不计;但字符串指针和字符数组某些情况下可能会比较大(比如读取文件的内容),此时会引起频繁的内存分配和数据拷贝,影响程序性能substr()
substr()
是个常用的函数,美中不足的时每次都要返回一个新生成的子串,很容易引起性能热点。实际上我们本意不是要改变原字符串,为什么不在原字符串基础上返回呢?
使用substr()
的代码案例:
1 |
|
运行结果:
Allocating 8 bytes
Allocating 8 bytes
Allocating 8 bytes
Yan
3 allocations
2.通过string_view优化字符串
介绍: std::string_view
是C++ 17标准中新加入的类,正如其名,它提供一个字符串的视图,即可以通过这个类以各种方法“观测”字符串,但不允许修改字符串。由于它只读的特性,它并不真正持有这个字符串的拷贝,而是与相对应的字符串共享这一空间。即——构造时不发生字符串的复制。同时,你也可以自由地移动这个视图,移动视图并不会移动原定的字符串。
通过调用 string_view构造器可将std::string
转换为std::string_view
对象
Tip:想了解更多关于C/C++的预处理请点击关于C/C++中的预处理 - 知乎 (zhihu.com)
代码案例:
1 |
|
运行结果:
//有#define STRING_VIEW 1
Allocating 8 bytes
Dylan
Yan
Chernikov
1 allocations//无#define STRING_VIEW 1
Allocating 8 bytes
Allocating 8 bytes
Allocating 8 bytes
Allocating 8 bytes
Dylan
Yan
Cherniko
4 allocations
可见,使用string_view减少了内存在堆上的分配
进一步优化:使用C风格字符串
1 | int main() |
运行结果:
Dylan
Yan
Chernikov
0 allocations
【P82】C++的单例模式
Singleton类只允许被实例化一次,用于组织一系列全局的函数或者变量,与namespace很像。例子:生成随机数的类、渲染器类
C++中的单例只是一种组织一堆全局变量和静态函数的方法
什么时候用单例模式:
当我们想要拥有应用于某种全局数据集的功能,且我们只是想要重复使用时,单例是非常有用的
有些单例的例子,比如一个随机数生成器类,我们只希望能够通过它得到一个随机数,我们只想实例化它一次(单例)来生成随机数生成器的种子,并建立起它所需要的所有辅助性工具。另一个例子就是渲染器,渲染器通常是一个全局性的东西,我们通常不会有一个渲染器的多个实例,在现实工作中,我们只有一个渲染器的实例,我们可以向它提出所有的渲染命令,然后它就会为我们渲染
实现单例的基本方法:
1)构造函数私有化,使得外部无法创建Singleton对象
2)提供一个静态访问该类的方法
设一个私有的静态的实例,并且在类外将其定义! 然后用一个静态函数返回其引用or指针,便可正常使用了
3)为了安全,标记拷贝构造函数为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
class Random {
public:
Random(const Random&) = delete; //删除拷贝复制函数
Random& operator=(const Random&) = delete; //关闭赋值运算符
static Random& getInstance() { //通过getInstance函数来获取唯一的一个实例
static Random Instance; //在此处实例化一次
return Instance;
}
static float Float() { return getInstance().IFloat(); } //调用内部函数,可用类名调用
private:
Random() {} //构造函数私有化,使得外部无法创建实例对象
std::default_random_engine e;
float m_RandomGenerator = e();
float IFloat() { return m_RandomGenerator; } //将函数实现放进private
};
int main()
{
Random& r = Random::getInstance();
float number = r.Float();
//or float number = Random::Float();
std::cout << "number:" << number << std::endl;
return 0;
}
【P83】C++的小字符串优化
VS开发工具在Release模式下面 (Debug模式都会在堆上分配) ,使用size小于16的string,不会分配内存,而大于等于16的string,则会分配32bytes内存以及更多,所以16个字符是一个分界线 (注:不同编译器可能会有所不同)
std::string
类型本身只占8字节。std::string
内部里的成员变量有一个指向字符串的指针(32位编译模式下占4字节)和一个储存字符串长度的int类型(占4字节),所以创建一个std::string
的对象时会在堆上分配8字节内存在Debug模式下,创建一个大于等于16的
std::string
变量(本质上是个对象)时,会先为string变量在堆上分配8字节内存,然后再为字符串在堆上分配内存(大于等于32bytes),然后std::string
对象里的指针便会指向该内存地址注:指针在32位编译模式下占4字节,在64位编译模式下占8字节
1 |
|
【P84】跟踪内存分配的简单方法
重写new和delete操作符函数,并在里面打印分配和释放了多少内存,也可在重载的这两个函数里面设置断点,通过查看调用栈即可知道什么地方分配或者释放了内存
一个class的new是分为三步:operator new(其内部调用malloc)返回void*、static_cast转换为这个对象指针、构造函数。而delete则分为两步:构造函数、operator delete。new和delete都是表达式,是不能重载的,而把他们行为往下分解则是有operator new和operator delete,是有区别的
直接用的表达式的行为是不能变的,不能重载,即new分解成上图的三步与delete分解成上图的两步是不能重载的。这里内部的operator new和operator delete底层其实是调用的malloc,这些内部的几步则是可以重载的
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
// 写这个函数意思是不用standard library的operator new
// 链接器实际上就会改为链接这个函数
void* operator new(size_t size) {
std::cout << "Allowcated " << size << " bytes\n"; //若在堆上分配内存则会打印此句
return malloc(size);
}
void operator delete(void* memory, size_t size) {
std::cout << "Freeing " << size << " bytes\n";
free(memory);
}
struct Object {
int x, y, z;
};
int main()
{
{
std::unique_ptr<Object> obj = std::make_unique<Object>();
}
std::string text1 = "Dylan";
return 0;
}还可以写一个简单统计内存分配的类,在每次new时统计分配内存,在每次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
36
37
38
39
40
41
42
43
44
45
46
47
struct AllocationMetrics {
uint32_t TotalAllocated = 0; //总分配内存
uint32_t TotalFreed = 0; //总释放内存
uint32_t CurrentUsage() { return TotalAllocated - TotalFreed; } //写一个小函数来输出当前在堆上用了多少内存
};
static AllocationMetrics s_AllocationMetrics; //创建一个全局静态实例
// 写这个函数意思是不用standard library的operator new
// 链接器实际上就会改为链接这个函数
void* operator new(size_t size) {
s_AllocationMetrics.TotalAllocated += size;
return malloc(size);
}
void operator delete(void* memory, size_t size) {
s_AllocationMetrics.TotalFreed += size;
free(memory);
}
struct Object {
int x, y, z;
};
//用一个函数输出我们的内存使用情况
static void PrintMemoryUsage() {
std::cout << "Memory Usage: " << s_AllocationMetrics.CurrentUsage() << " bytes\n";
}
int main()
{
PrintMemoryUsage();
std::string text1 = "Dylan";
PrintMemoryUsage();
{
std::unique_ptr<Object> obj = std::make_unique<Object>();
PrintMemoryUsage();
}
PrintMemoryUsage();
return 0;
}