c++函数式编程与TBB教程

§c++函数式编程

§函数对象

c++可以重载绝大多数运算符,其中包含函数调用操作符()。当一个类重载了函数调用操作符后,此类型的对象可以像普通函数一样调用,这种对象称为函数对象。如:

1
2
3
4
5
6
template<typename T>
struct greater {
    bool operator()(const T &lhs, const T &rhs) const {
        return lhs > rhs;
    }
};

使用方式为:

1
2
3
4
5
6
greater<int> g;
if (g(1, 2))
    // ...

std::vector<int> A;
std::sort(A.begin(), A.end(), greater<int>{});

函数对象的类型就是普通的自定义类型,可以有成员变量、构造函数和析构函数等,语法没有区别。和普通函数相比,函数对象有两个优点:1、编译器可以内联执行函数对象的调用;2、函数对象内部可以保持状态,这使得函数对象可以捕获外部变量的值并保存下来,这是c++实现闭包的关键。

§随机数生成

在之前已经接触过三个函数对象:default_random_engineuniform_real_distribution<T>random_device。它们都定义于头文件<random>,和标准命名空间std。其中default_random_engine为实现定义的随机数生成器,c++标准规定必须实现的算法有:

random_engine

random_device是使用硬件熵源的非确定随机数生成器。一般用于上表中随机数生成器的初始化。

uniform_real_distribution是随机数分布算法,用于将随机数生成器的结果按一定的统计概率密度函数分布。c++标准规定的随机数分布有:

distribution

default_random_enginerandom_device的函数调用操作符的签名为:

1
result_type operator()();

uniform_real_distribution的签名为:

1
2
template< class Generator >
result_type operator()(Generator &g);

因此生成均匀分布的随机数的方法为:

1
2
3
4
5
6
std::random_device rd;
std::default_random_engine re{rd()}
std::uniform_real_distribution<double> dist{0, 10};

for (int i = 0; i < 10; i++)
        A[i] = dist(re);    // 由dist.operator()来调用re()生成随机数,再映射至区间[0, 10]

§函数包装器

c++中除普通函数外,可以调用的对象还有函数对象、成员函数和lambda表达式,它们具有不同的类型和属性。为统一c++中所有具有函数行为的对象类型,c++11引入了函数包装器,使函数成为可以像变量一样,具有创建、赋值、返回等操作的一等公民(first class)。函数包装器定义于头文件functional,定义为:

1
2
3
// functional
template <class R, class... Args>
class function<R(Args...)>;

使用:

 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
33
34
35
#include <iostream>
#include <functional>

// 普通函数
int getOne() { return 1; }

// 函数对象
struct getTwo {
    int operator()() { return 2; }
};

// 成员函数
struct getNumber {
    // 非static成员函数
    int getThree() { return 3; }
    // static成员函数
    static int getFour() { return 4; }
};

int main() {
    std::function<int()> getNumber1{getOne};
    std::function<int()> getNumber2{getTwo{}};
    std::function<int(class getNumber *)> getNumber3{&getNumber::getThree};
    std::function<int()> getNumber4{&getNumber::getFour};
    std::function<int()> getNumber5 = []{ return 5; };
    
    getNumber get_number;
    std::cout << getNumber1() << ", "
              << getNumber2() << ", "
              << getNumber3(&get_number) << ", "   // 非static成员函数行为略有不同
              << getNumber4() << ", "
              << getNumber5() << "\n";
    
    return 0;
}

执行效果:1, 2, 3, 4, 5

std::function具有变量的行为,如上面的getNumber1getNumber2getNumber4可相互赋值,也可以传参和返回:

1
2
3
4
std::function<int()> f(std::function<int()> g) { return g; }

getNumber1 = getNumber2;
getNumber2 = f(getNumber4);

§题外话

有一个值得注意的地方是getNumber2的初始化歧义。在传统的c++中,对象的初始化语法使用圆括号包围参数,如:

1
2
getTwo get_two();   // 默认初始化
std::function<int()> getNumber2(getTwo());  // 先构造临时对象,再将它作为参数传递

很不幸,这两条语句都被c++编译器当作函数声明。这是比较容易看出来出错的,还有一些比较复杂的情况,如:

1
2
std::ifstream file("data.txt");
std::string str(std::istream_iterator<char>(file), std::istream_iterator<char>());

c++标准规定:在直接初始化的变量声明和函数声明有歧义的情况下,c++编译器始终选择函数声明。

为解决这个问题,c++11标准引入直接初始化列表,即以大括号包围初始化参数,以下三个都为变量声明:

1
2
3
getTwo get_two{};
std::function<int()> getNumber2{getTwo{}};
std::string str{std::istream_iterator<char>{file}, std::istream_iterator<char>{}};

但要注意这个方法也不是十全十美的,如:

1
2
std::vector<int> vec(10, 1);
std::vector<int> vec{10, 1};

如果{}内的变量类型全部一致则可能被当成std::initializer_list<T>,如果存在以std::initializer_list<T>为参数的构造函数则优先采用。

§lambda表达式

