第21章介绍了一个叫做 transform 的函数,它是标准库的一部分。它对序列中的每个元素运用函数或者函数对象,并且可以获得一个新的序列。这样,如果 a 是一个有100个元素的数组,f 是一个函数,则
transform(a, a + 100, a, f);
将对 a 的每个元素调用 f,并把结果存回到 a 的相应元素中。
第21章还举了一个例子,说明如何使用 transform 来定义一个让数组的每个元素和一个整数 n 相加的函数对象:
class Add_an_integer {
public:
Add_an_integer(int n): n(n0) {}
int operator() const (int x) { return x + n; }
private:
int n;
};
我们可以把这些函数对象之一当作传给 transform 的参数。
transform(a, a + 100, a, Add_an_integer(n));
为了这个目的定义一个类有点小题大作,所以标准库提供了用于简化这项工作的类和函数。
1. 为什么是函数对象
首先要记住的是,Add_an_integer 是函数对象类型,而不是函数类型。之所以要用函数对象,是因为使用函数对象可以将一个函数和一个值捆绑到单个实体中。如果我们愿意把 n 放到具有一个文件作用域(file scope)的变量中,我们本来也可以使用函数的:
static int n;
static int add_n(int x) { return x + n; }
这样使用文件作用域变量是非常令人不方便的,所以我们还是采用函数对象。
函数对象的优点就在于它们是对象,这就意味着,原则上对别的对象可以做的事情,对它们一样可以做。实际上,标准库为我们提供了所有需要的东西,使我们根本不必定义辅助函数或者对象就能获得与 Add_an_integer 同样的效果。要让序列的所有元素都加 n,我们只需写如下函数
transform(a, a + 100, a, bind1st(plus<int>(), n));
看上去似乎不很明白,但是子表达式
bind1st(plus<int>(), n)
使用标准库创建了一个函数对象,该函数对象具有与
Add_an_integer(n)
相同的,以后作为 transform 的最后个参数所必需的属性。
那么,这个子表达式是如何工作的呢?
2. 用于内建操作符的函数对象
为了理解表达式
bind1st(plus<int>(), n)
我们从子表达式
plus<int> ()
开始。这里的 plus 表示的是一个类型,而不是一个函数,所以 plus<int>()是一个等价于类型为 plus<int>的无名对象的表达式。这样的对象就是把那些把两个类型为 int 的值相加,并以它们的和作为结果的函数对象。所以,譬如如果我们有如下代码
plus<int> p;
int k = p(3, 7);
则 k 被初始化为值 10.类似的,我们可以定义
int k = (plus<int>()) (3, 7);
该语句也令 k 为值10。
除了使 plus<int>() 成为函数对象的 operator() 成员外,类 plus<int> 还有 3 个其他成员,它们是类型名。这 3 个类型成员分别是 first_argument_type、second_argument_type 以及 result_type;从它们的名字就可以知道它们的含义。比如说 plus<int>::first_argument_type 就是 int 的一个完全名称。稍后我们会明白为什么说访问这些类型会很有用处。
标准库包括内建操作符所需要的绝大多数函数对象。它们存在的原因是显而易见的。C++没有办法在类似
bind1st(plus<int>(), n)
的表达式中直接应用内建操作符+。
3. 绑定者(Binders)
我们已经知道如何用标准库创建一个将两个值相加的函数对象;现在我们需要创建一个能够记住一个值,并把该值加到它的(单个)参数上的函数对象。两个分别叫做 bind1st 和 bind2nd 的库模板函数简化了这项工作。
如果 f 是类似 plus 的函数对象,有一个接受两个参数的 operator(),而且如果 x 是一个可以作为 f 第一个参数的值,那么
bind1st(f, x)
将生成一个新的函数对象,该函数对象只接受一个参数,它有一种有趣的特性,就是
(bind1st(f, x)) (y)
具有和
f(x, y)
相同的值。取名为 bind1st 是为了表现该函数的特点:创建一个函数对象,该函数对象绑定了函数的第一个参数。也就是说,调用 bind1st 后返回的函数对象记住了某个值,并把这个值作为第一个参数提供给用户调用的函数。
以下是 bind1st 的定义说明:
(bind1st(plus<int>(), n)) (y)
等价于 n + y,这正是我们想要的。但是它是如何工作的?
理解这样一个表达式的最简单的方法就是将它分成几块来分别研究。要这样做我们可以编写这样的代码:
// p是一个将两个整数相加的函数对象
plus<int> p;
// b是一个将参数加到 n 上去的函数对象
some_type b = bind1st(p, n);
// 初始化 z 为 n + y
int z = b(y);
但是 b 的类型是什么呢?
获得答案还需要另一个标准库模板类型,叫做 binder1st。其第一个模板参数就是传给 bind1st 的第一个参数的类型(也就是将要调用的函数或者函数对象)。也就是说,要声明前面的 b,我们应该编写语句:
// p 是一个将两个整数相加的函数对象
plus<int> p;
// b 是一个将参数加到 n 上去的函数对象
binder1st<plus<int>> b = bind1st(p, n);
// 初始化 z 为 n + y
int z = b(y);
现在可以更容易看清楚发生了什么事情了:和前面一样,p 是一个函数对象,负责把两个数相加; b 是一个函数对象,负责将 n 绑定在(被相加的)两个数中的第一个数上,于是 z 就成为 n + y 的结果。
4. 更深入的探讨
假定我们来写 binder1st 的声明。起初是很简单的。我们知道 binder1st 是个函数对象,所以需要一个 operator():
template <class T> class binder1st {
public:
T1 operator() (T2);
// ...
};
Here is our first problem: What are the right types for T1 and T2?
When we call bind1st wight argument f and x, we want to get a funciton object that can be called with the second argument of f (the one that is not bound) and return a result that is the same type of the result of f. But how do we figure out what those types are? We tried that with function composition in Chapter 21 and saw how hard it was.
Fortunately, our task is greatly simplified by the convention, mentioned in Section 22.2, that the relevant function objects have type members whose names are first_argument_type, second_argumnt_type, and result_type. 如果我们要求只有遵循这个约定的类才能使用 binder1st,我们就能很容易地为 operator() 以及相同情况下的构造函数填写类型:
template <class T> class binder1st {
public:
binder1st(const T &, const T::first_argument_type &);
T::result_type operator() (const T::second_argument_type*);
// ...
};
利用同一个约定,我们还可以这样声明 bind1st:
template<class F, class T> binder1st<F> bind1st(const F &, const T &);
关于 bind1st 和 binder1st 的定义留作读者练习。
5. 接口继承
模板类 plus 是函数对象类家族的成员之一,这些类都定义了成员 first_argument_type、second_argument_type 和 result_type。只要我们的一些类都具有某些相同的特殊成员,就应该考虑把这些成员放到一个基类中。C++库正是这样做的。实际上,plus 有一个叫做 binary_funciton 的基类,定义如下:
template <class A1, class A2, class R> class binary_function {
public:
typedef A1 first_argument_type;
typedef A2 second_argument_type;
typedef R result_type;
}
它极大的方便了定义其他函数对象的工作。例如,我们可以这样定义 plus:
template <class T> class plus: public binary_function<T, T, T> {
public:
T operator() (const T & x, const T & y) const {
return x + y;
}
};
除了包括类 binary_function 外,标准库还有一个 unary_function 基类,定义如下:
template<class A, class R> class unary_function {
public:
typedef A argument_type;
typedef R result_type;
};
比如,这个类可以当作 negate 的基类使用,negate 的对象对值执一元操作-:
template <class T> class negate: public unary_function<T, T> {
public:
T operator() (const T & x) const {
return -x;
}
};
还有很多类似的函数;在任何一本关于 STL 或者即将问世的 C++ 标准库的书里都能找到全部细节。
6. 使用这些类
假设 c 是某种标准库容器,x 是一个可以存放到容器中的值。那么
find(c.begin(), c.end(), x);
将生成一个指向 c 中第一个与 x 相等的元素的迭代器,如果不存在等于 x 的元素就获得一个指向紧跟在容器尾部后的元素的迭代器。我们可以使用函数配接器以一种更精巧的方法来得到同样的结果:
find_if(c.begin(), c.end(), bind1st(equal_to<c::value_type>(), x))
这里,因为我们想知道是否对于每个元素 e 都存在 e > x,所以采用 bind2nd,而不用别的方法。
假设 x 和 w 都是容器 ,且具有相同个数的元素。那么,我们就可以通过下面的代码将 w 的每个元素与 v 中的相应元素相加:
transform(v.begin(), v.end(), w.begin(), v.begin(), plus<v::value_type>());
这里,我们采用了具有 5 个参数的 transform.前两个参数是用来限制区间范围的迭代器;第三个参数是要和第一个区间大小相等的第二个区间的头部。这个版本的 transform 依次获得每个区间的元素,并用它们作为参数来调用作为 transform 的第5个参数,结果存放到由 transform 的第 4 个参数指定开始位置的序列中。本例中 transform 的第 5 个参数是一个函数对象,它将两个类型为 v::value_type 的值相加,并获得一个相同类型的结果。
更为普遍的是,本例可以不局限于数字;只要 v 和 w 的容器类型允许 + 操作,就可以对参数采用适当的 + 操作。
标准库包括能够用普通构造函数对象的函数适配器。和上一个例子一样,如果有类似
char* p[N];
的字符指针数组,我们就可以找出每个指向包含 “C” 的以 null 结尾的字符串的指针,并用指向字符串 “C++” 的指针进行替换:
replace_if(p, p + N, not1(bind2nd(ptr_fun(strcmp), "C")), "C++");
本例使用了库函数 replace_if。它的头两个参数限定了一个区间,第三个参数是判断替不替换容器元素,第四个参数用于替换的值。
第三个参数的判断本身就涉及到 3 个函数适配器: not1、bind2nd 以及 ptr_fun。配接器 ptr_fun 创建了一个适合于传递给 strcmp 的函数对象。bind2nd 使用这个对象来创建另一个函数对象,新产生的函数对象将用 “C” 来和它的参数进行比较。对 not1 的定义否定了判断的意义,如果它的参数相等,而 0 又被普遍当作 false 解释,那么这个否定对于适应 strcmp 返回 0 的情况是很有必要的。
7. 讨论
这种编程方式是不是很难理解?为什么每个人都要这样编写程序?
原因之一是理解的难易程度总是和熟悉程度密切相关。大多数学习 C 和 C++ 的人都在某个时候遇到过这样的问题:
while ((*p++ = *q++) != 0)
;
最初几次看到这样的代码可能会困惑不解,但是很快概念的强化就会在心理上打开通道,以致理解这种水平的程序,反而比理解单独的操作要容易。
另外,这些程序不比相应的那些常规程序运行得慢。理论上它们可以更快:因为这些函数配接器是标准库的一部分,编译器在适当的时候可以识别它们,并生成特别高效的代码。
这种编程方式使得一次处理整个容器成为现实,而不必采用循环来逐个处理单个元素。这也使得程序更短小、更可靠,如果你熟悉了,就会觉得易于理解了。