Effective STL之容器

一、慎重选择容器类型

1、容器分类

  • 标准STL序列容器:vector,string,deque,list;

  • 标准STL关联容器:set,multiset,map,multimap;

  • 非标准关联容器(基于散列表):hash_set,hash_multiset,hash_map,hash_multimap;

  • 几种标准的非STL容器:数组、bitset、stack、queue、priority_queue;

  • 连续内存容器:vector,string,deque;

  • 基于节点的容器:list。

2、主要容器分析

  • 连续内存容器(也叫做基于数组的容器)在一个或多个(动态分配)的内存块中保存它们的元素。如果一个新元素被插入或者已存元素被删除,其他在同一个内存块的元素就必须向上或者向下移动来为新元素提供空间或者填充原来被删除的元素所占的空间。这种移动影响了效率和异常安全;

  • 基于节点的容器在每个内存块(动态分配)中只保存一个元素。容器元素的插入或删除只影响指向节点的指针,而不是节点自己的内容。所以当有东西插入或删除时,元素值不需要移动。

3、容器选择

  • vector、list和deque提供给程序员不同的复杂度,因此应该这么用:vector是一种可以默认使用的序列类型,当很频繁地对序列中部进行插入和删除时应该用list,当大部分插入和删除发生在序列的头或尾时可以选择deque这种数据结构。

  • 你需要“可以在容器的任意位置插入一个新元素”的能力吗?如果是,你需要序列容器,关联容器做不到。

  • 你关心元素在容器中的顺序吗?如果不,散列容器就是可行的选择。否则,你要避免使用散列容器。

  • 你需要哪一类迭代器?如果必须是随机访问迭代器,在技术上你就只能限于vector、deque和string。

  • 当插入或者删除数据时,是否非常在意容器内现有元素的移动?如果是,你就必须放弃连续内存容器。

  • 容器中的数据的内存布局需要兼容C吗?如果是,你就只能用vector。

  • 查找速度很重要吗?如果是,你就应该看看散列容器(优于)排序的vector(优于)标准的关联容器大概是这个顺序。

  • 你需要有可靠地回退插入和删除的能力吗?如果是,你就需要使用基于节点的容器。如果你需要多元素插入的事务性语义,你就应该选择list,因为list是唯一提供多元素插入事务性语义的标准容器。事务性语义对于有兴趣写异常安全代码的程序员来说非常重要。

  • 你要把迭代器、指针和引用的失效次数减到最少吗?如果是,你就应该使用基于节点的容器,因为在这些容器上进行插入和删除不会使迭代器、指针和引用失效(除非它们指向你删除的元素)。一般来说,在连续内存容器上插入和删除会使所有指向容器的迭代器、指针和引用失效。

  • 你需要具有有以下特性的序列容器吗:1)可以使用随机访问迭代器;2)只要没有删除而且插入只发生在容器结尾,指针和引用的数据就不会失效?这个一个非常特殊的情况,但如果你遇到这种情况,deque就是你梦想的容器。(有趣的是,当插入只在容器结尾时,deque的迭代器也可能会失效,deque是唯一一个“在迭代器失效时不会使它的指针和引用失效”的标准STL容器。)

二、确保容器中的对象拷贝正确而高效

  容器可以存储对象。当向容器添加对象(insert或push_back),添加到容器的对象是指定对象的拷贝;同理,取出对象时也是通过拷贝。因为会发生拷贝,如果这个拷贝过程比较“昂贵”,那么这可能会是性能的瓶颈。容器中的对象越多,那么就很可能在拷贝上消耗更大的代价。此外,还有一个非传统意义上的“拷贝”对象,把这样的对象放进容器会导致不幸。
  因为继承的存在,拷贝时可能会发生分割。即,如果用基类对象建立容器,而插入派生类对象,这时通过基类的拷贝构造函数插入,派生类对象会被切割为基类对象(剥离):

vector vw; 
class SpecialWidget:public Widget {...};    // SpecialWidget从上面的Widget派生

SpecialWidget sw; 
vw.push_back(sw); // sw被当作基类对象拷入vw,当拷贝时它的特殊部分丢失了

  避免上面的问题的一个解决方法是建立指针容器,这样拷贝更快,且没有分割问题,但是指针容器本身也有问题。要避免这个问题的办法是建立智能指针的容器。

三、用empty来代替检查size()是否为0

  对于任意容器c,下面的代码:

if(c.size()==0)

  本质上等价于:

if(c.empty())

  empty的典型实现是一个返回size是否为0的内联函数。但首选应该是empty,因为对于所有标准容器,empty是一个常数时间操作,但对于list,size的花费为线性时间。

  list之所以不能提供常数时间的size实现,是因为list特有的splice有很多要处理的东西。例如:

