C++中的函数对象

编程设计中有一个概念,说是当看到一只鸟走起来像鸭子、游泳起来像鸭子、叫起来也像鸭子,那么这只鸟就可以被称为鸭子。在C++中,我们把所有能当做函数使用的对象统称为函数对象。

本文简单介绍在C++中可以被当做函数使用的对象。这些内容并不复杂,但是要想在C++中进行函数式编程,就必须要深入理解这些内容,有选择地使用,这样才能在不损失性能的前提下利用更高层的抽象解决问题。

一般来说,如果我们列出一个对象,而它的后面又跟有由花括号包裹的参数列表,就像f(arg1, arg2, ...),这个对象就被称为函数对象。

具体来说,函数对象有这几类:

函数类

一个函数类,即一个重载了括号操作符()的类。当用该类的对象调用此操作符时,其表现形式如同普通函数调用一般,因此取名叫函数类。举个最简单的例子:

1
2
3
4
5
6
7
8
class FuncObjType
{
public:
void operator() ()
{
cout<<"Hello C++!"<<endl;
}
};

FuncObjType中重载了()操作符,因此对于一个该类的对象val,可以这样调用该操作符:val()。调用结果即输出以上代码中的内容。该调用语句在形式上跟以下函数的调用完全一样:

1
2
3
4
void val()
{
cout<<"Hello C++!"<<endl;
}

既然用函数对象与调用普通函数有相同的效果,为什么还有搞这么麻烦定义一个类来使用函数对象?主要在于函数对象有以下的优势:

  • 函数对象可以有自己的状态。我们可以在类中定义状态变量,这样一个函数对象在多次的调用中可以共享这个状态。但是函数调用没这种优势,除非它使用全局变量来保存状态。
  • 函数对象有自己特有的类型,而普通函数无类型可言。这种特性对于使用C++标准库来说是至关重要的。这样我们在使用STL中的函数时,可以传递相应的类型作为参数来实例化相应的模板,从而实现我们自己定义的规则。

Lambda

C++11的一大亮点就是引入了Lambda表达式。利用Lambda表达式,可以方便的定义和创建匿名函数。Lambda表达式通过在最前面的方括号[]来明确指明其内部可以访问的外部变量,这一过程也称过Lambda表达式“捕获”了外部变量。类似参数传递方式(值传递、引入传递、指针传递),在Lambda表达式中,外部变量的捕获方式也有值捕获、引用捕获、隐式捕获。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <vector>
#include <vector>
#include <iostream>
#include <algorithm>

int main()
{
int count = 0;
std::vector<std::string> words{ "An", "ancient", "pond" };

std::for_each(words.cbegin(), words.cend(),
[&count](const std::string& word)
{
if(isupper(word[0])) {
std::cout << word << " " << count << std::endl;
count++;
}
});
}

这样的代码在编译时,lambda表达式会自动转变成一个类——它每一个成员变量都对应着一个捕获的变量。这个类根据lambda表达式的参数列表重载了operator(),一个可能的实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 匿名类名由编译器自动生成
class lambda_implementation {
public:
lambda_implementation(int& n) : count_(n) {}

void operator()(const std::string& word) const
{
if(isupper(word[0]))
{
std::cout << word << " " << count << std::endl;
count_++;
}
}
private:
int& count_;
}

Lambdas 并没有允许你做任何之前的代码做不到的事情,但是它在帮助你保持代码的逻辑精简的同时不需要写一个普普通通的函数类。

std::function

当你需要一个非模板函数对象作为类的成员或者函数参数时,你必须指定函数对象的具体类型。C++中的函数对象并没有一个基类,但是标准库提供了一个模板类std::function来代表所有的函数对象。

1
2
3
4
5
6
7
8
std::function<float(float, float)> test_function;

test_function = std::fmaxf; // Ordinary function
test_function = std::multiplies<float>(); // class with a call operator
test_function = std::multiplies<>(); // class with a generic call operator
test_function = [x](float a, float b) { return a * x + b; }; // lambda
test_function = [x](auto a, auto b) { return a * x + b; }; // generic lambda
test_function = [](std::string s) { return s.empty(); }; //ERROR!

std::function并不像std::vector<T>等容器对包含的类型做了抽象,而是抽象了函数对象的参数和返回值。无论是普通函数,还是函数指针、lambdas,又或是任何可以被当做函数使用的对象,只要拥有相同参数和返回值,均可以用同一类std::function表示。

尽管std::function非常有用,但是它也带来了性能损失。这是因为为了隐藏包含的函数对象类型,提供通用的调用接口,std::function使用了叫做type erasure的技术。简单来说是通过虚函数的调用在运行期来决定具体调用,因此编译器无法内联(inline)函数调用,也无法进行更多优化。

std::bind和闭包

大多数的编程范式都提供了提高代码重用的方式,比如在面向对象编程中,我们可以通过抽象出特定类来将复杂系统拆分成小的组件,在降低耦合的同时也可以分开设计、测试代码。

在函数式编程中,通过组合现有的函数,我们可以创造出新的函数。标准库中的std::bind就是可以创造闭包(closure)的工具。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#include <algorithm>

class Foo
{
public:
void methodA();
void methodInt(int a);
};
class Bar
{
public:
void methodB();
};

void main()
{
std::function<void()> f1; // 无参数,无返回值

Foo foo;
f1 = std::bind(&Foo::methodA, &foo);
f1(); // 调用 foo.methodA();
Bar bar;
f1 = std::bind(&Bar::methodB, &bar);
f1(); // 调用 bar.methodB();

f1 = std::bind(&Foo::methodInt, &foo, 42);
f1(); // 调用 foo.methodInt(42);

std::function<void(int)> f2; // int 参数,无返回值
f2 = std::bind(&Foo::methodInt, &foo, _1);
f2(53); // 调用 foo.methodInt(53);
}

通过std::bind,我们可以为同一个类的不同对象可以分派不同的实现,从而实现不同的行为。这种方式使得我们不在需要设计通过继承与虚函数来实现多态,无疑为程序库设计提供的新的方式。

程序库的设计不应该给使用者带来不必要的限制(耦合),而继承是仅次于最强的一种耦合(最强耦合的是友元)。