lambda表达式即闭包,或称为匿名函数。在c++中,lamdba表达式是能够捕获作用域中的变量的无名函数对象。语法为:

1
[捕获列表](形参列表) 说明符 -> 返回类型 { 函数体 }

其中形参列表、说明符和返回类型都是可选的。

简单直观起见,此处使用两个实例来说明lambda表达式和函数对象的对应关系:

 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
int a;
std::vector<int> vec;

[a, &vec](int c, const std::vector<int> &d) mutable -> void {
    a = c;
    vec = d;
};

class ClosureType {
    int a;                  // 复制捕获
    std::vector<int> &vec;  // 引用捕获
public:
   	ClosureType(int a, std::vector<int> &vec) : a{a}, vec{vec} {}
    
    void operator()(int c, std::vector<int> &d) {
        a = c;
        vec = d;
    }
};


[a](int b) -> bool { return b < a; };

class ClosureType {
    int a;
public:
    ClosureType(int a) : a{a} {}
    
    bool operator()(int b) const {
        return b < a;
    }
};

可见,捕获列表中的变量成为函数对象的成员变量,根据复制捕获或者引用捕获的方式来决定成员变量是否为引用类型。参数列表和返回类型分别成为operator()的参数列表和返回类型。说明符可选mutable,如果lambda表达式省略mutable,则operator()声明为const成员函数,不可更改捕获列表中的变量。

lambda的捕获列表除了可以将变量一一列出之外,还可以使用=&捕获当前上下文的所有变量:

1
2
3
4
[&]() {};            // 以引用方式捕获所有变量
[=]() {};            // 以复制方式捕获所有变量
[&, i]() {};         // i以复制方式捕获,其它变量以引用方式捕获
[=, &i]() {};        // i以引用方式捕获,其它变量以复制方式捕获

如果lambda表达式不接受参数,可以不写参数列表。如果lambda表达式没有返回语句或者只有一条返回语句,可以不写返回类型。

例:创建一个自定义比较函数的优先队列

1
2
3
#include <queue>
auto cmp = [](int left, int right) { return left > right; };
std::priority_queue<int, std::vector<int>, decltype(cmp)> q(cmp);

例:递归函数

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
std::vector<std::vector<int>> mat(M, std::vector<int>(N));  // MxN的矩阵

std::function<int(int, int)> f;
f = [&f, &mat, M, N](int i, int j) -> int {
    int length = 0;
    if (i > 0 && mat[i - 1][j] > mat[i][j])
        length = std::max(length, f(i - 1, j));
    if (i < M && mat[i + 1][j] > mat[i][j])
        length = std::max(length, f(i + 1, j));
    if (j > 0 && mat[i][j - 1] > mat[i][j])
        length = std::max(length, f(i, j - 1));
    if (j < N && mat[i][j + 1] > mat[i][j])
        length = std::max(length, f(i, j + 1));
    return length + 1;
};

int max_length = 0;
for (int i = 0; i < M; ++i) {
    for (int j = 0; j < N; ++j)
        max_length = std::max(max_length, f(i, j));
}

需要注意,lambda表达式的类型是闭包类型(ClosureType),不是std::function,因此不能赋值:

1
2
auto cmp = [](int left, int right) { return left > right; };
cmp = [](int left, int right) { return left < right; };   // error

§柯里化

柯里化(currying)是将接受多个参数和函数变换成接受一个参数,而返回接受剩余参数的新函数的技术。通俗理解就是对参数列表进行切片:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
int add(int a, int b, int c) {
    return a + b + c;
}

std::function<int(int, int)> curried_add(int a) {
    return [a](int b, int c) { return a + b + c; };
}

// curried_add只接受一个参数,但需要调用两次
int c = curried_add(1)(2, 3);  // 6

// 也可以固定某些参数,生成新的函数
auto add1 = curried_add(1);
int c = add1(2, 3);            // 6

在许多时候,我们不想写上面的curried_add这样的函数,而是希望直接将函数的部分参数固定,以得到像add1这样的函数。c++11之前使用bind1st和bind2nd来绑定一个二元函数的参数,c++11之后使用bind来绑定任意函数的任意多个参数,对于未绑定的那些参数,使用占位符替换。

bind声明于头文件<functional>,声明为:

1
2
template< class F, class... Args >
/*unspecified*/ bind( F&& f, Args&&... args );

使用方法为:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
#include <functional>

int add(int a, int b, int c) {
    return a + b + c;
}

auto add1 = std::bind(add, 1, std::placeholders::_1, std::placeholders::_2);
auto add2 = std::bind(add, std::placeholders::_1, 2, std::placeholders::_2);
auto add3 = std::bind(add, std::placeholders::_1, std::placeholders::_2, 3);

add1(2, 3);  // 6
add2(1, 3);  // 6
add3(1, 2);  // 6

通过使用相同的占位符,还可以将两个参数绑定在一起却又不固定:

1
2
auto add1 = std::bind(add, 1, std::placeholders::_1, std::placeholders::_1);
add1(2);  // 1+2+2=5