list<int> list1; 
list<int> list2; 
... 
list1.splice(                   // 把list2中
    list1.end(), list2,         // 从第一次出现5到
    find(list2.begin(), list2.end(), 5),        // 最后一次出现10
    find(list2.rbegin(), list2.rend(), 10).base()   // 的所有节点移到list1的结尾。
);
//上面这段代码假设了list2在5后面有个10。执行完上面代码后,list1中有多少元素,在遍历find(list2.begin(), list2.end(), 5)和find(list2.rbegin(), list2.rend(), 10).base()之间有多少元素之前,无法得知list1中元素个数。

  list中如果把size设计成常数时间操作,那么list成员函数在更新list时也要更新size的大小,包括splice。这时splice就是线性时间操作了。size和splice不能都是常数时间操作,必须有一个让步。

四、区间成员函数优先于与之对应的单元素成员函数

  使用区间成员函数有以下好处:

  • 更少的函数调用

  • 更少的元素移动

  • 更少的内存分配

   例:将v2后半部的元素赋值给v1:
  单元素操作:

for (auto ci = v2.begin() + v2.size() / 2;ci != v2.end();++ci)
    v1.push_back(*ci);

  使用区间成员函数assign():

v1.assign(v2.begin() + v2.size() / 2, v2.end()); 

五、如果容器中包含了通过new操作创建的指针,切记在容器对象析构前将指针delete掉

void dosomething()
{
    vector vwp;
    for (int i=0; inew widget);
    ...
}//这里发生了widget的泄露

【Note】:
1)容器销毁前需要自行销毁指针所指向的对象;否则就造成了内存泄漏;
2)使用排序等算法时,需要构造基于对象的比较函数,如果使用默认的比较函数,其结果是基于指针大小的比较,而不是对象的比较;

  最好的办法是:使用智能指针!

六、慎重选择删除元素的方法

1、删除特定值

(1)连续内存容器(vector、deque或string)使用 erase-remove。例:

c.erase(remove(c.begin(), c.end(), 1963), c.end());

  当c是vector、string或deque时,erase-remove惯用法是去除特定值的元素的最佳方法。

(2)以上方法也适合于list,但是,list的成员函数remove更高效。例:

c.remove(1963);

  当c是list时,remove成员函数是去除特定值的元素的最佳方法。

(3)对于关联容器,解决问题的适当方法是调用erase。例:

c.erase(1963);

  当c是标准关联容器时erase成员函数是去除特定值的元素的最佳方法。

2、删除判断式

(1)对于序列容器(vector、string、deque和list),我们要做的只是把每个remove替换为remove_if。例:

bool badValue(int x);   //返回x是否是“bad
//当c是vector、string或deque时这是去掉badValue返回真的对象的最佳方法。
c.erase(remove_if(c.begin(), c.end(), badValue), c.end());
//当c是list时这是去掉badValue返回真的对象的最佳方法。
c.remove_if(badValue);

(2)对于标准关联容器效率低的remove_copy_if。例:

AssocContainerc;
...
AssocContainergoodValues;
remove_copy_if(c.begin(),c.end(),inserter(goodValues,goodValues.end()),badValue);
c.swap(goodValues);

3、要在循环内做某些操作

(1)对于序列容器vector、string、deque和list(用返回值更新迭代器):

for ( SeqContainer::iterator i = c.begin();  i != c.end();)
{
        if (badValue(*i))
         {
                logFile << "Erasing " << *i << '\n';
                i = c.erase(i);            
        }                                 
        else
                ++i;
}

(2)对于标准关联容器(对迭代器递增):

AssocContainer c;
...
// for循环的第三部分是空的;i现在在下面自增对于坏的值,把当前的i传给erase,然后作为副作用增加i;对于好的值,只增加i。
for ( AssocContainer::iterator i = c.begin();   
        i != c.end();                       
        /*nothing*/ ){                        
        if (badValue(*i)) c.erase(i++);      
        else ++i;                                    
}

七、STL的线程安全性

1、线程安全的情况

  • 多个读取者是安全的。多线程可能同时读取一个容器的内容,这将正确地执行。当然,在读取时不能有任何写入者操作这个容器。

  • 对不同容器的多个写入者是安全的。多线程可以同时写不同的容器。

2、线程不安全的情况

  • 在对同一个容器进行多线程的读写、写操作时。

  • 在每次调用容器的成员函数期间都要锁定该容器。

  • 在每个容器返回的迭代器(例如通过调用begin或end)的生存期之内都要锁定该容器。

  • 在每个在容器上调用的算法执行期间锁定该容器。

  看到风险了吧?在工程中多线程操作STL的场景应该还是比较常见的,一个典型的例子就是用其来做生产者——消费者模型的队列或者其他共享队列,这样为了应对线程安全问题我们必须自己对容器操作进行封装。

template    // 获取和释放容器的互斥量
class Lock {                    // 的类的模板核心;
public:                         // 忽略了很多细节
        Lock(const Containers container)
                        : c(container)
        {
                getMutexFor(c); // 在构造函数获取互斥量
        }
        ~Lock()
        {
                releaseMutexFor(c); // 在析构函数里释放它
        }
private:
        const Container& c;
};

你可能感兴趣的:(#,C++STL,stl,c++)