6.2 函数对象

学习本章前面的例子,读者可能会注意到函数gt15()的使用限制。如果用其他数而不是15来作为比较的阈值该怎么办?可能会需要gt20()或gt25()等等。为它们再编写单独的函数会耗费时间而且不合理,因为当编写应用代码时程序员需要知道所有要求的值。

后者的限制意味着不能使用运行时的值来控制查找,这是不能接受的。为了克服这个困难,需要有一种在运行时把信息传递给判定函数的方式。例如,程序员可能需要一个能用任意比较值来初始化一个判定大于的函数(greater-than function)。遗憾的是,不能把这个值作为一个函数参数进行传递,因为这是个一元判定函数,比如gt15()。它单独地应用于序列中的每一个值,因此必须只能有一个参数。

和往常一样,跳出这个两难的局面的方法就是创建一个抽象。在这里,需要这样的一个抽象,它实现起来像函数同时保存状态,使用时却不用考虑函数参数的个数。这种抽象称为函数对象(function object)。[1]

函数对象是重载了operator()的类的一个实例,operator()是函数调用运算符。这个运算符允许用函数调用语法并使用对象。如同其他对象一样,可以通过该对象的构造函数来初始化它。下面程序中的函数对象可以取代gt15():

6.2 函数对象 - 图1

6.2 函数对象 - 图2

当创建函数对象f时,传递作为比较对照的固定值(4)。编译器像下面的函数调用一样计算表达式f(3):

6.2 函数对象 - 图3

返回值是表达式3>value的值,在本例中当value是4时为假。

因为这样的比较还可以应用于除int外的其他类型,只要把gt_n()定义为一个类模板就有意义了。无需用户亲自做—标准库已经帮你做了。下面对函数对象的描述不仅使这个主题更清晰,而且读者对通用算法如何工作有了一个更好的理解。

6.2.1 函数对象的分类

标准C++库根据函数对象的运算符operator()使用参数的个数和返回值的类型对其进行分类。这种分类是基于函数对象的运算符operator()使用参数的个数分别为零个、一个或两个的情况进行:

发生器(Generator):一种没有参数且返回一个任意类型值的函数对象。随机数发生器就是发生器的一个例子。标准库提供一个发生器,就是在<cstdlib>中声明的函数rand()以及一些算法,如generate n(),它将发生器应用于序列。

一元函数(unary_function):一种只有一个任意类型的参数,且返回一个可能不同类型(比如可能是void)值的函数对象。

二元函数(binary_function):一种有两个任意类型的(可能是不同类型)参数,且返回一个任意类型(包括void)值的函数对象。

一元判定函数(Unary Predicate):返回bool型值的一元函数。

二元判定函数(binary_predicate):返回bool型值的二元函数。

严格弱序(Strict Weak Ordering):一种更广义地理解“相等”概念的二元判定函数。一些标准容器认为在两个元素中,如果任何一个都不小于另外一个(使用operator<()),则二者相等。这对于比较浮点型值及其他类型的对象很重要,因为operator==()是不可靠的或者说是不可行的。同时这种概念也适用于想在struct的所有字段的一个子集上对数据记录(struct)的序列进行排序的情况。这种比较方案被认为是一种严格弱序,因为具有相等关键字集(equal key)的两个记录作为对象的整体而言,两个记录不是真正的“相等”,但对于正在使用的这个比较来说是相等的。这种观念的重要性在下一章中将会更加明显。

另外,某些算法假定对它们处理的对象类型的有关操作是有效的。现在用下面这些术语来介绍这些假定:

小于可比较(LessThanComparable):含有小于运算符operator<的类。

可赋值(Assignable):含有对于同类型指定赋值操作符operator=的类。

相等可比较(EqualityComparable):含有对于同类型相等运算符operator==的类。

在本章后面将使用这些术语来描述标准库中的通用算法。

6.2.2 自动创建函数对象

头文件<functional>定义了大量有用的通用函数对象。毋庸置疑,它们是很简单的,但可以用它们来组成更加复杂的函数对象。因此,在大多数情况下,无需编写任何函数就可以构造出复杂的判定函数。可以用函数对象适配器(function object adaptor)[2]来获得一个简单的函数对象,并且调整它们用来与操作链中的其他函数对象配合。

