【读书笔记】【Effective STL】容器

第 1 条:慎重选择容器类型。

  • C++ 中不同的容器分类如下:【标准 or 非标准】【STL or 非 STL】【序列 or 关联】
    1. 标准 STL 序列容器:vectorstringdequelistforward_list (C++11)、array (C++11)。【提供随机访问迭代器】
      • vector 可以作为 string 的替代。【条款 13】
      • vector 作为标准关联容器的替代。【条款 23】
    2. 标准 STL 关联容器:setmultisetmapmultimapunordered_set (C++11)、unordered_multiset (C++11)、unordered_map (C++11)、unordered_multimap (C++11)。【提供双向迭代器】
    3. 非标准序列容器:slist (单向链表,即提供单向迭代器)和 rope (“重型” string)。【条款 50】
    4. 非标准关联容器:hash_sethash_multisethash_maphash_multimap。【条款 25】
    5. 标准的非 STL 容器:数组、bitsetinclude )、valarrayinclude )、stackinclude )、queueinclude ) 和 priority_queueinclude )。【本书不多讨论,因为是非 STL 容器】【条款 16 提及数组】【条款 18 提及 bitset
  • 选择容器的几个思路:
    1. 是否需要在容器的任意位置插入新元素?如果需要,选序列;关联容器做不到(因为必须遍历到该位置才可以插入,不可随机插入)。
    2. 是否关心容器中的元素是如何排序的?如果不关心,哈希容器可行;否则,避免哈希容器。
    3. 选择的容器必须是标准 C++ 的一部分吗?前面提及的容器中,只有 slistrope 非标准。
    4. 需要哪种类型的迭代器?如果必须是随机访问迭代器,容器的选择就限定为 vectordequestring(还有一个 rope);如果要求使用双向迭代器,则必须避免 slist 以及哈希容器的一个常见实现。
    5. 当发生元素的插入或删除操作时,避免移动容器中原来的元素是否很重要?如果是,就要避免连续内存的容器。
    6. 容器中数据的布局是否需要和 C 兼容?如果需要兼容,就只能选择 vector
    7. 元素的查找速度是否是关键的考虑因素?如果是,就要考虑哈希容器、排序的 vector 和标准关联容器(按顺序先后优先选择)。
    8. 如果容器内部使用了引用计数技术(reference counting),你是否介意?如果介意,就要避免使用 stringrope 也需要避免);如果避免使用 string 的情况下,可考虑 vector 来表示字符串。
    9. 对插入和删除操作,你需要事务语义(transactional semantics)吗?也就是说,在插入和删除操作失败时,你需要回滚的能力吗?如果需要,就要使用基于节点的容器;如果对多个元素的插入操作(即针对一个区间的形式)需要事务语义,则你需要选择 list,因为在标准容器中,只有 list 对多个元素的插入操作提供了事务语义。【事务语义对于编写异常安全(exception-safe)的代码有帮助】【使用连续内存的容器也可以获得事务语义,但是要付出性能上的代价,代码也不直观(《Exceptional C++》第 17 条)】
    10. 你需要使迭代器、指针和引用变为无效的次数最少吗?如果是这样,就要使用基于节点的容器,因为对这类容器的插入和删除操作从来不会使迭代器、指针和引用变得无效(除非它们指向了一个你正在删除的元素)。【针对连续内存容器的插入和删除操作一般会使指向该容器的迭代器、指针和引用变得无效】
    11. 如果序列容器的迭代器是随机访问类型,而且只要没有删除操作发生,且插入操作只发生在容器的末尾,则指向数据的指针和引用就不会变为无效,这样的容器是否对你有帮助?这是非常特殊的情形,但如果你面对的情形正是如此,则 deque 是你所希望的容器。【当插入操作仅在容器末尾发生时,deque 的迭代器有可能会变为无效;deque 是唯一的、迭代器可能会变为无效而指针和引用不会变为无效的 STL 标准容器

第 2 条:不要试图编写独立于容器类型的代码。

  • 注意标题,本条款讨论的是独立于容器类型的代码。
  • STL 是以泛化原则为基础的:数组泛化为容器(容器又泛化为顺序或关联容器),函数泛化为算法,指针泛化为迭代器。
    • 要注意,类似的容器才被赋予相似的功能;序列容器和关联容器之间有很多不相容的操作,不同容器有各自优缺点,它们本身并不是设计来交换使用的。(试图编写对序列容器和关联容器都适用的代码几乎是毫无意义的)
  • 要想泛化容器的概念使其变得通用,往往会走入死胡同;容器设计上本身有很多细节,比如不能随便把容器中数据传递到 C 接口(只有 vector 支持这一点),比如 vector 实际上并不存储 bool 类型变量(因此也无法直接用 bool 变量实例化某些容器)。
  • 本条款讨论的内容与 typedef 无关。
    • typedef 其实只是类型别名,它所带来的封装效果只是词法上的。
  • 要想减少在替换容器类型时所需要修改的代码,可以把容器隐藏到一个类中,并尽量减少那些通过类接口(而使外部)可见的、与容器相关的信息。
    • 例如想创建一个顾客列表,不要直接使用 list;而是创建一个新类,将 list 隐藏在其 private 部分中。
    • 这里的好处在于,如果想要换底层容器(比如换成 vector),客户所受影响比较少。
    • 这个理念在于:虽然程序员不能编写独立于容器类型的代码,但是尽量封装使得客户使用上更直观。

