美文网首页Effective STL
【Effective STL(1)】容器

【Effective STL(1)】容器

作者: downdemo | 来源:发表于2018-02-27 14:46 被阅读42次

    01 仔细选择你的容器

    02 小心对“容器无关代码”的幻想

    • 序列容器支持push_front、push_back,但关联容器不支持
    • 关联容器提供logN复杂度的lower_bound、upper_bound和equal_range,但序列容器没有
    • 不同的容器是不同的,优缺点有重大不同。它们不被设计成可互换的,而且你做不了什么包装的工作
    • 若有必要改变容器类型,使用封装简化改变,最简单的就是通过自由地对容器和迭代器类型使用typedef
    • 不要这样写
    class Widget {...};
    vector<Widget> vw;
    Widget bestWidget;
    ... // 给bestWidget一个值
    vector<Widget>::iterator i = // 寻找和bestWidget相等的Widget
    find(vw.begin(), vw.end(), bestWidget);
    
    • 改成
    class Widget { ... };
    typedef vector<Widget> WidgetContainer;
    typedef WidgetContainer::iterator WCIterator;
    WidgetContainer cw;
    Widget bestWidget;
    ...
    WCIterator i = find(cw.begin(), cw.end(), bestWidget);
    
    • 如果问题的改变是简单的加上用户的allocator时特别方便
    class Widget { ... };
    template<typename T> // 关于为什么这里需要一个template
    SpecialAllocator { ... }; // 请参见条款10
    typedef vector<Widget, SpecialAllocator<Widget> > WidgetContainer;
    typedef WidgetContainer::iterator WCIterator;
    WidgetContainer cw; // 仍然能用
    Widget bestWidget;
    ...
    WCIterator i = find(cw.begin(), cw.end(), bestWidget); // 仍然能用
    
    • typedef不能阻止用户使用(或依赖)任何他们不应该用的(或依赖的)。使用class避免把容器类型暴露给用户,比如建立一个客户列表不要直接用list,建立一个CustomerList类,把list隐藏在private
    class CustomerList {
    private:
        typedef list<Customer> CustomerContainer;
        typedef CustomerContainer::iterator CCIterator;
        CustomerContainer customers;
    public: // 通过这个接口
        ... // 限制list特殊信息的可见性
    };
    

    03 使容器里对象的拷贝操作轻量而正确

    • 如果给基类类型容器插入派生类对象,拷入容器时派生类的特有部分会被删除
    • 分割问题表明把派生类对象插入基类对象的容器几乎总是错的,解决办法是是建立指针而非对象的容器,拷贝指针很快,不过指针的容器也有STL相关的问题,智能指针的容器是一个好的选择

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

    • 事实上empty的典型实现是一个返回size是否返回0的内联函数,对于所有的标准容器,empty是一个常数时间的操作,但对于一些list实现,size花费线性时间。

    05 尽量使用区间成员函数代替他们的单元素兄弟

    • 给定两个vector,使v1和v2的后半部分一样
    v1.assign(v2.begin() + v2.size() / 2, v2.end());
    
    • assign对于所有标准序列容器(vector,string,deque和list)都有效,完全代替一个容器内容应该想到赋值
    • 不用区间成员函数来解决这个问题,就必须写一个循环遍历赋值,损失效率
    vector<Widget> v1, v2; // 假设v1和v2是Widget的vector
    v1.clear();
    for (vector<Widget>::const_iterator ci = v2.begin() + v2.size() / 2;
        ci != v2.end(); ++ci)
            v1.push_back(*ci);
    
    • 避免循环的一个做法是用copy算法,但其实copy中也存在一个循环,效率损失仍然存在
    v1.clear();
    copy(v2.begin() + v2.size() / 2, v2.end(), back_inserter(v1));
    
    • copy可以用一个insert的区间版本代替
    v1.insert(v1.end(), v2.begin() + v2.size() / 2, v2.end());
    
    • 使用区间成员函数代码更少也更清晰明确,不仅仅是编程风格的区别,实际上存在效率问题。再比如要把一个int数组拷贝到vector前端,使用insert的区间形式,只有一次调用
    int data[numValues]; // 假设numValues在其他地方定义
    vector<int> v;
    ...
    v.insert(v.begin(), data, data + numValues); // 把data中的int插入v前部
    
    • 如果用循环迭代调用插入,需要调用numValues次insert,每次insert一个元素到v,插入点以上的每个元素都必须向上移动一次,如果v在插入前有n个元素,则一共会发生n*numValues次移动,如果元素类型为类,则有n*numValues次函数调用:(n-1)*numValues次赋值操作符和numValues次拷贝构造函数
    vector<int>::iterator insertLoc(v.begin());
    for (int i = 0; i < numValues; ++i) {
        insertLoc = v.insert(insertLoc, data[i]);
        ++insertLoc;
    }
    
    // 或者用copy,本质上和上面代码是一样的
    copy(data, data + numValues, inserter(v, v.begin()));
    
    • 区间insert函数直接把现有元素移到最后的位置,开销是每个元素一次移动,共n次移动,numValues次拷贝构造函数,n-numValues次赋值操作符,比单元素插入少n*(numValues-1)次,如果numValues是100,insert的区间形式就少了99%移动
    • 用区间版本代替单元素插入的方法时,不要忘记有些单元素变量采用不同的函数名伪装自己。如果一个循环调用push_front或push_back,或一个算法,如copy,参数是front_inserter或者back_inserter,采用insert的区间形式作为优先策略。常见的区间成员函数用法如下
    // 区间构造
    container::container(InputIterator begin, // 区间的起点
        InputIterator end); // 区间的终点
    // 区间插入,所有序列容器提供
    void container::insert(iterator position, // 区间插入的位置
        InputIterator begin, // 插入区间的起点
        InputIterator end); // 插入区间的终点
    // 关联容器使用比较函数决定元素位置,省略了position参数
    void container::insert(lnputIterator begin, InputIterator end);
    // 序列容器区间删除
    iterator container::erase(iterator begin, iterator end);
    // 关联容器区间删除
    void container::erase(iterator begin, iterator end);
    // 区间赋值
    void container::assign(InputIterator begin, InputIterator end);
    

    06 警惕C++最令人恼怒的解析

    • 假设有一个int文件,将这些int拷贝到一个list中
    ifstream dataFile("ints.dat");
    list<int> data(istream_iterator<int>(dataFile), istream_iterator<int>());
    
    • 想法看上去合理,但实际上编译器会把这句话分析为一个名为data的函数。解决办法是在数据声明中从使用匿名istream_iterator对象后退一步,仅仅给迭代器名字
    ifstream dataFile("ints.dat");
    istream_iterator<int> dataBegin(dataFile);
    istream_iterator<int> dataEnd;
    list<int> data(dataBegin, dataEnd);
    

    07 当使用new得指针的容器时,记得在销毁容器前delete那些指针

    • 当一个指针的容器被销毁时,会销毁每个元素,但new的对象不会调用delete
    void f()
    {
        vector<A*> v;
        for (int i = 0; i < 10; ++i)
        v.push_back(new A);
        ...
    } // 出了作用域A泄漏!
    
    • 可以这样
    void f()
    {
        vector<A*> v;
        ...
        for (vector<A*>::iterator i = v.begin(); i != v.end(); ++i) delete *i;
    }
    
    • 但这比for_each代码多且不够清晰,另外这段代码也不是异常安全的,如果删除前抛出异常仍然有资源泄露,所以要把delete放到一个函数对象中
    template<typename T>
    struct DeleteObject : public unary_function<const T*, void> {
        void operator()(const T* ptr) const
        {
            delete ptr;
        }
    };
    
    void f()
    {
        ...
        for_each(v.begin(), v.end(), DeleteObject<A>);
    }
    
    • 然而,这里需要指定删除的对象类型为A,既然v的元素类型已经是A,这里再指出就显得冗余,而且易出错,解决办法是把模板化从DeleteObject移到内部的operator(),编译器知道DeleteObject::operator()的指针类型,通过指针类型自动实例化一个operator()
    struct DeleteObject {
        template<typename T> // 模板化加在这里
        void operator()(const T* ptr) const
        {
            delete ptr;
        }
    };
    // 使用时无需再指定类型
    for_each(v.begin(), v.end(), DeleteObject());
    
    • 但仍不是异常安全的,解决方案是用智能指针容器代替指针容器
    void f()
    {
        vector<shared_ptr<A>> v;
        for (int i = 0; i < 10; ++i) v.push_back(shared_ptr<A>(new A));
        ...
    } // 这里没有泄漏
    

    08 永不建立auto_ptr的容器

    09 在删除选项中仔细选择

    • 删除容器Container<int> c中的所有值为1963的对象,删除方法因容器类型不同而不同,没有通用方法
    // 连续内存容器(vector、deque、string)最好的删除方法是erase-remove惯用法
    c.erase(remove(c.begin(), c.end(), 1963), c.end()); 
    // list的成员函数直接remove更高效
    c.remove(1963);
    // 关联容器没有remove,方法是调用erase,只花费对数时间(序列容器为线性时间)
    c.erase(1963);
    
    • 如果问题修改为删除bool f(int x)返回值为true的对象,序列容器只要把remove替换为remove_if
    c.erase(remove_if(c.begin(), c.end(), f), c.end());
    c.remove_if(f); // c为list
    
    • 关联容器有两种方法,一种容易编码但效率低的方法是用remove_copy_if把需要的值拷贝到一个新容器,然后把原容器的内容和新的交换
    remove_copy_if(c.begin(), c.end(), 
        inserter(c2, c2.end()), f);
    c.swap(c2);
    
    • 另一种方法是避免拷贝开销,直接遍历原容器删除
    for (AssocContainer<int>::iterator it = c.begin(); it!= c.end(); ++i) {
        if (badValue(*it)) c.erase(it);
    }
    
    • 但这样会出现未定义行为,当元素被删除,对应的迭代器就失效了,erase返回时it已经失效,for循环里的++it就是错的。解决方法是erase前得到下一个元素的迭代器
    for (AssocContainer<int>::iterator it = c.begin(); it != c.end();) {
        if (f(*it)) c.erase(it++);
        else ++it;
    }
    
    • 再增加问题,每次删除后要把一条消息写到日志文件中,对关联容器很简单,只要加个打印
    ofstream logFile;
    AssocContainer<int> c;
    for (AssocContainer<int>::iterator it = c.begin(); it !=c.end();) {
        if (f(*it)) {
            logFile << "Erasing " << *it <<'\n';
            c.erase(it++); // 删除元素
        }
        else ++it;
    }
    
    • 但vector、string和deque不能再使用erase-remove惯用法,因为没有办法让erase或remove写日志文件,也不能仿照关联容器用erase,因为erase会使序列容器的被删元素和之后所有的元素迭代器失效,再用自增显然会出错。解决方法是使用erase的返回值保持迭代器有效
    for (SeqContainer<int>::iterator it = c.begin(); it != c.end();) {
        if (f(*it)){
        logFile << "Erasing " << *it << '\n';
        it = c.erase(i);
        }
        else ++it;
    }
    
    • 对于list,关联容器和序列容器的做法都可行

    10 注意分配器的协定和约束

    • 分配器最初被设想为抽象内存模型,在定义的内存模型中提供指针和引用的typedef才有意义。C++标准中类型T的对象的默认分配器(allocator<T>)提供typedef allocator<T>::pointer和allocator<T>::reference,而且也希望用户定义的分配器也提供这些typedef,问题是在C++里没有办法捏造引用
    • 标准允许库实现假设每个分配器的pointer typedef是T*的同义词,每个分配器的reference typedef与T&相同。库实现可以忽视typedef并直接使用原始指针和引用,所以写出提供新指针和引用类型的分配器的方法也没用,因为STL实现将忽视typedef
    • 标准允许STL实现认为相同类型的分配器等价,原因是,假设有两个容器v1和v2,把v2赋值到v1,销毁v1时v1的分配器要能回收由v2的分配器分配的内存,如果两者不等价,接合操作就很难实现
    • 相同类型的分配器等价是十分严厉的约束,这代表如果要可移植,分配器就不能有状态,即不能有任何非静态数据成员,这表明不能有从两个不同的堆分配的SpecialAllocator<int>,它们是不等价的
    • 分配器在分配原始内存方面类似operator new,但它们的接口不同,两者都带有一个指定要分配多少内存的参数,但对于operator new,这个参数指定的是字节数,而对于allocator<T>::allocate指定的是内存里能容纳多少个T对象。在sizeof(int) == 4的平台上,容纳一个int的内存得把4传给operator new,把1传给allocator<int>::allocate
    void* operator new(size_t bytes);
    pointer allocator<T>::allocate(size_type numObjects);
    // 本质上pointer就是T*的typedef
    
    • 大多数标准容器从未调用它们例示的分配器,对list和所有标准关联容器都是如此(set、multiset、map和multimap)。原因是这些是基于节点的容器,这些容器所基于的数据结构是每当值被储存就动态分配一个新节点
    // list的可能实现
    template<typename T, typename Allocator = allocator<T>>
    class list {
    private:
        Allocator alloc; // 用于T类型对象的分配器
        struct ListNode { // 链表里的节点
            T data:
            ListNode *prev;
            ListNode *next;
        };
        ...
    };
    
    • 添加一个新节点到list时需要从分配器获取内存,需要的不是T的内存而是包含了一个T的ListNode的内存,那使Allocator对象没用了,因为它为T分配内存而不为ListNode分配内存。list需要的是从它的分配器类型那里获得用于ListNode的对应分配器的方法,而分配器不能提供list需要的,这就是list不让Allocator做任何分配的原因
    • 分配器模板A(例如,std::allocator,SpecialAllocator,等)都被认为有一个叫做rebind的内嵌结构体模板。rebind带有一个类型参数U,并且只定义一个typedef,other。 other是A<U>的一个简单名字。list<T>可以通过Allocator::rebind<ListNode>::other从它用于T对象的分配器(Allocator)获取对应的ListNode对象分配器
    template<typename T>
    class allocator {
    public:
        template<typename U>
        struct rebind {
            typedef allocator<U> other;
        }
    ...
    };
    
    • 如果你想要写自定义分配器,必须
      • 把分配器做成一个模板,模板参数T代表要分配内存的对象类型。
      • 提供pointer和reference的typedef,让pointer是T*,reference是T&。
      • 不要给你的分配器对象状态,分配器不能有非静态的数据成员
      • 传给分配器的allocate成员函数需要分配的对象个数而不是字节数,函数返回T*指针(通过pointer typedef),即使还没有T对象被构造
      • 提供标准容器依赖的内嵌rebind模板

    11 理解自定义分配器的正确用法

    • 假如有一个仿效malloc和free的程序,用于管理共享内存的堆
    void* mallocShared(size_t bytesNeeded);
    void freeShared(void *ptr);
    
    • 希望能把STL容器的内容放在共享内存中
    template<typename T>
    class SharedMemoryAllocator {
    public:
        ...
        pointer allocate(size_type numObiects, const void *localityHint = 0)
        {
            return static_cast<pointer>(mallocShared(numObiects * sizeof(T)));
        }
        void deallocate(pointer ptrToMemory, size_ type numObjects)
        {
            freeShared(ptrToMiemory);
        }
        ...
    };
    
    • 使用SharedMemoryAllocator
    typedef vector<double, SharedMemoryAllocator<double>> SharedDoubleVec;
    ...
    { // 开始一个块
        SharedDoubleVec v; // 建立一个元素在共享内存中的vector
        ...
    }
    
    • v分配来容纳它元素的内存将来自共享内存,但v本身只是一个普通的基于堆的对象,所以它将被放在运行时系统为基于堆的对象使用的任何内存,而非共享内存。为了把v的内容放进共享内存,必须这样
    // 分配足够的共享内存
    void *pVectorMemory = mallocShared(sizeof(SharedDoubleVec));
    // 用placement new建立一个SharedDoubleVec对象
    SharedDoubleVec *pv = new (pVectorMemory) SharedDoubleVec;
    ...
    pv->~SharedDoubleVec(); // 销毁共享内存中的对象
    freeShared(pVectorMemory); // 销毁原来的共享内存块
    
    • 除非真的要让一个容器(与它的元素相反)在共享内存里,否则避免手工的分配/建造/销毁/回收的过程。另外上述代码忽略了mallocShared可能返回一个null指针,产品代码必须考虑这种可能性。共享内存中的vector的建立由placement new完成而不是基本的new
    • 再举一个例子,有两个堆,命名为Heap1和Heap2类。每个堆类有用于进行分配和回收的静态成员函数
    class Heap1 {
    public:
        ...
        static void* alloc(size_t numBytes, const void *memoryBlockToBeNear);
        static void dealloc(void *ptr);
        ...
    };
    class Heap2 { ... }; // 有相同的alloc/dealloc接口
    
    • 要在不同的堆里联合定位一些STL容器的内容,首先,设计一个分配器,使用像Heap1和Heap2那样用于真实内存管理的类
    template<typenameT, typename Heap>
    class SpecificHeapAllocator {
    public:
        pointer allocate(size_type numObjects, const void *localityHint = 0)
        {
            return static_cast<pointer>(Heap::alloc(numObjects * sizeof(T),
            localityHint));
        }
        void deallocate(pointer ptrToMemory, size_type numObjects)
        {
            Heap::dealloc(ptrToMemory);
        }
        ...
    };
    
    • 然后使用SpecificHeapAllocator来把容器的元素集合在一起
    // 把v和s的元素放进Heap1
    // 把L和m的元素放进Heap2
    vector<int, SpecificHeapAllocator<int, Heap1>> v;
    set<int, SpecificHeapAllocator<int Heap1>> s;
    
    list<Widget,
        SpecificHeapAllocator<Widget, Heap2>> L;
    map<int, string, less<int>,
        SpecificHeapAllocator<pair<const int, string>, Heap2>> m;
    

    12 对STL容器线程安全性的期待现实一些

    • SGI定义的STL对多线程支持的黄金规则
      • 多个读取者是安全的。多线程可能同时读取一个容器的内容,这将正确地执行。当然,在读取时不能
        有任何写入者操作这个容器
      • 对不同容器的多个写入者是安全的。多线程可以同时写不同的容器
    • 下列代码查找vector<int>中第一次出现5的位置,如果找到了,就把这个值改为0
    vector<int> v;
    vector<int>::iterator first5(find(v.begin(), v.end(), 5)); // 行1
    if (first5 != v.end()){ // 行2
        *first5 = 0; // 行3
    }
    
    • 多线程环境中,另一个线程可能在行1完成后修改v中的数据,这样行2对first5和v.end的检测就没有意义,同理行3中对*first5的赋值是不安全的,因为另一个线程可能在行2和行3之间执行并以某种方式使first5失效。要让上述代码线程安全,v必须从行1到行3保持锁定,STL实现很难,同步原语(如信号灯,互斥量)通常开销很大。不能期望任何STL实现解决问题,必须手工对付这些情况中的同步控制
    vector<int> v;
    ...
    getMutexFor(v);
    vector<int>::iterator first5(find(v.begin(), v.end(), 5));
    if (first5 != v.end()) { // 这里现在安全了
        *first5 = 0; // 这里也是
    }
    releaseMutexFor(v);
    
    • 更面向对象的解决方案是创建一个Lock模板类,在构造函数里获得互斥量并在析构函数里释放,使getMutexFor和releaseMutexFor的调用不匹配的机会减到最小。使用一个类(像Lock)来管理资源的生存期(例如互斥量)的办法通常称为资源获得即初始化,即RAII
    // 获取和释放容器的互斥量的类的模板核心
    // 忽略了很多细节
    template<typename Container>
    class Lock {
    public:
        Lock(const Containers container) : c(container)
        {
            getMutexFor(c);
        }
        ~Lock()
        {
            releaseMutexFor(c);
        }
    private:
        const Container& c;
    };
    
    • 使用方法
    vector<int> v;
    ...
    { // 建立新块
        Lock<vector<int> > lock(v);
        vector<int>::iterator first5(find(v.begin(), v.end(), 5));
        if (first5 != v.end()) {
        *first5 = 0;
        }
    }
    

    相关文章

      网友评论

        本文标题:【Effective STL(1)】容器

        本文链接:https://www.haomeiwen.com/subject/xlfmxftx.html