4、 标准库:1991-1998
在1991年之后,C++标准的草稿中的最大改变是标准库。尽管我们做对标准文档做了非常多的细小的改进,与之相比较,语言特征只有很少的改变。为获得这样的观点,注意到标准总共有718页:310页定义了语言,366页定义了标准库。其它的就是附录等。另外,C++标准库通过引用包含C标准库,那是另外的81页。参考手册TC++PL2[118]作为标准化的基本文档,包含154页用于语言,而仅用了一页用于标准库。
到目前为止,标准库的最具创新的组成部分是“STL”—容器、迭代器和算法。STL不仅影响并继续影响编程和设计技术,还将影响新的语言特征的方向。因此,STL在这里被看成是第一位的,而且比标准库的其它组件有更详细的阐述。
4.1 STL
STL是主要的创新并变成标准的一部分,许多关于编程技术的新的思考就是从那时起开始出现的。基本上,STL是一种革命,与以前C++社团对的容器及容器的使用的想法不同。
4.1.1 STL之前的容器
从Simula的最早期,就已经有了容器(例如list):当且仅当它的类(显式或隐式)派生自特定的Link并且/或Object类时,对象能够放到一个容器中。这个类包含管理容器中的对象所需要的链接信息并为元素提供了一种公用的类型。基本上,这样的容器是存储指向对象的引用(指针)的容器。我们可以用图来表示这么的一个list:
其中link来自基类Link。
类似的,一个“面向对象”的vector基本上就是一个数组,存储对象的引用。用下面的图来表示这样的一个vector:
在vector数据结构中的指向对象的引用,指向的就是Object基类的对象。这意味着基础类型的对象,比如int和double,不能直接放到容器中,直接支持基本类型的数组类型必须与其它容器区分开:
而且,对于简单类的对象,比如complex和point,如果我们要把它们放到容器中则没有办法在时间和空间上进行优化。也即是说,Simula风格的容器是侵入式的,依赖于插入元素类型中的数据字段,并且通过指针(引用)提供对对象的间接访问。而且,Simula风格的容器不是静态类型安全的。比如,Circle可以添加到一个list中,但当从list中抽取出来时,我们只知道它是一个Object,需要使用转换(显式类型转换)去重新获得它的静态类型。
因此,Simula容器提供了对内建类型和用户自定义类型(后者只有某些可以放在容器中)的不同对待。类似的,数组与用户定义的容器也被区别对待(只有数组可能保存基础类型)。这直接违背了C++中的两条最清晰的语言-技术理想:
·对内建类型和用户自定义类型提供相同的支持;
·你不需要为你不需要的东西付出代价(0开销原则)
Smalltalk对容器的处理方法跟Simula一样,尽管它让基类通用,从而不需要隐式转换。后来的语言也有这个同样的问题,比如Java和C#(尽管它们—象Smalltalk—利用通用类,C#2.0还应用类似于 C++的特化去优化整数容器)。许多早期的C++库(比如,NIHCL[50],早期的AT&T库[5])也是根据这样的模型。它拥有许多重要的实用性,许多设计者也很熟悉它。然而,我认为这既没有规律性也没有效率(时间和空间),对于真正的通用库来说,这是不可接受的(你能从我的TC++PL3[126]§16.2中找到我的分析总结)。
没有解决这些逻辑上和性能上的问题的方法,导致在1985年的C++中没有提供一个合适的标准库(参见D&E[121]§9.2.3),这就是那个“最大的失误”的基本原因:我不想绑定任何不能直接将内建类型作为元素和不是静态类型安全的东西。甚至在第一个“带类的C”的论文[108]中,就开始跟这个问题做斗争,不成功地使用宏解决了这个问题。而且,我特别不想提供covariant container。考虑一个可以存储某些“通用”对象的vector:
void f(Vector& p)
{
p[2] = new Pear;
}
void g()
{
Vector apples(10); // 用于存放apple的容器
for(int i=0; i<10; ++i)
apples[i] = new Apple;
f(apples);// 现在apple里面包含了pear
}
g的作者假定apples是Apple的vector,然而,vector是一个完美的普通的面向对象的容器,因此它真的包含(指针指向)对象。因为Pear也是对象,f可以毫无问题地把Pear放到容器里面去。它需要运行时的检查(隐式或显式)去捕获错误/误解。我记得当别人向我解释这个问题时(80年代早期)时我是何等地震惊,这太拙劣了。我决定我不能写这样的容器,这将带给用户肮脏的诧异和严重的运行时检查代价。在现代C++(比如,1988年后),这个问题通过使用由元素类型进行参数化的容器得以解决。特别地,标准C++提供vector<Apple>作为解决方法。注意到vector<Apple>不能转换到vector<Fruit>,尽管Apple可以隐式转换到Fruit。
4.1.2 STL的出现
在1993年末,我发现有一种用于容器的新方法和使用,那是由Alex Stepanov所开发的。他把他所建造的库称之为“STL”。Alex那时在HP实验室工作,但他早期在贝尔实验室干过几年,在那里他和Andrew Koenig在一起,也是在那里,我和他一起讨论过库的设计和模板机制。他曾激发我更加努力地在模板机制的一般性及效率上进行工作,但幸运的是,他没有使我信服让模板更象Ada的generics。如果当时他说服了我,那么他将不会去设计和实现STL!
Alex展示了他长达十年的在泛型编程技术上的研究的最新成果,泛型编程技术的目标是基于严格的数学基础的“最一般、最有效率的代码”。它是容器和算法的框架。他首先向Andrew解释它的想法,然后Andrew用了数天的时间向我展示STL的用法。我的第一反应是迷惑,我发现STL风格的容器及其使用方法非常奇特,甚至是丑陋的、冗赘的。比如,你对vector<double>根据它们的绝对值进行排序,代码象下面这样:
sort(vd.begin(), vd.end(), Absolute<double>());
这里,vd.begin()和vd.end()指向vector中元素序列的头部和尾部,Absolute<double>()比较绝对值。
STL将算法与数据分离,那正是传统的做法,而不是面向对象的做法。而且,它还分离算法的策略决策,比如算法上的排序标准和搜索标准。其结果是无比的灵活性并且—出人意外的—高性能。
象许多熟悉面向对象编程的程序员一样,我认为我只是粗略地知道怎样使用容器进行编程。我猜想那是某些类似于Simula风格的经过模板扩充的容器,用于静态类型安全,也许还用于迭代器接口的抽象类。STL代码看起来非常不同,然而,多年后我总结出一个清单,那些我认为对于容器来说是很重要的属性都列于其上:
1、单独的容器是简单和高效的
2、容器提供它的“自然的”操作(比如,list提供put和get,vector提供下标操作)
3、简单操作,比如成员访问操作,不需要函数调用用于它们的实现
4、提供公共函数(可以通过迭代器或基类)
5、容器默认是静态类型安全的,并且是homogeneous(即在同一个容器的所有元素的类型相同)
6、heterogeneous容器能够由存储指向共同基类的指针的homogeneous容器所实现
7、容器是非侵入式的(比如,要成为容器的成员,一个对象不需要有特殊的基类或链接的字段)
8、容器能够包含内建类型的元素
9、容器能够包含有外部强加布局的structs
10、容器能应用在一般的框架上(容器和在容器上的操作)
11、没有sort成员函数的容器也能排序
12、“公共服务”(比如persistence)能独立地提供给一组容器(通过基类?)
在[124]中可以找到一个稍微不同的版本。
令我吃惊的是STL满足列表上的所有要求除了最后一个,我曾经想过使用一个公共基类为所有派生类(比如所有对象或所有容器)提供服务(比如persistence)。然而,我以前不(现在也不)认为这样的服务对容器来说是重要的。有趣地,某些“公共服务”能由“concepts”(§8.3.3)来表达,特别是在表达期望能从一组类型中得到些什么时,所以C++0x(§8)很可能将STL容器与上面列表的理想靠得更近些。
它花了我一些时间—很多周—去习惯STL。在那之后,我担心将这么一个全新风格的库引入到C++社区是否太迟。考虑到让标准委员会的成员们在标准化进程的最后阶段接受一些新的和创新性的东西的可能性,我决定(正确的决定)让那些奇特的东西降到最低。尽管这样,标准仍延迟一年—而C++社团急切地需要标准。还有,委员会从根本上就是保守的团体而STL又太具革命性。
因此,尽管机率是很低的,但我仍带着希望辛勤工作。毕竟,我真的觉得C++没有足够大和足够好的标准库[120](D&E [121]§9.2.3)是非常坏的。Andrew Koenig尽他最大的可能为我鼓气,增加我的勇气,Alex Stepanov把他所知道的都教给了Andy和我,幸运的,Alex还没有完全领会到获得大多数同意的困难就通过了委员会这一关。因此他没有气馁,在技术方面不断工作同时教我和Andrew。我开始向其它人解释隐藏在STL后面的理念。比如,D&E上§15.6.3.1上的例子来自STL,我引自Alex Stepanov的原话:“C++是一门威力强大的语言—我们遇到的第一个语言—允许构造具有数学的精确、优美和抽象的泛型编程组件,具有跟手工写的代码一样的高效。”这通常是对泛型编程的引述,特别是对STL。
Andrew Koenig和我邀请Alex在1993年10月的标准委员会会议上做晚会致词,在San Jose,加利福尼亚:“它以The Science of C++ Programming为标题,并处理许多axioms of regular types—与构造、赋值和相等有关。我也阐述了现在称之为前向迭代器的公理(axiom)。我没有提到任何容器,而只提到一个算法:find[105]”。那次谈话是大胆的,令我吃惊和高兴的是,基本上把委员会的态度从“在这个阶段不可能做这么大的事情”转变到“好吧,让我们看看”。
那正是我们需要的突破。在接下来的4个月中,我们(Alex,他的同事Meng Lee,Andrew和我)在一起实验、争论、沟通、教学、编程、重设计和文档化,这样Alex才能在1994年3月在San Diego,加利福尼亚州向委员会呈上一份完整的STL描述。1994年Alex又安排了一次会议,面向HP中的C++库实现者。与会者有Tom Keffer(Rogue Wave),Meng Lee(HP),Nathan Myers(Rogue Wave),Larry Podmolik(Anderson Consulting),Mike Vilot,Alex和我。我们就许多原则和细节取得一致意见,但STL的大小成为主要障碍。在是否需要STL中的大部分上并没有达成一致,有个(现实的)担心就是委员会没有时间去检查和更正式地说明这么大的一个东西,而且当有许多事情需要去理解、实现、文档、教育时,人们总是感到沮丧。最后,在Alex的催促下,我拿起笔砍掉大概全文的三分之二,对于每一个设施,我对Alex和其它库专家的解释是—非常简短地—为什么它不能被砍掉和为什么它将让大多数C++程序员从中受益。这是个非常可怕的锻炼。Alex最后宣称那样做让他心碎。然而,剩余的部分现在就成了STL[103],并且在1994年10月在Waterloo,加拿大的会议中进入了ISO C++的标准—而这是原始的、完整的STL所不能做到的。即使是修改后的“精简的STL”,仍将标准延迟了一年多。库的实现者们对库的大小和复杂性非常担心,关注提供一个优质的编译器的代价。比如,我记得Roland Hartinger(代表Simens和德国)担心接受STL将花费他的部门100万马克。现在回想,我想我造成的伤害比我们预期的要小得多。
在关于是否接纳STL的讨论中,我记得:Beman Dawes平静地向委员会解释说,他认为STL对于普通的程序员来说太复杂了,但作为练习,他已经实现了差不多10%,所以他不再认为STL超出标准的能力。Beman以前是(现在仍是)委员会中的一名少有的应用程序建造者之一。不幸的是,委员会往往被编译器制造者、库和工具的建造者所主宰。
我把STL归功于Alex Stepanov。他在STL出现之前的10年就开始工作于基本的理想和技术,不成功地使用Scheme和Ada[101]。然而,Alex总是第一个坚持参加探索的人。David Musser(Rensselaer Polytechnic Institute的一位教授)和Alex在泛型编程上一起工作了差不多20年,Meng Lee和Alex在HP一起工作过,帮助编程实现初始的STL。Alex和Andrew Koenig之间的email讨论也是有帮助的。除了砍掉STL的那部分实践,我在技术上的贡献是很少的。我建议把与内存相关的各种信息放到一个单独的对象中—这变成了allocator。我还在在Alex的黑板上草拟了最初的需求表,因此创建了标准文档上说明STL模板需求的格式。这些需求表实际上就是语言表达能力不足的指示器—这样的需求应该是代码的一部分。参见§8.3.3
Alex命名他的容器、迭代器和算法为STL库,通常这是“Standard Template Library”的缩写。然而,库在有这名字之前就已经存在,并且标准库的许多部分依赖于模板。机智的人建议“STepanov and Lee”作为另一种解释。Alex最后的话是:“STL就是STL。”正如其它情况,缩写究其一生代表自己。
4.1.3 STL理想和概念
那么STL是什么?它来源于一种尝试,应用数学的一般性的理想去解决数据和算法的问题。对容器里面的对象进行排序并写一个算法去操作这些对象,对这类问题的思考也就是理想的方向,独立和组合地表达概念:
·用代码直接表达概念
·用代码直接表达概念之间的关系
·用独立的代码直接表达独立的概念
·组合代码自由表达概念,只要组合言之有理。
我们想要能够:
·存储不同类型(比例,int,point和指向Shape的指针)的对象
·存储不同类型的对象到各种容器(比如,list,vector和map)中
·应用各种的算法(sort,find和accumulate)于容器中的对象
·使用各种标准(比较、判断等)于算法之上
而且,我们需要使用的对象、容器和算法是静态类型安全的,尽可能地快,尽可能地紧凑而不冗赘的,并且是可读的。同时满足这些要求是不容易的。事实上,我花了超过10年的时间在寻找解决这一问题的方法,但是没有完全成功(§4.1.2)。
STL解决方法是基于用它们的元素类型参数化容器,并且将算法和容器完全分离。每一种类型的容器都提供一种迭代器类型,所有对容器元素的访问能通过只使用这种类型的迭代器完成。迭代器定义了算法和算法所要操作的数据之间的接口。一方面,算法能够使用迭代器实现,而不需要知道它所要应用到的容器的信息。每一种迭代器是完全独立的,除了对要求的操作,比如*和++提供相同的语义。
算法使用迭代器,容器的实现者实现他们的容器的迭代器。
让我们考虑一个著名的例子,就是那个Alex Stepanov最先向委员会展示的(San Jose,加利福尼亚州,1993)。我们需要从不同的容器中查找不同类型的元素。首先,这儿有两个容器:
vector<int> vi; // int的vector
list<string> ls; // string的list
vector和list是标准库版本中的用模板实现的容器。一个STL容器是非入侵式的数据结构,你可以拷贝任何类型的元素。我们可以通过图来表达list<string>(双向链表),如下:
注意到链接信息不是元素类型的一部分,STL容器(这里的list)为它的元素(这里的string)管理内存并提供链接信息。
类似的,我们可以表示一个vector<int>象这样:
注意到元素是存储在由vector和list所管理的内存中的,这跟§4.1.1所提到的Simula风格的容器是不一样的,它最小化分配操作,最小化每个对象的内存,并且保存一个间接地访问元素方法。相应的代价就是当对象第一次插入到容器时的拷贝操作,如果拷贝的代价很高,那么程序员则倾向于使用指针作为元素。
假定容器vi和ls都被相应元素类型的值恰当地初始化了。那么从vi中查找第一个值为777的元素和在ls中查找第一个值为“Stepanov”元素则是有意义的:
vector<int>::iterator p = find(vi.begin(), vi.end(), 777);
list<string>::iterator q = find(ls.begin(), ls.end(), "Stepanov");
基本想法是你能把任何容器中的元素当成元素序列。容器“知道”它的第一个元素的位置,也知道它的最后一个元素的位置。我们把指向一个元素的对象称之为“迭代器”。我们可以使用一对迭代器来代表容器中的元素。begin()和end(),其中begin()指向第一个元素,end()是最后一个元素的下一个。通过图来表示这种一般的模型:
end()迭代器指向最后一个元素的下一个而不是最后一个元素,这样使得我们也可以使用相同的方法来表示空序列:
通过迭代器你能够做什么?你可以获得迭代器所指向的元素的值(象指针一样使用*),让迭代器指向下一个元素(象指针一样使用++),比较两个迭代器看它们是否指向同一元素(使用==或!=),令人意外的,这些操作对于实现find()已经足够了:
templae< class Iter, class T >
Iter find( Iter first, Iter last, const T& val ){
while( first != last && *first != val )
++first;
return first;
}
这是一个简单、非常简单的函数模板,习惯C和C++指针的人们发现这些代码非常容易读:first != last检查我们是否到达终点,*first != value检查我们是否找到我们正在查找的值(val)。如果没有,我们增加迭代器first,使得它指向下一个元素,然后再检查。当find()返回时,它的值要么指向第一个值为val的元素;要么指向最后一个元素的下一个(end())。所以我们可以写:
vector<int>::iterator p = find(vi.begin(),vi.end(),777);
if (p != vi.end()) { // we found 777
// ...
} else { // no 7 in vi
// ...
}
这是非常非常简单的。它就象数学课本的前几页那么简单,能够快速翻过。然而,我知道我不是惟一花大量时间想搞清楚这儿到底发生了什么事,想搞清楚为什么这是一个好想法的人。象简单的数学,the first STL rules and principles generalize beyond belief。
首先考虑算法:在调用find(vi.begin, vi.end(), 777 ),迭代器vi.begin()和vi.end()在算法中分别变成first和last。对于find(),first只是“某种指向int的东西”。vector<int>::iterator的明显实现就是一个指向int的指针int*。因此,*变成指针解引用,++变成指针递增,!=变成指针比较。就是说,find()的实现是显然和最优的。
请注意,STL没有使用函数调用去访问操作(比如*和!=),那对算法来说是有效的参数,因为他们依赖于模板的参数。在这儿,模板根本不同于大多数“generics”机制,“generics”机制依赖于间接的函数调用(象虚函数),正如由Java和C#所提供的一样。对于一个好的优化器,vector<int>::iterator可以是一个类,对于*和++操作就象提供内联函数一样没有开销,这样的优化器在现在是很普通的,使用迭代器类而不是指针,通过捕获没有根据的假设可以改进类型检查,比如用于vector的迭代器就是一个指针:
int *p = find(vi.begin(), vi.end(), 777); // 错误
// 冗赘的但是正确的写法如下
vector<int>::iterator q = find(vi.begin(), vi.end(), 777 );
C++0x将提供处理这种冗赘的方法,参见§8.3.2。
另外,不定义算法和它的类型参数之间的接口作为一组带独特类型的函数提供了很大的灵活性,这被证明是非常重要的[130](§8.3.3)。比如,标准库算法copy能用于不同类型的容器之间的拷贝:
void f(list<int>& lst, vector<int>& v)
{
copy(lst.begin(), lst.end(), v.begin());
// ...
copy(v.begin(), v.end(), lst.end());
}
为什么我们不抛弃迭代器,转而使用指针呢?一个理由是vector<int>::iterator可以是一个提供范围检查的类。查看另一个函数调用find(),我们可以得到另一个不是那么精妙的解释:
list<string>::iterator q = find(ls.begin(),ls.end(),"McIlroy");
if (q != ls.end()) { // we found "McIlroy"
// ...
} else { // no "McIlroy" in ls
// ...
}
这儿,list<string>::iterator不会成为string*。事实上,对于大多数普通的链表的实现,list<string>::iterator都会变成一个Link<string>*,其中Link是一个节点类型,象下面这样的:
template<class T> struct Link {
T value;
Link* suc;
Link* pre;
};
那意味着*表示p->value(“返回值域”),++意味着p->suc(“返回下一个节点的指针”),而 !=则是指针比较(比较Link*)。同样的,实现是显然和最优的。然而,这与我们早些时候看到的vector<int>::iterator完全不同。
我们使用模板的组合和重载解析去选择根本上不同的,仍是优化的,find()的定义中使用到的这些操作的实现。注意到没有运行时的分派,没有虚函数调用。事实上,只是调用微不足道的内联的函数和基本的操作,例如*和++用于指针。在执行时间和代码量上,我们都取得了绝对的优化。
为什么不使用“序列”或“容器”作为基础概念而是使用“迭代器对”呢?部分原因在于“迭代器对”是比“容器”更一般的概念。比如,对于迭代器,我们可以对容器的前半部进行排序:sort(vi.begin(), vi.begin()+vi.size()/2)。另一个原因是STL遵守C++的设计规则,我们必须提供转变路径和统一地支持内建类型和用户定义类型。如果某人把数据放到普通的数组里面,那该怎么办?我们仍然能够使用STL算法,比如:
int buf[max];
// ... fill buf ...
int* p = find(buf,buf+max,7);
if (p != buf+max) { // we found 7
// ...
} else { // no 7 in buf
// ...
}
这里,find()中的*,++和!=真的就是指针操作!。象C++自身,STL也兼容老的概念如C数组。因此,STL满足总是提供转变路径的C++理想(§2)。它同样也满足对内建类型(本例中的数组)和用户定义类型(比如vector)提供统一对待的理想。
解释算法使用迭代器而不是容器或明确的序列抽象的另一个理由,是想要获得最优的性能:直接使用迭代器而不是通过获得指针或是来自另一个抽象的索引,消除一层间接性。
正如接受把容器和算法作为ISO C++标准库框架一样,STL由许多容器组成(比如vector,list和map)和数据结构(比如数组)都能当成序列使用。除此之外,有大概60种算法(比如find,sort,accumulate和merge),不能在这儿一一陈列。详细信息请参考[6,126]。
因此,我们使用简单算术来观察STL技术是怎么将算法从容器中分离出来的,从而减少了我们必须自己写和维护的代码量。有60*12(即720)种算法和容器的组合,但在标准中只有60+12(即72)种定义。这种分离将组合的乘变成了简单的加。如果我们考虑用于算法的元素类型和策略(policy)参数(函数对象,参见§4.1.4),我们会有更加深刻的印象:假定我们有N个算法和M个可选的准则(策略)和X个有Y个元素类型的容器,那么,使用STL的方法只需要N+M+X+Y个定义,而“手工来写”的话,则要求N*M*X*Y种定义。在现实设计中,这种差别不是太强烈因为设计者通常通过转换、类的派生、函数参数等的组合解决N*M*X*Y的问题。但是STL方法比以前的方法更加的清晰和系统化。
STL优雅和高性能的关键是—象C++自身—是基于直接的内存和计算的硬件模型。STL中序列的概念是从硬件的角度把内存看成是一组对象的序列。STL的基本语义直接映射到硬件指令,允许算法的优化实现。支持编译期的模板解析和优先使用内联,也是有效映射STL的高级表达到硬件层的关键。
4.1.4 函数对象
STL和泛型编程一般来说都欠了—直率的并且公认的(例如,[124])—函数式编程的债。lambda和高阶函数在哪?C++没有直接支持任何象lambda和高阶函数那样的东西(尽管有提议添加嵌套函数、闭合、lambdas等,参见§8.2),作为替代,类定义应用操作符(application operator,即operator()),称之为函数对象(甚至仿函数),取代其角色并成为现代C++参数化中的主要机制。函数对象建立在一般C++机制的基础上,提供前所未有的灵活性和性能。
STL框架,到现在为止的描述,是有些苛刻。每个算法只做标准指定它做的一件事。比如,使用find(),我们找一个和我们给的参数相同值的元素。事实上查找一个具有某些属性的值是更加普遍的,比如不分大小写地匹配一个字符串或匹配一个允许非常细微的差异的浮点数。
比如查找一个满足某种判断的值,而不是7,比如查找小于7的值:
vector<int>::iterator p = find_if(v.begin(),v.end(),Less_than<int>(7));
if (p != vi.end()) { // element < 7
// ...
} else { // no such element
// ...
}
Less_than<int>(7)是什么?它是一个函数对象;即是一个类的对象,它具有operator(),定义来执行一个动作。
template<class T> struct Less_than {
T value;
Less_than(const T& v) :value(v) { }
bool operator()(const T& v) const{ return v<value; }
};
比如:
Less_than<double> f(3.14); // f holds 3.14
bool b1 = f(3); // true: 3<3.14 is true
bool b2 = f(4); // false: 4<3.14 is false
从2005年来看,在D&E和TC++PL1中没有提到函数对象是奇怪的。对它们的描述应该需要完整一节。甚至用户定义operator()的使用也没有提到,尽管它在很早以前就已经出现了并且其功能很独特。比如,它是我在一开始允许重载的一组操作符中的一个(在=之后;参见D&E§3.6) [112],这些操作符和其它东西用于模拟Fortran的下标符号。
我们使用STL算法find_if,使用Less_than<int>(7)到vector的元素上。find_if与find()的定义的区别在于使用一个用户提供的判断而不是相等:
template<class In, class Pred>
In find_if(In first, In last, Pred pred)
{
while (first!=last && !pred(*first))
++first;
return first;
}
我们简单用!pred(*first) 代替 *first != value。函数模板find_if()将接受任何能把给定元素值作为它的实参的对象。特别地,我们可以调用find_if(),把一个普通的函数作为它的第三个参数:
bool less_than_7(int a)
{
return a<7;
}
vector<int>::iterator p = find_if(v.begin(),v.end(),less_than_7);
然而,这个例子显示为什么我们经常选择函数对象而不是函数:函数对象能被1个或多个参数初始化,并能携带信息,用于后面的使用。函数对象能够带状态,这会产生更加一般和更加优雅的代码。如果需要,我们还可以在后面检查状态,比如:
template<class T>
struct Accumulator { // keep the sum of n values
T value;
int count;
Accumulator() :value(), count(0) { }
Accumulator(const T& v) :value(v), count(0) { }
void operator()(const T& v){ ++count; value+=v; }
};
一个Accumulator对象能够重复地传递给算法,局部的结果是存储在对象中,比如
int main()
{
vector<double> v;
double d;
while (cin>>d) v.push_back(d);
Accumulator<double> ad;
ad = for_each(v.begin(),v.end(), ad);
cout << "sum==" << ad.value
<< ", mean==" << ad.value/ad.count
<< ’/n’;
return 0;
}
标准库算法for_each把序列中的每个元素作为它的第三个参数的实参,并将第三个参数作为返回值返回。替代函数对象的方法可以是混乱地使用全局变量保持value和count。在多线程系统,使用全局变量不仅混乱,而且会给出不正确的结果。
有趣的是,使用简单的函数对象比相同的函数有更好的性能。原因在于它们通常是没有虚函数的简单类,因此当我们调用一个成员函数时,编译器知道我们调用的是哪一个函数。那样的话,就算是一个头脑简单的编译器也能获得内联所需要的所有信息。另一方面,函数是通过指针传递进来的,优化器通常没有能力对与指针相关的东西进行优化。这是非常有效的(比如,在速度上可能是50倍)当我们传递一个对象或函数用于相同的简单操作,比如sort中的比较准则。特别的,当对某些带简单比较操作符(比如int和double)的类型进行排序 [125],STL的sort()比传统的qsort()要强几倍,原因就在于内联函数对象。受到函数对象的刺激,一些编译器现在也能对函数指针进行内联只要在调用时把它作为常量传递进去。比如,今天的某些编译器能够内联compare的调用到qsort中:
int compare(double* a, double* b) { /* ... */ }
// ...
qsort(p,max,sizeof(double),compare);
在1994年,没有一个C/C++的编译器可以做到。
函数对象是C++的机制用于高阶构造,它不是最优雅的表达高阶的想法,但在通用语言中,它拥有惊人的表现力和与生俱来的高效。要从常规函数式编程设施的一般编译器中获得相同的效率(在时间和空间上)的代码,要求优化器要非常完备。作为具有强表现力的例子,Jaakko Järvi
和Gary Powell展示如何提供和使用lambda类,使得下面的例子具有跟前面例子一样 [72]:
list<int> lst;
// ...
Lambda x;
list<int>::iterator p = find_if(lst.begin(),lst.end(),x<7);
注意重载解析使得我们可以让元素类型int隐式(推演得到)。如果你只是想让<工作,那么你不需要创建一个普通库,你在少于十几行的代码中增加Lambda的定义和<就可以完成。使用Less_than,上面的例子可以简单重写为:
class Lambda {};
template<class T>
Less_than<T> operator<(Lambda,const T& v)
{
return Less_than<T>(v);
}
所以,在find_if调用中的参数x<7变成了调用operator<(Lambda, const int &),而它又产生一个Less_than<int>的对象。那就是我们在这一节中使用的第一个例子。不同的是,我们获得了更简单和更直观的语法。这是一个展示C++强大的表达能力和展示库的接口能够比它的实现更简单的好例子。自然,运行时刻或空间的开销也不会比手工写的循环去查找一个小于7的值多。
C++与高阶函数最接近的地方是返回一个函数对象的函数模板,比如operator<返回一个有恰当类型和值的Less_than对象。好几个库已经扩展了这种想法,广泛地支持函数式编程(比如Boost的函数对象和高阶程序库[16]和FC++[99])。
4.1.5 粹取
C++没有提供一般的编译期的查询类型属性的方法。在STL和许多其它库中,使用模板提供用于不同的类型的泛型类型安全设施,这成为一个问题。最开始,STL使用重载去解决这个问题,(比如,注意到类型int是通过x<7推演出来的§4.1.4),然而,使用重载是非系统化的,因此难以使用和错误检测。基本的解决方法是由Nathan Myers在模板化iostream和string[88]的工作中发现的。基本想法是提供辅助模板,“粹取”,去包含一组类型中想要的信息。考虑怎么找到由迭代器所指向的元素的类型,对于一个list_iterator<T>,它的元素的类型是list_iterator<T>::value_type;对于普通指针T*,它的类型就是T。我们可以表达如下:
template<class Iter>
struct iterator_trait {
typedef Iter::value_type value_type;
};
template<class T>
struct iterator_trait<T*> {
typedef T value_type;
};
就是说,迭代器的value_type是它的成员类型value_type。然而,指针是迭代器的普通形式,它们没有自己的成员类型。所以,对于指针,我们使用指针所指向的类型作为value_type。涉及到的语言构筑称之为偏特化(1995添加到C++,§5)。粹取会导致稍微的代码膨胀(尽管他们没有对象代码或运行时的代价),尽管这种技术具有很强的扩展性,但当添加新的类型时还是时常需要程序员的关注。然而,“concept”机制(§8.3.3)承诺提供直接的语言支持去表达类型属性的想法,那将减少粹取的使用。
4.1.6 迭代器分类
到目前为止所描述的STL模型的结果变成一团乱麻,因为每种算法依赖于特定容器所提供的迭代器的特性。为获得协同工作的能力,迭代器接口不得不被标准化。它可以简单地通过定义一组用于所有迭代器的操作符。然而,这样做则违反现实:从算法的观点看list、vector和输出流,它们有本质上不同的属性。比如,你可以通过下标来访问vector的元素,你可以增加一个元素到list中而无需扰乱相邻之间的元素,你可以从输入流中而不是从输出流读取数据。因而STL提供了迭代器的分类:
输入迭代器(输入流)
输出迭代器(输出流)
前向迭代器(我们可以反复读和写同一元素,单向list)
双向迭代器(双向list)
随机访问迭代器(vector和数组)
这样的分类对于那些希望让算法与容器协同工作的程序员来有指导的作用。它允许我们最小化算法与容器之间的耦合。不同的算法使用不同的迭代器种类,通过重载自动选择最恰当的算法(在编译期)。
4.1.7 复杂度要求
STL包含了所有标准库操作和算法的复杂度测量(使用大O符号)。这对于一个用于工业的语言的基础库来说是新奇的。过去的希望是,现在仍是,设置先例以更好地规范说明库。另一方面—不是那么的创新—在详细描述库时系统地使用前提和后置条件(postcondition,操作完成时必须满足的约束)。
4.1.8 Stepanov的看法
这儿对STL的描述集中在C++环境中的语言和库问题。为了获得补充看法,我询问了Alexander Stepanov的看法[106]:
在1976年10月,我发现到某些算法—可以并行减少—与monoids关联:一组元素与一个相关联的操作。这个发现让我相信存在这么一种可能性,把每一个有用的算法与数学理论相关联。而这种关联允许最大可能的使用和有意义的分类。正如数学家知道把理论提升到他们最一般的场景,我也想提升算法和数据结构。知道算法使用的数据的准确类型是罕见的需求,因为大多数的算法可以处理许多类似的类型。为了写一个算法,只需要知道要操作的数据的属性。我把在算法调用的一组具有类似属性的类型称之为算法的underlying concept。还有,为了选择有效率的算法,需要知道这些操作的复杂度。也即是说,对于概念来说,复杂度是接口的一个重要的部分。
在70年代末,我开始关注John Backus在FP[7]上的工作。虽然他的函数式编程的想法从本质上震撼了我,但我意识到他尝试永久固定函数式形式的数字从根本上就是错误的。函数式形式的个数—或者,我现在称之为泛型算法的个数—总是随着我们不断地发现新的算法而不断地在增加。在1980年,我和Dave Musser、Deepak Kapur一起开始在Tecton语言上用代数理论去描述算法。语言本身是实用的,因为在那时我没有意识到内存和指针是编程的基本部分。我还花了许多时间学习亚里士多德和他的后继者的著作,这让我更好地理解在对象上的基础操作,象相等、拷贝和局部与整体之间的关系。
1984年起我开始与Aaron Kershenbaum合作,他是图形算法方面的专家。他让我相信要认真对待数组。我认识序列是递归可定义的,因为它一般被认为“理论上健全的”方法。Aaron向我展示许多基本依赖于随机访问的算法。我们在Scheme中制造了一大堆组件,它们能一般地实现一些复杂的图形算法。
Scheme上的工作导致承诺在Ada上制造一个一般的库。Dave Musser和我制造一个通用库,用于处理链接结构。由于那时的Ada编译器状况,我尝试去实现能够用于任何序列结构(list和数组)上的算法失败了。我有许多相同的STL算法,但不能编译它们。基于这项工作,Dave Musser和我发表了一篇文章介绍泛型编程的观点,坚持对有用的高效的算法进行抽象。我从Ada中学到的东西是静态键入的值(the value of static typing)是一种设计工具。Bjarne Stroustrup从Simula中学到这一点。
1987年在贝尔实验室,Andy Koenig教我C的语义,在C后面的抽象机器被揭开了。我还读了大量的UNIX和Plan 9的代码:Ken Thompson和Rob Pike的代码当然影响了STL。无论如何,1987年C++还没有为STL做准备,我必须跟进。
在那时,我发现我对Euler的著作和对数学本质的理解发生了重大的转变。我是de-Bourbakized,不再相信集合,从Cantorian的天堂中被驱逐出来。但我仍然相信抽象,但现在我知道,抽象只有目标而没有起点。我还学到,人只有适应现实中的抽象,没有其它路可走。对我来说,数学不再是一门科学理论,而是关于数字和形状的科学。
1993年,在无关联的工程上工作5年之后,我回到泛型编程。Andy Koenig建议我写一份把我的库加入到C++标准中的提议。Bjarne Stroustrup热情地赞同这项提议,然后在一年内,STL被标准接纳。STL是20年的思考的结果,但在两年之内形成。
STL只是有限的成功。然而当它变成一个被广泛使用的库,它本质的东西并没有被理解。人们对使用(滥用)C++模板的泛型编程仍感到困惑。泛型编程是对算法和数据结构的抽象和分类。它从Knuth那儿得到灵感,而不是来自于类型理论。它的目标是增量构建系统分类的有用的、高效的和抽象的算法和数据结构。这个目标仍然是个梦想。
你能从www.stepanovpapers.com中找到导致STL产生的工作的参考。我对Alex的思想将产生的长远的影响比他本人还要乐观。然而,我们都同意,STL只是这个长途旅行的第一步。
4.1.9 STL的影响
STL对关于C++思考的影响是巨大的,在STL之前,我列出C++支持的三个基本的编程风格(“范例”)[113]:
·过程编程
·数据抽象
·面向对象编程
我认为模板是对数据抽象的一种支持。在使用STL一段时间后,我总结出第四种风格:
·泛型编程
这项技术是基于模板的使用,并且主要由函数式编程的技术所启发,与传统的数据抽象有质的不同。人们对类型、对象和资源的思考是不同的。新C++库已经使用模板完成—静态类型安全并且是高效的。模板是系统编程和高性能数值计算的关键[67],对于高性能数值计算,资源管理和正确性是关键的。STL本身并不总是适合那些领域。比如,它没有提供直接支持线性代数,它能巧妙地在禁止自由存储使用的硬实时系统中使用。然而,STL说明什么能够由模板完成并给出许多有效使用技巧的例子。比如,迭代器(和allocator)的使用,分离逻辑内存访问与实际物理内存是许多高性能数值计算技术的关键[86,96],使用小的、容易内联的对象是许多嵌入式系统编程优化的关键。一些技术被标准委员会的关于性能的技术报告所记载(§6.1)。C++社团在90年代末和2000年初开始重视STL和泛型编程,以致在某种程度上是反应过度的—另外一方面—大的软件开发社团倾向于过度使用“面向对象”技术,而那依赖于类层次和虚函数。
显然,STL并不完美,也没有完美的东西。不管怎么样,它打开了一片新大陆并且它的影响超越了庞大的C++社团(§9.3)。它同样也激发了许多更守条例和更冒险的使用模板技术的方法。人们谈论“模板元编程”(§7.2.2)和generative programming [31]并试着将由STL领导的技术向前推进并超越STL。另一条战线是考虑C++怎么才能更好地支持有效的模板使用(concept,auto等,参见 §8)。
不可避免地,STL的成功同时也带来了它自身的问题。人们想把所有的代码都写成STL风格的。然而,象许多其它风格或技术,STL风格或泛型编程并不是所有问题的理想的解决之道。比如,泛型编程依赖于模板和在编译期对所有名字绑定完全解析的重载。它不支持在运行时进行解析的绑定的机制,那是类层次和它们相关的面向对象设计技术所支持的。象所有成功的语言机制和编程技术,模板和泛型编程变得流行起来甚至是被滥用。程序员基于模板实例和推演是完全图灵的,创建真实的过分装饰的和易损坏的结构,正如我早期观察到的C++的面向对象的设施和技术:“不能因为你能够做到,就意味着必须这么干”。发展一种综合使用C++支持的不同的编程风格的简单框架是未来几年的主要挑战。作为一种编程风格,“多范例编程”[121]没有完全得到发展,它通常提供许多更优雅和更好的替代方法[28],但我们(还)没有一个简单和系统的方法去组合这些编程风格,甚至它的名字就暴露了它基本的弱点。
STL的另一个问题是它的容器是非侵入的。从代码清晰的角度和独立的观点看,非侵入有着巨大的优势。然而,它意味着我们需要拷贝元素到容器或插入具有默认值的对象到容器中,然后在后面再给它想要的值。有时这样做是低效和不方便的。比如,人们不愿意往vector中插入大对象就是基于这样的原因。作为替代方法,他们插入指向这些大对象的指针到容器中。类似的,标准容器提供的用于元素的隐式的内存管理是相当方便的,但有些应用(比如,在某些嵌入和高性能系统)中,这样的隐式内存管理必须避免。标准容器提供保证避免隐式内存管理的特性(比如reserve),但它们必须被理解,使用起来才能避免问题。
4.2 标准库的其它部分
从1994年起,STL所主宰了标准库的工作,并提供它的主要创新点。然而,STL并不是惟一的工作。事实上,标准库还提供其它的组件:
·基本的语言运行时支持(内存管理 、运行时类型信息(RTTI)、异常等)
·C标准库
·STL(容器、算法、迭代器、函数对象)
·iostreams(由字符类型模板化,隐式的本地化)
·locales(objects characterizing cultural preferences in I/O)
·string(由字符类型模板化)
·bitset(有逻辑操作的一组bit)
·complex(由标量模板化)
·valarray(由标题模板化)
·auto_ptr(由类型模板化的用于管理对象的资源句柄)
有多种理由,其它库组件的故事比STL较无趣和较少启发性。大多数时间,在这些组件上的工作进展是与其它工作隔离的。没有全面的设计或设计哲学。比如,bitset是带范围检查的而string则不是;而且,几个组件的设计(比如string、complex和iostream)是受到兼容性的制约。由于设计者尝试处理所有的要求、约束和已经存在的惯例,好几个组件(iostream和locale)遭受到“second-system effect”。基本上,标准委员会不包含“由委员会设计”,因此STL反映了一种明确的哲学和一致的风格,而大多数其它组件则不是这样的。每一个都代表它自己的风格和哲学,有些(比如string)同时表现出好几种风格和哲学。我认为complex在这儿算是例外,它基本上就是我原来的设计[91],允许不同的标量类型进行模板化:
complex<double> z; // double-precision
complex<float> x; // single-precision
complex<short> point; // integer grid
它很难与数学混为一谈。
委员会的确有过多次关于标准库的范围的严肃讨论。讨论的背景是关于小的和被广泛接受的C标准库(C++标准采纳它,只对它做了微小的修改)和大公司的基础库。在标准化进程的早些年里,我明确表达了一些用于划定C++标准库范围的方针:
首先,现在被广泛使用的关键库必须标准化。这意味C++与C标准库之间的接口必须确定,iostream库也必须规定。除此之外,基本的语言支持也必须详细说明。
其次,委员会必须看它能否对满足“更有用的和标准的类”的公共要求。比如string,不会被委员会变成一个一团糟的设计并且不会与其它C++工业库竞争。任何超越C库和被委员会接纳的iostream的库必须是自然地由模块构建而不是野心勃勃的框架。标准库的重要角色是让独立开发的和更野心勃勃的库更容易沟通。
最后一句划定了标准委员会的工作范围。对标准库的需求的详尽规范,包含构建模块用于更加有雄心的库和框架,强调绝对的效率和极端的一般性。我经常把对容器元素的访问涉及到虚函数调用时将不可能有足够的效率和容器不能存储任意类型则不具有足够的一般性(参见§4.1.1)作为例子,去说明这些需求的重要性。委员会觉得标准库的作用应该是支持而不是取代其它库。
在1998年,标准化工作的最后,委员会普遍都觉得我们在库方面工作做得还不够,还有实验不够充分,我们对库的性能测试方面的关注太少。问题是在将来怎么强调这些问题。一方面—传统的标准进程—通过技术报告的方法(参见§6.2)。另一方面是由Beman Dawes在1998年启动的,称为Boost[16]。下面这段话引用自boost.org(2006年8月):
“Boost提供免费的同行检查的可移植的C++源码库。我们强调库要跟C++标准库一起很好地工作。Boost库打算成为广泛地有用的,且对于广大范围的应用程序是可用的。Boost许可鼓励商业和非商业的使用。我们的目标是建立“已存在的实践”和提供参考实现,因此Boost库是适合最后成为标准库的一部分。C++标准委员会的库技术报告(TR1)中已经包含了10个Boost库,使之成为未来C++标准的一部分。更多的Boost库正被提议到即将来临的TR2。”
Boost繁荣发展,成为一个库的重要来源,并且通常是标准委员会和C++社团的想法的来源。