条款22:避免原地修改set和multiset的键

优质
小牛编辑
154浏览
2023-12-01

条款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来说是不可能的(除非你使用映射),但是它对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> {	// 参见条款40
		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中元素的键。其余的雇员数据只是虚有其表。在这里,没有理由不能把一个特定雇员的头衔改成某个有趣的东西。像这样:

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中元素的值,而且你的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的
}								// 常量性

这可以得到i指向的对象,告诉你的编译器把映射的结果当作一个(非常数)Employee的引用,然后在那个引用上调用setTitle。除了解释为什么这可以工作,我将解释为什么一种可替代的方法不表现人们经常期望的样子。

很多人想到的是这段代码:

if (i != se.end()){							// 把*i
	static_cast<Employee>(*i).setTitle("Corporate Deity");		// 映射到
}								// 一个Employee

它也等价于如下内容:

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键的常量性。

你肯定听过映射是危险的,而且我希望本书能清楚到让我相信你可以尽量避免它们的程度。进行映射将临时剥去类型系统的安全性, 而且当你把安全网抛至脑后时,我们已经讨论的缺陷例证了所能发生的。

大多数映射可以避免,那包括我们刚刚考虑的。如果你要总是可以工作而且总是安全地改变set、multiset、map或multimap里的元素,按五个简单的步骤去做:

  1. 定位你想要改变的容器元素。如果你不确定最好的方法,条款45提供了关于怎样进行适当搜寻的指导。
  2. 拷贝一份要被修改的元素。对map或multimap而言,确定不要把副本的第一个元素声明为const。毕竟,你想要改变它!
  3. 修改副本,使它有你想要在容器里的值。
  4. 从容器里删除元素,通常通过调用erase(参见条款9)。
  5. 把新值插入容器。如果新元素在容器的排序顺序中的位置正好相同或相邻于删除的元素,使用insert的“提示”形式把插入的效率从对数时间改进到分摊的常数时间。使用你从第一步获得的迭代器作为提示。

这是同一个累人的雇员例子,这次以安全、可移植的方式写:

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,如果你进行任何容器元素的原地修改,你有责任确保容器保持有序。