9.6. 动态内存分配
使用new与delete或者malloc与free,可以动态分配对象与数组。在编译时刻要求的内存数量未知时,这是有用的。这里简述动态内存分配的4个典型用法:
动态内存分配的好处有:
动态内存分配的坏处有:
在确定是否使用动态内存分配时,权衡好坏是重要的。在数组大小或对象个数在编译时刻已知,或者可以定义一个合理上限时,没有理由使用动态内存分配。
在分配数量有限时,动态内存分配的代价可以忽略不计。因此,在程序有一个或少数几个可变大小数组时,动态内存分配是有优势的。制作覆盖最坏情形非常大的数组的替代方案浪费缓存空间。在程序有几个大数组且每个数组的大小是关键步长倍数(参考上面第76页)的情形里,很可能导致数据缓存的竞争。
如果数组中元素数在程序执行期间增长,最好从一开始就分配最终的数组大小,而不是逐步分配更多的内存。在大多数系统中,你不能增加已经分配的内存块的大小。如果不能预测最终大小或预测结果太小,那么分配一个新的更大的内存块,把旧内存块的内容拷贝到新内存块是必要的。当然,这是低效的,而且会导致堆空间碎片化。一个替代方案是保持多个内存块,以链表或者内存块索引的形式。使用多个内存块的方法使得访问数组元素更复杂、更耗时。
可变数量对象的集合通常实现为链表。链表中每个元素有自己的内存块以及指向下一个块的指针。因为下面的原因,链表比线性的数组效率低:
对所有的对象分配一大块内存(内存池)通常比对每个对象分配一小块内存更高效。
分配可变大小数组new与delete一个鲜为人知的替代方案是alloca。这是一个在栈上,而不是堆上,分配内存的函数。在调用alloca的函数返回时,空间自动释放。在使用alloca时,无需显式释放空间。对比new与delete或者malloc与free,alloca的优势在于:
下面的例子展示了如何使用alloca制作可变大小数组:
// Example 9.3
#include
void SomeFunction (int n) {
if (n > 0) {
// Make dynamic array of n floats:
float * DynamicArray = (float *)alloca(n * sizeof(float));
// (Some compilers use the name _alloca)
for (int i = 0; i < n; i++) {
DynamicArray[i] = WhateverFunction(i);
// ...
}
}
}
显然,函数不应该返回指向使用alloca分配对象的指针或引用,因为在该函数返回时,它被释放了。Alloca可能与结构化异常处理不兼容。关于alloca使用的限制,参考你的编译器手册。
9.7. 容器类
一旦使用了动态内存分配,建议将分配的内存封装在一个容器类中。容器类必须有确保分配的每一个对象都被释放的析构函数。这是防止内存泄露及其他与动态内存分配相关常见编程错误的最好方法。
对数组增加边界检查,对更多具有先进先出、先进后出访问、排序以及查找、二叉树、哈希表等的先进数据结构,容器类也是便利的。
通常以模板形式制作容器类,其中包含的对象类型作为模板参数提供。使用模板没有性能损失。有现成的容器类可用于许多用途。最常用的容器集合是伴随大多数现代C++编译器的标准模板库(STL)。使用现成容器的好处是,你无需重新发明轮子。STL中的容量是通用、灵活、经过良好测试且对许多用途十分有用。
不过,STL针对通用与灵活性设计,而执行速度、内存经济性、缓存效率与代码大小优先程度不高。特别是在STL中内存分配有不必要的浪费。某些STL模板,比如list、set与map倾向于分配比容器内对象个数多的内存块。STl deque(双端队列)对每四个对象分配一个内存块。STL vector在同一个内存块中分配所有的对象,但每次填满,这个内存块就重新分配,这出现得相当频繁,因为每次块大小仅增长不超过50%。一个向STL vector插入10个元素,一次一个的实验最终导致大小分别为1,2,3,4,6,9与13对象的7次内存分配(MS Visual Studio 2008版)。可以预测或估计最终所需大小,在向vector加入第一个对象前,通过调用vector::reserve来防止这个浪费行为。其他STL容器没有这样一个预先保留内存的特性。
使用new及delete(或者malloc与free)频繁分配与释放内存,导致内存变得碎片化,缓存效率下降。对内存管理与垃圾收集,有巨大的开销代价,如上面提到的。
STL的通用性还有代码尺寸方面的代价。事实上,STL被批评造成代码膨胀与复杂性增加(en.wikipedia.org/wiki/Standard_Template_Library)。保存在STL容器里的对象允许有构造函数与析构函数。每次移动对象时,调用每个对象的拷贝构造函数与析构函数,这发生得相当频繁。如果保存的对象本身也是容器时,这是必须的。但在STL中把一个矩阵实现为vector的vector,这很常见,肯定是一个非常低效的解决方案。
许多容器使用链表。链表是制作可扩展容器一个便利的方法,但它效率很低。在许多情形里,线性数组比链表快。
在STL中用于访问容器元素的所谓迭代器,对许多程序员而言是笨拙的,如果你可以使用一个具有简单索引的线性列表,它们是不必要的。在某些情形里,好的编译器可以优化掉迭代器额外的开销,但不是全部。
幸好,在执行速度、内存经济性及小代码尺寸比代码通用性优先级更高时,有更高效的替代方案。最重要的措施是内存池。将许多东西一起保存在一个大内存块中,比将每个对象保存自己分配的内存块里,效率更高。如果没有拷贝构造函数以及析构函数可调用,包含许多对象的大内存块可以通过调用memcpy一次拷贝或移动,而不是分别移动每个对象。
我实现了使用这些方法改进效率的一组容器类例子。它们作为本手册的附录,包含几个用途的类与模板,保存在www.agner.org/optimize/cppexamples.zip。所有这些例子都对速度与减少内存碎片优化。出于安全原因,包括了边界检查,但如果性能原因要求,可以在调试后移除。在STL的性能不能满足的情形下,使用这些例子容器。
在对一个特殊用途选择容器时,应该考虑以下因素:
我在www.agner.org/optimize/cppexamples.zip提供了合适容器类的几个例子。如果不需要STL容器的完整通用性与灵活性,这些可用作标准模板库(STL)的替代。你可以编写自己的容器类或修改现有的类,以适应特定的需要。
9.8. 字符串
字符串通常有在编译时刻未知的可变长度。在像string、wstring或Cstring类中字符串的储存,在每次创建或修改字符串时,使用new与delete分配一个新内存块。如果程序创建或修改许多字符串,这会相当低效。
在大多数情形里,处理字符串最快的方式是使用字符数组的旧式C方式。可以通过C函数,比如strcpy、strcat、strlen、sprintf等操作字符串。但小心这些函数不检查数组溢出。数组溢出会导致在程序别处不可预期的错误,诊断它们非常困难。确保数组对字符串处理足够大,包括结尾的0,在必要时进行溢出检查,是程序员的责任。常见字符串函数的快速版本,以及用于字符串查找与解析的高效函数在www.agner.org/optimize/asmlib.zip的asmlib库里提供。
如果你希望在不危害安全的情况下提升速度,你可以在内存池里保存所有的字符串,如上面所示。例子在www.agner.org/optimize/cppexamples.zip的本手册附录里提供。