需要注意,bind的返回类型是实现定义的,不是std::function,但可以转换为std::function:

1
2
3
4
std::function<int(int,int)> add1 = std::bind(add, 1, std::placeholders::_1,
                                             std::placeholders::_2);
std::function<int(int)> add2 = std::bind(add, 1, std::placeholders::_1,
                                         std::placeholders::_1);

例:生成随机数

1
2
3
4
5
std::function<int(void)> random_function = std::bind(
        std::uniform_int_distribution<int>(0, 10),
        std::default_random_engine{std::random_device{}()});
for (int i = 0; i < 10; ++i)
    A[i] = random_function();

§TBB

与OpenMP类似的框架还有微软的PPL(Parallel Patterns Library)和英特尔的TBB(Thread Building Blocks),PPL和TBB以库的方式提供,不需要其它厂商的额外支持。PPL只能支持visual studio,而TBB是开源软件,可以支持大多数常见的平台和编译器,此处简单介绍TBB库。

§例1、向量加法

 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
// 文件:vector_add_tbb.cpp
#include <iostream>
#include <vector>
#include <tbb/blocked_range.h>
#include <tbb/parallel_for.h>

int main() {
    const int n = 10;
    std::vector<int> v1 = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
    std::vector<int> v2 = {10, 9, 8, 7, 6, 5, 4, 3, 2, 1};
    std::vector<int> v3(n);

    tbb::parallel_for(tbb::blocked_range<int>(0, n),
            [&v1, &v2, &v3](const tbb::blocked_range<int> &range) {
                for (int i = range.begin(); i != range.end(); ++i)
                    v3[i] = v1[i] + v2[i];
            }
    );

    for (int i = 0; i < n; ++i)
        std::cout << v3[i] << " ";
    std::cout << std::endl;

    return 0;
}

编译命令:g++ -std=c++11 vector_add_tbb.cpp -o vector_add_tbb -ltbb

执行:

1
2
$ ./vector_add_tbb
11 11 11 11 11 11 11 11 11 11

§例2、向量内积

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 文件:vector_dot_tbb.cpp
#include <iostream>
#include <vector>
#include <tbb/blocked_range.h>
#include <tbb/parallel_reduce.h>

int main() {
    const int n = 10;
    std::vector<int> v1 = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
    std::vector<int> v2 = {10, 9, 8, 7, 6, 5, 4, 3, 2, 1};

    int sum = tbb::parallel_reduce(tbb::blocked_range<int>(0, n), 0,
            [&v1, &v2](const tbb::blocked_range<int> &range, int s) -> int {
                for (int i = range.begin(); i != range.end(); ++i)
                    s += v1[i] * v2[i];
                return s;
            },
            [](int s1, int s2) -> int { return s1 + s2; }
    );

    std::cout << sum << std::endl;

    return 0;
}

编译:g++ -std=c++11 vector_dot_tbb.cpp -o vector_dot_tbb -ltbb

执行:

1
2
$ ./vector_dot_tbb
220

§例3、求最大值

 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
33
34
35
36
37
38
39
40
41
42
43
44
45
#include <iostream>
#include <vector>
#include <algorithm>
#include <random>
#include <limits>
#include <functional>
#include <tbb/blocked_range.h>
#include <tbb/parallel_reduce.h>

std::ostream &operator<<(std::ostream &out, const std::vector<int> &vec) {
    if (vec.empty())
        return out;
    for (int i = 0; i + 1 < vec.size(); ++i)
        out << vec[i] << ", ";
    return out << vec.back();
}

int main() {
    const int N = 16;
    std::vector<int> nums(N);

    std::function<int(void)> random_function = std::bind(
        std::uniform_int_distribution<int>(0, 100),
        std::default_random_engine{std::random_device{}()});

    std::generate(nums.begin(), nums.end(), random_function);
    std::cout << "numbers: " << nums << std::endl;

    int max_value = tbb::parallel_reduce(
        tbb::blocked_range<int>(0, nums.size()),
        std::numeric_limits<int>::min(),
        [&nums](const tbb::blocked_range<int> &range, int max_value) -> int {
            for (int i = range.begin(); i != range.end(); ++i)
                max_value = std::max(max_value, nums[i]);
            return max_value;
        },
        [](int a, int b) -> int {
            return a > b ? a : b;
        }
    );

    std::cout << "max value: " << max_value << std::endl;

    return 0;
}

执行结果:

1
2
numbers: 50, 36, 20, 81, 98, 55, 62, 98, 39, 2, 1, 26, 45, 25, 48, 93
max value: 98

TBB比OpenMP的抽象层次更高,更适合c++程序设计,除parallel_for和parallel_reduce以外,还提供了parallel_pipline、parallel_sort、parallel_scan、concurrent_unordered_map、concurrent_queue和concurrent_vector等并行算法或并发容器,可以大大节省程序员的精力。对于一些精细控制的任务,TBB不合适的,可以搭配OpenMP使用。

§参考

[1] TBB文档:https://software.intel.com/content/www/us/en/develop/documentation/tbb-documentation/top.html.

加载评论