zoukankan      html  css  js  c++  java
  • Effective STL~3 关联容器(条款19~25)

    第19条:理解相等(equality)和等价(equivalence)的区别

    相等关系

    相等基于operator。如果表达式“xy”返回true,则x和y值相等;否则,不相等。
    相等不一定意味着等价。比如,Widget类内部有一个记录最近一次被访问的时间,而operator==可能忽略该域

    class Widget {
    public:
        ...
    private:
        TimeStamp lastAccessed;
        ...
    };
    
    bool Widget::operator== (const Widget& lhs, const Widget& rhs)
    {
        // 忽略了lastAccessed域的代码
    }
    

    这样,2个Widget对象即使lastAccessed域不同,但仍然相等。

    等价关系

    等价关系是以“在已排序的区间中对象值的相对顺序”为基础的。
    对于2个Widget w1和w2,关联容器set的默认比较函数是less,而less只是简单调用针对Widget的operator<。
    如果下面表达式为真,则w1和w2对于operator<具有等价的值:

    !(w1 < w2) && !(w2 < w1) // w1 < w2和w2 < w1都不为真
    

    关联容器的元素比较

    一般地,关联容器的比较函数并不是operator<,也不是less,是用的用户定义的判别式(predicate,条款39)。每个标准关联容器都通过key_comp(MSVC STL实现叫key_compare)成员函数使排序判别式可被外部使用。因此,如果下面表达式为true,则按关联容器c的排序准则,2个对象x和y具有等价的值:

    !c.key_comp()(x, y) && !c.key_comp()(y, x) // 在c的排列顺序中,x在y之前不为true,y在x之前也不为true
    

    MSVC map的默认key_comp就是less

    考虑写一个不区分大小写的set,需要自定义一个set的比较函数,比较时忽略字符串中字符的大小写

    bool ciStringCompare(const string& s1, const string& s2); // 实现见条款35
    
    struct CIStringCompare // : public binary_function<string, string, bool>
    {
           bool operator() (const string& lhs, const string& rhs) const
           {
                  return ciStringCompare(lhs, rhs);
           }
    };
    
    // 客户端
    {
           set<string, CIStringCompare> ciss;
           ciss.insert("Persephone");
           ciss.insert("persephone");
    
           if (ciss.find("persephone") != ciss.end())
           { // 检查成功
                  cout << "ciss.find success" << endl;
           }
           if (find(ciss.begin(), ciss.end(), "persephone") != ciss.end())
           {// 检查失败
                  cout << "find success" << endl;
           }
    }
    

    关联容器set的缺省比较函数是less,用来决定如何排序,find成员函数调用的是该函数。
    示例中,find成员函数调用函数对象CIStringCompare,而find算法通常调用operator==(并非equal_to)。因此,find成员函数能按忽略大小写方式比较2个字符串,而find算法则没有忽略大小写。

    [======]

    第20条:为包含指针的关联容器指定比较类型

    假设你有一个包含string*的set,插入一些动物名字:

    set<string*> ssp;
    ssp.insert(new string("Anteater"));
    ssp.insert(new string("Wombat"));
    ssp.insert(new string("Lemur"));
    ssp.insert(new string("Penguin"));
    

    如果想让集合中的元素,按key字母顺序打印怎么办?
    如果按通常的遍历方式,会发现只能打印出一连串16进制数据:

    // 期望打印按key的字符串顺序排列的集合,但实际只会打印一串16进制地址
    for (set<string*>::const_iterator i = ssp.begin(); i != ssp.end(); ++i)
    {
           cout << *i << endl;
    }
    

    打印结果:

    006D9A68
    006DF310
    006DF3E8
    006DF358
    

    为什么?
    因为set中存储的并非string对象,而是string的指针(string*)。

    // 解决不能打印字符串问题,但实际并非按key的字符串顺序排列
    // 打印方式1:使用for显式循环遍历set
    for (set<string*>::const_iterator i = ssp.begin(); i != ssp.end(); ++i)
    {
           cout << **i << endl;
    }
    
    // 打印set方式2:使用for_each + 函数
    for_each(ssp.begin(), ssp.end(), print);
    
    void print(const string* ps)
    {
           cout << *ps << endl;
    }
    
    // 打印set方式3:使用for_each + lambda
    for_each(ssp.begin(), ssp.end(), [](const string* ps) { cout << *ps << endl; } );
    

    现在,可以输出字符串了,但,依然没能解决集合中字符串顺序问题。

    比较类型

    问题在于,set默认使用less比较类型对其中string*元素进行比较、排序,而我们需要的是对该指针所指字符串进行比较。
    因此,我们可以为set自行定义一个比较函数类型(注意不是比较函数):

    // 函数对象作为比较类型,用于比较字符串大小
    struct StringPtrLess
    {
           bool operator()(const string* ps1, const string* ps2) const
           {
                  return *ps1 < *ps2;
           }
    };
    
    set<string*, StringPtrLess> ssp; // set第二个模板参数接受的是一个比较类型,而非比较函数
    ssp.insert(new string("Anteater"));
    ssp.insert(new string("Wombat"));
    ssp.insert(new string("Lemur"));
    ssp.insert(new string("Penguin"));
    

    注意:为什么是比较类型,而非比较函数?因为set模板参数只接受比较类型,不接受比较函数;否则无法通过编译。

    通用模板

    为了写一个通用的解除指针引用的函数子类型,我们可以将StringPtrLess改写成函数模板Dereference,然后配合transform和ostream_iterator一起使用:

    // 当向该类型的函数子传入T*时,它们返回const T&
    struct Dereference
    {
        template<typename T>
        const T& operator() (const T* ptr) const
        {
            return *ptr;
        }
    };
    
    // 客户端
    ...
    // 通过解除指针引用,“转换”ssp中的每个元素,并把结果写到cout
    transform(ssp.begin(), ssp.end(), ostream_iterator<string>(cout, "\n"),  Dereference());
    

    ssp集合中的string*元素,经过transform和Dereference函数子的转换后,输出到ostream_iterator迭代器的就是string&。当然,用这种算法的技巧不是本条款重点,重点是为关联容器创建比较类型。

    我们也可以为比较子函数准备一个通用的模板(就像是less针对指针类型的偏特化版本):

    struct DereferenceLess
    {
           template<typename PtrType>
           bool operator() (PtrType pT1, PtrType pT2) const
           {
                  return *pT1 < *pT2;
           }
    };
    
    // 客户端像这样定义基于DereferenceLess的set
    set<string*, DereferenceLess> ssp;
    ...
    

    另外,本条款不仅适用于包含指针的关联容器,也适用于一些其他包含智能指针和迭代器的容器。也就是说,如果有一个包含智能指针或迭代器的容器,那么也要考虑为其指定一个比较类型。

    [======]

    第21条:总是让比较函数在等值情况下返回false

    一个set,能否用less_equal作为比较类型?
    我们先看下面的例子,连续插入2个10

    set<int, less_equal<int>> s; // s用 "<=" 来排序
    s.insert(10); // 第1次插入10,这里称为10(A)
    s.insert(10); // 第2次插入10,这里称为10(B)
    

    第2次插入10(B)的时候,set必须确定10是否已经存在,而set是通过遍历内部数据结构,检查是否存在10(A)与10(B)相同。对于关联容器,“相同”的定义是等价(条款19),也就是用集合的比较函数。

    // 关联容器元素等价要检查的表达式
    !c.key_comp()(x, y) && !c.key_comp()(y, x)
    

    这里我们用的比较函数是less_equal即operator<=。因此,set会检查表达式:

    // 使用less_equal<T>作为set比较类型,set会对元素等价做以下检查,用来判断2个关键字10(A)与10(B)是否等价
    !(10(A) <= 10(B)) && !(10(B) <= 10(A))        // 检查10(A)和10(B)的等价性
    
    => !(10 <= 10) && !(10 <= 10) // 由于10(A),10(B)都是10
    => !(true) && !(true)
    => false && false
    => false
    => 10(A)和10(B)不等价 这与10(A)、10(B)都是10矛盾
    

    显然,使用less_equal作为set比较类型,导致set容器破坏。同样的,如果我们想让set按关键字降序排列,在比较子函数中对 "operator<"取反同样也是错误的。因为"<"求反得到的是">=",也包含了等号("="),而等号会导致关联容器破坏。

    set按关键字降序排列,错误的比较类型:

    //错误示范代码
    struct StringPtrGreater{
            bool operator() (const string* ps1, const string* ps2) const
            {
                    return !(*ps1 < *ps2);                // 简单求反,这是不对的
            }
    }
    

    正确的比较类型应该是

    // OK
    struct StringPtrGreater{
            bool operator() (const string* ps1, const string* ps2) const
            {
                    return *ps2 < *ps1; // 返回*ps2是否在*ps1之前
            }
    }
    

    因为关联容器都是用比较类型来判断元素的“等价”关系的,因此比较函数在等值情况下,不要返回true。

    [======]

    第22条:切勿直接修改set或multiset中的键

    不能修改set、multiset,map、multimap中的键。

    为什么不能修改map的key?

    对于map、multimap<K, V>类型对象,元素类型是pair<const K, V>,键的类型是const K,因此不能修改。但如果用const_cast转型去掉常量性(constness),就可以修改。

    map<int, string> m;
    m.insert(make_pair(1, "a"));
    m.insert(make_pair(2, "b"));
    m.insert(make_pair(3, "c"));
    m.begin()->first = 10;            // 错误:map的键不能修改
    m.begin()->second = "1a";         // OK:map的值可以修改
    
    multimap<int, string> mm;
    mm.insert(make_pair(1, "aa"));
    mm.insert(make_pair(1, "bb"));
    mm.insert(make_pair(2, "cc"));
    mm.begin()->first = 11;           // 错误:multimap的键不能修改
    mm.begin()->second = "11aa";      // OK:map的值可以修改
    

    不建议修改set的key

    对于set、multiset类型的对象,容器中元素类型是T,而非const T。因此,只要愿意,是可以随时修改set或multiset中的元素的。
    注意:在支持C++11以后编译器中,STL set/multiset的实现可能通过const限定符,不允许通过operator*和operator->修改容器中的元素了(同map)。
    但是,即使编译器允许,也一定不要改变set/multiset的key part,因为这部分信息会影响容器的排序性。
    不过,也有例外情况,那就是不修改被包含对象的键部分,只修改被包含元素的其他部分,则是可以的,因为这有实际应用含义,相应的,你也应该为set设置一个自定义的键部分的比较类型。

    比如,你可以修改除idNumber(员工ID)以外的所有Employee的数据成员,只要set排序绑定idNumber即可。

    // 员工class
    class Employee
    {
    public:
           const string& name() const;
           void setName(const string& name);
           const string& title() const;
           void setTitle(const string& title);
           int idNumber() const;
    };
    // 员工set的比较类型,专门比较员工ID
    struct IDNumberLess
    {
           bool operator() (const Employee& lhs, const Employee& rhs) const
           {
                  return lhs.idNumber() < rhs.idNumber();
           }
    };
    int main()
    {
           typedef set<Employee, IDNumberLess> EmpIDset;
           EmpIDset se;
           // ...
           Employee selectedID;
           auto i = se.find(selectedID);
           if (i != se.end())
           {
                  // 修改员工职位称号
                  it->setTitle("Corporate Deity"); // 有些STL实现认为这不合法
           }
           return 0;
    }
    

    C++标准关于是否能通过迭代器调用operator->和operator*,修改set容器的key并没有统一的说法,由此,不同编译器STL实现可能有的允许,有的不允许。
    因此,试图修改set中的元素的代码是不可移植的。

    什么时候可以修改set容器key?

    • 不关心可移植性;
    • 如果重视可移植性,又要修改元素中非键部分,可以通过const_cast强转,然后修改;
    // const_cast去掉常量性后,再修改set的key part
    auto i = se.find(selectedID);
    if (i != se.end())
    {
           const_cast<Employee&>(*i).setTitle("Corporate Deity");
    }
    
    // 错误写法1
    static_cast<Employee>(*i).setTitle("Corporate Deity");
    // 错误写法2 <=> 错误写法1,
    ((Employee)(*i)).setTitle("Corporate Deity");
    // 错误写法3 <=> 错误写法1
    Employee tempCopy(*i);
    tempCopy.setTitle("Corporate Deity");
    

    错误写法1和2都可以通过编译,但无法修改i所指对象内容,因为类型转换的结果是产生一个临时匿名对象,修改的也是这个临时对象,语句结束后就销毁了。

    [======]

    第23条:考虑用排序的vector替代关联容器

    如果查找速度要求很高,考虑非标准的哈希容器几乎总是值得的(条款25)。而如果哈希函数选择得不合适,或者表太小,则哈希表的查找性能可能会显著降低,虽然实践中并不常见。

    下面探讨一下排序的vector和关联容器区别:
    标准关联容器通常被实现为平衡的二叉查找树,对插入、删除、查找的混合操作做了优化。但没办法预测出下一个操作是什么。

    应用程序使用数据结构的过程可以明显分为3个阶段:
    1)设置阶段
    创建一个新的数据结构,并插入大量元素。在这个阶段,几乎所有的操作都是插入和删除操作。几乎没有查找。
    2)查找阶段
    查询该数据结构,以找到特定信息。在这个阶段,几乎所有操作都是查找,很少插入、删除。
    3)重组阶段
    改变该数据结构的内容,或许是删除所有的当前数据,再插入新的数据。在行为上,与阶段1类似。该阶段结束后,应用程序又回到阶段2。

    排序的vector和关联容器的优缺点
    对这种方式使用数据结构的应用程序而言,排序的vector比关联容器提供更好的时间、空间性能。

    • 大小方面
      假设class Widget大小 12byte,1个pointer大小4byte
      如果选择关联容器set,使用平衡二叉树存储,每个树节点至少包含:1个Widget,3个指针(1个左儿子指针,1个右儿子指针,通常还有一个父节点指针)。共计24byte;
      如果选择已排序vector,除了空闲空间(必要时可以通过swap技巧清除,见条款17),没有额外空间开销。共计12byte。
      这样,1个内存页(4096byte),如果用关联容器,可以存储170个Widget对象;如果用排序的vector,可以存储341个Widget对象。显然,相同内存可以存放更多vector元素。

    • 时间方面
      使用关联容器,其二叉树节点会散布在STL实现的内存管理器所管理的全部地址空间,查找时容易导致页面错误;
      使用vector,相邻元素在物理内存是相邻的,执行二分搜索时,将最大限度减少页面错误;

    排序的vector缺点:元素必须保持有序。这意味着,一旦有一个元素添加、删除,其后所有元素必须移动,这对于vector是非常昂贵的。

    [======]

    第24条:当效率至关重要时,请在map::operator[]与map::insert之间谨慎做出选择

    从效率角度看,当向map添加元素时,优先使用insert;当更新map中元素时,优先使用operator[]。

    例如,当我们有如下Widget class和map映射关系

    class Widget
    {
    public:
           Widget();
           Widget(double weight);
           Widget& operator=(double weight);
    private:
           double weight;
    };
    
    map<int, Widget> m;
    

    向map插入数据

    通常,如果使用operator[]插入

    m[1] = 1.50;
    m[2] = 3.67;
    m[3] = 10.5;
    m[4] = 45.8;
    m[5] = 0.0003;
    

    而m[1] = 1.50功能上等同于:

    typedef map<int, Widget> IntWidgetMap; // typedef为了方便使用键值1和默认构造的值对象创建一个新map条目
    
    pair<IntWidgetMap::iterator, bool> result =
           m.insert(IntWidgetMap::value_type(1, Widget()));
    
    result.first->second = 1.50;
    

    效率低原因在于:先默认构造一个Widget,然后立刻给它赋新值。这样,多了1次默认构造临时对象、析构临时对象、1次operator=运算符的开销。
    而如果直接用insert赋新值,则会省去这些步骤。

    m.insert(IntWidgetMap::value_type(1, 1.50));
    

    更新map中的元素

    当我们做更新操作时,形势恰好反过来。operator[]不仅从语法形势上看,更简洁,而且不会有构造、析构任何pair或Widget的开销。

    int k = 0;
    Widget v;
    ... // 设置k,v
    
    m[k] = v; // 使用operator[] 把键k对应的值改为v
    
    m.insert(IntWidgetMap::value_type(k, v)).first->second = v; // 使用insert把键k对应的值改为v
    

    综合insert和operator[]的优势

    有没有一种高效方法,既能在插入时使用insert,更新时使用operator[]更新值?
    答案是有的,可以先查询元素是否已存在于map中。如果不存在,就调用insert插入元素;如果已存在,就调用更新其值。

    template<typename MapType, typename KeyArgType, typename ValueArgType>
    typename MapType::iterator
    efficientAddOrUpdate(MapType& m, const KeyArgType& k, const ValueArgType& v)
    {
           // map中查找lb, 使得lb是第一个满足 lb键 >= k 的迭代器
           typename MapType::iterator lb = m.lower_bound(k);
           if (lb != m.end() && !(m.key_comp()(k, lb->first))) // map::key_comp默认为less<T>
           { // map中已存在k
                  lb->second = v;
                  return lb;
           }
           else
           { // map中不存在k
                  typedef typename MapType::value_type MVT;
                  return m.insert(lb, MVT(k, v));
           }
    }
    
    // 客户端
    map<int, Widget> m;
    efficientAddOrUpdate(m, 1, 1.5);
    efficientAddOrUpdate(m, 10, 1.5);
    efficientAddOrUpdate(m, 1, 1.5);
    

    KeyArgType和ValueArgType不必是映射表map中的类型,只要能转换成存储在映射表中的类型即可。也可以用MapType::key_type和MapType::mapped_type来替代,不过这样会导致调用时不必要的类型转换。

    [======]

    第25条:熟悉非标准的哈希容器

    原文提到的hash_前缀的哈希容器,在C++11及以后,hash_set、hash_multiset、hash_map、hash_multimap已经废弃,目前已经替换为unordered_前缀的版本:unordered_set、unordered_multiset、unordered_map、unordered_multimap。

    在已有的几种哈希容器的实现中,最常见的2个分别来自于SGI(条款50)和Dinkumware。
    哈希容器是关联容器,需要知道存储在容器中的对象类型、用于这种对象的比较函数(类型)、用于这些对象的分配子。哈希容器也要求指定一个哈希函数。哈希容器声明如下:

    // 通用的哈希容器的类模板声明式
    template<typename T,                      // 存储在容器中的对象类型
           typename HashFunction,             // 哈希函数
           typename CompareFunction,          // 比较函数
           typename Allocator = allocator<T>> // 分配子
    class hash_container;
    
    SGI为HashFunction和CompareFunction提供了默认类型
    
    template<typename T,
           typename HashFunction = hash<T>,
           typename CompareFunction = equal_to<T>,
           typename Allocator = allocator<T>>
    class hash_set;
    

    SGI容器(hash_set、hash_map等)使用equal_to作为默认的比较函数,而标准关联容器(set、map等)使用less。也就是说,SGI哈希容器通过测试2个对象是否相等,而不是等价来决定容器中的对象是否有相同的值。因为标准关联容器通常是用树来存储的,而哈希容器则不是。
    Dinkumware哈希容器则采用了不同策略,虽然仍然可以指定对象类型、哈希函数类型、比较函数类型、分配子类型,但它把默认的哈希函数和比较函数放在一个单独的类似于traits(特性,见Josuttis的The C++ Standard Library)的hash_compare类中,并把hash_compare作为容器模板的HashingInfo参数的默认实参。(traits class技术用于萃取类型)

    如Dinkumware的hash_set声明:

    template<typename T, typename CompareFunction>
    class hash_compare;
    
    template<typename T,
           typename HashingInfo = hash_compare<T, less<T>>,
           typename Allocator = allocator<T>>
    class hash_set;
    

    HashingInfo类型中存储了容器的哈希函数和比较函数,同时还有些枚举值,用于控制哈希表中桶的最小数目,以及容器中元素个数与桶个数的最大允许比率。当超过这个比率时,哈希表桶数增加,表中某些元素要被重新做哈希计算。

    HashingInfo的默认值hash_compare,看起来像这样:

    template<typename T, typename CompareFunction = less<T>>
    class hash_compare
    {
    public:
           enum
           {
                  bucket_size = 4,                      // 元素个数与桶个数的最大比率
                  min_buckets = 9                       // 最小的桶数目
           };
           size_t operator()(const T&) const;           // 哈希函数
           bool operator()(const T&, const T&) const;   // 比较函数
           // ... // 省略了其他细节,包括对CompareFunction的使用
    };
    

    重载operator()的做法同时实现了哈希函数和比较函数,其思想同条款23的一个应用。
    Dinkumware的方案允许你编写自己的类似于hash_compare的类或派生出新的类。只要你的类定义了bucket_size、min_buckets、2个operator()函数(1个带1个参数用于哈希函数,另1个带2个参数用于比较函数)及其他省去的一些东西即可。

    SGI实现把表元素放在一个单向链表中,以解决哈希冲突问题;Dinkumware实现把元素放在双向链表中。

    [======]

  • 相关阅读:
    C#编程总结(三)线程同步 多线程
    配置 Spring.NET
    C# Redis
    WPF MvvmLight RelayCommand 绑定Command 的使用
    WCF编程系列(一)初识WCF
    C#/WPF程序开机自动启动
    C# 创建Windows Service(Windows服务)程序
    前端Js框架汇总
    列式存储和行式存储
    llvm-3.4.2 编译失败 解决方案
  • 原文地址:https://www.cnblogs.com/fortunely/p/15715265.html
Copyright © 2011-2022 走看看