3.6、多维自由内存空间上的数组
如果需要在运行时决定多维数组的维度,可以使用在自由内存空间上的数组。与一维动态分配的数组通过指针访问一样,多维动态分配的数组也可以通过指针访问。不同的地方在于在二维数组中,需要用一个指向指针的指针;在一个N维的数组中,需要N层的指针。一开始,好像正确的方式是声明并且分配一个动态分配的多维数组如下:
char** board { new char[i][j] }; // BUG! Doesn't compile
该代码编译不通过,因为多维自由内存空间上的数组与栈上数组不一样。其内存构成不是连续的。实际情况是,是在自由内存空间上为第一个下标维度用分配一个连续的数组开始。数组的每一个元素实际上是指向另一个保存了第二个下标维度的元素的数组的指针。下图展示了2乘2动态分配游戏板的构成。
不幸的是,编译器不会为你对子数组进行内存分配。你可以像一维自由内存空间上的数组那样分配第一维的数组。但是每一个子数组必须显示分配。下面的函数正确地为一个二维数组分配了内存:
char** allocateCharacterBoard(size_t xDimension, size_t yDimension)
{
char** myArray { new char*[xDimension] }; // Allocate first dimension
for (size_t i { 0 }; i < xDimension; ++i) {
myArray[i] = new char[yDimension]; // Allocate ith subarray
}
return myArray;
}
同样的,当你想要对自由内存空间上的多维数组进行内存释放时,delete[]语法也不能为你清理子数组。释放数组的代码与分配的代码相对应,如下函数:
void releaseCharacterBoard(char**& myArray, size_t xDimension)
{
for (size_t i { 0 }; i < xDimension; ++i) {
delete [] myArray[i]; // Delete ith subarray
myArray[i] = nullptr;
}
delete [] myArray; // Delete first dimension
myArray = nullptr;
}
上面的为多维数组分配内存的例子并不是一个非常高效的解决方案。它首先为第一维分配了内存,接着为每一个子数组分配了内存。结果就是内存块在内存中散落各处,对于这样的数据结构上的算法来讲有很大的性能影响。如果算法在连续的内存上会跑得更快。好的解决方案是分配一个单独的内存块,足够保存xDimension * yDimension个元素,用像x*yDimension + y的公式来访问(x,y)位置的元素。
既然你已经知道了数组工作的细节,推荐你尽可能避免这些旧的C风格的数组,因为它们不能提供内存安全。在这儿解释这么多,是因为你会在遗留的代码中会碰到。在新的代码中,应该使用C++标准库函数,比如std::array与vector。例如,使用vector<T>来用一维动态数组。对于二维动态数组 ,可以使用vector<vector<T>>,再多维的数组也类似。当然了,直接使用像vector<vector<T>>这样的数据结构也比较烦,特别是要构造它们,也会有前面讨论的同样的内存碎片问题。所以啊,如果在你的应用中确实需要N维动态数组,考虑写一个helper类,提供一个易于使用的接口。例如,对于二维数据,有同样长的行,你可以考虑写(当然也可以重用)一个Matrix<T>或者Table<T>类模板,把内存分配/释放与用户访问元素算法隐藏下来,我们以后会专门讨论写类模板的细节。
不要使用C风格的数组,要使用C++标准库函数,比如std::array,vector等等。
3.7、使用指针
指针由于相对容易遭到滥用而声誉不好。因为指针就是内存地址,理论上可以手动修改,甚至做出像下面代码这样可怕的事情来:
char* scaryPointer { (char*)7 };
这行代码建立了一个指向内存地址7的指针,可能是一个随机垃圾值,或者是在应用在其他地方使用的内存。如果你开始使用不是你想用的内存区域,例如,使用new或者在栈上,最终你会让与对象相联的内存崩溃掉,或者在自由内存空间上管理的内存,程序会执行错误。这样的一个执行错误会以多种方式显现。例如,它会显示为一个无效的结果,因为数据被破坏掉了,或者是硬件触发的异常,因为访问了不存在的内存,或者尝试写受保护的内存。如果你足够幸运,就会得到一个通常以被操作系统进行程序或者C++运行库终止的严重错误,如果你很不幸,那就会得到错误的结果。记住,错误的结果往往比直接终止更可怕。
3.8、指针的思维模型
有两种方式来考虑指针。更具有数学思维的读者会将指针认为是地址。这种观点使指针易于计算,易于理解。指针在内存中并不神秘;它们只是在内存中对应相应位置的数字。下图展示了一个以地址为基础的世界中的2乘2的网格。
对空间展示更熟悉的读者可能从“箭头”指针观点获益更多。指针就是对程序说的一种指向层次,“嗨,看这儿。”这种观点,多层指针就成为了数据道路上的单个步骤。下图显示了一个在内存中的图形化的指针观点。
当你把指针指向某个值时,通过使用*操作符,你在告诉程序更深一层地访问内存。以地址为基础的观点,会认为把指针指向值是在内存中的一个跳跃,跳到指针显示的地址。以图形化的观点,每一个指针指向值都对应到从尾至头的箭头方向所指。
当你使用&操作符来拿到某个位置的地址,你就是在内存中间接加了一层。以地址为基础的观点,程序注意到位置的数字地址,可以作为一个指针进行存储。在图形化的观点中,&操作符生成了一个新箭头,它的头在表达式的位置结束。箭头的尾部可以被存储为一个指针。
3.9、指针转化
因为指针只是内存地址(或者是指向某处的箭头),它们在类型上就会很弱。一个指向XML文档的指针与指向一个整数的指针的大小一样。编译器会很容易地把任何指针类型转化为其他指针类型,使用C风格的代码如下:
Document* documentPtr { getDocument() };
char* myCharPtr { (char*)documentPtr };
当然了,使用这样的指针会有运行时的灾难后果。静态转化提供了相对安全点的转化。编译器拒绝执行把指针静态转化为无关的数据类型:
Document* documentPtr { getDocument() };
char* myCharPtr { static_cast<char*>(documentPtr) }; // BUG! Won't compile
我们以后会对不同风格的类型转化进行详细讨论。