《Exceptional C++ style》开放样章译稿(第一部分)/Draft 0.1/Date 12.25.2004
By 谢轩(infinitude_CN)
34. 索引表(Index Tables) 难度5
索引表确实是一种有用的idiom而且它是一种值得了解的技术。但我们如何才能有效地实现这一技术呢……不仅如此,应该比“有效”更好,“完美”怎么样?
JG问题
1. 谁会受益于清晰易懂的代码?
Guru问题
2. 下面代码呈现了在已存在的容器中创建索引表的一种有趣而有用的idiom。如果需要更详细的解释,请看原文[Hicks00]。
批评这段代码并找出:
a) 类似于无效语法或nonportable convention的机制上的错误。
b) 风格上的改进,使代码的清晰度、重用性和可维护性得以改善。
// program sort_idxtbl(¡) to make a permuted array of indices
#include <vector>
#include <algorith>
template <class RAIter>
struct sort_idxtbl_pair
{
RAIter it;
int i;
bool operator<( const sort_idxtbl_pair& s )
{ return (*it) < (*(s.it)); }
void set( const RAIter& _it, int _i ) { it=_it; i=_i; }
sort_idxtbl_pair() {}
};
template <class RAIter>
void sort_idxtbl( RAIter first, RAIter last, int* pidxtbl )
{
int iDst = last-first;
typedef std::vector< sort_idxtbl_pair<RAIter> > V;
V v( iDst );
int i=0;
RAIter it = first;
V::iterator vit = v.begin();
for( i=0; it<last; it++, vit++, i++ )
(*vit).set(it,i);
std::sort(v.begin(), v.end());
int *pi = pidxtbl;
vit = v.begin();
for( ; vit<v.end(); pi++, vit++ )
*pi = (*vit).i;
}
main()
{
int ai[10] = { 15,12,13,14,18,11,10,17,16,19 };
cout << "#################" << endl;
std::vector<int> vecai(ai, ai+10);
int aidxtbl[10];
sort_idxtbl(vecai.begin(), vecai.end(), aidxtbl);
for (int i=0; i<10; i++)
cout << "i=" << i
<< ", aidxtbl[i]=" << aidxtbl[i]
<< ", ai[aidxtbl[i]]=" << ai[aidxtbl[i]]
<< endl;
cout << "#################" << endl;
}
解答
清晰度:简短的训诫
1. 谁会受益于清晰易懂的代码?
简而言之,对所有人都有好处。
首先,清晰的代码更易于调试,也正因为清晰,刚写完的代码也不会有很多错误,所以编写清晰的代码可以让你的生活更轻松,即使只是在很短的时间内。(相关案例,请参考第27条围绕“例27-3”的讨论。)此外,当你一个月或一年之后重读你的代码时——你一定会希望代码很好而且正在实际中应用——你会发现更容易“重拾”那些清晰的代码,明白出了什么问题。绝大多数程序员认为在头脑中记住代码的全部细节并保持甚至只是几周时间都是很困难的,尤其是在转向其它的工作之后,要记住这些就更加困难了。经过几个月甚至几年,即使是重看自己代码,也很容易让人设想那是一个陌生人写的,虽然那个陌生人恰好遵循你个人的编码风格。
利己方面已经说得够多了。让我们来看看有利别人的方面:维护代码的人也将获益于代码的清晰性和可读性。毕竟,想将代码维护好是要“全神贯注于(grok)”代码的。罗伯特.海因莱因(Robert Heinlein)杜撰了“grok”这个词,意指深入而完整地理解;在此处,这个词还包含有理解代码本身内在的工作方式、代码的副作用以及其与其它子系统的交互方式的意思。总而言之,当没有完全理解时改写代码太容易引入新的错误了。清晰、可理解的代码更容易让人“全神贯注于”其中,因此,对于这种代码的修补就变得不那么脆弱、危险,而且不太会引入“无意的”副作用。
然而,最重要的是,由于所有这些原因,你的最终用户将得益于清晰、可理解的代码:这种代码从一开始就只有很少的原始错误;这种代码更容易被正确地维护,而且在维护过程中不会引入很多新错误。
原则:一般情况下,优先考虑编写清晰、正确的代码。
深入剖析索引表
2. 面代码呈现了在已存在的容器中创建索引表的一种有趣而有用的idiom。如果需要更详细的解释,请看原文[Hicks00]。
批评这段代码并找出:
a) 类似于无效语法或nonportable convention的机制上的错误。
b) 风格上的改进,使代码的清晰度、重用性和可维护性得以改善。
让我们再次重复大牛们反复说的:这些代码呈现了在已存在的容器中创建索引表的一种有趣而有用的idiom。我常常地发现必须以不同的方式访问相同的容器,例如使用不同的顺序排序。因此,这样的方法的确是很有用的:以一个主容器保存数据(譬如vector<Employee>),以若干副容器保存指向主容器中元素的iterator,从而支持各种各样的访问方法(例如,set<vector<Employee>::iterator, Funct>,其中Funct是用以间接比较Employee对象的仿函数,这样可以产生不同于对象在vector中物理存储顺序的排序)。
正如已经说过的,风格也是很重要的。原作者爽快地允许我将他的代码作为相关案例,而我并不想在此提及他;我才刚刚采用通过剖析和批评发表的代码的方式来解释编码风格原则的技术,而很早以前诸如P.J.Plauger等强人(luminaries)就已经这么做了。我从前批评过其它人发表的资料,同时也让别人来批评我的资料,而且我肯定进一步的剖析将勿庸置疑遵循这个模式。
说完所有的这些,让我们来看看我们能对这一特定片断的代码做些什么改进。
更正机制性的错误
a) 类似于无效语法或nonportable convention的机制上的错误。
具有建设性的批判的第一个方面就是代码中的机制性错误。像下面列出的这些机制性错误在大多数平台上是无法通过编译的。
1. 正确地拼写标准头文件名。在这个例子里,头文件<algorithm>被误作<algorith>。我首先猜测,这也许是因为测试原始代码的是8字符文件名的系统的缘故,但即使是我老版本Windows(基于8.3文件名系统)上的老版本VC++也无法编译这代码。甚至是在限制诸多的文件系统中,编译器本身也是被要求能支持任何标准的长的头文件名,即使编译器是隐式地将它映射成较短的文件名(或根本不映射到文件上)。
接下来,考虑:
main()
2. 正确地定义main函数。这种无修饰的main函数signature从来都不是标准C++[C++98]风格的,虽然只要编译器给出相应的警告,这就是个一致性扩展(conforming extension)。这种main函数signature曾经在pre-1999 C是有效的,因为在pre-1999 C中有隐式int规则,但这在C++(从没有隐式的int)和C99[C99](which as far as I can tell didn’t merely deprecate implicit int, but actually removed it outright)中都是非标准的。在C++标准中,请参看:
§3.6.1/2:可移植代码必须将main定义为int main()或int main(int,char*[])两种形式中的一种。
§7/7 脚注78,和§7.1.5/2脚注80:隐式int是被禁止的。
附录 C(兼容性),对7.1.5/4的注释:明确指出裸main()在C++中是无效的,必须被写作int main()。
原则:不要依赖隐式int;这不是与标准一致的可移植的C++。特别的,“void main()”或仅有“main()”从来不是标准C++,虽然仍有很多编译器将它们作为一致性扩展加以支持。
cout << “#################” << endl;
3. 永远记得#include你需要的类型定义的头文件。这个程序使用了cout和endl但却没有#include<iostream>。为什么这在代码的原始开发者的系统上能工作呢?因为C++标准头文件能够互相#include,但不像C,C++没有指定哪些标准头文件#include哪些的其它标准头文件。
在这个案例中,程序有#include <vector>和<algorithm>,而在原始系统上,也许恰好某个头文件间接地#include <iostream>了。这些代码也许在基于原始代码所使用的库实现上能行得通,而它在我这里恰巧也能正常工作,但它并不是可移植的,而且这也不是种好的风格。
4. 遵循More Exceptional C++[Sutter02]的36条中的关于使用名字空间(namespace)的原则。就cout和endl而言,程序必须以std::限定它们,或者像这样写:using std::cout; using std::endl;。不幸的是,忘记名字空间域限定符的情况仍然很普遍——我得赶紧指出,这位作者对vector和sort做了正确的域限定,这倒很不错。
改进风格
b) 风格上的改进将提高代码的清晰性、复用性和可维护性。
越过机制性错误,在这个代码范例中,有些地方我个人会以不同的方式完成。首先,要对helper struct作两点注解:
template <class RAIter>
struct sort_idxtbl_pair
{
RAIter it;
int i;
bool operator<( const sort_idxtbl_pair& s )
{ return (*it) < (*(s.it)); }
void set( const RAIter& _it, int _i ) { it=_it; i=_i; }
sort_idxtbl_pair() {}
};
5.务必要是const正确的。在此例中,sort_idxtbl_pair::operator< 并不会修改*this,所以它应当被声明为const成员函数。
原则:时时落实const的正确性。
6.消除冗余代码。该程序显式地写出了类sort_idxtbl_pair的默认构造函数,然而它却与隐式生成的版本没有区别。这看上去是没有必要的。此外,sort_idxtbl_pair是数据成员都是public的struct,虽然有一个独特的set操作可以添加一些syntactic sugar,但因为它只在一处被调用,所以这个小小的额外复杂度并没带来太多好处。
原则:避免代码重复和冗余。
下面,我们进入核心函数sort_idxtbl来看看:
template <class RAIter>
void sort_idxtbl( RAIter first, RAIter last, int* pidxtbl )
{
int iDst = last-first;
typedef std::vector< sort_idxtbl_pair<RAIter> > V;
V v( iDst );
int i=0;
RAIter it = first;
V::iterator vit = v.begin();
for( i=0; it<last; it++, vit++, i++ )
(*vit).set(it,i);
std::sort(v.begin(), v.end());
int *pi = pidxtbl;
vit = v.begin();
for( ; vit<v.end(); pi++, vit++ )
*pi = (*vit).i;
}
7.选择有意义的恰当的名字。这个案例中,sort_idxtbl是个有误导性的名字因为这个函数并不是对一个索引表排序...而是创建了一个索引表!另一方面,代码使用了模板参数名RAIter来指示这是个随机访问的(random-access)iterator,这一点可以得个不错的分数;因为这个随机访问特性是该版本的代码所需要的,所以将模板参数命名为此可以作为一个很好的提示。
原则:选择清晰而有意义的名字。
8.保持一致性。在sort_idxtbl中,有时变量是在for循环初始化语句中被置初值的,而有时却又不是这样。这使得代码更难以阅读,至少对我来说是这样的。Your mileage may vary on this one.
9.消除莫明其妙的复杂性。这个函数喜好莫明其妙的局部变量!有三处代码可以为证。第一,变量iDst被初始化为last-first,并只使用了一次;为什么不在使用处直接写last-first来摆脱这种混乱的状况呢?第二,vector的iterator——vit被创建处,下标已经可以使用了而且可以一样地用,如果这样代码会更清晰些。第三,局部变量it被初始化为函数参数的值,而在此之后函数参数的值再没被使用过;我个人的偏好是,使用函数参数(即使你改变参数的值——完全没有问题!),而不是引入另一个名字。
10.复用(第一部分):更多地复用标准库。 现在原先的程序因为复用std::sort拿了个高分——很不错哦。但是干嘛不采用std::copy,而要手工实现最后的那个循环来完成复制工作呢?为什么要重新做一个只比std::pair多一个比较函数的专用的sort_idxtbl_pair呢?除了写起来更简单,复用还使你的代码更具可读性。Humble thyself and reuse!
原则:在任何适当的地方,了解并使用(复用)标准库设施而不是自己去实现。
11.复用(第二部分):让实现本身更易于复用,从而可以一石二鸟。在初始代码中,除了函数本身,没有任何东西是可以直接复用的。外覆类sort_idxtbl_pair与它的用途捆绑得太紧,它根本不是“独立可复用的”(independently reusable)。
12.复用(第三部分):改进signature。原先的signature
template<class RAIter>
void sort_idxtbl(RAIter first, RAIter last, int* pidxtbl)
用了一个裸的int*指针指向输出区,我通常会避免这样做,我更赞同使用托管的存储空间(比如,一个vector)。但最终用户要能够调用sort_idxtbl并将输出放到一个普通数组或一个vector或是其它什么东西里。那么,“能够放入任何容器”这样的要求就将只需要一个iterator,不是吗?(参看Item 5和 6。)
template< class RAIn, class Out >
void sort_idxtbl( RAIn first, RAIn last, Out result )
原则:避免不必要的类型硬编码,从而拓展泛型组件的可复用性。
13.复用(第四部分),或者叫“尽量使用!=来比较iterator”:进行iterator的比较时,务必使用!=(对各种类型的iterator都可用)而不是 < (只对random-access的iterator有效),当然除非你真的一定要使用 < 而且你刻意只支持random-access的iterator。原始的程序中使用了 < 来比较iterators,这对于random-access的iterator没有问题,而程序的本意也就是创建索引表放入vector和数组中,而这两者都支持random-access的访问方式。但是,我们没理由不想让这些功能可以作用在诸如list和set的其它不支持random-access访问的容器上啊!而原始代码不能适用于这些容器就是因为使用了 < 而不是!=来比较iterator。
正如Scott Meyers在[Meyers96]第32条中阐述的,“在‘将来时’下编程。”(”Program in the future tense.”)他论述道:
(此处参照More Effective C++中文版)
原则:尽量使用 != 而非 < 比较iterator。
14.除非你真的需要旧的值,否则尽量使用前置递增。在这样的代码中,对于iterator,应该习惯性地使用前置递增(++i)而避免后置递增(i++);详见[Sutter00,第39条]。诚然,在原始代码中这两者也许没有什么本质区别,因为vector<T>::iterator也许是复制代价很小的T*(尽管也许不是这样,这是由STL实现决定的),但如果我们要实现第13点建议,就不能局限在vector<T>::iterator上,而且大多数其它的iterator都是class类型的——或许复制的代价仍然不是很昂贵,可我们何必要无谓地引入这潜在的效率损失呢?
原则:尽量选择使用前置递增而非后置递增,除非你确实需要使用旧的值。
上面几点已经覆盖了大多数重要的问题。不过还是有一些可以拿来批判的东西,只是我觉得没有必要把注意力放在这些不是很重要的问题上;例如:产品的代码中应该有说明类和函数的用途和语义的注释,但这不适用于杂志文章所附的代码,因为文章里会有语言组织得更好、更为详细的解释。
我故意没有对主函数的风格进行批判(as opposed to the mechanical errors already noted that would cause the mainline to fail to compile),因为毕竟这个主函数只是一个演示的工具,它帮助读者了解索引表这个设施是如何使用的,而索引表本身才是焦点所在。
总结
让我们在保持原始代码的接口的基础上换一种设计。40 把我们的批评限定在改正代码的机制性错误和基本风格的范围内,然后,考虑下面三种改进版本。它们每一个版本都有各自的优点、缺点以及风格偏好,在代码注释中有相应的解释。这三个版本的共同点就是更为清晰、更易理解、更适于移植——这样的代码在你我的公司中应该是很有价值的。
// An improved version of the code originally published in [Hicks00].
//
#include <vector>
#include <map>
#include <algorithm>
// Solution 1 does some basic cleanup but still preserves the general structure
// of the original’s approach. We’re down to 17 lines (even if you count "public:"
// and "private:" as lines), where the original had 23.
//
namespace Solution1 {
template<class Iter>
class sort_idxtbl_pair {
public:
void set( const Iter& it, int i ) { it_ = it; i_ = i; }
bool operator<( const sort_idxtbl_pair& other ) const
{ return *it_ < *other.it_; }
operator int() const { return i_; }
private:
Iter it_;
int i_;
};
// This function is where most of the clarity savings came from; it has 5 lines,
// where the original had 13. After each code line, I’ll show the corresponding
// original code for comparison. Prefer to write code that is clear and concise,
// not unnecessarily complex or obscure!
//
template<class IterIn, class IterOut>
void sort_idxtbl( IterIn first, IterIn last, IterOut out ) {
std::vector<sort_idxtbl_pair<IterIn> > v(last-first);
// int iDst = last-first;
// typedef std::vector< sort_idxtbl_pair<RAIter> > V;
// V v(iDst);
for( int i=0; i < last-first; ++i )
v[i].set( first+i, i );
// int i=0;
// RAIter it = first;
// V::iterator vit = v.begin();
// for (i=0; it<last; it++, vit++, i++)
// (*vit).set(it,i);
std::sort( v.begin(), v.end() );
// std::sort(v.begin(), v.end());
std::copy( v.begin(), v.end(), out );
// int *pi = pidxtbl;
// vit = v.begin();
// for (; vit<v.end(); pi++, vit++)
// *pi = (*vit).i;
}
}
// Solution 2 uses a pair instead of reinventing a pair-like helper class. Now we’re
// down to 13 lines, from the original 23. Of the 14 lines, 9 are purpose-specific,
// and 5 are directly reusable in other contexts.
//
namespace Solution2 {
template<class T, class U>
struct ComparePair1stDeref {
bool operator()( const std::pair<T,U>& a, const std::pair<T,U>& b ) const
{ return *a.first < *b.first; }
};
template<class IterIn, class IterOut>
void sort_idxtbl( IterIn first, IterIn last, IterOut out ) {
std::vector< std::pair<IterIn,int> > s( last-first );
for( int i=0; i < s.size(); ++i )
s[i] = std::make_pair( first+i, i );
std::sort( s.begin(), s.end(), ComparePair1stDeref<IterIn,int>() );
for( int i=0; i < s.size(); ++i, ++out )
*out = s[i].second;
}
}
// Solution 3 just shows a couple of alternative details¡ªit uses a map to avoid a
// separate sorting step, and it uses std::transform() instead of a handcrafted loop.
// Here we still have 15 lines, but more are reusable. This version uses more space
// overhead and probably more time overhead too, so I prefer Solution 2, but this
// is an example of finding alternative approaches to a problem.
//
namespace Solution3 {
template<class T>
struct CompareDeref {
bool operator()( const T& a, const T& b ) const
{ return *a < *b; }
};
template<class T, class U>
struct Pair2nd {
const U& operator()( const std::pair<T,U>& a ) const { return a.second; }
};
template<class IterIn, class IterOut>
void sort_idxtbl( IterIn first, IterIn last, IterOutIterOut out ) {
std::multimap<IterIn, int, CompareDeref<IterIn> > v;
for( int i=0; first != last; ++i, ++first )
v.insert( std::make_pair( first, i ) );
std::transform( v.begin(), v.end(), out, Pair2nd<IterIn const,int>() );
}
}
// I left the test harness essentially unchanged, except to demonstrate putting
// the output in an output iterator (instead of necessarily an int*) and using the
// source array directly as a container.
//
#include <iostream>
int main() {
int ai[10] = { 15,12,13,14,18,11,10,17,16,19 };
std::cout << "#################" << std::endl;
std::vector<int> aidxtbl( 10 );
// use another namespace name to test a different solution
Solution3::sort_idxtbl( ai, ai+10, aidxtbl.begin() );
for( int i=0; i<10; ++i )
std::cout << "i=" << i
<< ", aidxtbl[i]=" << aidxtbl[i]
<< ", ai[aidxtbl[i]]=" << ai[aidxtbl[i]]
<< std::endl;
std::cout << "#################" << std::endl;
}
40 原作者([Hicks00]的作者)也报告了来自另外一个读者的反馈,他展示了另一种优雅的,但完全不同的方法:他创建了一个类似容器的对象,这个对象包覆了原始容器及其iterator,而且允许使用不同顺序的迭代访问。