举例说明,现在仅使用标准函数对象来完成前面介绍的gt15()的工作。标准函数对象greater是一个二元函数对象,当它的第1个参数大于第2个参数时返回true。不能通过一个算法如remove_copy_if()直接把它应用到整数序列,因为remove_copy_if()是一个一元判定函数。可以通过用greater的第1个参数与某个固定值进行比较的方式,来构造一个一元判定函数。用函数对象适配器bind2nd作为固定的第2个参数的值,其值为15,如下列程序所示:

6.2 函数对象 - 图4

这个程序没用前面用户自己定义的判定函数gt15(),却产生了与CopyInts3.cpp相同的结果。函数对象适配器bind2nd()是一个模板函数,它创建一个binder2nd类型的函数对象。仅存储和传递两个参数给bind2nd(),其中第1个参数必须是一个二元函数或函数对象(即带有两个参数的可以被调用的任意对象)。binder2nd中的operator()函数,它本身是一个一元函数,该函数调用存储的二元函数,并传递引入的参数及其存储的固定值。

为了更具体地解释这个例子,现在调用由bind2nd()创建的binder2nd的一个名为b的实例。当创建b时,它接收两个参数(greater<int>()和15)并且保存它们。调用名为g的greater<int>的实例和名为o的输出流迭代器的实例。这时在前面的程序中对remove_copy_if()的调用在概念上可表示成下面这样:

6.2 函数对象 - 图5

伴随着remove_copy_if()在序列中的迭代,对每个元素调用b来决定当复制到目的流时是否忽略该元素。如果用e来标记当前元素,remove_copy_if()中的调用等价于:

6.2 函数对象 - 图6

但是binder2nd的函数调用运算符还要回来调用g(e,15),所以上面的调用与下面的调用一样:

6.2 函数对象 - 图7

这就是我们要寻求的比较。这里还有一个bind1st()适配器,它创建一个binder1st对象,该对象是相关联的输入二元函数确定的第一个参数。

这里有另外一个例子,用来计算某个序列中不等于20的元素的个数。这次使用前面介绍过的算法count_if()。程序中有一个标准二元函数对象equal_to,还有一个函数对象适配器not1(),该函数对象适配器以一元函数对象作为参数并转化其实际值。下面的程序将会完成这个任务:

6.2 函数对象 - 图8

如在前面的例子中的remove_copy_if()一样,count_if()调用由位于它的第3个参数(称其为n)位置的函数对序列中的每一个元素进行判定,并且在每次返回true时使其内部的计数器增1。如前所述,如果称序列中当前元素为e,则语句

6.2 函数对象 - 图9

在count_if实现过程中,可以解释为

6.2 函数对象 - 图10

结束时如下所示:

6.2 函数对象 - 图11

这是因为not1()返回的是调用它的一元函数参数的结果的逻辑否定。equal_to的第1个参数是20,因为在这里用bind1st()来代替bind2nd()。由于在参数中相等性测试是对称的,在这个例子中可以使用bind1st()或bind2nd()。

下面的表格显示了产生标准函数对象的模板,还显示了模板应用的表达式的种类:

6.2 函数对象 - 图12

6.2.3 可调整的函数对象

标准函数适配器例如bind1st()和bind2nd(),对它们处理的函数对象做一些相关的假设。考虑前面的CountNotEqual.cpp程序中最后一行的表达式:

6.2 函数对象 - 图13

bind1st()适配器创建了一个binder1st类型的一元函数对象,它仅存储equal_to<int>的一个实例及值20。函数binder1st:operator()需要知道它的参数类型和它的返回值类型;否则,它就不是一个有效的声明。解决这一问题的简便方式是,期望所有的函数对象提供这些类型的嵌套类型定义。对于一元函数,是类型名为argument_type和result_type;对于二元函数对象,为first argument_type、second_argument_type和result_type。看看头文件<functional>中bind1st()和binder1st的实现就显示了这一期望。首先检查一下可能出现在典型的库实现中的bind1st():

6.2 函数对象 - 图14

注意,模板参数Op,代表正在由bind1st()调整的二元函数的类型,它必须含有一个名为firstargumenttype的嵌套类型。(注意,如同在第5章中解释的那样,使用typename来通知编译器它是一个成员类型名。)现在看看binder1st在它的函数调用运算符的声明中如何使用Op中的类型名:

6.2 函数对象 - 图15

为这些类提供类型名的函数对象,称为可调整的函数对象(adaptable function object)。

