作为开始,了解一些关于我们所讨论的主题的背景知识是很有必要的。为此,我们会转而回顾过去,看看那些不使用任何现代 C++ 技术的代码——即 C++98/03 规范下的代码。
在本章中,我们将会学习:
- 如何将旧式的函数对象传给 C++ 标准库中的各种算法。
- 函数对象类型的限制。
- 为什么函数助手不够好。
- C++0x/C++11 中 引入 Lambda 的动机。
C++98/03 中的可调用对象
标准库的一个基本设计思想是对于像 std::sort,std::for_each,std::transform 等这样的泛型函数,能够接受任何可调用对象然后对输入容器中的每个元素依次调用它。然而,在 C++98/03 中,可调用对象只包括函数指针和重载了调用操作符的类类型(通常被称为“函子”)。
举例来说,我们有一个打印一个向量中所有元素的应用程序。
在第一个版本中,我们使用普通的函数来实现:
// Ex1_1: 一个基础的函数对象.
#include <algorithm>
#include <iostream>
#include <vector>
void PrintFunc(int x) {
std::cout << x << '\n';
}
int main() {
std::vector<int> v;
v.push_back(1); // C++03 不支持统一初始化!
v.push_back(2); // 只有 push_back 可用... :)
std::for_each(v.begin(), v.end(), PrintFunc);
}
上面的代码使用 std::for_each 来迭代 vector(我们使用的是 C++98/03,所以没有基于范围的 for 循环!),然后它将 PrintFunc 作为一个可调用对象传递。
我们可以使用调用操作符将此函数转换为类类型:
// Ex1_2: 一个简单的打印功能的函数对象.
#include <algorithm>
#include <iostream>
#include <vector>
struct Printer {
void operator()(int x) const {
std::cout << x << '\n';
}
};
int main() {
std::vector<int> v;
v.push_back(1);
v.push_back(2); // C++98/03 中没有初始化器列表...
std::for_each(v.begin(), v.end(), Printer());
}
这个例子定义了一个重载了 operator() 的结构体,因此你能够像普通函数一样去“调用”它:
Printer printer;
printer(); // 调用 operator()
printer.operator()(); // 等价调用
而非成员函数通常是无状态的(你可以在常规函数中使用全局变量或静态变量,但这不是最好的解决方案,这样的方法很难跨多个 lambda 调用组控制状态),函数式的类类型却可以持有非静态成员变量从而能够保存状态。一个典型的例子是记录一个可调用对象被一个算法调用的次数。解决方案通常需要维护一个计数器,然后在每次调用时更新它的值:
// Ex1_3: 带状态的函数对象.
#include <algorithm>
#include <iostream>
#include <vector>
struct PrinterEx {
PrinterEx(): numCalls(0) { }
void operator()(int x) {
std::cout << x << '\n';
++numCalls;
}
int numCalls;
};
int main() {
std::vector<int> v;
v.push_back(1);
v.push_back(2);
const PrinterEx vis = std::for_each(v.begin(), v.end(), PrinterEx());
std::cout << "num calls: " << vis.numCalls << '\n';
}
在上例中,数据成员 numCalls 被用在调用运算符重载中计数此函数的调用次数。std::for_each 返回我们传入的函数对象,因此我们能够得到该对象并获取其数据成员。
如你所料,我们能够得到以下输出:
1
2
num calls: 2
我们还可以从调用作用域“捕获”变量。为此,我们必须创建一个数据成员,并在构造函数中初始化它。
// Ex1_4: 捕获变量的函数对象.
#include <algorithm>
#include <iostream>
#include <string>
#include <vector>
struct PrinterEx {
PrintEx(const std::string& str) :
strText(str), numCalls(0) { }
void operator()(int x) {
std::cout << strText << x << '\n';
++numCalls;
}
std::string strText;
int numCalls;
};
int main() {
std::vector<int> v;
v.push_back(1);
v.push_back(2);
const std::string introText("Elem: ");
const PrinterEx vis = std::for_each(v.begin(), v.end(),
PrinterEx(introText));
std::cout << "num calls: " << vis.numCalls << '\n';
}
在这个版本中,PrinterEx 带有一个额外参数去初始化其数据成员。之后在调用运算符中使用这个变量,输出如下:
Elem: 1
Elem: 2
num calls: 2
何谓“函子”
在上面的小节中,我们有时将带有 operator() 的类类型叫做“函子”。虽然这个术语很方便,而且比“函数对象类类型”要短得多,但并不正确的。
从词源上来看,“函子”来自于函数式编程,它有不同的含义而不是 C++ 中的术语。
引用 Bartosz Milewski 中对于函子的定义:
函子是类别之间的映射。给定两类别 C 和 D,一个函子 F 能将 C 中的对象映射到 D 中的对象——它是作用在对象上的函数。
这个定义看上去相当抽象,但幸运的是,我们还可以去看到一些简化版 。在《C++函数式编程》这本书的第 10 章中,作者 Ivan Cukic 将这个抽象的定义“翻译”成更适合 C++ 语言的版本:
拥有一个定义在其上的变换(或映射)函数的类模板 F 是一个函子。
此外,这样的变换函数必须遵守恒性等和可组合性这两条规则。“函子”一词在 C++ 规范中没有以任何形式出现(即使在 C++ 98/03 中也是如此),因此在本书的其余部分,我们将尽量避免使用它。
当然,您还可以通过以下资源的阅读来了解更多关于函子的内容:
- Functors, Applicatives, And Monads In Pictures - adit.io
- Functors | Bartosz Milewski’s Programming Cafe
- What are C++ functors and their uses? - Stack Overflow
- Functor - Wikipedia
函数对象类类型的问题
如你所见,创建一个重载了调用运算符的类类型非常强大。你可以有全流程的把控,你可以以任何喜欢的方式设计它们。
然而,在 C++98/03 中,问题在于当你要用一个算法调用一个函数对象时,你却不得不在不同的地方定义它。这可能意味着可调用对象可以在源文件的前面或后面几十或几百行,甚至位于不同的翻译单元中。
作为一种可能的解决方案,您可能尝试过编写局部类,因为 C++ 支持这样的语法。但这并不适用于模板。代码如下:
// 一个局部函数对象类型
int main() {
struct LocalPrinter {
void operator()(int x) const {
std::cout << x << '\n';
}
};
std::vector<int> v(10, 1);
std::for_each(v.begin(), v.end(), LocalPrinter());
}
尝试在 GCC 上用 -std=c++98
参数来编译它将会得到如下错误提示:
error: template argument for
'template<class _IIter, class _Funct> _Funct
std::for_each(_IIter, _IIter, _Funct)'
uses local type 'main()::LocalPrinter'
看起来,在 C++ 98/03 中,无法用局部类型实例化模板。
C++ 程序员很快就理解了这些限制,并找到了在 C++98/03 中绕过这个问题的方法。一种解决方案就是准备一组辅助类。让我们看下一节。
使用辅助类
那么,究竟什么是辅助类和和预定义的函数对象呢?
如果你去查看标准库中的 <functional> 头文件,你将会一系列可以立即用于标准库算法的类型和函数。
例如:
-
std::plus<T>()
- 接受两个参数并返回它们的和。 -
std::minus<T>()
- 接受两个参数并返回它们的差。 -
std::less<T>()
- 接受两个参数返回是否第一个参数小于第二个参数。 -
std::greater_equal<T>()
- 接受两个参数返回是否第一个参数大于等于第二个参数。 -
std::bind1st
- 创建一个将第一个参数固定为所给值的可调用对象。 -
std::bind2nd
- 创建一个将第二个参数固定为所给值的可调用对象。 -
std::mem_fun
- 创建一个成员函数的包装对象。 - 等等。
让我们编写一些得益于这些辅助类的代码:
// Ex1_5: 使用旧式的 C++98/03 样式的辅助类。
#include <algorithm>
#include <functional>
#include <vector>
int main() {
std::vector<int> v;
v.push_back(1);
v.push_back(2);
// .. push back until 9...
const size_t smaller5 = std::count_if(v.begin(), v.end(),
std::bind2nd(std::less<int>(), 5));
return smaller5;
}
该示例使用 std::less 并通过使用 std::bind2nd 固定其第二个参数(bind1st, bind2nd 和其他函数辅助器已在 C++11 中弃用,并在 C++ 17 中移除。本章中的代码仅用于说明 C++ 98/03 中的问题。请在您的项目中使用更加现代的替代方案。)。这整个组件被传递到 count_if 中。您可能已经猜到了,代码最终转换成了一个执行简单比较的函数:
return x < 5;
如果您想要更多现成的帮助程序,那么您还可以查看 boost 库,例如boost::bind
。
不幸的是,这种方法的主要问题是语法复杂且难以学习。
例如,编写包含两个或多个辅助函数的代码很不自然。如下例所示:
// Ex1_6:辅助器的组合。
#include <algorithm>
#include <functional>
#include <vector>
int main() {
using std::placeholders::_1;
std::vector<int> v;
v.push_back(1);
v.push_back(2);
// push_back until 9...
const size_t val = std::count_if(v.begin(), v.end(),
std::bind(std::logical_and<bool>(),
std::bind(std::greater<int>(),_1, 2),
std::bind(std::less_equal<int>(),_1,6)));
return val;
}
改代码使用了 std::bind(它来自 C++ 11,所以我们作弊了,它不是 C++ 98/03)
来完成 std::greater,std::less_equal 以及 std::logical_and 的连接。此外,代码使用 _1 作为第一个输入参数的占位符。
虽然上面的代码可以工作,并且你可以在局部定义它,但是你可能已经看出来它的
复杂以及不自然的语法。且不说这个组合只代表了一个简单的条件:
return x > 2 && x <= 6;
那么,是否还有更好用更直接的方法呢?
新特性引入的动机
如你所见,在 C++98/03,调用标准库的一些算法和工具总是需要定义并传入一个可调用对象。然而,所有的可选方案都或多或少有一些限制。例如,你不能定义一个局部函数对象类型,或是使用辅助函数对象的组合,但它很复杂。
幸运的是,在 C++11 中我们终于看到了许多改进!
首先,C++ 标准委员会取消了模板实例化对局部类类型的限制。从 C++11 开始,你可以在任何你需要的局部作用域编写重载了调用操作符的类类型。
更重要的是,C++11 还带来了另一个想法:如果我们有一个简短的语法,然后编译器可以将它“展开”为相应的局部函数对象的定义呢?
这就是“lambda 表达式”的诞生!
如果我们看看 N3337—— C++11 的最终草案,我们可以看到一个关于 lambdas 的单独部分:[expr.prim.lambda]。
让我们在下一章中看看这个新特性。