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实现把元素放在双向链表中。

[======]

原文地址:https://www.cnblogs.com/fortunely/p/15715265.html