● 标准STL序列容器:vector、string、deque和list。
● 标准STL关联容器:set、multiset、map和multimap。
● 非标准序列容器slist和rope。slist是一个单向链表,rope本质上是一个重型字符串。(“绳子(rope)”是重型的“线(string)”。明白了吗?)你可以找到一个关于这些非标准(但常见的)容器的概览在条款50。
● 非标准关联容器hash_set、hash_multiset、hash_map和hash_multimap。我在条款25检验了这些可以广泛获得的基于散列表的容器和标准关联容器的不同点。
● vector
● vector作为标准关联容器的替代品。就像条款23所说的,有时候vector可以在时间和空间上都表现得比标准关联容器好。
● 几种标准非STL容器,包括数组、bitset、valarray、stack、queue和priority_queue。因为它们是非STL容
器,所以在本书中关于它们我说得很少,虽然条款16提到了数组比STL容器更有优势的一种情况,而条款18揭示了为什么bitset可能比vector
STL是建立在泛化之上的。数组泛化为容器,参数化了所包含的对象的类型。函数泛化为算法,参数化了所用的迭代器的类型。指针泛化为迭代器,参数化了所指向的对象的类型。
独立的容器类型泛化为序列或关联容器,而且类似的容器拥有类似的功能。标准的内存相邻容器(参见条款1)都提供随机访问迭代器,标准的基于节点的容器(再参见条款1)都提供双向迭代器。序列容器支持push_front或push_back,但关联容器不支持。关联容器提供对数时间复杂度的lower_bound、upper_bound和equal_range成员函数,但序列容器却没有。
既要和序列容器又要和关联容器一起工作的代码并没有什么意义。这里的罪魁祸首是不同的序列容器所对应的不同的迭代器、指针和引用的失效规则。
不同的容器是不同的,而且它们的优点和缺点有重大不同。它们并不被设计成可互换的,而且你做不了什么包装的工作。如果你想试试看,你只不过是在考验命运,但命运并不想被考验。
容器容纳了对象,但不是你给它们的那个对象。此外,当你从容器中获取一个对象时,你所得到的对象不是容器里的那个对象。取而代之的是,当你向容器中添加一个对象(比如通过insert或push_back等),进入容器的是你指定的对象的拷贝。拷进去,拷出来。这就是STL的方式。
如果你用一个拷贝过程很昂贵对象填充一个容器,那么一个简单的操作——把对象放进容器也会被证明为是一个性能瓶颈。容器中移动越多的东西,你就会在拷贝上浪费越多的内存和时钟周期。此外,如果你有一个非传统意义的“拷贝”的对象,把这样的对象放进容器总是会导致不幸。
当然由于继承的存在,拷贝会导致分割。那就是说,如果你以基类对象建立一个容器,而你试图插入派生类对象,那么当对象(通过基类的拷贝构造函数)拷入容器的时候对象的派生部分会被删除。
分割问题暗示了把一个派生类对象插入基类对象的容器几乎总是错的。
一个使拷贝更高效、正确而且对分割问题免疫的简单的方式是建立指针的容器而不是对象的容器。
对于所有的标准容器,empty是一个常数时间的操作,但对于一些list实现,size花费线性时间。
直接用for循环的效率是最低的,copy函数的效率高于for 循环,但其实现中仍然包含了一个for循环。
所以,几乎所有目标区间是通过插入迭代器(比如,通过inserter,back_inserter或front_inserter)指定的copy的使用都可以——应该——通过调用区间成员函数来代替。比如这里,这个copy的调用可以用一个insert的区间版本代替:
v1.insert(v1.end(), v2.begin() + v2.size() / 2, v2.end());
尽量使用区间成员函数来代替单元素兄弟的三个可靠的论点。区间成员函数更容易写,它们更清楚地表达你的意图,而且它们提供了更高的性能。
区间函数使用总结:
n 区间构造。所有标准容器都提供这种形式的构造函数:
container::container(InputIterator begin, // 区间的起点
InputIterator end); // 区间的终点
n 区间插入。所有标准序列容器都提供这种形式的insert:
void container::insert(iterator position, // 区间插入的位置
InputIterator begin, // 插入区间的起点
InputIterator end); // 插入区间的终点
n 关联容器使用它们的比较函数来决定元素要放在哪里,所以它们了省略position参数。
void container::insert(lnputIterator begin, InputIterator end);
n 区间删除。每个标准容器都提供了一个区间形式的erase,但是序列和关联容器的返回类型不同。序列容器提供了这个:
iterator container::erase(iterator begin, iterator end);
而关联容器提供这个:
void container::erase(iterator begin, iterator end);
n 区间赋值。所有标准列容器都提供了区间形式的assign:
void container::assign(InputIterator begin, InputIterator end);
假设你有一个int的文件,你想要把那些int拷贝到一个list中。这看起来像是一个合理的方式:
ifstream dataFile("ints.dat");
list
istream_iterator
这里的想法是传一对istream_iterator给list的区间构造函数(参见条款5),因此把int从文件拷贝到list中。这段代码可以编译,但在运行时,它什么都没做。它不会从文件中读出任何数据。它甚至不会建立一个list。那是因为第二句并不声明list,而且它也不调用构造函数。其实它做的是⋯⋯,它做得很奇怪。
一个更好的解决办法是在数据声明中从时髦地使用匿名istream_iterator对象后退一步,仅仅给那些迭代器名字。以下代码到哪里都能工作:
ifstream dataFile("ints.dat");
istream_iterator
istream_iterator
list
STL中的容器非常优秀。它们提供了前向和逆向遍历的迭代器(通过begin、end、rbegin等);它们能告诉你所容纳的对象类型(通过value_type的typedef);在插入和删除中,它们负责任何需要的内存管理;它们报告容纳了多少对象和最多可能容纳的数量(分别通过size和max_size);而且当然当容器自己被销毁时会自动销毁容纳的每个对象。
给了这样聪明的容器,很多程序员不再担心用完以后的清除工作。呵呵,他们说,他们的容器会帮他们解决那个麻烦。在很多情况下,他们是对的,但当容器容纳的是指向通过new分配的对象的指针时,他们就错了。的确,当一个指针的容器被销毁时,会销毁它(那个容器)包含的每个元素,但指针的“析构函数”是无操作!它肯定不会调用delete。
一个这样的智能指针是Boost库(参见条款50)中的shared_ptr。利用Boost的shared_ptr,本条款的原始例子可以重写为这样:
void doSomething()
{
typedef boost::shared_ ptr
// to Widget"
vector
for (int i = 0; i < SOME_MAGIC_NUMBER; ++i)
vwp.push_back(SPW(new Widget)); // 从一个Widget建立SPW,
// 然后进行一次push_back
... // 使用vwp
} // 这里没有Widget泄漏,甚至
// 在上面代码中抛出异常
你不能有的愚蠢思想是你可以通过建立auto_ptr的容器来形成可以自动删除的指针。那是很可怕的想法,非常危险。
STL容器很智能,但它们没有智能到知道是否应该删除它们所包含的指针。当你要删除指针的容器时要避免资源泄漏,你必须用智能引用计数指针对象(比如Boost的shared_ptr)来代替指针,或者你必须在容器销毁前手动删除容器中的每个指针。
如果你的目标是智能指针的容器,这不意味着你不走运了。智能指针的容器是很好的,条款50描述了你在哪里可以找到和STL容器咬合良好的智能指针。只不过auto_ptr不是那样的智能指针。完全不是。
1、去除一个容器中有特定值的所有对象:
(1)如果你有一个连续内存容器(vector、deque或string——参见条款1),最好的方法是erase-remove惯用法(参见条款32):
c.erase(remove(c.begin(), c.end(), 1963), c.end());
//当c是vector、string或deque时,erase-remove惯用法是去除特定值的元素的最佳方法
(2)上述方法也适合于list,但是,正如条款44解释的,list的成员函数remove更高效:
c.remove(1963);
//当c是list时, remove成员函数是去除特定值的元素的最佳方法
(3)当c是标准关联容器(即,set、multiset、map或multimap)时,使用任何叫做remove的东西都是完全错误的。
对于关联容器,解决问题的适当方法是调用erase:
c.erase(1963);
// 当c是标准关联容器时erase成员函数是去除特定值的元素的最佳方法
2、去除一个容器中满足一个特定判定式的所有对象:
消除下面判断式(参见条款39)返回真的每个对象的问题:
bool badValue(int x); // 返回x是否是“bad”
(1)对于序列容器(vector、string、deque和list),我们要做的只是把每个remove替换为remove_if,然后就完成了:
// 当c是vector、string或deque时这是去掉badValue返回真的对象的最佳方法
c.erase(remove_if(c.begin(), c.end(), badValue), c.end());
// 当c是list时这是去掉 badValue返回真的对象的最佳方法
c.remove_if(badValue);
(2)对于标准关联容器,它不是很直截了当。有两种方法处理该问题,一个更容易编码,另一个更高效。
A、“更容易但效率较低”的解决方案用remove_copy_if把我们需要的值拷贝到一个新容器中,然后把原容器的内容和新的交换:
AssocContainer
...
AssocContainer
remove_copy_if(c.begin(), c.end(),inserter(goodValues, goodValues.end()),badValue); // 从c拷贝不删除的值到goodValues
c.swap(goodValues); // 交换c和goodValues
B、更高效但第一时间不容易想到
AssocContainer
...
for (AssocContainer
i != c.end(); ){ // for循环的第三部分是空的;i现在在下面自增
if (badValue(*i)) c.erase(i++); // 对于坏的值,把当前的
else ++i; // i传给erase,然后作为副作用增加i;对于好的值,只增加i
}
3、在循环内做某些事情(除了删除对象之外):
(1)如果容器是标准序列容器,写一个循环来遍历容器元素,每当调用erase时记得都用它的返回值更新你的迭代器。
for (SeqContainer
if (badValue(*i)){
logFile << "Erasing " << *i << '\n';
i = c.erase(i); // 通过把erase的返回值
} // 赋给i来保持i有效
else
++i;
}
(2)如果容器是标准关联容器,写一个循环来遍历容器元素,当你把迭代器传给erase时记得后置递增它。
1、常规知识点
C++标准委员会向标准中添加了词语,把分配器弱化为对象,同时也表达了他们不会让操作损失能力的希望。
分配器是对象,那表明它们可能有成员功能,内嵌的类型和typedef(例如pointer和reference)等等,但标准允许STL实现认为所有相同类型的分配器对象都是等价的而且比较起来总是相等。
记住当list元素从一个list被接合到另一个时,没有拷贝什么。取而代之的是,调整了一些指针,曾经在一个list中的节点发现他们自己现在在另一个list中。
所以由一个分配器对象(比如L2)分配的内存可以安全地被另一个分配器对象(比如L1)回收。
它意味着可移植的分配器不能有任何非静态数据成员,至少没有会影响它们行为的。
注意这是一个运行期问题。有状态的分配器可以很好地编译。它们只是不按你期待的方式运行。确保一个给定类型的所有分配器都等价是你的责任。如果你违反这个限制,不要期待编译器发出警告。
2、分配器在分配原始内存方面与operator new区别
我早先提及了分配器在分配原始内存方面类似operator new,但它们的接口不同。如果你看看operator new和allocator
void* operator new(size_t bytes);
pointer allocator
// 记住事实上“pointer”总是T*的typedef
(1)两者都带有一个指定要分配多少内存的参数,但对于operator new,这个参数指定的是字节数,而对于allocator
(2)operator new和allocator
3、如果你想要写自定义分配器,让我们总结你需要记得的事情:
● 把你的分配器做成一个模板,带有模板参数T,代表你要分配内存的对象类型。
● 提供pointer和reference的typedef,但是总是让pointer是T*,reference是T&。
● 决不要给你的分配器每对象状态。通常,分配器不能有非静态的数据成员。
● 记得应该传给分配器的allocate成员函数需要分配的对象个数而不是字节数。也应该记得这些函数返回
T*指针(通过pointer typedef),即使还没有T对象被构造。
● 一定要提供标准容器依赖的内嵌rebind模板。
只要你遵循相同类型的所有分配器都一定等价的限制条件,你将毫不费力地使用自定义分配器来控制一般内存管理策略,群集关系和使用共享内存以及其他特殊的堆。
1、标准C++的世界是相当保守和陈旧的。在这个纯洁的世界,所有可执行文件都是静态链接的。不存在内存映射文件和共享内存。
2、当涉及到线程安全和STL容器时,你可以确定库实现允许在一个容器上的多读取者和不同容器上的多写入者。你不能希望库消除对手工并行控制的需要,而且你完全不能依赖于任何线程支持。
3、在STL容器(和大多数厂商的愿望)里对多线程支持的黄金规则:
● 多个读取者是安全的。多线程可能同时读取一个容器的内容,这将正确地执行。当然,在读取时不能有任何写入者操作这个容器。
● 对不同容器的多个写入者是安全的。多线程可以同时写不同的容器。
4、取而代之的是,你必须手工对付这些情况中的同步控制。 在这个例子里,你可以像这样做:
vector
...
getMutexFor(v);
vector
if (first5 != v.end()) { // 这里现在安全了
*first5 = 0; // 这里也是
}
releaseMutexFor(v);
一个更面向对象的解决方案是创建一个Lock类,在它的构造函数里获得互斥量并在它的析构函数里释放它,这样使getMutexFor和releaseMutexFor的调用不匹配的机会减到最小。这样的一个类(其实是一个类模板)基本是这样的:
template
class Lock { // 的类的模板核心;
public: // 忽略了很多细节
Lock(const Containers container)
: c(container)
{
getMutexFor(c); // 在构造函数获取互斥量
}
~Lock()
{
releaseMutexFor(c); // 在析构函数里释放它
}
private:
const Container& c;
};
使用一个类(像Lock)来管理资源的生存期(例如互斥量)的办法通常称为资源获得即初始化。
无论何时,你发现你自己准备动态分配一个数组(也就是,企图写“new T[...]”),你应该首先考虑使用一个vector或一个string。(一般来说,当T是一个字符类型的时候使用string,否则使用vector,但我们在本条款的后面将遇到的情况中,vector
我想到了一个(也是唯一一个)用vector或string代替动态分配数组会出现的问题,而且它只关系到string。很多string实现在后台使用了引用计数(参见条款15),一个消除了不必要的内存分配和字符拷贝的策略,而且在很多应用中可以提高性能。
如果你在多线程环境中使用了引用计数的字符串,你可能发现避免分配和拷贝所节省下的时间都花费在后台并发控制上了。就应该注意线程安全性支持所带来的的性能下降问题。
要知道你正在使用的string实现是否是引用计数的,通常最简单的方式是参考库的文档。另一种方法是看库的string实现的源代码。如果你选择了这个方法,就要记住string是一个basic_string
要看的是basic_string模板。最容易检查的地方是可能的类构造函数。看看它是否在某处增加了引用计数。如果是,string就是引用计数的。如果不是,要么就是string不是引用计数,要么就是你看错了代码。
你想在已经确定string的引用计数支持是一个性能问题的多线程环境中运行,第一,看看你的库实现是否可以关闭引用计数,通常是通过改变预处理变量的值。当然那是不可移植的,但使工作变得可能,值得研究。第二,寻找或开发一个不使用引用计数的string实现(或部分实现)替代品。第三,考虑使用vector
1、 四个相关成员函数。
在标准容器中,只有vector和string提供了所有这些函数。
● size()告诉你容器中有多少元素。它没有告诉你容器为它容纳的元素分配了多少内存。
● capacity()告诉你容器在它已经分配的内存中可以容纳多少元素。那是容器在那块内存中总共可以容纳多少元素,而不是还可以容纳多少元素。如果你想知道一个vector或string中有多少没有被占用的内存,你必须从capacity()中减去size()。如果size和capacity返回同样的值,容器中就没有剩余空间了,而下一次插入(通过insert或push_back等)会引发上面的重新分配步骤。
● resize(Container::size_type n)强制把容器改为容纳n个元素。调用resize之后,size将会返回n。如果n小于当前大小,容器尾部的元素会被销毁。如果n大于当前大小,新默认构造的元素会添加到容器尾部。如果n大于当前容量,在元素加入之前会发生重新分配。
● reserve(Container::size_type n)强制容器把它的容量改为至少n,提供的n不小于当前大小。这一般强迫进行一次重新分配,因为容量需要增加。(如果n小于当前容量,vector忽略它,这个调用什么都不做,string可能把它的容量减少为size()和n中大的数,但string的大小没有改变。在我的经验中,使用reserve来从一个string中修整多余容量一般不如使用“交换技巧”,那是条款17的主题。)
这个简介明确表示了只要有元素需要插入而且容器的容量不足时就会发生重新分配(包括它们维护的原始内存分配和回收,对象的拷贝和析构和迭代器、指针和引用的失效)。所以,避免重新分配的关键是使用reserve尽快把容器的容量设置为足够大,最好在容器被构造之后立刻进行。
2、使用方法和不使用的区别
(1)在大多数STL实现中,这段代码在循环过程中将会导致2到10次重新分配。
vector
for (int i = 1; i <= 1000; ++i) v.push_back(i);
(2)在循环中不会发生重新分配。
vector
v.reserve(1000);
for (int i = 1; i <= 1000; ++i) v.push_back(i);
3、使用原则
通常有两情况使用reserve来避免不必要的重新分配。
第一个可用的情况是当你确切或者大约知道有多少元素将最后出现在容器中。
第二种情况是保留你可能需要的最大的空间,然后,一旦你添加完全部数据,修整掉任何多余的容量。
1、string和char*指针一样大的实现很常见,也很容易找到string是char*7倍大小的string实现
2、string容纳的信息:
实际上每个string实现都容纳了下面的信息:
● 字符串的大小,也就是它包含的字符的数目。
● 容纳字符串字符的内存容量。(字符串大小和容量之间差别的回顾,参见条款14。)
● 这个字符串的值,也就是,构成这个字符串的字符。
● 另外,一个string可能容纳
它的配置器的拷贝。对于为什么这个域是可选的解释,转向条款10并阅读关于这个古怪的管理分配器的规则。
● 依赖引用计数的string实现也包含了
这个值的引用计数。
3、不同实现方式的总结
string实现的自由度比乍看之下多得多,也很显然,不同的实现以不同的方式从它们的设计灵活性中得到好处。让我们总结一下:
● 字符串值可能是或可能不是引用计数的。默认情况下,很多实现的确是用了引用计数,但它们通常提供了关闭的方法,一般是通过预处理器宏。条款13给了一个你可能要关闭的特殊环境的例子,但你也可能因为其他原因而要那么做。比如,引用计数只对频繁拷贝的字符串有帮助,而有些程序不经常拷贝字符串,所以没有那个开销。
● string对象的大小可能从1到至少7倍char*指针的大小。
● 新字符串值的建立可能需要0、1或2次动态分配。
● string对象可能是或可能不共享字符串的大小和容量信息。
● string可能是或可能不支持每对象配置器。
● 不同实现对于最小化字符缓冲区的配置器有不同策略。
1、 将vector和string的数据传给遗留的API
void doSomething(const int* pInts, size_t numInts);
doSomething(&v[0], v.size());
当vector中没有元素时,如果你基于某些原因决定键入v.begin(),就应该键入&*v.begin(),因为这将会产生和&v[0]相同的指针,报错。
2、类似从vector上获取指向内部数据的指针的方法,对string不是可靠的,因为(1)string中的数据并没有保证被存储在独立的一块连续内存中,(2)string的内部表示形式并没承诺以一个null字符结束。这解释了string的成员函数c_str存在的原因,它返回一个按C风格设计的指针,指向string的值。因此我们可以这样传递一个string对象s给这个函数,
void doSomething(const char *pString);
像这样:
doSomething(s.c_str());
即使是字符串的长度为0,它都能工作。
3、如果你将v传给一个修改其元素的C风格API的话,典型情况都是没问题,但被调用的函数绝不能试图改变vector中元素的个数。比如,它绝不能试图在vector还未使用的容量上“创建”新的元素。如果这么干了,v的内部状态将会变得不一致,因为它再也不知道自己的正确大小了。v.size()将会得到一个不正确的结果。并且,如果被调用的函数试图在一个大小和容量(参见条款14)相等的vector上追加数据的话,真的会发生灾难性事件。
如果你将一个有序vector传给一个可能修改其数据的API函数,你需要重视vector在调用返回后不再保持顺序的情况。
4、如果你想用C风格API返回的元素初始化一个vector
(1)你可以利用vector和数组潜在的内存分布兼容性将存储vecotr的元素的空间传给API函数:
// C API:此函数需要一个指向数组的指针,数组最多有arraySize个double
// 而且会对数组写入数据。它返回写入的double数,不会大于arraySize
size_t fillArray(double *pArray, size_t arraySize);
vector
// 它的大小是maxNumDoubles
vd.resize(fillArray(&vd[0], vd.size())); // 让fillArray把数据
// 写入vd,然后调整vd的大小
// 为fillArray写入的元素个数
这个技巧只能工作于vector,因为只有vector承诺了与数组具有相同的潜在内存分布。但是,如果你想用来自C风格API的数据初始化string对象,也很简单。只要让API将数据放入一个vector
// C API:此函数需要一个指向数组的指针,数组最多有arraySize个char
// 而且会对数组写入数据。它返回写入的char数,不会大于arraySize
size_t fillString(char *pArray, size_t arraySize);
vector
// 它的大小是maxNumChars
size_t charsWritten = fillString(&vc[0], vc.size()); // 让fillString把数据写入vc
string s(vc.begin(), vc.begin()+charsWritten); // 从vc通过范围构造函数,拷贝数据到s(参见条款5)
(2)让C风格API把数据放入一个vector,然后拷到你实际想要的STL容器中的主意总是有效的:
size_t fillArray(double *pArray, size_t arraySize); // 同上
vector
vd.resize(fillArray(&vd[0], vd.size()));
deque
list
set
(3)此外,这也提示了vector和string以外的STL容器如何将它们的数据传给C风格API。只要将容器的每个数据拷
到vector,然后将它们传给API:
void doSomething(const int* pints, size_t numInts); // C API (同上)
set
...
vector
if (!v.empty()) doSomething(&v[0], v.size()); // 传递数据到API
1、 使用方法
vector
同样的技巧可以应用于string:
string s;
... // 使s变大,然后删除所有它的字符
string(s).swap(s); // 在s上进行“收缩到合适”
2、原理
表达式vector
1、做为一个STL容器,vector
2、如果c是一个T类型对象的容器,且c支持operator[],然而vector
3、vector
4、标准库提供了两个替代品,它们能满足几乎所有需要。
第一个是deque
第二个vector