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
如果下面表达式为真,则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
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成员函数调用函数对象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
因此,我们可以为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
// 使用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
注意:在支持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实现把元素放在双向链表中。
[======]