17 | 函数式编程:一种越来越流行的编程范式
上一讲我们初步介绍了函数对象和 lambda 表达式,今天我们来讲讲它们的主要用途——函数式编程
一个小例子
如果给定一组文件名,要求数一下文件里的总文本行数,你会怎么做?
函数的原型: 我们期待接受两个 C 字符串的迭代器,用来遍历所有的文件名;返回值代表文件中的总行数。
int count_lines(const char** begin, const char** end);
要测试行为是否正常,我们需要一个很小的 main 函数:
int main(int argc, const char** argv) { int total_lines = count_lines( argv + 1, argv + argc); cout << "Total lines: " << total_lines << endl; }
雏形
最传统的命令式编程大概会这样写代码:
int count_file(const char* name) { int count = 0; ifstream ifs(name); string line; for (;;) { getline(ifs, line); if (!ifs) { break; } ++count; } return count; } int count_lines(const char** begin, const char** end) { int count = 0; for (; begin != end; ++begin) { count += count_file(*begin); } return count; }
简化一
int count_file(const char* name) { int count = 0; ifstream ifs(name); for (auto&& line : istream_line_reader(ifs)) { ++count; } return count; }
简化二
使用之前已经出场过的两个函数,transform和 accumulate,代码可以进一步简化为:
int count_file(const char* name) { ifstream ifs(name); istream_line_reader reader(ifs); return distance(reader.begin(), reader.end()); } int count_lines(const char** begin, const char** end) { vector<int> count(end - begin); transform(begin, end,count.begin(),count_file); return accumulate(count.begin(), count.end(),0); }
这个就是一个非常函数式风格的结果了。上面这个处理方式恰恰就是 map-reduce。transform 对应 map,accumulate 对应 reduce。而检查有多少行文本,也成了代表文件头尾两个迭代器之间的“距离”(distance)。
函数式编程的特点
函数式编程期望函数的行为像数学上的函数,而非一个计算机上的子程序。这样的函数一般被称为纯函数(pure function),要点在于:
- 会影响函数结果的只是函数的参数,没有对环境的依赖
- 返回的结果就是函数执行的唯一后果,不产生对环境的其他影响
这样的代码的最大好处是易于理解和易于推理,在很多情况下也会使代码更简单。在我们上面的代码里,count_file 和 accumulate 基本上可以看做是纯函数(虽然前者实际上有着对文件系统的依赖),但 transform 不行,因为它改变了某个参数,而不是返回一个结果。下一讲我们会看到,这会影响代码的组合性。
我们的代码中也体现了其他一些函数式编程的特点:
- 函数就像普通的对象一样被传递、使用和返回。
- 代码为说明式而非命令式。在熟悉函数式编程的基本范式后,你会发现说明式代码的可读性通常比命令式要高,代码还短。
- 一般不鼓励(甚至完全不使用)可变量。上面代码里只有 count 的内容在执行过程中被修改了,而且这种修改实际是 transform 接口带来的。如果接口像[第 13 讲] 展示的 fmap 函数一样返回一个容器的话,就可以连这个问题都消除了。(C++ 毕竟不是一门函数式编程语言,对灵活性的追求压倒了其他考虑。)
高阶函数
既然函数(对象)可以被传递、使用和返回,自然就有函数会接受函数作为参数或者把函数作为返回值,这样的函数就被称为高阶函数。我们现在已经见过不少高阶函数了,如:
- sort
- transform
- accumulate
- fmap
- adder
事实上,C++ 里以 algorithm(算法) 名义提供的很多函数都是高阶函数。
许多高阶函数在函数式编程中已成为基本的惯用法,在不同语言中都会出现,虽然可能是以不同的名字。我们在此介绍非常常见的三个,map(映射)、reduce(归并)和 filter(过滤)。
- Map 在 C++ 中的直接映射是 transform(在
头文件中提供) 。它所做的事情也是数学上的映射,把一个范围里的对象转换成相同数量的另外一些对象。这个函数的基本实现非常简单,但这是一种强大的抽象,在很多场合都用得上。 - Reduce 在 C++ 中的直接映射是 accumulate(在
头文件中提供) 。它的功能是在指定的范围里,**使用给定的初值和函数对象,从左到右对数值进行归并*。在不提供函数对象作为第四个参数时,功能上相当于默认提供了加法函数对象,这时相当于做累加;提供了其他函数对象时,那当然就是使用该函数对象进行归并了。 - Filter 的功能是进行过滤,筛选出符合条件的成员。它在当前 C++(C++20 之前)里的映射可以认为有两个:copy_if 和 partition。这是因为在 C++20 带来 ranges 之前,在 C++ 里实现惰性求值不太方便。上面说的两个函数里,copy_if 是把满足条件的元素拷贝到另外一个迭代器里;partition 则是根据过滤条件来对范围里的元素进行分组,把满足条件的放在返回值迭代器的前面。另外,remove_if 也有点相近,通常用于删除满足条件的元素。它确保把不满足条件的元素放在返回值迭代器的前面(但不保证满足条件的元素在函数返回后一定存在),然后你一般需要使用容器的 erase 成员函数来将待删除的元素真正删除。
命令式编程和说明式编程
传统上 C++ 属于命令式编程。命令式编程里,代码会描述程序的具体执行步骤。好处是代码显得比较直截了当;缺点就是容易让人只见树木、不见森林,只能看到代码啰嗦地怎么做(how),而不是做什么(what),更不用说为什么(why)了。
说明式编程则相反。以数据库查询语言 SQL 为例,SQL 描述的是类似于下面的操作:你想从什么地方(from)选择(select)满足什么条件(where)的什么数据,并可选指定排序(order by)或分组(group by)条件。你不需要告诉数据库引擎具体该如何去执行这个操作。事实上,在选择查询策略上,大部分数据库用户都不及数据库引擎“聪明”;正如大部分开发者在写出优化汇编代码上也不及编译器聪明一样。
这并不是说说明式编程一定就优于命令式编程。事实上,对于很多算法,命令式才是最自然的实现。以快速排序为例,很多地方在讲到函数式编程时会给出下面这个 Haskell(一种纯函数式的编程语言)的例子来说明函数式编程的简洁性:
quicksort [] = [] quicksort (p:xs) = (quicksort left) ++ [p] ++ (quicksort right) where left = filter (< p) xs right = filter (>= p) xs
这段代码简洁性确实没话说,但问题是,上面的代码的性能其实非常糟糕。真正接近 C++ 性能的快速排序,在 Haskell 里写出来一点不优雅,反而更丑陋。
说明式编程跟命令式编程可以结合起来产生既优雅又高效的代码。对于从命令式编程成长起来的大部分程序员,我的建议是:
- 写表意的代码,不要过于专注性能而让代码难以维护——记住高德纳的名言:“过早优化是万恶之源。”
- 使用有意义的变量,但尽量不要去修改变量内容——变量的修改非常容易导致程序员的思维错误。
- 类似地,尽量使用没有副作用的函数,并让你写的代码也尽量没有副作用,用返回值来代表状态的变化——没有副作用的代码更容易推理,更不容易出错。
- 代码的隐式依赖越少越好,尤其是不要使用全局变量——隐式依赖会让代码里的错误难以排查,也会让代码更难以测试。
- 使用知名的高级编程结构,如基于范围的 for 循环、映射、归并、过滤——这可以让你的代码更简洁,更易于推理,并减少类似下标越界这种低级错误的可能性。
不可变性和并发
在多核的时代里,函数式编程比以前更受青睐,一个重要的原因是函数式编程对并行并发天然友好。影响多核性能的一个重要因素是数据的竞争条件——由于共享内存数据需要加锁带来的延迟。函数式编程强调不可变性(immutability)、无副作用,天然就适合并发。更妙的是,如果你使用高层抽象的话,有时可以轻轻松松“免费”得到性能提升。
#include <chrono> #include <execution> #include <iostream> #include <numeric> #include <vector> using namespace std; int main() { vector<double> v(10000000, 0.0625); { auto t1 = chrono::high_resolution_clock::now(); double result = accumulate(v.begin(), v.end(), 0.0); auto t2 = chrono::high_resolution_clock::now(); chrono::duration<double, milli>ms = t2 - t1; cout << "accumulate: result " << result << " took " << ms.count() << " ms\n"; } { auto t1 = chrono::high_resolution_clock::now(); double result =reduce(execution::par,v.begin(), v.end()); //和上面唯一不同一行代码! auto t2 = chrono::high_resolution_clock::now(); chrono::duration<double, milli>ms = t2 - t1; cout << "reduce: result " << result << " took " << ms.count() << " ms\n"; } }
Y 组合子
在 C++ 中的实用性非常弱。我们只看它解决的问题:如何在 lambda 表达式中表现递归。
回想一下我们用过的阶乘的递归定义:
int factorial(int n) { if (n == 0) { return 1; } else { return n * factorial(n - 1); } }
注意里面用到了递归,所以你要把它写成 lambda 表达式是有点困难的:
auto factorial = [](int n) { if (n == 0) { return 1; } else { return n * ???(n - 1); //注意这个地方函数名在哪里呢。我们想应该得在()中传进来吧!对没错,下面有个Y组合子来做这件事 } }
下面是完整的代码实现:
#include <functional> #include <iostream> #include <type_traits> #include <utility> using namespace std; // Y combinator as presented by Yegor Derevenets in P0200R0 // <url:http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2016/p0200r0.html> template <class Fun> class y_combinator_result { Fun fun_; public: template <class T> explicit y_combinator_result(T&& fun) : fun_(std::forward<T>(fun)) { } template <class... Args> decltype(auto) operator()(Args&&... args) { // y(f) = f(y(f)) return fun_(std::ref(*this),std::forward<Args>(args)...); } }; template <class Fun> decltype(auto) y_combinator(Fun&& fun) { return y_combinator_result<std::decay_t<Fun>>( //传入函数对象类型和函数对象本身 std::forward<Fun>(fun)); } int main() { // 上面的那个 F auto almost_fact =[](auto f, int n) -> int { if (n == 0) return 1; else return n * f(n - 1); }; // fact = y(F) auto fact = y_combinator(almost_fact); cout << fact(10) << endl; }
注意大家不要被这个东西吓住了。它是一个不会变的死东西。我们同样可以写一个求斐波那契的lambda来使用它
auto abc = [](auto f, int a)->int { if (a == 1 || a == 2) return 1; else return f(a - 1)+f(a - 2); }; auto fact1 = y_combinator(abc); cout<<fact1(4)<<endl;
同样是可以工作的!
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· DeepSeek 开源周回顾「GitHub 热点速览」
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· .NET 10首个预览版发布:重大改进与新特性概览!
· AI与.NET技术实操系列(二):开始使用ML.NET
· 单线程的Redis速度为什么快?