第 3 条:确保容器中的对象拷贝正确而高效。

  • 当向容器中加入对象(通过如 insertpush_back 之类的操作)时,存入容器的是所指定的对象的拷贝;当从容器中取出一个对象(通过如 frontback 之类的操作)时,所得到的是容器中所保存的对象的拷贝。
    • 进去的是拷贝,出来的也是拷贝,这就是 STL 的工作方式。【copy-in-copy-out】
  • 一旦一个对象被保存到容器中,它经常会进一步被拷贝。【即对容器进行元素的插入或删除操作、使用排序算法或其他操作,那么对象将会被移动(拷贝)】【拷贝对象是 STL 的工作方式
    • 利用一个对象的拷贝成员函数(拷贝构造函数和拷贝赋值操作符)就可以很方便地拷贝该对象。
  • 本条款的问题在于:
    • 在存在继承关系的情况下,拷贝动作会导致剥离(slicing)。
    • 也就是说,如果你创建了一个存放基类对象的容器,却向其中插入派生类的对象,那么在派生类对象(通过基类的拷贝构造函数)被拷贝进容器时,它所特有的部分(即派生类中的信息)将会丢失。
  • 使拷贝动作高效、正确,并防止剥离问题发生的一个简单办法是使容器包含指针而不是对象。
    • 即使用 Widget* 的容器,而不是 Widget 的容器。
    • 但指针的容器也有一些自身的、与 STL 相关的问题。【条款 7 和条款 33】
  • 虽然本条款中强调 STL 一直在拷贝,但其实 STL 已经在避免不必要的拷贝。【例如 vector 会自动扩容,它与数组相比,已经迈出了一大步】