因为所有的标准函数对象以及用户为了使用函数对象适配器而自己创建的函数对象都期望这些类型名称,所以头文件<functional>提供了两种模板来定义这些类型:unary_function和binary_function。当为派生自这些类的简单函数对象填写参数类型时,可以由这些类型作为模板参数。例如,假设要想使本章前面定义的函数对象gt_n成为可调整的,我们需要做的工作如下所示:

6.2 函数对象 - 图16

所有的unary_function都提供合适的类型定义,这些正如读者在定义中所见到的,由模板参数推断而来:

6.2 函数对象 - 图17

这些类型通过gt_n变成可使用的,因为它是从unary_functio n公有派生来的。binary_function模板使用起来与其类似。

6.2.4 更多的函数对象例子

下面的FunctionObjects.cpp例子,对多数内建的基本函数对象模板提供了简单的测试。读者可以看到,用这种方式如何使用每个模板,并取得相应的操作结果。为简便起见,在这个例子中使用了下面这些发生器当中的一个:

6.2 函数对象 - 图18

6.2 函数对象 - 图19

在本章中的后面部分,将在列举的各种各样的例子中使用这些生成函数。SkipGen函数对象,返回一个算术序列当前元素的下一个数,它们共同的差值保存在数据成员skp中。URandGen对象在指定范围内产生一个惟一的随机数。(它使用set容器,set容器将在下一章中介绍。)CharGen对象返回一个随机的字母表中的字符。下面是一个使用UrandGen的程序例子:

6.2 函数对象 - 图20

6.2 函数对象 - 图21

这个例子使用了一个简单的函数模板print(),它能够打印任意类型的序列并且可以附加可选择的信息。这个模板包含在头文件PrintSequence.h中,详细内容将在本章后面介绍。

这两个模板函数用来自动处理测试各种函数对象模板的过程。有两个模板函数,是因为函数对象可能是一元的也可能是二元的。testUnary()函数有一个源vector、一个目的vector和一个用在源vector上来产生目的vector的一元函数对象。在testBinary()中,将两个源vector传送给一个二元函数来产生目的vector。在这两种情况下,模板函数仅回转并调用transform()算法,transform()算法将位于其第4个参数位置的一元函数或函数对象应用于序列中的每一元素上,并将结果输出到第3个参数所指示的序列中,在本例中与输入序列相同。

对于每个测试,用户都想看到描述测试的字符串和附加测试结果的字符串。为了自动完成这些工作,可以方便地使用预处理器;宏T()和B()分别含有用户想要执行的表达式。对表达式求值后,它们将相应范围的序列传递给print()。为了产生这一信息,通过预处理器将表达式“字符串化”(stringized)。用这种方法,用户就可以看到相应的表达式代码,这些代码在程序执行后存储在结果vector中。

最后一个小工具BRand是一个创建随机bool型值的发生器对象。为了完成这一工作,它从rand()得到一个随机数并且检查它是否大于(RAND_MAX+1)/2。如果随机数均匀地分布,则值大于(RAND_MAX+1)/2的情况将以50%的概率出现。

在main()中,创建了3个int型的vector:x和y是源数值,r是结果值。为了用不大于50的随机数初始化x和y,使用Generators.h中的URandGen类型的一个发生器来完成这个任务。标准generate_n()算法通过调用第3个参数(必须是一个发生器)、一个给定的次数(在第2个参数中指定)来建立由第1个参数指定的一个序列。因为有一个x被y除的操作,所以必须保证y的值不为0,以防计算结果溢出。这是靠再次使用transform()算法完成的,它从y中获得源值并把结果写回y。用下面的表达式创建了一个函数对象:

6.2 函数对象 - 图22

表达式用plus函数对象来使第1个参数增1。如同本章前面所做的一样,在这里使用绑定程序适配器来构造一个一元函数,这样仅调用transform()就能将其应用到一个序列上。

程序中的另外一个测试是比较两个vector中的元素是否相等,因此值得注意的是要保证至少有一对元素是相等的;这里包含0元素。

一旦打印了这两个vector, T()测试产生数字型值的每一个函数对象,B()测试产生布尔型结果的每一个函数对象。在打印vector时,将结果放入vector<bool>,它对于真值产生'1',对假值产生'0'。下面是执行FunctionObjects.cpp的输出结果:

6.2 函数对象 - 图23

如果在输出结果中想使布尔型值显示为“真”和“假”而不是1和0,则要调用cout.setf(ios:boolalpha)。

