1. 概述
本篇来讨论数组,在开始数组之前,我们先来理解指针是什么,有关于指针的内容可以看C++指针这篇。指针基本上是C++数组工作方式的基础,所以我们一定要理解这一点。
什么是数组,数组基本上是元素的集合,按特定的顺序排列的一堆东西。在我们的例子中,C++数组就是表示一堆的变量组成的集合,一般是一行相同类型的变量。
数组如此重要和有用的原因,是我们经常想要表示一大堆数据的数据集合。对于我们来说,创建一大堆变量是没有意义的,这些数据应该放在一个数据集中。因为变量需要手动创建,我们需要进入代码中,指定变量并给它们命名。然而有时候,我们只是想要能够存储50个整数,代表某种数据。我不想去详细说明,整数1号,整数2号...一直到整数50号。因为首先,这太恐怖了,无法维护,想象一下,要设置所有这些变量等于0,那么我们就要写50行代码,手工将这些代码设置为0,处理这么多变量真的很难。在这种情况下,我们想做的是使用一个数组来包含所有50个相同类型的元素。在这种情况下,这些整数,处理起来就轻松多了。记住,数组基本上就像在一个变量中有多个变量,我们给一个数组起一个名字,通过这个,我们可以创建数组,引用尽可能多的变量。
2. 案例
1. 项目准备
准备一个简单的项目,项目中有一个main.cpp文件,文件内容如下
2. 开始案例
定义数组非常简单,假设我们想要一个有5个整数的数组,我们写上我们想要的的数组类型,然后给它一个名字,然后加上中括号,中括号里面给上我们想要放入多少个元素,如int exemple[5];
现在,我们有了一个拥有5个整数的数组,而且分配了足够的空间来存储这5个整数。
现在我们要设置和访问这些整数。我们可以写数组的名字然后跟上中括号,在中括号内写上一种叫做索引(index)的东西。索引(index)是我们在数组中指向的那一个变量或者元素。第一个元素的索引是0,因为在C++中,下标是从0开始的,这意味着下标0代表第一个元素。example[0], 我们可以把这个设置成任意整数,因为它只是一个整数,这是一个整数数组,当我们在一个特定的索引上访问这些元素之一时,我们得到这个元素的类型就是数组的类型,这里是整形。例如我让它等于2
你可能会注意,我们为5个整形分配了空间,第一个的索引是0,这意味着既然我们分配了5个整形,最后一个索引,应该是4而不是5,因为如果是5的话,就相当于第六个元素了。
其他的元素先不设置。
读取这些元素很简单,假设我们想打印某个元素,我们只需要指明索引就行了
如果我们想打印这个数组,我们需要打印这个数组的地址
因为exemple这实际上是一个指针类型。我说过,当我们索引数组中的元素时,我们会得到底层的数据类型,在本例中是int。当然,我可以创建一个新变量,然后将数组中的值复制给这个变量
可以看到这里的数据类型,只是一个普通的int类型。
如果我试图访问一个索引,不在数组里面,例如试图访问-1或5,我们会得到Memory access violation(内存访问违规)
因为我在试图访问不属于我的内存,在debug模式下,我们会得到一个程序崩溃的错误消息,来帮助我们调试那些问题。然而,在release模式下,我们可能不会得到报错信息,这意味着我们已经写入了不属于我们的内存。这一点非常重要,我们要确保,我们总是在数组的边界以内写东西。因为如果你没有,它会导致一些很难调试的问题,因为我们刚刚修改了内存,这些内存不是这个数组的一部分。而是有可能是源代码中,另一个变量的一部分。我们只是在没有意识到的情况下,将代码中的其他变量改掉了。所以要确保我们设置了安全检查,确保我们写的东西没有超出界限。
数组与for循环经常在一起。因为for循环可以通过索引来遍历,在一个特定的范围内,如果我们想设置example数组中的每一个值,可以通过for循环实现。如果没有for循环,我们需要检查所有这些索引,并手动设置。
然而,通过创建一个for循环,遍历数组的整个长度
我们所做的是循环遍历整个数组,我们从索引0到索引4,因为索引4是最后一个小于5的值。我们也可写成小于等于4
但是没人会这样写,因为这涉及到性能的问题,因为你在做小于以及等于的比较,所以它必须做等于比较,而不仅是做小于比较。
所以它几乎总是写成小于的形式,而不是小于等于的形式。
我们在std::cin.get();这行打上断点
F5运行我们的程序,我们可以看这在我们的内存中是什么样子的。
在内存视图上,我们要找到数组的地址,example实际上是它自己的内存地址,因为example是一个整形指针。所以我们在内存视图的地址栏输入exemple
回车
可以看到,数组值一字排开,所以数组最重要的一点,他们是连续的存储数据,这意味着他们的数据排成一排,我给5个整数分配了内存空间,这意味着我将它们一个接一个的放进内存中。在内存中,每个整数是4个字节,所以我在这里得到的是一行20字节的内存。它被分成几个4字节段,当然并不是真的分成了4字节段,而是,当我们通过代码访问它时,它被分为4字节段,即使没有字面上的分开。这就是我们在内存视图中看到的,我们会看到5个2,每个占据4字节,因为它们是int整形。当我们通过example[i]来访问特定索引时,它实际上做的是,对这个内存取了一个偏移量。在索引2的地方,example[2],它会从数组开头开始,可以看到,它只需要增加8个字节,因为每个整数有4个字节,我们想要访问元素2,这是索引从0开始之后的第三个元素,所以索引是2的元素,地址偏移是每个元素的大小乘2,向后8个字节到这里
如果我们给exemple[2]赋值,它会写入到它所在的这部分内存中。
数组实际上只是一个指针。int exemple[5];一个整形指针,通过这段内存,它包含5个整数,这意味着我们可以在这里创建一个变量,它是一个整形指针,并给它赋值example,int* ptr = example。
可以看到它正常工作。编译也正常。
因为example只是一个整形指针。
现在,正如我指出的,访问2号元素,设置等于5或类似的东西
结果写入从指针开始8个字节的偏移量(的地址),所以这部分实际上可以用指针简单的重写,指针算术上是ptr + 2,因为我们向前了2个元素,然后逆引用并设置为6,*(ptr + 2) = 6;,我们将这部分内容放到for循环后面,并加上断点,如下
F5运行我们的程序,并在地址栏输入exemple指针
回车
可以看到第三个元素的数字被设置成了5。
我们按下F10,让程序往下走,
可以看到第三个元素的数字变成了6。
注意,ptr + 2对于这个指针,我们写上+2,并不是指字节,我这样做的原因是,当我们处理指针运算时,当我们只是在一个指针上加上像2这样的值,来计算实际要加的字节数(偏移)时,这取决于类型,这里由于指针是一个int指针,将会增加2*4的偏移,因为4是每个int型的字节大小。
如果我真的想用字节来处理,我们可以将这个ptr指针转换成一字节的类型,例如char*。如果我这样做,我就得加上刚才提到的8个字节*((char*)ptr + 8) = 6;,因为我想写的是一个四字节的整数,不仅仅是一个字节char的大小,一旦我们加上的是8个字节,我们需要把类型转换回int指针类型*(int*)((char*)ptr + 8) = 6;,这个指向的就是整形了。我可以令它等于6。这是相当奇怪的代码
我们F5运行程序,并在内存视图的地址栏输入exemple回车
可以看到,第三个元素被设置为了5
我们继续按下F10
可以看到,我们得到了完全相同的结果。这样做相当的花里胡哨,但实际上我在这里的写的*(int*)((char*)ptr + 8)就是这个索引exemple[2]。这不是魔术,这就是数组的原理,它们只是一个连续的数据块。我们可以像索引一本书一样索引它们,然后写到特定的页面。刚才这种情况是写入整数。
我们还可以在堆上(heap)创建一个数组。我们还没有讨论栈和堆,以及它们的内存是如何运作的,我们将在后续讲到它们,很快。
同样的,我们可以通过new关键字来创造一个对象(实例),同样,我们也可以通过使用new来创建一个数组。int* another = new int[5];,
这代码和之前的代码是一个意思,然而,它们的生存期是不同的,int exemple[5];这个是在栈上创建的,当代码执行在main函数的介绍括号时,这个example就会被销毁,因为跳出了作用域。如果是在堆上创建的话,直到我们程序把他销毁之前,他都是处于活动状态,所以我们需要调用delete关键字来删除,因为这是一个数组,我们在这里使用数组的操作符[]来分配内存,所以我们使用了带方括号的new关键字,所以我们删除也需要使用方括号来删除它delete[] another;
我们写上一个for循环,来给数组赋值,并加上断点。
按F5运行我们的程序
在内存视图的地址栏中输入exemple指针,回车
同样的我们在内存视图的地址栏中输入another指针,回车
我们也会得到一样的结果。那么为什么要动态的使用new来分配,而不是在栈上创建呢?最大的原因是生存期,用new来分配的内存,它将会一直存在,直到我们调用delete来删除它。如果我们有一个函数返回一个数组,例如,我们必须使用一个new关键字来分配它,除非我们传入一个数组的地址参数,如果我们想返回一个数组,这个数组是在函数中创建的,那么我们需要使用new关键字。
1. 间接寻址
此外还有一件事需要注意,那就是间接寻址,因为我们实际上有一个指针,那个指针会指向另一个内存块,这个内存块保存了我们实际的数组,这将会产生某种内存碎片(memory fragmentation)、缓存丢失(cache miss),这个在后续中再去了解。
举个简单的例子,如果我创建一个名为Entity的类,然后将example数组移动到Entity类中。然后我们写一个构造函数,将for循环放入其中。如下
然后,我们在main函数中创建Entity对象,去掉多余的代码,如下
按下F5运行我们的程序
如果我们到Entity对象e的内存地址,并按下回车
我们可以看到这里的内存情况,Entity的内存地址上,实际上就是一行,包含了数组的所有的2,所有的数据。
然而,如果,我们在Entity类的成员变量使用new来创建对象,如下
我们运行我们的代码,并在地址栏中输入&e
我们根本没有看到2,我们看到是另一个内存地址,这个内存地址就是int* exemple,现在我们可以将这个内存地址放入内存视图的地址栏中,但是要反过来写,因为Endian(字节存储次序)的原因。
回车
我们可以拿到我们的实际数据2。这就是所谓的(memory indirection)内存间接寻址。
我们实际上得到e的内存地址,它包含另一个地址,是我们数组的实际内存地址。这意味着当我们想要访问这个数组时,我们基本是要在代码周围跳来跳去,首先找到Entity,接着找到数组。所以,只要有可能,我们应该在栈上创建数组来避免这种情况,因为像这样在内存中跳来跳去肯定会影响性能。
现在,我还想提一下,C++11里面的数组。在C++11中,我们有标准数组std::array,这是一个内置数据结构,在C++11库中。很多人喜欢用它来代替我在这里展示的原始数组,因为它有很多优点,比如它有边界检查,记录数组大小,另外一点,我还没提到的一点是,实际上没有办法计算出原始数组的大小。如果我们像在Entity中这样在堆中分配一个数组int* exemple = new int[5];,在很多语言中可能会有size()这样的函数,类似于exemple->size(),但是C++中不能,因为我们无法知道数组的大小,其实,虽然我说不可能,其实显然有一些方法,因为当我们删除这个数组时,编译器需要知道实际上要释放多少内存。是的,有一种方法是,通过编译器相关的东西,它可能有时存储在数组的一个负索引里面,比如负的索引一,这取决于很多因素,我们还不能确认,所以也无法信任这种。因此,我们不应该在数组内存中,访问数组的大小,这是危险的。如果我们在栈上分配一个数组int a[5],我们实际上不知道它的实际大小,它是在栈上分配的,也就是说,这是栈上的(地址)加上偏移量。所以如果我们写sizeof(a),我们将实际得到的是数组占了多少字节
int是4个字节,我们有5个元素,也就是20个字节,这个sizeof(a)会返回20个字节,如果我们想知道里面有多少元素,我们可以把它们除以数据类型的大小,就像这样sizeof(a) / sizeof(int),这代码会给出元素的计数,这里喜欢称它为计算器,而不是大小size,就个人而言,使用size时,更倾向于字节数,使用count时,更倾向于元素数量。
int count = sizeof(a) / sizeof(int);这个式子得出的也就是我们已经分配的元素的数量。
然而,我们如果用exemple做同样的事情,
sizeof(exemple)我们实际得到的是一个整形指针的大小是4个字节,除以sizeof(int),得到的结果是1,这是错误的。所以要使用这个方法,我们必须在栈中分配数组。如果我们要把它变成一个栈分配的数组,它也会起作用。
然而,我们不能真的相信这个方法,当我们将它放在某个函数里面或其他东西,它如果一旦变成了int指针,那结果就错了,完蛋。所以我们要做的是自己维护自己的数组大小。从这个意义上说,这糟糕透了,但它就是这样运作的,我们必须自己维护它。
我们可以声明一个常量size,大小为5,const int size = 5;,然后将这个size放入到int exemple[size]这里。
结果报错了,因为我们不能这么做,当我们在栈中为数组申请内存时,它必须是一个编译时就需要知道的常量。这里要打个*号,因为这是C++,有这个问题。所以,我们必须要标记为static,这里还可以使用常量表达式constexpr,在类中的常量表达式必须是静态的。
不过我们可以使用const
所以这就是处理这个问题的方法。在这个例子中我们可以将size命名为exempleSize,因为它是专门处理exemple数组的。
然后,我们就可以把这个放进我们的for循环中,这些东西就知道我们的数组的大小了
如果我们使用C++11,std::array,我们要确保加入了array头文件,我们可以写std::array后面接上尖括号<>,我们需要类型名和大小,然后命名std::array<int, 5> another;
然后,要填满它,在for循环中,我们可以写上another.size(),得到数组的大小。这是一种很简单的处理方法。
当然,这确实会有开销,因为如果我们想的话,它会做所有的边界检查,它实际上也保持了一个整数size(数组大小),所以这会有一些开销,通常这都是值得的。