第 4 条:调用 empty 而不是检查 size() 是否为0。

  • 本条款讨论的是下述两种方案哪种更好:
    if(c.size() == 0)...
    
    if(c.empty())...
    
    // 在实现上,size()是返回distance(begin(), end())【其实就是list调用distance是线性时间】
    // empty()是返回begin()==end()
    
  • 调用 empty()(它本身是 inline),它所做的仅仅是返回 size 是否为 0。【书中如此写,但是其实 empty() 的实现是判断 begin() 是否等于 end()】【即大多数情况下,调用 empty() 就是相当于调用 size()
    • empty() 对所有的标准容器都是常数时间操作。
    • 而对一些 list(双向循环链表)实现,size() 耗费线性时间(list 调用 distance() 需要耗费线性时间)。【C++11 之前,C++11 之后 list::size() 已经同样是常数时间操作了】
  • 至于 list 不提供常数时间的 size() 函数,原因如下:
    • list 所独有的链接操作 splice() 需要保证,只能舍弃常数时间的取 size() 行为。【前面说到 C++11 之后 list::size() 已经同样是常数时间操作,则应该就是舍弃了 splice() 行为的特殊看待】
    • list::splice 实现 list 拼接的功能;将源 list 的内容部分或全部元素删除,拼插入到目的 list。【重要的是 splice() 只用常数时间拼接了两个 list,这个操作对 merge()reverse()、`sort() 有很大的帮助】【在实现上,它只是把两个节点相连,过程中没有成员被拷贝或被移动】
    • splice() 函数的其中一个声明如下:
      void splice ( iterator position, list<T,Allocator>& x );
      // position是要操作的list对象的迭代器,x是被剪的对象
      // 即x转到position上
      
  • 总结一下:
    • list 中为了保证独特的 splice() 操作,所以舍弃了随机访问的便利。
    • size() 的实现中,它调用的是 distance(c.begin(), c.end())。【也就是说,vector::size()list::size() 代码实现上是一样的,都是调用 distance(),关键在于 distance() 的实现】
      • 如果传参是随机访问迭代器(如 vector),distance() 实现上就直接 _end - _begin,这就是常数时间的操作。【vector 提供随机访问迭代器,而随机访问迭代器能像指针一样进行算术计算,而不仅仅是单步向前】
      • 如果传参不是随机访问迭代器(如list,即链表),distance() 在实现上需要麻烦一些:需要从 _begin 位置开始,开始遍历(其实就是递增迭代器),走完全程。【list 的迭代器是双向迭代器,只能单步前进】
    • 但大多数场景下,size() 的作用也只是拿来判断是否为空,如果使用容器是 list,尽量直接调用 empty(),不然容易出现性能问题。
    • 本条款很有助于理解 STL 中的 iterator 模式。(不同迭代器有不同的功能可提供)

第 5 条:区间成员函数优先于与之对应的单元素成员函数。

  • 如题,优先使用针对区间的成员函数。

  • 区间成员函数是指使用两个迭代器参数来确定该成员操作所执行的区间这样的成员函数。

    • 如果不适用区间成员函数,一般就要在成员函数外围显式循环,这样代码可能会出问题(因为维护代码正确性的责任落到了程序员头上)。【条款 43 描述了为什么算术调用优于手写的循环】
  • 本条款有个实例,考虑的场景是:将一个 int 数组拷贝到 vector 的前端(旧式 C API 可能遗留下来的数据就存放在 int 数组中);考虑的两个函数是:区间函数 copy() 和单元素函数 insert()。【注意 insert() 也有区间版本,与单元素版本相比,参数个数不同】

  • 首先,先看下区间版本的 insert() 解决这个问题的代码:【没有问题】

    // 数据定义如下:
    int data[numValues];   // 假设numValues在其他地方定义
    vector<int> v;
    
    // 区间版本的insert使用如下:
    v.insert(v.begin(), data, data + numValues); // 把data中的int插入v前部
    
  • 接着,看看如何使用单元素版本的 insert() 来解决这个问题:【要注意的细节很多】

    // 单元素版本的insert使用如下:
    vector<int>::iterator insertLoc(v.begin());
    for (int i = 0; i < numValues; ++i) {
      insertLoc = v.insert(insertLoc, data[i]);// 第一个点:需要更新迭代器,不然会失效
    	++insertLoc;// 第二个点:需要移动迭代器,没有这行,插入的数据会以相反的顺序拷贝到v的前面
    }
    
  • 最后,看看使用区间版本的 copy() 的情况:【没有问题】【但实现上调用 copy() 函数和循环单元素版本 insert() 其实是一致的】

    // 区间版本的copy使用如下:
    copy(data, data + numValues, inserter(v, v.begin()));
    
  • 那么究竟单元素版本的 insert()区间版本的 insert() 相比,付出了什么代价呢?【除了第三条代价之外,前两个讨论也使用于 deque

    1. 没有必要的函数调用。
      1. 单元素版本的 insert() 花费了 numValues 次函数调用;区间版本的 insert() 只花费了 1 次函数调用。
      2. 或者有人认为 inline 可以解决问题,但 inline 只是给编译器建议,并不一定真的内联。
    2. 无效率的元素移动操作。【即使内联也无法减免】
      1. 假设移动前 v 中有 n 个元素,那么,单元素版本的 insert() 要花费 n*numValues 那么多次的移动,区间版本的 insert() 只花费了 n 次移动(因为区间版本其实就是提前告诉编译器要插入 numValues 个,然后直接在 vector 中构造 numValues 次,紧接着把原先前 n 个位置搬到后 n 个位置,所以只拷贝了 n 次)。
      2. 相比单元素 insert() 策略,区间 insert() 少执行了 n*(numValues-1) 次移动。
      3. 但这个高效率的结果(即调用区间 insert() 而带来的高效率)在某个特定的场景下会失效,也就是当传入的迭代器是输入迭代器的时候;这是因为输入迭代器没有提供前向迭代器的功能,不能在一次移动中把一个元素移动到它的最终位置,导致还是需要一步步移动,所以期望中的优点就会消失。【输出迭代器虽然也没有提供前向迭代器,但它和这个问题没有关联,因为输出迭代器不能用于为 insert() 指定一个区间】
    3. 当元素移动遇到内存分配。【vectorstring 都有动态扩容机制】
      1. 这里描述的其实是 vector 的动态扩容机制就导致了最好不要过多、不要单次的插入元素;因为内存满了的时候,vector 会尝试分配更多内存(从旧内存把它的元素拷贝到新内存,销毁旧内存里的元素,回收旧内存)。
      2. 所以一开始就利用区间版本函数来获知要分配多少内存(注意这里仍然是需要给的是前向迭代器,不然没法一次性计算 distance()),便可一次性分配。
  • 最后,标准序列容器中,还剩下 list 未讨论,我们可以分析一下:在同样的场景下使用 list,前面的三条代价哪条仍适用。

    • 代价一:重复函数调用的问题仍然存在。
    • 代价二和代价三:问题不存在了,因为 list 是链表管理方式。
    • 代价四(新代价):过多重复地对 list 中的一些节点的 nextprev 指针赋值。
      • 每当一个元素添加到一个链表时,它自身有 prenext 节点,然后它的 pre 也要更新 pre.next 节点,它的 next 也要更新 next.pre 节点。
      • 当一系列新节点通过调用 list 的单元素 insert() 一个接一个添加时,除了最后一个以外的其他新节点都会设置它的 next 指针两次。
        • 如果 numValues 个节点插入 A 前面,插入节点的 next 指针会发生 numValues-1 次多余的赋值,而且 Aprev 指针会发生 numValues-1 次多余的赋值;合计 2*(numValues-1) 次没有必要的指针赋值。
  • 最后总结一下区间函数:区间构造(所有标准容器都提供)、区间插入(部分版本的 insert(),所有标准序列容器都提供)、区间删除(部分版本的 erase(),每个标准容器都提供,但序列和关联容器的返回类型不同)和区间赋值(部分版本的 assign(),所有标准容器都提供)。

    • 区间删除 erase() 函数,序列和关联容器的返回类型不同;序列容器的 erase() 返回一个迭代器,关联容器的 erase() 返回 void。【erase() 的关联容器版本返回一个迭代器(被删除的那个元素的下一个)会招致一个无法接受的性能下降】

第 6 条:当心 C++ 编译器最烦人的分析机制。

  • 问题提出:

    // 以下代码会带来问题:
    std::ifstream dataFile("ints.dat");
    std::list<int> data(std::istream_iterator<int>(dataFile),
                          std::istream_iterator<int>());
    
    // 在这里,程序员的原意是:
      // 把一个存有整数(int)的文件ints.dat拷贝到一个list中
    // 但这段代码虽然通过了编译,但是运行期什么也不会发生
    
  • 为了讲解问题产生的原因以及引入后面的解决方法,首先需要枚举函数参数声明的方式。【分析下面的代码中函数是如何声明,而编译器又是如何看待它们的】

    // 下面三行代码是一样的结果:
    	// 都声明了一个带double参数并返回int的函数
    int f1(double d); 
    int f2(double(d)); // d 两边的括号被忽略,即可以给参数名加上圆括号
    int f3(double); // 这里只是传参可以没有名字,说明这个参数在函数中没有意义
    
    // 另一种情况,下面三行代码也是一样的结果:
    	// 都声明了同一种函数
    		// 参数是一个指向不带任何参数的函数的指针
    		// 返回double值
    int g1(double(*pf)()); // g1以指向函数的指针为参数
    int g2(double pf()); // 同上,pf为隐式指针
    int g3(double()); // 同上,省去参数名
    
    // 这里的分析就是在于:括号中有无参数
    	// f2其实就代表了f1和f3,因为参数名称d两边的括号可以省略
    	// g1、g2和g3中参数pf并不重要,重要的是后面的空括号,它决定了这里参数是指针
    
  • 有了这些前置知识,我们再回头分析场景问题中的那个代码出现了什么问题:

    std::ifstream dataFile("ints.dat");
    std::list<int> data(std::istream_iterator<int>(dataFile), std::istream_iterator<int>());
    
    // 前面说了,程序员的原意是:
    	// 把一个存有整数(int)的文件ints.dat拷贝到一个list中。
    // 但是,编译器的理解是:
    	// 声明了一个函数,名称为data;
    		// 返回类型是list
    		// 第一个参数名称为dataFile,类型为istream_iterator
    		// 第二个参数无名,类型是函数指针,这个指针指向的函数不带参数、返回值是istream_iterator
    
  • 所以现在对这个问题的分析已经几乎结束了(除了未提出解决方案),但目前应该可以理解下面代码的问题:

    class Widget{...};// 声明一个Widget类,里面有默认构造函数
    // 想要声明一个类,需要如此做:
    Widget w;
    
    // 但有人可能写出如此代码:
    Widget w_error();// 这行代码声明了一个名叫w_error的函数
    
    // 这可能是C++11增加列表初始化(list-initialization)的其中一个原因
    Widget w_right{};// 和前一个代码理念上相近
    
  • 最后的最后,提出解决方案:

    // 方案一:加多一对括号,强迫编译器按照我们的方式来工作。
      // 之所以新添的括号不会被省略,我猜测是因为括号内有类型名称,而不是只有变量名称
    std::list<int> data1((std::istream_iterator<int>(dataFile)),
                            std::istream_iterator<int>());
    
    // 方案二:避免使用匿名对象,给这些迭代器一个名称
      // 虽然与通常的STL风格有些违背,但为了让代码对于所有编译器都没有二义性,这一代价是值得的
    std::istream_iterator<int> dataBegin(dataFile);
    std::istream_iterator<int> dataEnd;
    std::list<int> data2(dataBegin, dataEnd);
    

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

  • STL 的容器已经比较智能,能做的事情特别多;但是当容器里包含的是通过 new 的方式而分配的指针时,就需要程序员自己解决善后清理工作。【解决方法也很直白】

  • 方法一:手动循环 delete。

    • 要么显示循环 delete 每一个容器里的元素,要么调用 for_each() 函数。
    • 要注意的是,本书中利用 for_each() 举的例子,写了一个函数子类(function class)去 delete,但是该函数子类是通过继承 unary_function 来实现的:【本条款的题外话】
      • 在 C++11 之前,程序员想要把自定义类更适配地模仿 STL 运算符类,需要继承 unary_function 或者 binary_function;继承之后,它就与其他的一元、二元运算符统一,同时也能继续扩展。【不继承,其实也能编译运行,但其他的配接器没法获得此运算符类的参数型别】
      • unary 是一元运算符,unary_function 类内有两个 typedef,一个是传入参数,一个是返回类型;同理,binary 是二元运算符,binary_function 类内有三个 typedef,两个是传参,一个是返回类型。
      • 但是 C++11 之后,unary_functionbinary_function 被移除了。
    • 除了上述提及的废弃语法之外,书中利用 for_each() 函数调用自定义类来 delete 指针的代码中,还有一个值得注意的地方:
      // 书中讨论的第一套代码:
      template<typename T>// 模板类
      struct DeleteObject :
        public unary_function<const T*, void> {
        void operator()(const T* ptr) const
        {
          delete ptr;
        }
      };
        // 在使用的时候,就需要标明要删除对象的类型是什么
      vector<Widget*> v;
      for_each(v.begin(), v.end(), DeleteObject<Widget>());// 要删除Widget*类型的指针
        // 容易出问题的是下面这种使用情况:
      class SpecialString : public string{...};// 这里本身有问题,这样的public继承不提倡
                            // string无虚析构函数,而SpecialString从string的继承是public
      deque<SpecialString*> d;
      for_each(d.begin(), d.end(), DeleteObject<string>());// 注意这里
                            // 考虑的是程序员把Derived类指针错写成Base类指针。
                            // 我猜测主要还是因为没有虚析构函数,所以如果告诉编译器只删除base类,
                            // 就会有一部分derived类资源没有被delete,因为调用的根本不是派生类的析构。
      
      // 书中讨论的第二套代码:
      struct DeleteObject {// 注意既没有类的模板化,也没有继承
        template<typename T>
        void operator()(const T* ptr) const
        {
          delete ptr;
        }
      };
        // 最后这套代码对SpecialString的使用方式如下:
      for_each(d.begin(), d.end(), DeleteObject());// 如此程序员不会像前面说的那样主动犯错了
      
  • 方法二:容器一开始就只存放智能指针。

    • 这个方法自然是比前面的方法要好得多,因为 for_each() 版本始终是非异常安全的。
    • 这里提议的是使用 boost::shared_ptr 来进行管理。
    • 但是不能认为可以通过创建 auto_ptr 的容器就能使指针被自动销毁。【下一条款就讨论这个问题】

第 8 条:切勿创建包含 auto_ptr 的容器对象。

  • auto_ptr 的容器是被禁止的。【auto_ptr 本身禁止拷贝】
  • 原因其实很简单:拷贝一个 auto_ptr 意味着将其自身的值转移走
    auto_ptr<Widget> pw1(new Widget);
    auto_ptr<Widget> pw2(pw1);// 拷贝构造,pw1的值转移到了pw2中,pw1置为null
    pw1 = pw2;// 拷贝赋值,pw2的值又转移回去了
    
  • 而 STL 的里面很多算法都是拷贝完成的,例如排序算法。
    • auto_ptr 的容器排序的过程中,容器里的对象会被置为空。

第 9 条:慎重选择删除元素的方法。

  • 本条款描述了三种场景。

  • 场景一:要删除容器中特定值的所有对象。【例如删除容器中所有值为 1963 的元素】

    • 如果容器是 vectorstringdeque,则使用 erase-remove 习惯用法:

      // erase-remove习惯:
      std::vector<int> c1;
      c1.erase(std::remove(c1.begin(), c1.end(), 1963), c1.end());
      
    • 如果容器是 list,则使用 list::remove()

      // 容器是list,用list的成员函数remove效果最好
      std::list<int> c2;
      c2.remove(1963);
      
    • 如果容器是一个标准关联容器,则使用它的 erase() 成员函数:

      // 容器是一个标准关联容器,使用成员函数erase最好
      std::set<int> c3;
      c3.erase(1963);
      
  • 场景二:要删除容器中满足特定判别式(条件)的所有对象。【例如删除判别式返回 true 的每一个对象】

    • 如果容器是 vectorstringdeque,则使用 erase-remove_if 习惯用法;
    • 如果容器是 list,则使用 list::remove_if()
    • 如果容器是一个标准关联容器,则使用 remove_copy_if()swap();或者写一个循环来遍历容器中的元素,记住当把迭代器传给 erase() 时,要对它进行后缀递增。
  • 场景三:要在循环内做某些(除了删除对象之外的)操作。【例如每次元素被删除时,都向一个日志文件中写一条信息

    • 如果容器是一个标准序列容器,则写一个循环来遍历容器中的元素,记住每次调用 erase() 时,要用它的返回值更新迭代器;
    • 如果容器是一个标准关联容器,则写一个循环来遍历容器中的元素,记住当把迭代器传给 erase() 时,要对迭代器做后缀递增。

第 10 条:了解分配子(allocator)的约定和限制。

  • 分配子有很多奇怪的地方:
    • 奇怪之处一。分配子最初的设计意图是提供一个内存模型的抽象(允许库开发者忽略在某些 16 位操作系统上 nearfar 指针的区别),但这个目的并没有达到;另一个目的是将分配器设计成促进全功能内存管理器的发展,但事实表明那种方法在 STL 的一些部分会导致效率损失。
    • 奇怪之处二。为了避免影响效率,C++ 标准委员会在标准中降低了分配子作为对象的要求(我理解就是加了很多限制?);同时也表示,希望不会影响操作的效率。
    • 奇怪之处三。正如 operator newoperator new[],STL 分配器负责分配(和回收)原始内存;但分配器的客户接口与 operator newoperator new[] 甚至 malloc 几乎没有相似之处。
    • 奇怪之处四。大多数标准容器从未向它们相关的分配器索要内存。
  • 条款 11 是解释分配子能用来做什么,本条款就是解释它们不能用来做什么(分配子的限制)。
  • 编写自定义的分配子,需要注意以下几点。
  • 第一,你的分配子是一个模板,模板参数 T 代表为它分配内存的对象的类型
  • 第二,提供类型定义 pointer 和 reference,但是始终让 pointer 为 T*,reference 为 T&
    1. 第一个限制是它们遗留下来的对指针和引用的类型定义。
    2. 一个类型为 T 的对象,它的默认分配子(allocator)提供了两个类型定义,分别是 allocator::pointerallocator::reference;用户定义的分配子也应该提供这些类型定义。
    3. 其实这里是有一个直观的问题:我们提供了引用的类型定义,然而 C++ 中并没有办法仿冒引用。
      • 再往更深处走就是说:要模仿引用的行为,需要重载 operator.(点操作符),而这种重载是被禁止的。
      • 文中提出可以利用代理类来模拟引用的行为,但代理类也会引发新问题。
    4. 正因为分配子里没有用到代理技术,所以降低分配器的指针和引用的有效性是另有原因;实际上标准明确地允许库实现假设每个分配器的 pointer typedef 是 T* 的同义词,每个分配器的 reference typedef 与 T& 相同。【这段话的意思是说,C++ 标准允许库实现者可以忽略 typedef 而直接使用原始指针和引用;所以即使程序员提供了新的指针和引用类型,也有可能被忽略】
  • 第三,千万别让你的分配子拥有随对象而不同的状态(per-object state);通常,分配子不应该有非静态的数据成员
    1. 首先要知道分配子其实就是一类对象,意味着它可以以偶成员函数、嵌套类型和类型定义;但 C++ 标准中,STL 的实现可以假定所有相同类型的分配器对象都是等价的,且它们比较起来总是相等。【这是一个相当严格的约束】
      • 这一段话的出发点(或者说好处)在于这里:
        template<typename T>				// 一个用户定义的分配器
        class SpecialAllocator {...};			// 模板
        typedef SpecialAllocator<Widget> SAW;		// SAW = “SpecialAllocator
                    // for Widgets”
        list<Widget, SAW> L1;
        list<Widget, SAW> L2;
        ...
        L1.splice(L1.begin(), L2);			// 把L2的节点移到L1前端
          // 在前面提到splice的时候就说过,它没有拷贝什么
          // 所以这个操作是迅速且异常安全的
        
        // 重点在于,当L1被销毁的时候,它要销毁它的所有节点(回收内存)
          // 而L1的一部分其实是从L2来的
          // 所以对于L1来说,只有相同类型的分配器对象都是等价的,它才能安全地回收L2的对象
        
    2. 因为分配子必须做到同类型就相等,所以这就暗含了一个条件:可移植的在不同 STL 实现下都能正确工作的分配子必须没有状态
      1. 也就是说,分配子不可以有任何非静态的数据成员
      2. 所以,你不能让一个分配子从某个堆中分配,而另一个分配子从另外的堆中分配,这会产生不等价的两个分配子。【这是运行期问题,带状态的分配子仍然能通过编译】
      3. 最后要说的是,既然是运行期问题,就需要人为地做到不给分配子添加非静态成员。
  • 第四,记住,传给分配子的 allocate() 成员函数的是那些要求内存的对象的个数,而不是所需的字节数;同时要记住,这些函数返回 T* 指针(通过 pointer 类型定义),即使尚未有 T 对象被构造出来。【参数类型和返回类型都不同】
    1. 分配器在分配原始内存方面类似 operator new,但它们的接口不同,代码如下:
      void* operator new(size_t bytes);
      pointer allocator<T>::allocate(size_type numObjects);
                // 记住事实上“pointer”总是T*的typedef
      // 对于operator new,参数(size_t类型)指定的是字节数;
      // 而对于allocator::allocate,它指定的是内存里要能容纳多少个T对象。
      
    2. allocator::allocate 返回的指针并不指向一个 T 对象,因为 T 还没有被构造。(意思即是只是头指针?)
    3. 整体来说,实现 operator new 的知识无法应用于分配子中。【因为它们大体都不同】
  • 第五,一定要提供嵌套的 rebind 模板,因为标准容器依赖该模板。
    1. 之所以一定要提供 rebind,是因为前面说到的:大多数标准容器从未调用它们相关的分配器。

    2. 尤其是对于 list 和所有标准关联容器来说,分配器无法分配这些基于节点的容器:【allocator 直观上根本没法满足 list 的实现,但它内部实现了 rebind,所以我们只是进去拿到了 rebind 里的一些东西】

      template<typename T,			// list的可能
      typename Allocator = allocator<T> >	// 实现
      class list{
      private:
      	Allocator alloc;		// 用于T类型对象的分配器
      
      	struct ListNode{		// 链表里的节点
      		T data:
      		ListNode *prev;
      		ListNode *next;
      	};
      	...
      };
      
      // 在这里可以看出,我们想要的不是T的分配子运作
      	// 而是希望它能够分配一个ListNode的内存(ListNode包含了一个T类元素)
      
    3. list 需要的是从它的分配器类型那里获得用于 ListNode 的对应分配器的方法。

    4. 现在就要介绍 rebind 了。

      // 前面刚说到,list 从分配器里获得 ListNode 的对应分配器,所以分配器中又给予了一个新的 typedef,叫做 other
      // allocator的实现:
      template<typename T>			// 标准分配器像这样声明,
      class allocator {			// 但也可以是用户写的
      public:					// 分配器模板
        template<typename U>
        struct rebind{
          typedef allocator<U> other;
        }
        ...
      };
      
      // 这里的神奇之处在于:
      // T的分配器对应的分配器类型是Allocator。
          template<typename T,
          typename Allocator = allocator<T> >	 // 是这个语句中的Allocator
          class list{...} 
          
          // ListNodes的对应分配器类型是:
          Allocator::rebind<ListNode>::other
      
    5. 所以结果就是:list 可以通过 Allocator::rebind::other 从它用于 T 对象的分配器(叫做 Allocator)获取对应的 ListNode 对象分配器。

第 11 条:理解自定义分配子的合理用法。

  • 自定义分配子适合的场景有:

    • STL 默认的内存管理器 allocator 太慢,或者太占内存,或者导致了太多内存碎片。【可以实现更好的分配器】
    • alloctor 是线程安全的(即为了维护线程安全必定付出了额外的代价),而程序员所关注的是单线程的环境,所以不愿意为线程同步付出额外的开销。【节省开销】
    • 程序员清楚某些容器中的对象通常是一起使用的,所以想把它们放在一个特殊堆中的相邻位置上。【以实现更好的局部化】
    • 程序员想建立一个与共享内存相对应的特殊的堆,然后在内存中放置容器,供其他进程使用。【共享】
  • 场景一:例如考虑如何采用 mallocfree 内存模型来管理一个位于共享内存的堆。【就需要自定义一个分配子类】

    • 代码如下:【容器 v 本身是栈中元素;但容器 v 中的元素是通过分配子在共享内存中取得】【所以可以看出,容器只是管理元素,而真正存储元素的位置是由分配子来决定

      void* mallocShared(size_t bytesNeeded)
      {
      	return malloc(bytesNeeded);
      }
       
      void freeShared(void* ptr)
      {
      	free(ptr);
      }
       
      template<typename T>
      class SharedMemoryAllocator { // 把STL容器的内容放到共享内存(即由mallocShared生成的)中去
      public:
      	typedef T* pointer; // pointer是个类型定义,它实际上总是T*
      	typedef size_t size_type; // 通常情况下,size_type是size_t的一个类型定义
      	typedef T value_type;
       
      	pointer allocate(size_type numObjects, const void* localityHint = 0)
      	{
      		return static_cast<pointer>(mallocShared(numObjects * sizeof(T)));
      	}
       
      	void deallocate(pointer ptrToMemory, size_type numObjects)
      	{
      		freeShared(ptrToMemory);
      	}
       
      	template<typename U>
      	struct rebind {
      		typedef std::allocator<U> other;
      	};
      };
       
      int test_item_11()
      {
      	typedef std::vector<double, SharedMemoryAllocator<double>> SharedDoubleVec;
      	// v所分配的用来容纳其元素的内存将来自共享内存
      	// 而v自己----包括它所有的数据成员----几乎肯定不会位于共享内存中,v只是普通的基于栈(stack)的对象,所以,像所
      	// 有基于栈的对象一样,它将会被运行时系统放在任意可能的位置上。这个位置几乎肯定不是共享内存
      	SharedDoubleVec v; // 创建一个vector,其元素位于共享内存中
       
      	// 为了把v的内容和v自身都放到共享内存中,需要这样做
      	void* pVectorMemory = mallocShared(sizeof(SharedDoubleVec)); // 为SharedDoubleVec对象分配足够的内存
      	SharedDoubleVec* pv = new (pVectorMemory)SharedDoubleVec; // 使用"placement new"在内存中创建一个SharedDoubleVec对象
      	// ... // 使用对象(通过pv)
      	pv->~SharedDoubleVec(); // 析构共享内存中的对象
      	freeShared(pVectorMemory); // 释放最初分配的那一块共享内存
       
      	return 0;
      }
      
  • 场景二:想把一些 STL 容器的内容放在不同的堆中。

    • 代码如下:【这个场景的解决方法也说明了:在自定义 allocator 时,需要遵守同一类型的 allocator 必须是等价的】【在这里的 Heap1Heap2 都只是类型,不是对象;否则的话就将是不等价的分配器】
      // 假设有两个堆,命名为heap1和heap2类
      	// 每个堆类有用于进行分配和回收的静态成员函数
      class Heap1 {
      public:
      	...
      	static void* alloc(size_t numBytes, const void *memoryBlockToBeNear);
      	static void dealloc(void *ptr);
      	...
      };
      
      class Heap2 { ... };		// 有相同的alloc/dealloc接口
      
      // 接下来设计一个分配器,使用像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来把容器的元素集合在一起:
      vector<int, SpecificHeapAllocator<int, Heap1 > > v;			// 把v和s的元素都放进Heap1
      set<int, SpecificHeapAllocator<int Heap1 > > s;
      
      list<Widget, SpecificHeapAllocator<Widget, Heap2> > L;		// 把L和m的元素放进Heap2
      map<int, string, less<int>, SpecificHeapAllocator<pair<const int, string>,
      					Heap2> > m;
      

第 12 条:切勿对 STL 容器的线程安全性有不切实际的依赖。

  • 本节讲解 STL 容器对于线程安全性的知识。

  • 对于一个 STL 实现,最多只能期望:

    1. 多线程可以同时安全读取同一个容器。【共享读没问题】
    2. 多线程可以同时写入不同容器。【独占写】
  • 当一个库试图实现完全的容器线程安全时可能采取以下方案:【但其实很轻易就知道,这些都有问题,并不能解决并发问题,这是为了引出后面的RAII思想】

    • 方案一:在每次调用容器的成员函数期间都要锁定该容器。
    • 方案二:在每个容器返回的迭代器(例如通过调用begin或end)的生存期之内都要锁定该容器。
    • 方案三:在每个在容器上调用的算法执行期间锁定该容器。(但实际做不到,因为算法是模板,它无法知道所操作的容器)
  • 从一个场景代码中进行分析这三个方案是否就已经可行,代码如下:
    ```cpp
    vector v;
    vector::iterator first5(find(v.begin(), v.end(), 5)); // 行1
    if (first5 != v.end()){ // 行2
    *first5 = 0; // 行3
    }
    // 要让上面的代码成为线程安全的,v必须从行1到行3保持锁定
    // 所以其实STL无法自动推断出这个

    // 在多线程环境中:
      // 另一个线程可能在行 1完成后执行,再返回到行 2 的时候,这里的 first5 可能失效。
      // 另一个线程可能在行 2 和行 3 之间执行,也可能令 first5 失效。
    // 用前面的三种方案能解决这些问题吗?答案是否。
      // 行1中begin和end调用都返回得很快,以至于不能提供任何帮助;(即方案二无法起作用,因为begin和end生存期很短)
      // 它们产生的迭代器只持续到这行的结束,而且find也在那行返回。(即方案三也无作用,因为find直接返回了)
    ```
    
  • 真正的解决方案是手动进行同步控制。

  • 方案一:手动添加互斥量。

    vector<int> v;
    ...
    getMutexFor(v);
    vector<int>::iterator first5(find(v.begin(), v.end(), 5));
    if (first5 != v.end()) {						// 这里现在安全了
    	*first5 = 0;						// 这里也是
    }
    releaseMutexFor(v);
    
  • 方案二:面向对象地去解决。

    • 这个 Lock 类在它的构造函数里获得互斥量,并在它的析构函数里释放它。【其实就是 RAII 思想】
      // 创建一个Lock类:
      template<typename Container>				// 获取和释放容器的互斥量
      class Lock {						// 的类的模板核心;
      public:							// 忽略了很多细节
      	Lock(const Containers container)
      			: c(container)
      	{
      		getMutexFor(c);				// 在构造函数获取互斥量
      	}
      
      	~Lock()
      	{
      		releaseMutexFor(c);			// 在析构函数里释放它
      	}
      
      private:
      	const Container& c;// 置为const
      };
      
      // 使用如下:
      	// 注意要建立新块
      	// 建立一个新快{}的作用是让互斥量的释放来得更快
      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,c++)