引言
这个系列将讲解C++需知道的一切内容,涵盖这门语言的基础知识。本节将深入讲解C++的标准模版库以及内部运行机制。
对象生存周期
今天我们要简单介绍对象的生存期、内存以及如何在栈上生存。
关于生存期对于基于栈的变量意味着什么?这可以分为两部分,第一部分是你必须理解栈上的东西是如何存在的,这样你才能真正写出不会崩溃的代码,能正常工作的代码;第二部分是一旦你知道了它是如何运作的,那要如何利用好它做我想做的事情,想出聪明的办法来做事情。今天我们会看一些例子来说明我的意思。
首先我们要了解栈的概念。栈可以被认为是一种数据结构,你可以在上面堆叠一些东西,假设你的桌子上有一堆书,为了访问中间的一个,你得先把前面几个拿掉然后找到中间那本书。当然,在现实生活中你可以直接把它拔出来,但这不是栈在编程中的工作方式。
所以每次我们在 C++ 中进入一个作用域,我们是在 push 栈帧,它不一定是非得是将数据推进(push进)一个栈帧。你可以把它想成是把一本书放进书堆上,你在此作用域下(这本书内)声明的变量就像在你的书里写东西,一旦你的作用域结束,你将这本书从书堆中拿出来扔掉了,你在书里声明的每一个基于栈的变量、你在书中栈里创造的所有对象都消失了。
这既是祝福也是诅咒,但如果你知道自己在做什么,那么很显然百分百是件好事。所以我要展示一些例子,让你们知道这一切是如何结合在一起的,以及这一切是如何运作的。
首先,让我们来谈谈作用域。作用域可以是任何东西,比如函数作用域、if
语句作用域、for
或 while
循环作用域,或者空作用域:
|
我们还有类作用域,这意味着当我声明一个像 Entity
这样的类时这里有一个栈中初始化的变量,不是在堆上分配的。这个变量也在这个类的作用域中,这意味着当这个类消失时,变量也会消失:
|
让我们来看看实际情况,我们写一个简单的 Entity
类:
class Entity { |
所以现在我们在构造函数中创造 Entity
,在析构函数中销毁 Entity
。回到主函数,在空作用域中声明 Entity e
,这样它就不会创建在堆上,而是创建在栈上:
int main(){ |
这将调用默认构造函数。在中间设置断点,观察控制台打印情况:
Created Entity!
被打印到控制台。此时我们已经在作用域的最后,继续调试:
我们正在调用析构函数销毁 Entity
。很明显,这些内存已经被释放了。如果我要对它进行堆分配:
int main(){ |
再次调试,你可以看到我们的 Entity
永远不会被销毁。当然,当应用程序终止时,操作系统会清除这些内存。
所以很清楚,你应该看到基于栈的变量和基于堆的变量在对象生存期上的区别。基于栈的变量在我们一出作用域就被释放、被销毁了,这就是本节的重点,我只是想让你们记住,如果你在栈上声明某个东西创建一个变量,当它超出范围就会消失。
现在,有了这方面知识,让我们来看看一些你常常会做的事情。一个很好的例子是我想在函数中创建一个数组,也许是整数数组或者返回一个 int
型指针:
int* createArray() { |
这看起来非常合理,先是创建一个数组,然后返回指向该数组的指针,看起来没有问题。不,你完全错了,让我们看看为什么。
通过创建一个这样的数组,我们没有在堆上分配它,只是在栈上声明它。当我们返回一个指向它的指针时,它返回一个指向栈内存的指针。一旦我们离开作用域,这个栈内存就会被清除。
如果你想写一个这样的函数,你基本上有两个选择:你可以让这个数组在堆上分配,从而确保它的生存期会一直存在;或者你可以将这里创建的数据赋值给一个在栈作用域之外存在的变量,举个例子:
+void createArray(int* array) { |
在局部创建数组是一个典型的错误,我经常看见人们创建一个基于栈的变量,然后尝试返回它的指针。一旦函数结束,你就超出了作用域,这些变量就嗝屁了。
既然这种栈上变量会自动销毁,那有没有办法让它变得有用呢?有没有一种办法我们可以利用它,把它用于好的方面?
答案是是的,这在很多方面都非常有用,可以帮助我们自动化代码。我们可以用它来做的一件事就是比如类的作用域,像智能指针或作用域锁,很多例子我们以后会讲到,但最简单的例子可能是作用域指针。它基本上是一个类,一个指针的包装器,在构造时用堆分配指针,然后在析构时删除指针,所以我们可以自动化这个 new
和 delete
,让我们看看如何编写这样的类:
class scopedPtr { |
所以这个 Entity
我仍然想在堆上分配,然而我想在超出作用域之后自动删除,我们能做到吗?答案是肯定的,我们可以使用标准库中的 unique_ptr
,这是一个作用域指针,但是这个例子我们写一个 scopedPtr
类以便看到它是如何工作的。让我们看看如何使用它:
int main(){ |
这看起来是一样的代码,但不同的是一旦我们超出了作用域,它就会被销毁,因为 scopedPtr
类的对象是在栈上被分配的,这意味着 e
如果被删除了,会调用析构函数中的 delete
被包装的指针。让我们设置断点检查一下:
这是一个很好的例子,因为这是 unique_ptr
做的最基本的事情。这种基于栈的变量自动构造,自动析构是非常有用的。
智能指针
今天我们将专注于智能指针是什么,它能为你做什么。
我们讲了 new
在堆上分配内存,需要 delete
来释放内存,智能指针是实现这一过程自动化的一种方式,当你调用 new
时不需要调用 delete
。在很多情况下使用智能指针,我们甚至不需要调用 new
。所以很多人都倾向于这种 C++ 编程风格,他们从不调用 new
或 delete
,智能指针是实现这样的一种方法。
智能指针本质上是一个原始指针的包装。当你创建一个智能指针,它会调用 new
并为你分配内存,然后基于你使用的智能指针,这些内存会在某一时刻自动释放。让我们来看第一个,也是最简单的智能指针 unique_ptr
。
unique_ptr
unique_ptr
是作用域指针,是超出作用域时它会被销毁,然后调用 delete
。为啥叫 unique 指针呢?因为它必须是唯一的吗?你不能复制一个 unique_ptr
,因为如果你复制一个 unique_ptr
,那么你会有两个指针,两个 unique_ptr
指向同一个内存块。如果其中一个死了,它会释放那块内存,也就是说你指向同一块内存的第二个 unique_ptr
指向了已经被释放的内存,所以你不能复制 unique_ptr
。
unique_ptr
时你想要一个作用域指针的时候,它是你唯一的参考。让我们来看一个 unique_ptr
的例子:
|
要访问所有这些智能指针,你首先要做的是包括 memory
头文件。这里我们的 Entity
类只包含一个构造函数和一个析构函数。
在主函数这里,如果我想在特定的作用域下创建一个 unique_ptr
,需要创建一个新的空作用域并用 unique_ptr
来分配 Entity
:
int main(){ |
这里不能用 new Entity()
这样的构造函数,因为 unique_ptr
的构造函数实际上是 explict
的,需要显式调用构造函数,没有构造函数的隐式转换。这就是使用 unique_ptr
的方式之一,然后你可以像访问任何东西一样访问它:
#include <iostream> |
然而,构造 Entity
另一种更好的办法是把这个 Entity
赋值给 std::make_unique
:
int main(){ |
这对于 unique_ptr
很重要,主要原因是出于异常安全。在之后会有单独的一章讲异常处理,但不管怎么说,最好的方式是调用 make_unique
,因为如果构造函数恰巧抛出异常,它会稍微安全一些,我们最终不会得到一个没有引用的空指针,从而造成内存泄露。
一旦我们得到了这个 unique_ptr
,我们就可以调用任何我们想要的方法。设置断点观察 Entity
的生存周期:
作用域结束时,我们的 Entity
会被自动销毁,这是最简单的智能指针。它是非常有用的,开销很低,甚至没有开销,只是一个栈分配对象,当栈分配对象死亡时,它将调用 delete
在你的指针上,并释放内存。
问题是,如果你想复制或分享这个指针,使这个指针可以被传递到一个函数中或者另一个类中,你会遇到一个问题,因为你不能复制它。如果我尝试在这里做另一个 unique_ptr
叫做 e0
或者类似的东西,赋值为 Entity
,会得到一个错误信息:
如果你去看 unique_ptr
的定义,可以看到拷贝构造函数和拷贝构造操作符实际上都被删除了:
这就是为什么你会得到一个编译错误。那是专门用来防止你把自己挖进坟墓的,因为你不能复制这个,当某一个 unique_ptr
死后,它们都会死,因为这个堆分配对象的底层内存会被释放。
shared_ptr
如果你喜欢分享,这就是 shared_ptr
。shared_ptr
有点不同,它更牛逼一点,因为它还在底层做了很多其他的事情。shared_ptr
实现的方式实际上取决于编译器和你在编译器中使用的标准库,然而在我所见过的所有系统中它使用的是引用计数。
shared_ptr
的工作方式是通过引用计数。引用计数基本上是一种可以跟踪你的指针有多少个引用的方法,一旦引用计数达到零,它就被删除了。举个例子,我创建了一个共享指针 shared_ptr
,再创建另一个 shared_ptr
来复制它,我的引用计数是2。当第一个死的时候,我的引用计数器减少1,然后当最后一个 shared_ptr
死了,我的引用计数回到零,我就死了,内存被释放。
所以如果你使用共享指针 shared_ptr
,可以这样写:
std::shared_ptr<Entity> sharedEntity = std::make_shared<Entity>(); |
在这种情况下,你也可以使用 new Entity()
,编译得很好,但你绝对不想将 shared_ptr
这样用。在 unique_ptr
中,不直接调用 new
的原因是因为异常安全,但是 shared_ptr
有所不同,因为它需要分配另一块叫做控制块的内存,用于存储引用计数。如果你先创建一个 new Entity()
,然后将其传递给 shared_ptr
构造函数,它必须做两次内存分配:先做一次 new Entity()
的分配,然后是控制内存块的分配。而如果你使用 make_shared
,你能把它们组合起来,这样更有效率。而且对于那些讨厌 new
和 delete
的人,显然会从你的代码库中删除 new
关键字,因为他们会使用 std::shared_ptr
而不是 new
,我打赌你们一定会喜欢。
所以有了共享指针,你当然可以进行复制:
int main(){ |
设置断点观察生存周期:
当第一个作用域(里面的括号)死亡时,这个 shared-ptr
死掉了。然而它并没有析构我的 Entity
,因为 e0
仍然是活的,并且持有对该 Entity
的引用。当退出最后一个作用域(外面的括号),它就死亡了,所以的引用都消失了,这就是你的底层 Entity
被删除的时候。
weak_ptr
好吧,还有一个东西你可以和 shared_ptr
一起使用:weak_ptr
。你可以用它来做什么?它只是像声明其他东西一样声明,可以给它赋值为 sharedEntity
:
int main(){ |
这里所做的和之前复制 sharedEntity
所做的一样,但之前会增加引用计数,而这里不会。当你将一个 shared_ptr
赋值给另一个 shared_ptr
,它会增加引用计数,但你把一个 shared_ptr
赋值给一个 weak_ptr
时,它不会增加引用计数。
如果你不想要 Entity
的所有权,就像你可能在排序一个 Entity
列表,你不关心它们是否有效,你只需要存储它们的一个引用就可以了。关于 weak_ptr
你可能会问,底层的对象还活着,可以做任何想做的事,但是它不会让底层对象保持存活,因为它实际上不会增加引用计数。
如果我们把这个 shared_ptr
换成一个 weak_ptr
,然后做我之前做过的事情:
int main(){ |
![](https://cdn.jsdelivr.net/gh/Yousazoe/picgo-repo/img/截屏2021-07-20 下午6.10.36.png)
![](https://cdn.jsdelivr.net/gh/Yousazoe/picgo-repo/img/截屏2021-07-20 下午6.11.15.png)
现在这个 weak_ptr
指向的是一个无效的 Entity
,然而你可以问一个弱指针:“你过期了吗?你还有效吗?”这是很聪明的指针。
至于我们什么时候应该使用它们,你应该试着一直使用它们。说实话,它们会使你的内存管理自动化,它们防止你因为忘记调用 delete
而意外泄漏内存,它们真的很有用。shared_ptr
是有一点开销的,因为它的引用计数系统,但话又说回来,很多人倾向于编写自己的内存管理系统,也一样会有一些开销。
所以这是一个非常微妙的话题,因为 C++ 的新一代程序使用这些功能,但还有很多人使用 new
和 delete
。我两者都用,因为总有时候你可能想用 unique_ptr
和 shared_ptr
,但也有需要 new
和 delete
的时候,所以我不认为智能指针已经完全取代了 new
和 delete
。
当你要声明一个堆分配的对象,并且你不希望自己来清理,因为你不想显式调用 delete
或显式管理内存时,你就应该使用智能指针,尽量使用 unique_ptr
因为它有一个较低的开销。但如果你需要在对象之间共享,不能使用 unique_ptr
的时候,就使用 shared_ptr
。
拷贝构造函数
今天我们要讨论的是拷贝以及拷贝构造函数。
拷贝指的是要求复制数据,赋值内存。当我们想要把一个对象或原语或一段数据从一个地方复制到另一个地方时,我们实际上有两个副本。大多数时候我们想要复制对象以某种方式修改它们,当我们只是想读取或要修改一个已经存在的对象的时候可以避免不必要的复制,我们当然想这样做(不要复制),因为复制需要时间。
所以一方面,拷贝复制是非常有用的东西,可以让程序按照我们想要的方式工作;但另一方面,不必要的复制是不好的,我们想尽量避免这种情况,因为这会浪费性能。在 C++ 中理解复制是如何工作的、如何让它工作以及如何避免让它工作对于理解语言以及能够高效正确地编写 C++ 代码非常重要。
为了高效演示这些,我要写一个完整的例子:字符串类。我会讲到复制如何产生这种效果的,以及当我们不想复制的时候,我们可以做什么来移除复制,包括如果我们要添加复制,如何正确的复制。让我们跳进代码,看看这个:
|
如果我声明一个变量 a
,然后声明变量 b
并赋值给 a
,我实际上做的是创建一个副本。a
和 b
是两个独立变量,它们有不同的内存地址,由于这个原因如果 b = 3
,a
仍然是2,我的内存中有两个不同的值。
在类中是同样的道理:
|
如果我有一个 Vector2
类,此时把 b.x
设置为5,a.x
仍然是2,因为我复制的是值,是把 a
的值给了 b
,就像刚才整数的例子一样,它们是两个独立的变量,占用了两个不同的内存地址。
现在,我们要在堆中使用 new
关键字来进行分配:
int main(){ |
情况完全不同了,现在的 Vector2
是一个指针。我是在复制一些东西,但我没有复制包含 x
和 y
的实际向量,复制的是指针。现在我有两个指针,它们本质上有相同的值。
此时如果我 b++
修改指针,我的 a
指针仍然是完整的。但是如果我访问这个内存地址设置为某个值,在这个情况下是会同时影响 a
和 b
:
int main(){ |
我在这里做的不是影响指针,而是影响内存地址。这是很重要的一点,当你写等号的时候使用复制操作符将一个变量设置为另一个变量时,你总是在复制值。其实你不能重新分配引用,当你复制引用时把一个引用赋值给另一个引用,你实际上是在改变指向,因为引用只是别名(并没有复制)。
所以引用除外,每当你编写一个变量被赋值另一个变量的代码时,你总是在复制。在指针的情况下,你在复制指针,也就是内存地址,而不是指针指向的实际内存。
考虑到这一点,我们来写一个字符串类,看看我们怎样才能使它具有可复制性,以及我们可能会面临哪些挑战。现在我要用一种 C++ 非常原始的方式来写这个 String
类:
class String { |
字符串是由一组字符组成的,我要做的第一件事是放置一个字符数组 buffer
,这将指向我的字符缓冲区,然后用 size
来保存大小。
接着是构造函数,首先要计算这个字符串有多长,这样我们就可以把这个字符串的数据复制到缓冲区中。这里除了 for
循环,也可以使用 memcpy(dst, src, size)
,其中 dst
为目的、src
为来源、size
为大小:
class String { |
我们实际上还需要空终止符,但我故意不写,这样你们就能看到发生了什么。
现在让我们写可以打印字符串的东西,用它来打印字符串。我想使用 std::cout
,所以我要做的是重载左移运算符。这里有一个小问题是无法获取私有成员变量 buffer
,我们可以将这个运算符重载函数作为这个类的友元从而访问。
回到主函数,我们设置为 Yousazoe
然后像这样把它放入 cout
:
int main(){ |
运行结果没有问题,可能是恰好后面是空终止符。调试查看内存,果然如此:
为了安全起见,我们还是加上空终止符:
class String { |
好了,现在我们有了一个基本的设置,一切看起来都很好。但实际上我们这里发生了内存泄漏:
buffer = new char[size + 1]; |
我们没有用 delete
,当然如果你用智能指针或者 vector
你是不需要 delete
的,但我们使用 new
关键字分配原始数组:
class String { |
浅拷贝
好的,现在回到主函数把它们都打印出来:
int main(){ |
运行可以看到 Yousazoe
被打印了两次,这正是我们所期望的。一切似乎没有问题,回车代码执行完 cin.get()
之后出现问题:
代码崩溃了,如果你看看调用堆栈似乎也很难看出发生了什么。发生了什么事终止了我的程序?
我们在这里做的是复制这个 String
,C++ 自动为我们做的是将所有类成员变量复制到一个新的内存地址里。现在问题来了,内存中有两个 String
,因为它们直接进行了复制所以这种复制被称为“浅拷贝”,它所做的是复制这个指针,内存中的两个 String
对象有相同的 char*
值。换句话说,这个 buffer
的内存地址对于两个 String
对象是相同的,当我们到达作用域尽头两个对象都被销毁了,析构函数被调用了两次 delete
程序会崩溃,程序试图两次释放同一个内存块。
这就是为什么我们会崩溃,因为内存已经释放了,已经不是我们的了,我们无法再次释放它。
也许有一个更好的例子来证明两者是一样的。假设我们想要修改 second
字符串:
int main(){ |
当然为了让 []
操作符起作用,我们需要写重载函数:
char& operator[](unsigned int index) { |
让我们运行一下试试:
结果是两个同样的 Yoasazoe
,为什么会这样?看起来我们复制了,其实我们并没有完全复制。我们真正需要做的是,分配一个新的 char
数组来存储复制的字符串,而我们现在做的是复制指针,两个字符串对象指向完全相同的内存缓冲区。也就是说,当我们想要改变其中一个的时候,它同时改变了它们:因为它们指向同一个内存块;或者当我们删除其中一个时,它会把它们两个都删除:因为它们指向同一个内存块。
深拷贝
我们想要复制内存,希望第二个字符串拥有自己的指针以拥有自己唯一的内存块。当我们修改或删除第二个字符串时,它不会触及第一个字符串,反之亦然。我们能做到这一点的方法,是执行一种叫做“深拷贝”的东西,也就是说我们实际上复制了整个对象,不是我们在上面看到的那种浅拷贝,浅拷贝不会去到指针的内容或指针所指向的地方,也不回去复制它。
那么我们如何执行深拷贝呢?当然我们可以写一个克隆的方法或函数,然后让它返回一个新字符串。但我们不使用这种方法,我们的方法是:拷贝构造函数。
拷贝构造函数是一个构造函数,当你复制第二个字符串时它会被调用;当你试图创建一个新的变量并给它分配另一个变量,这个变量和你正在创建的变量有相同的类型。
让我们来写一个拷贝构造函数:
class String { |
C++ 在默认情况下会给你提供一个拷贝构造函数,你可以使用拷贝构造函数做几件事:拷贝构造函数的定义与声明。
回到主函数,运行程序:
注意的是现在我有 Yousazoe
和 Yoasazoe
两个字,这意味着当我们改变第二个字符串时它没有改变第一个字符串。而当我按下回车,程序成功终止,没有崩溃。
如果你决定写一个打印函数 printString()
调用 std::cout
替代主函数中直接使用:
+void printString(String string) { |
运行程序:
你可以看到我们有三个 String
的复制,有些荒谬,因为我们不需要做这些复制。当我们每次复制一个字符串时,我们在堆上分配内存,复制所有内存,最后释放内存,这完全没有必要。
我们真正想做的是将现有的字符串直接进入这个 printString()
函数,我们不需要复制它,可以直接引用现有的 String
:
+void printString(const String& string) { |
我想告诉你的是,总是通过 const
引用去传递对象。我们以后会深入地讨论它的优化,因为在某些情况下,复制可能更快,但无论如何在基础使用中,const
引用更好。
箭头操作符
今天我们讨论 C++ 中的箭头操作符,包括对结构体与类的指针可以做什么、实现我们自己的运算符重载,来看看那它时如何运作的。
操作符
这里是我的源码,基本的 Entity
类:
|
因为某种原因,但现在我们发现不能用 e.Print()
调用函数:
-e.Print(); |
因为这只是一个指针,也就是一个数值不是对象,怎么能这样调用。我们实际上需要逆向引用 *ptr
,可以这么做:
int main(){ |
我还可以直接用我的指针,先进行逆向引用再调用函数:
int main(){ |
这也是可以的,它工作得很好,但它看起来有点笨重。所以我们能做的是使用箭头操作符,而不是逆向引用指针然后调用。我们可以用一个箭头来替换所有这些:
int main(){ |
本来我们需要手动去逆向引用调用我们的函数或变量,现在不需要那么做了,一个箭头就可以搞定。变量也同样适用:
#include <iostream> |
ok,这基本上是箭头操作符的默认用法了,也是你使用它90%的情况。然而,作为一个运算符 C++ 实际上可以重载它,并在你自己的自定义类中使用它,我会给大家举个例子,来说明为什么要这么做以及怎么做。
重载
假设我有一个智能指针的类:
|
这方法看起来太乱了,我希望能够像使用堆分配的 Entity
一样使用它。这就是为什么你要重载箭头运算符,让它为你做这个事情:
#include <iostream> |
这就是重载箭头运算符的方法,对于你自己类中的函数,这是非常强大有用的,因为你可以看到你可以在你自己的类型中定义自己的构造函数并实现自动化,它看起来像是普通代码,这正是我们想要的。
很多人会说这有点让人困惑,因为可能看起来像普通代码,但它不是。然而我认为如果你正确使用它,那么这个真的很有用,可以让你的代码保持干净。
偏移量
最后我再给你们演示一种方法,如何实际使用箭头操作符来获取内存中某个成员变量的偏移量。假设我们有一个 Vector3
的结构体:
|
假设我想要找出这个变量 y
在内存中的偏移量。我们知道这个结构体是由浮点数构成的,每一个有四字节,所以 x
的偏移量是0,因为在结构体的第一项;y
的偏移量是4个字节;而 z
的偏移量则是8个字节。
但如果我突然移动这个会发生什么?
struct Vector3 { |
在类中,它们的工作方式是一样的,但在内存中会有不同的布局。也许我想写一些东西来告诉我这些成员的偏移量,可以用箭头运算符做一些类似的事情。
我想做的事访问这些变量,但不是通过有效的内存地址,地址从0(这里也可以写成 nullptr
)开始:
0; |
然后把它转换成一个 Vector3
指针:
(Vector3*)0; |
然后用箭头来访问 x
:
((Vector3*)0)->x; |
这将会得到这些内存的布局,我要做的是取这个 x
的内存地址,然后得到这个 x
的偏移量;
&((Vector3*)0)->x; |
最后把它转换成 int
类型,打印出来:
int main(){ |
所以我们在这里做的是使用箭头运算符来获取内存中某个值的偏移量。这非常有用,当你把数据序列化为一串字节流时,当你想要计算某些东西的偏移量时,当我们开始做图形编程、游戏引擎系列的时候,我们会接触这种令人兴奋的代码,因为我们总是要处理字节流。
动态数组
今天我们将讨论 C++ 动态数组,特别是标准库中的 vector
类(std::vector
)。
现在我们终于开始写一些 C++ 标准库的东西了,它非常重要。标准模板库本质上是一个库,里面装满了容器、容器类型,这些容器包含特定的数据。之所以成为标准模板库,因为它可以模板化任何东西,整个库模板化意味着容器的底层数据类型实际上由你决定,所有东西由模板组成。基本上,你需要知道的就是如何使用它们。要使用标准模板库,你不需要了解模板,你只需要知道模板可以处理你提供的底层数据类型,这非常酷,用处很多,这意味着你不需要编写自己的数据结构或类似的东西。
所以 C++ 在 std
命名空间中提供给我们一个叫做 vector
的类。第一个问题是它为什么叫做 vector
?这背后其实有一个故事,在下面的描述页面上有一个文章讨论了这个问题:
- Why is it called “vector”? ► https://stackoverflow.com/questions/5...
它其实不应该被称为向量,它应该被称为 ArrayList
,因为这更有意义,它本质上是一个动态数组,不是向量。vector
是一个很奇怪的名字,很多初学 C++ 的人都感到困惑,其中之一是:vector
是一个数学向量吗?不,不是的,它有点像一个集合,一个不强制其实际元素具有唯一性的集合。
换句话说,它基本上是一个数组,但与 C++ 普通数组类型不同的是它可以调整大小,这意味着当你创建这个 vector
动态数组时,它没有固定大小。如果你想用一个特定的大小初始化它,你可以给它一个特定的大小,但一般情况下不给它一个大小,你只需要创建这个 vector
数组,然后把元素放进去。每次你往里面放一个元素,数组大小会增长,所以我开始时不知道数组中有多少元素,然后把10个元素放入数组中,然后我有了一个包含10个元素的数组。
对于那些刚接触编程或计算机的人来说,可能会想怎么做到的?怎么可能让数组改变大小?在 C++ 这门课中,我们会重写很多 C++ 中存在的数据结构,在很多情况下我们会对它们进行优化,使它们比标准模板库中的快很多,因为标准模板库的速度不是优先考虑的东西,所以在很多情况下工作室和团队会创建自己的容器库。举个例子,我在 EA 工作,我们使用的是 EASTL(EA 的标准模板库),通常来说比 STL 快得多。
std::vector
实际上是如何工作的我们将在之后更详细的讨论这个问题,然而这里的要点是当你超过了分配的大小怎么办?当你创建一个 vector
,它可能分配10个元素,当你超过这个大小时它会在内存中创建一个比第一个大的新数组,把所有东西复制到这里,然后删除旧的那个,这样你就有了一个新数组。
基本使用
在实践中,它实际上倾向于经常分配,所以除非你正确设置,否则未必能得到最佳性能。现在,让我们创建一个动态数组吧:
|
我这里有一个基本的 Vertex
结构体,内置一个特定的顶点位置。还有一个输出运算符的重载,这样我们就可以很方便把它打印在控制台上了。
如果我们想要一个静态数组有两个选择。不考虑 std::arrayy
的话我们可以创建一个有5个元素的静态数组,但这种方法我们需要绑定大小,即便你在堆上创建,它仍然和这个大小挂钩:
int main(){ |
我们想要不断添加顶点就需要一种方式,当你到达它们的最大容量时重新调整容量,使容量变得更大,这样你就可以存储更多的数据。
这个问题的另一个解决方案是分配变态数量的 vertex
:
Vertex* vertices = new Vertex[500000000]; |
基本上这个程序在某种程度上支持无限个顶点,但当然这也不是理想的,因为这意味着你要用掉这么多的内存,而如果我们只有五个顶点,这是一个巨大的浪费。
所以我们可以用 vector
类来代替:
#include <iostream> |
很多人都会问:“我是否应该把指向堆的类对象的指针存储在我的 vector
中,或者我应该存储栈分配的类或结构体”。答案是视情况而定,主要考虑的是存储 Vertex
对象比存储指针在技术上更优,因为如果是 Vertex
对象你的内存分配将是一条线上的,动态数组是内存连续的数组,这意味着它在内存中不是碎片,而是在一条线上。
如果你像这样将 Vertex
对象存储在一条线上,这是最优的,因为如果你想要遍历它们、设置它们、改变它们、读取它们,或者不管你想对它们做什么,它们都在同一条高速缓存线上。唯一的问题是,如果要调整 vector
的大小它需要复制所有的数据,这可能是一个非常缓慢的操作。而指针不同,实际的内存保持不变,因为你只是正确地保存了指向内存的指针,所以实际的内存保持不变。
我不想过多讨论这个问题,要点是如果可能的话尽量使用对象而非指针,指针是你最后的选择如果你真的需要那么做。
那么现在我有了 vector
,我该如何添加东西进去呢?这很简单,你只需要调用 push_back()
:
int main(){ |
现在是遍历所有这些并打印它们。在 C 语言风格的数组中我们不知道数组的大小,但在这种情况下因为 vector
是一个完整的类,我们实际上知道它的大小,我们可以问它:
int main(){ |
我们也可以使用基于 range 的 for
循环语句:
int main(){ |
可以得到两次相同的结果。在后者循环中实际上是将每个 Vertex
复制到这个循环范围中,我们不想这样做想避免复制:
for (const Vertex& v : vertices) { |
通过加入 const
和 &
,这样就不会复制数据。
最后,如果我们想清除 Vertex
列表,我们只需要:
vertices.clear(); |
数组大小会归为0。我们也可以通过 clear()
单独移除某个 Vertex
:
int main(){ |
我们确实删除了第二个元素。
我要提到的另一件事是当你将这些 vector
传递给函数或者其他东西时,你要确保你是通过引用传递它们的。如果你不会修改它们,那么就用 const
引用:
void Function(const std::vector<Vertex>& vertices) { |
这样可以确保你没有把整个数组复制到这个函数中,你确实是通过引用传递它们的。
使用优化
今天我将展示如何以一种更优化的方式使用 vector
类,简单讲一下它是如何工作的,我们如何编写代码让它运行得更快。
优化 vector
的使用,你们应该知道 vector
是如何工作的,以及如何改变它使之更好地工作。所以基本上 std::vector
是这样工作的:
- 你创建一个
vector
,然后开始push_back()
向数组中添加元素 - 如果
vector
的容量不够大,不能容纳你想要的新元素,需要做的是vector
要分配新的内存,至少足够容纳这些想要加入的新元素 - 当前的
vector
的内容从内存中的旧位置复制到内存中的新位置,然后删除旧位置的内存
这就是发生的事情,所以当我们尝试 push_back
一个元素时如果容量用完,则会调整大小,重新分配,这就是将代码拖慢的原因之一。事实是,我们需要复制所有现有元素,不断地重新分配,这是一个缓慢的操作,也是我们要避免的。
事实上,这就是我们现在对于复制的优化策略。我们如何避免复制对象?如果我们处理的是 vector
,特别是 vector
的对象,我们没有存储 vector
指针,我们存储的是 vector
对象。所以我们需要知道,复制是什么时候发生的,为什么会发生,让我们看看一些代码来弄清楚:
|
上一次我们有了这个顶点 Vertex
类,刚给它添加了一个构造函数,在主函数有一些默认的代码,非常简单。
现在让我们看看幕后发生了什么,并确定当前这些代码,实际发生了多少次(如果有的话)复制。一个很好的方法是给 Vertex
类添加一个拷贝构造函数,在其中打印一些东西看看拷贝构造函数什么时候被调用:
#include <iostream> |
运行代码,在控制台上我们得到了三个 Vertex Copied
。回到主函数,我使用构造函数来替换前面添加元素的部分:
int main(){ |
我认为这样更容易读懂,因为你知道发生了什么。运行这段代码,会复制6次:
现在你可能会问自己,为什么 C++ 复制了我的 Vertex
六次?究竟发生了什么?我们进一步调试它,设置断点:
我们现在没有任何复制,当然,因为我们还没有 push_back
任何 Vertex
。
现在我们已经 push_back
了一个元素,一个 Vertex
,我们有了一个复制,为什么会这样?原因是,当我们创建 Vertex
时我们实际上在主函数的当前栈帧中构造它,所以我们在 main
的栈上创建它。然后我们需要做的是,把从 main
函数放到实际的 vector
中,这是我们犯的第一个错误。
这也是我们可以优化的第一件事。我们可以在适当的位置(分配的内存中)构造那个 Vertex
,这个马上就会实现。
接着运行代码,我们得到了更多的拷贝。其中一份拷贝我们已经知道和之前类似,那为什么会有一个多余的复制?发生了什么?
我们可以看到 size = 2
,这意味着这个 vector
在物理上有足够的内存来存储两个顶点,而当我们继续运行程序,size
变为3以便有足够的内存来放入我们的第三个顶点。
这是另一个潜在的优化策略,我们的 vector
在这里调整了两次大小,默认情况下大小是1。当我们有第二个元素时,它移动到2;当我们添加第三个元素时,它移动到3。如果我们知道计划放进三个 Vertex
对象,为什么不直接告诉 vector
:“嘿,留下足够的三个对象的内存,这样你就不必调整两次大小了”。从一开始就给三个元素留下足够的内存,这是第二种优化策略。
为了防止有些朋友还不太明白这六次复制怎么来的,我来拆开:
1(第一个顶点)+ 1(第二个顶点)+ 1(移进第一个顶点)+ 1(第三个顶点)+ 2(移进第一、二个顶点)= 6
让我们来做个快速而简单的优化策略:
int main(){ |
对于容量我们可以调用 reserve()
函数,确保我们有足够的内存。
仅仅一行代码让我们少了三次复制,但我们可以做得更好。我想在实际的 Vertex
构造中使用 emplace_back
而非 push_back
,这种情况下不是传递我们已经创建的 Vertex
对象,我们只是传递了构造函数的参数列表:
int main(){ |
它告诉我们的 vector
:“嘿,在我们实际的 vector
内存中使用以下参数构造一个 Vertex
对象”。运行代码:
控制台非常清爽,再也没有复制了。看看我们是如何简单地优化它的,只要知道它是如何工作的,意识到对象实际上被复制了六次,写出优化代码也不难,这里这段代码会比我们最初的代码运行快很多。
静态链接
今天我们讲 C++ 库,特别是如何在我们的项目中使用外部库。
如果你用过其他语言比如 Java 或 C# 或 Python 等,添加库是一项非常简单的任务,你可能用的是包管理器,也可能不是,但无论如何都很简单。但是到了 C++ 这里,好像哪里都有问题。
以前我也有过这样的问题,我也不知道为什么,其实它真的很简单。基本上,当我们在处理 C++ 库时我们可以采取一些策略,我会给你们展示我的方法,也会讨论一些其他方法,但首先,我讨厌包管理器,讨厌链接到其他代码仓库之类的东西。我理想的项目设置是如果你检查我的远程存储库、代码仓库,你应该在存储库中有你需要的所有东西,以便你能够直接编译和运行项目的应用程序,而不需要考虑包管理去下载其他需要的库。
我讨厌这一切,特别是 C++ 有一些用于其他语言的包管理器并不是很有效。对于 C++ 来说我只是想克隆存储库,然后编译和运行。出于这个原因,我倾向于在实际解决方案中的实际项目文件中保留使用的库的版本,所以我实际上有那些物理二进制文件或代码的副本,这取决于我在解决方案的实际工作目录中使用的方法。
这就引出了另一个问题:我应该自己编译这些文件吗?或者它们应该链接到预构建的二进制文件?对于大多数严肃的项目,我绝对推荐实际构建源代码。如果你使用的是 VisualStudio,你可以添加另一个项目,该项目包含你的依赖库的源代码,然后将其编译为静态或动态库。
然而,如果你拿不到源码或你的计划只是一个快速项目,我不想花太多时间去设置它们,因为这是一种一次性的东西或者不是那么重要的项目,那么我可能会倾向于链接二进制文件,因为它会更快更容易。
所以今天我们将以二进制文件的形式进行链接,而不是获取实际依赖库的源代码并自己进行编译,确切来说,就是 GLFW 库。另一件事情是,在你的实际项目中或者你想链接的库中,二进制文件可能不可用,所以实际上你可能被迫自己去构建它,对于 Mac 或 Linux 来说尤其如此,因为对于 UNIX 系统人们通常喜欢自己构建代码,而对于 Windows 很多人只是想让东西能够运行就OK了,我只是希望一切能用就行了,这就是为什么存在预先构建的二进制文件。
这是 glfw.org,有几个下载的地方,如果点击了这个下载按钮会下载 glfw 源码。但如果我们点击上面的 Download,你可以看到 Windows 预编译二进制文件:
这里另外一个很好的例子是你可以看到 Linux 和 MacOS 必须自己编译它,但对于 Windows,我们有这些二进制文件。
这就引出了我们的第一个问题,我想要 32 位二进制文件还是 64 位二进制文件?这与你实际的操作系统无关,如果你和我一样用的是 64 位 Windows10,这并不意味着你应该获取 64 位二进制文件,而是意味着你为你的目标应用程序选择你想要的东西是 32 位还是 64 位。
所以如果为编译我的应用程序,作为 x86 也就是 win32 程序,那么我就要 32 位的二进制文件;如果我在编译一个 64 位应用程序,我就要 64 位的二进制文件,一定要把它们匹配起来,因为如果你不这么做,它们将无法链接。
在这个例子中,我要做一个 32 位的应用程序,所以我要获取 32 位的二进制文件,下载它们。一旦我下载并解压缩它们,就会有这个文件夹在这里:
里面有一大堆文件夹和文件,这是一种典型的 C++ 库文件布局。库通常包含有两部分,include
和 library
包括目录和库目录,包括目录是一堆我们需要使用的头文件,这样我们就可以实际使用预构建的二进制文件中的函数;然后 library
目录有那些预先构建的二进制文件,这里通常有两个部分:静态库和动态库,但并不是所有的库都为你提供这两种库。
GLFW 为你提供了两种,你可以选择静态链接还是动态链接。这里我简单讲下区别,静态链接意味着整个库会被放到你的可执行文件中,它在你的 .exe
文件中或其他系统的可执行文件;而动态链接库是在运行时被链接的,所以你可以选择在程序运行时装载动态链接库,有一个叫 loadLibrary()
的函数。你可以在 WindowsAPI 中使用它作为例子,它会载入你的动态库,可以从中拉出函数然后开始调用函数;你也可以在应用程序启动时加载你的 dll 文件,这就是动态链接库。
总结下来主要的区别是,库文件是否被编译到 .exe
文件中或链接到 .exe
文件中;还是只是一个单独的文件,在运行时你需要把它放在你的 .exe
文件旁边或某个地方,然后你的 .exe
文件可以加载它。因为这种依赖性,你需要把 .exe
和 .dll
文件放在一起,所以通常我喜欢静态的。
静态链接在技术上更快,因为编译器或链接器实际上可以执行链接优化。链接在技术上可以产生更快的应用程序,因为有许多优化方法可以应用。
动态库
多返回值
这次我们讨论什么是元组 tuple
,什么是 pair
,如何在 C++ 中处理多个返回类型以及我个人喜欢如何处理它们。
具体来说,我们有一个问题,就是我们有一个函数,这个函数需要返回两个字符串。有很多不同的方法可以实现返回两种类型,但显然在 C++ 默认情况下,你不能返回两种类型(在 Python 中你可以神奇的返回两种类型),我们如何返回不同类型的变量?
C++ 给了我们一些处理的办法,我个人很讨厌用这些东西,我喜欢用我自己的方式来处理。
模板
堆与栈内存比较
宏
静态数组
今天我们会讨论关于 C++ 的标准数组:std::array
。标准数组是 C++ 模板库的一部分,用来处理静态数组。
这里的数组指的是不增长的数组,当你创建这个数组时你来定义它有多大,也就是它有多少个元素以及里面有什么类型的元素。
函数指针
lambda
今天我们来聊聊 lambda。
lambda 本质上是我们定义一种叫做匿名函数的方式,我们用这种方式创建函数而不需要实际创建一个函数,就像是一个快速的一次性函数展示下需要运行的代码,我们更想将它视为一个变量,而不是一个正式的函数那样在我们实际编译的代码中作为一个符号存在。
那么首先,lambda 是用来做什么的?理解它是什么是一回事,但很明显理解如何使用它和何时使用它是完全不同的事情。这个问题的答案是只要你有一个函数指针,你都可以在 C++ 中使用 lambda,这就是它的工作原理。