一个绑定程序无需产生一元判定函数;它能创建任意的一元函数(即返回除bool型以外类型值的函数)。例如,可以用10乘以vector中的每个元素来使用带有绑定程序的transform()算法:

6.2 函数对象 - 图24

因为transform()的第3个参数与第1个参数一样,所以结果元素又被复制回源vector。本例中bind2nd()创建的函数对象产生一个int型结果。

由绑定程序“绑定”的参数不能是一个函数对象,但也无需是一个编译时常量。例如:

6.2 函数对象 - 图25

这里用20个在0到100之间的随机数填充一个数组,当然用户可以在命令行提供一个值,用来限制产生随机数的范围。在remove_copy_if()调用中,可以看到对于bind2nd()的绑定参数是在相同范围内的顺序序列的随机数。这是一次运行的输出结果:

6.2 函数对象 - 图26

6.2.5 函数指针适配器

算法无论在什么地方都要求有一个类似函数的实体,系统可以提供一个指向普通函数或是一个函数对象的指针。当算法通过函数指针调用时,就启用了本地的函数调用机制。如果是通过函数对象调用,则执行对象的operator()成员。在CopyInts2.cpp中,把原始的函数gt15()作为一个判定函数传递给remove_copy_if()。同时也把指向返回随机数的函数的指针传递给generate()和generate_n()。

不能通过诸如bind2nd()函数对象适配器来使用原始函数,因为这些函数对象适配器要求具有参数及结果类型的类型定义。不需要采用手工方式将原始的函数转化为函数对象,标准库为用户提供了一系列适配器来完成这一工作。ptr_fun()适配器把指向一个函数的指针转化成为一个函数对象。所有这些并不是为无参数函数设计的—也就是说,它们必须是一元或二元函数。

下面的程序用ptr_fun()来封装一个一元函数。

6.2 函数对象 - 图27

不能仅把isEven传递给not1,因为not1需要知道它使用的实际参数的类型和返回值的类型。ptr_fun()适配器可以通过模板参数推断出这些类型。ptr_fun()的一元版本的定义如下所示:

6.2 函数对象 - 图28

正如读者看到的,ptr_fun()的这种版本从fptr中推断出参数和结果的类型,并用它们来初始化一个存储fptr的pointer_to_unary_function对象。如代码的最后一行,对pointer_to_unary_function的函数调用操作符仅调用fptr:

6.2 函数对象 - 图29

因为pointer_to_unary_function派生于unary_function,产生合适的类型定义对not1是很有用的。

同时也有ptr_fun()的二元版本,它返回一个在执行上与一元情况类似的pointer_to_binary_function对象(派生于binary_function)。下面的程序使用ptr_fun()的二元版本来增加序列中的乘方个数。同时,在向ptr_fun()传递重载函数时也暴露出一个缺陷。

6.2 函数对象 - 图30

pow()函数在标准C++头文件<cmath>中对每个浮点数据类型进行重载,如下面程序所示:

6.2 函数对象 - 图31

因为有多种pow()的版本,编译器不知道选择哪一个。在这里,需要借助前面章节介绍的显式的函数模板特化来帮助编译器。[3]

用通用算法将一个成员函数转化为适于使用的函数对象更是巧妙。例如,假定这里有一个经典的“图形(shape)”问题,并且想对Shape容器内的每个指针都应用draw()成员函数:

6.2 函数对象 - 图32

for_each()算法将序列中每一个元素依次传递给由第3个参数指示的函数对象。在这里,希望函数对象封装成它自身类的一个成员函数,所以对于成员函数调用来说,函数对象“参数”成了对象指针。为了产生这样的函数对象,mem_fun()模板使用了指向成员的一个指针来作为它的参数。

mem_fun()函数,是通过传递成员函数所操作的对象的指针作为参数,来产生函数对象;而mem_fun_ref()函数则直接以对象作为参数。mem_fun()和mem_fun_ref()都有一组重载版本,用来处理接受零个或一个参数的成员函数,并且它们还分别有一组重载函数用来处理const和非const成员函数。不过,模板和重载机制负责它们的分类调用,你需要记住的只是何时应该使用mem_fun()而何时又应该使用mem_fun_ref()。

假设有一个对象(不是指针)的容器,现在想调用有一个参数的成员函数。传递的参数应该来自对象的第2个容器。为了完成这个调用,使用transform()算法的第2种重载版本:

6.2 函数对象 - 图33

6.2 函数对象 - 图34

