1. C++98/03 中的 Lambda

作为开始,了解一些关于我们所讨论的主题的背景知识是很有必要的。为此,我们会转而回顾过去,看看那些不使用任何现代 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 
#include 
#include 

void PrintFunc(int x) {
    std::cout << x << '\n';
}

int main() {
    std::vector 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 
#include 
#include 

struct Printer {
    void operator()(int x) const {
        std::cout << x << '\n';
    }
};

int main() {
    std::vector 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 
#include 
#include 

struct PrinterEx {
    PrinterEx(): numCalls(0) { }
    void operator()(int x) {
        std::cout << x << '\n';
        ++numCalls;
    }
    int numCalls;
};

int main() {
    std::vector 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 
#include 
#include 
#include 

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 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 v(10, 1);
    std::for_each(v.begin(), v.end(), LocalPrinter());
}

尝试在 GCC 上用 -std=c++98 参数来编译它将会得到如下错误提示:
error: template argument for
'template _Funct
std::for_each(_IIter, _IIter, _Funct)'
uses local type 'main()::LocalPrinter'
看起来,在 C++ 98/03 中,无法用局部类型实例化模板。

C++ 程序员很快就理解了这些限制,并找到了在 C++98/03 中绕过这个问题的方法。一种解决方案就是准备一组辅助类。让我们看下一节。


使用辅助类

那么,究竟什么是辅助类和和预定义的函数对象呢?

如果你去查看标准库中的 头文件,你将会一系列可以立即用于标准库算法的类型和函数。

例如:

  • std::plus() - 接受两个参数并返回它们的和。
  • std::minus() - 接受两个参数并返回它们的差。
  • std::less() - 接受两个参数返回是否第一个参数小于第二个参数。
  • std::greater_equal() - 接受两个参数返回是否第一个参数大于等于第二个参数。
  • std::bind1st - 创建一个将第一个参数固定为所给值的可调用对象。
  • std::bind2nd - 创建一个将第二个参数固定为所给值的可调用对象。
  • std::mem_fun - 创建一个成员函数的包装对象。
  • 等等。

让我们编写一些得益于这些辅助类的代码:

// Ex1_5: 使用旧式的 C++98/03 样式的辅助类。
#include 
#include 
#include 

int main() {
    std::vector 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(), 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 
#include 
#include 

int main() {
    using std::placeholders::_1;
    std::vector 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(),
                                     std::bind(std::greater(),_1, 2),
                                     std::bind(std::less_equal(),_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]。

让我们在下一章中看看这个新特性。

你可能感兴趣的:(1. C++98/03 中的 Lambda)