条款22:避免原地修改set和multiset的键
本条款的动机很容易理解。正如所有标准关联容器,set和multiset保持它们的元素有序,这些容器的正确行为依赖于它们的有序性。如果你改了关联容器里的一个元素的值(例如,把10变为1000),新值可能不在正确的位置,而且那将破坏容器的有序性。很简单,是吗?
这对于map和multimap特别简单,因为试图改变这些容器里的一个键值的程序将不能编译:
map<int, string> m;
...
m.begin()->first = 10; // 错误!map键不能改变
multimap<int, string> mm;
...
mm.begin()->first = 20; // 错误!multimap键也不能改变
那是因为map<K, V>或multimap<K, V>类型的对象中元素的类型是pair<const K, V>。因为键的类型const K,它不能改变。(嗯,如果你使用一个const_cast,正如我们将在下面看到的,你或许能改变它。不管相信与否,有时那是你想要做的。)
但注意本条款的标题没有提及map或multimap。那有一个原因。正如上面例子演示的,原地修改键对map和multimap来说是不可能的(除非你使用const_cast),但是它对set和multiset却是可能的。对于set<T>或multiset<T>类型的对象来说,储存在容器里的元素类型只不过是T,并非const T。因此,set或multiset里的元素可能在你想要的任何时候改变。不需要映射。(实际上,事情不完全那么简单。)
让我们从明白为什么set或multiset里的元素不是常数开始。假设我们有一个雇员的类:
class Employee {
public:
...
const string& name() const; // 获取雇员名
void setName(const string& name); // 设置雇员名
const string& getTitle() const; // 获取雇员头衔
void setTitle(string& title); // 设置雇员头衔
int idNumber() const; // 获取雇员ID号
...
}
如你所见,我们可以得到雇员各种各样的信息。不过,让我们做合理的假设,每个雇员有唯一的ID号,就是idNumber函数返回的数字。 然后,建立一个雇员的set,很显然应该只以ID号来排序set:
struct IDNumberLess{
public binary_function<Employee, Employee, bool> {
bool operator()(const Employees lhs,
const Employee& rhs) const
{
return lhs.idNumber() < rhs.idNumber();
}
};
typedef set<Employee, IDNumberLess> EmpIDSet;
EmpIDSet se; // se是雇员的set,
// 按照ID号排序
实际上,雇员的ID号是set中元素的键。其余的雇员数据则和set没什么关系。在这里,没有理由不能把一个特定雇员的头衔改成某个有趣的东西。像这样:
Employee selectedID; // 容纳被选择的雇员
... // ID号的变量
EmpIDSet::iterator i = se.find(selectedID);
if (i != se.end()){
i->setTitle("Corporate Deity"); // 给雇员新头衔
}
因为在这里我们只是改变雇员的一个与set排序的方式无关的方面(一个雇员的非键部分),所以这段代码不会破坏set。那是它合法的原因。但它的合法排除了set/multiset的元素是const的可能。
你可能想知道,为什么相同的逻辑不能应用于map和multimap的键?难道建立一个从雇员到,比如说,他们居住的国家的map,map的比较函数是IDNumberLess,正如前一个例子,没有意义吗?而且如果给定了这样一个map,难道没有理由在不影响雇员ID号的情况下改变一个雇员的职位,正如前一个例子?
坦率地说,我认为它可以。不过,由于同样的坦率,我怎么认为是不重要的。重要的是标准委员会怎么认为,而它认为的是map/multimap键应该是const而set/multiset的值不是。
因为set或multiset里的值不是const,所以试图改变它们可以编译。本条款的目的是提醒你如果你改变set或multiset里的元素, 你必须确保不改变一个键部分——影响容器有序性的元素部分。如果你做了,你会破坏容器,再使用那个容器将产生未定义的结果, 而且那是你的错误。另一方面,这个限制只应用于被包含对象的键部分。对被包含元素的所有其他部分来说,是开放的:随便改变!
即使set和multiset的元素不是const,实现仍然有很多方式可以阻止它们被修改。例如,实现可以让用于set<T>::iterator的operator*返回一个常数T&。即,它可以让set的迭代器解引用的结果是set元素的常量引用。在这样的实现下,将没有办法修改set或multiset的元素,因为所有访问那些元素的方法都将在让你访问之前加一个const。
这样的实现合法吗?可以证明是。也可以证明不是。标准在这里有矛盾,而根据Murphy定律(译注:Murphy定律就是“If there are two or more ways to do something, and one of those ways can result in a catastrophe, then someone will do it.”(当有两条或更多的路让你抉择,如果其中一条会导致失败,那么你一定会选到它。)随着时间流逝,这一“定律”逐渐进入习语范畴,其内涵被赋予无穷的创意,也出现了众多的变体,其中最著名的一条也被称为Finagle定律,具体内容为:“If anything can go wrong, it will.”(如果一个东西可能会出错,那它一定会出错。)这一定律被认为是对“Murphy定律”最好的模仿和阐述。),不同实现会以不同的方式解释它们。结果是发现这些代码,我早先声明可以编译的,却经常在某些STL实现上不能编译:
EmpIDSet se; // 同前,se是一个以ID号
// 排序的雇员set
Employee selectedID; // 同前,selectedID是一个带有
// 被选择ID号的雇员
...
EmpIDSet::iterator i = se.find(selectedID);
if (i != se.end()){
i->setTitle("Corporate Deity"); // 有些STL实现会
} // 拒绝这行
因为标准的模棱两可和它已经造成的解释区别,试图修改set或multiset中的元素的代码不可移植。
所以我们站在哪边?值得鼓舞的是,事情并不复杂:
啊,映射。我们已经看过有时候完全有理由改变set或multiset元素的非键部分,所以我觉得我得被迫向你演示怎样做。也就是怎样做才能既正确又可移植。它不难,但是它用到了太多程序员忽略的一个细节:你必须映射到一个引用。作为一个例子,再次看看我们刚看的不能在一些实现下编译的setTitle调用:
EmpIDSet::iterator i = se.find(selectedID);
if (i != se.end()) {
i->setTitle("Corporate Deity");// 有些STL实现会
} // 拒绝这样,因为*i是const
为了让它可以编译并且行为正确,我们必须映射掉*i的常量性。这是那么做的正确方法:
if (i != se.end()) { const_cast<Employee&>(*i).setTitle("Corporate Deity");//
}
这可以得到i指向的对象,告诉你的编译器把映射的结果当作一个(非常数)Employee的引用,然后在那个引用上调用setTitle。除了解释为什么这可以工作,我将解释为什么一种可替代的方法不表现人们经常期望的样子。
很多人想到的是这段代码:
if (i != se.end()){
static_cast<Employee>(*i).setTitle("Corporate Deity");
}
它也等价于如下内容:
if (i != se.end()) { // 同上,
((Employee)(*i)).setTitle("Corporate Deity"); // 但使用C
} // 映射语法
这两个都能编译,而且因为它们等价,所以它们错的原因也相同。在运行期,它们不能修改*i!在这两个情况里,映射的结果是一个*i副本的临时匿名对象,而setTitle是在匿名的物体上调用,不在*i上!*i没被修改,因为setTitle从未在那个对象上调用,它在那个对象的副本上调用。两个句法形式等价于这个:
if (i != se.end()){
Employee tempCopy(*i); // 把*i拷贝到tempCopy
tempCopy.setTitle("Corporate Deity"); // 修改tempCopy
}
现在应该清楚转换到引用的重要性了吧。通过映射到引用,我们避免了建立一个新对象。取而代之的是,映射的结果是一个现有对象的引用,i指向的对象。当我们在有这个引用指定的对象上调用setTitle时,我们是在*i上调用setTitle,而且那正是我们想要的。
我刚才写的适用于set和multiset,但当我们转向map和multimap时,细节变粗了。注意map<K, V>或multimap<K, V>包含pair<const K, V>类型的元素。那个const表明pair的第一个组件被定义为常量,而那意味着试图修改它是未定义的行为(即使转换掉它的常量性)。理论上,一个STL实现可能把这样的值写到一个只读的内存位置(比如,一旦写了就通过系统调用进行写保护的虚拟内存页),而且试图映射掉它的常量性,最多,没有效果。我从未听说一个实现那么做,但如果你是一个坚持遵循标准拟定的规则的人,你绝不会试图转换掉map或multimap键的常量性。
你肯定听过有些类型转换(cast)是危险的,而且我希望本书能清楚到让我相信你可以尽量避免它们的程度。进行转换将临时剥去类型系统的安全性, 而且当你把安全网抛至脑后时,我们已经讨论的缺陷例证了所能发生的。
大多数cast可以避免,那包括我们刚刚考虑的。如果你要总是可以工作而且总是安全地改变set、multiset、map或multimap里的元素,按五个简单的步骤去做:
这是同一个累人的雇员例子,这次以安全、可移植的方式写:
EmpIDSet se; // 同前,se是一个以ID号
// 排序的雇员set
Employee selectedID; // 同前,selectedID是一个带有
// 需要ID号的雇员
...
EmpIDSet::iterator i =
se.find(selectedID); // 第一步:找到要改变的元素
if (i!=se.end()){
Employee e(*i); // 第二步:拷贝这个元素
se.erase(i++); // 第三步:删除这个元素;
// 自增这个迭代器以
// 保持它有效(参见条款9)
e.setTitle("Corporate Deity"); // 第四步:修改这个副本
se.insert(i, e); // 第五步:插入新值;提示它的位置
// 和原先元素的一样
}
你将原谅我以这种方法放它,但要记得关键的事情是对于set和multiset,如果你进行任何容器元素的原地修改,你有责任确保容器保持有序。