因为容器持有对象,所以必须通过成员函数指针来使用mem_fun_ref()。这种版本的transform()使用第1个范围(对象生存的范围)的开始和结束点;第2个范围的开始点,就是持有的那个表示成员函数的参数;目的迭代器,在本例中就是标准输出;且为每个对象调用函数对象。用mem_fun_ref()和想要得到的成员指针来创建函数对象。注意,transform()和for_each()模板函数都是不完全的;transform()要求它调用的函数返回一个值,for_each()没有向它调用的成员函数传递所需的两个参数。因此,不能使用transform()调用返回值为void的成员函数,也不能调用只有一个参数的for_each()成员函数。

大多数任意类型的成员函数与mem_fun_ref()一起工作。如果用户使用的编译器没有增加任何一个默认参数而超过标准库中指定的正规参数[4],也可以使用标准库成员函数。例如,假设用户希望读一个文件并且查找其中的空白行。编译器可以允许像下面的程序一样使用string:empty()成员函数:

6.2 函数对象 - 图35

6.2 函数对象 - 图36

这个例子使用find_if()、mem_fun_ref()和string:empty()一起,在指定范围的序列中查找第1个空白行的位置。打开文件并将其读入到vector对象后,重复这个处理,在文件中查找每一个空白行。每次找到一个空白行时,就用字符串“A BLANK LINE”来取代该空白行。所有这些工作,是在不引用迭代器来选择当前字符串的情况下完成的。

6.2.6 编写自己的函数对象适配器

考虑如何编写一个把表示浮点数的字符串转化为相应实际数字值的程序。作为对该问题进行编程的开始,这里有个创建字符串的发生器:

6.2 函数对象 - 图37

当创建NumStringGen对象时,要告诉它字符串应该有多大。字符串由随机数发生器挑选出来的数字组成,并在中间插入一个小数点。

下面的程序使用NumStringGen来填写一个vector<string>。但是,要使用标准C库函数atof()来把字符串转化为浮点型数,string类型对象必须首先转化为char类型指针,这是因为从string到char没有自动类型转换。可以使用拥有mem_fun_ref()和string:c_str()的transform()算法,先把所有的string都转化为char,然后再使用atof进行转换。

6.2 函数对象 - 图38

这个程序做了两个转换:一是将C++字符串转换成C风格的字符串(字符数组),另一个是通过atof()将C风格的字符串转换成数值。把这两个运算组合成一个将会更好。毕竟,在数学上能组合函数,那么在C++中为什么不能呢?

简明的方法就是用两个函数作为参数并按合适的顺序应用它们:

6.2 函数对象 - 图39

6.2 函数对象 - 图40

本例中的unary composer对象存储函数指针atof和string:c_str,这样一来,在调用该对象的operator()时首先要应用后面的函数。compose()函数适配器是个便利的设置,因此用户无需明确提供全部的四个模板参数—F1和F2从调用中推断出来。

如果无需提供任何模板参数就更好了。这是靠对合适的函数对象坚持类型定义转换来完成的。换句话说,就是假定这些函数的组合是合适的。这就要求为atof()使用ptr_fun()。为了使其具有最大的灵活性,一旦把unary_composer传递到一个函数适配器,也能够使其适用。下面的程序这样做了并且很容易地解决了原来的问题:

6.2 函数对象 - 图41

6.2 函数对象 - 图42

本例中,必须再次使用typename来使编译器知道涉及的成员是一个嵌套类型。

一些实现[5]将函数对象的组合作为一个扩展来支持,C++标准委员会可能在标准C++的下一版本中增加这些功能。

[1]函数对象也称函数子(functor),函数子是一个具有类似行为的数学概念。

[2]依照C++标准,在这里的写成adaptor。在关于设计模式章节中我们根据习惯使用adapter。这两种写法都是可接受的。

[3]对于不同的库实现情况有所不同。如果pow()使用C链接,这就意味着读者没有将其理解为C++函数,那么这个例子将不能通过编译。ptr_fun要求是一指向普通重载的C++函数指针。

[4]如果编译器能够使用默认参数(这些参数是合法的)来定义string:empty,那么表达式&string:empty就能定义一指向成员函数的指针,这个成员函数包含所有参数。因为无法让编译器提供额外参数,在将算法通过mem_fun_ref应用于string:empty时将出现“缺少参数”错误。

[5]比如Borland C++第6版和Digital Mars编译器都提供的STLPort,而且STLPort基于SGI STL。