C++ 函数式编程





5.00/5 (30投票s)
本文将向您展示一种使用 C++ 的替代方式;如何在 C++ 中编写函数式代码。您将看到如何编写更简洁、更安全、更易读、更合理的代码。
引言
C++ 和函数式编程???? 听起来有些奇怪和吸引人,因为我们大多数人 知道 C++ 是面向对象编程语言。是的!OOP 最初是 C++ 开发的目的,因此它被称为 C with classes。但近年来,C++ 已经变得远不止于此。本文将向您展示更多;C++ 的替代用法;如何在 C++ 中编写函数式代码。这样,您就可以编写出比 C++ 常用做法更简洁、更安全、更易读、更合理,我敢打赌,也更美的代码。
请注意,本文不适合初学者,我假设您已经理解 C++ 和 OOP。我们将从简要介绍函数式编程开始,并在本文结束时,您将能够看到 C++ 如何支持函数式编码风格。
函数式编程
广义上讲,函数式编程只是一种更侧重于函数(有些人可能喜欢称之为函数式编程,即主函数由更低级别的函数定义,而这些函数又由其他函数定义)而不是对象和过程(如面向对象编程范例中所做的那样)的编程风格。
严格来说,函数式编程是一种编程范式,其中我们像编写数学函数一样编写计算机程序,即通过求值表达式(不改变状态和可变数据)。
这可能有点令人不知所措,但请暂时跟着我,我将解释这意味着什么。
声明式编程意味着描述需要解决的问题,而不是告诉编程语言如何解决它。函数式编程对声明式风格有所补充,因为它告诉计算机要解决什么,而且没有状态或变量的改变。这意味着函数式编程中的输出值仅取决于传递给函数的参数。
为什么要使用它?
FP 最小化了代码的活动部分(与封装活动部分的面向对象范例相反),因此代码更易于理解、更紧凑且更可预测。此外,FP 为我们提供的工具简单而富有表现力,更不用说更高的抽象级别了,因此我们不必担心细枝末节。但这些只是一些笼统和表面的优点。FP 是计算机世界中最古老的概念之一,那么为什么突然如此热门呢?嗯,FP 在并行处理方面暴露了其真正的潜力。程序可以在多个 CPU 上安全执行,您无需担心过度的锁定,因为它不使用或修改任何全局资源(纯函数,我们将在文章后面详细讨论)。而且,当我们特别讨论 C++ 中的 FP 时,以下是它提供的功能列表。
- 更强大、通用的算法
- 对事件的响应,即 GUI、IO/网络、任务、并行
- 组合和转换来自旧的调用约定.
- 操作执行流
这仅仅是个开始。函数式编程的不同概念提供了不同的好处。下图展示了 FP 的不同概念。
现在,让我们不要过多地进行理论阐述,而是花点时间在 C++ 中讨论和实现这些特性,并且不用担心,您最终会看到 FP 的美妙之处。
环境设置
您只需要一个文本编辑器和支持 C++11 和 C++14 的 C++ 编译器。代码已在 Windows 10、Microsoft Visual Studio 2017 上以及在 Ubuntu 上使用 gcc 编译器进行了测试。它应该可以在支持最新 C++ 功能的任何其他平台和编译器上正常工作。
一等函数
一等函数(First class functions)的行为类似于数据。这意味着您可以将函数作为参数传递给其他函数,或者可以从其他函数中返回它们。此外,函数还可以存储在数据结构中,或者作为参数传递给变量。一个例子是 C++11 中引入的lambda 函数。
Lambda 表达式
Lambda 是匿名(无名)的、就地定义的函数。如果说 lambda 是 C++ 在 FP 方面的伴侣,或者说 FP 在 C++ 中之所以成为可能,在很大程度上是因为 lambda,这并不过分。
让我们创建一个简单的 lambda 如下:
[] () {
std::cout << "Hello from C++ Lambda!" << std::endl;}
();
您会看到四对括号。让我们看看它们各自的含义:
[]
是 lambda 引入器或 lambda 闭包()
用于参数列表(如果 lambda 不接受任何参数,您可以省略这些括号){}
包含 lambda 的主体()
用于调用 lambda
这些表达式具有实现定义的类型(它是可调用的!),但它们与类型推导一起工作。(C++ 函数不是一等的,但我们有各种方法可以将函数表示为 C++ 中的一等实体。可调用(Callable)概念意味着我们可以通过给出一个合适的参数列表来调用类型 T
以产生返回值,例如,函数指针、成员函数指针以及所有带有调用运算符的类型。)
auto sum = [] (double A, double B) { return A + B; };
auto add = sum;
std::cout << add(3.25, 5.65) <<std::endl;
在这里,auto
用于类型推导。通过使用 auto
,编译器会通过查看值来尝试推断类型。另外,请注意这次我们为 lambda 提供了参数 (double A, double B)
。现在,如果所有 return 语句都返回相同的类型,那么它将自动确定。否则,我们需要使用 →
显式指定返回类型,如下所示:
[](double A, double B) → double { return A + B; };
但是如果我们想使用外部作用域中的变量呢?这可以通过lambda 引入器来实现。看下面的例子:
double pi = 3.1416;
auto func = [pi] () {
std::cout << “The value of pi is ”<< pi << std::endl;
};
将外部作用域变量引入 lambda 的这个过程称为捕获(capturing)。默认情况下,捕获是通过值进行的,但您也可以通过引用或两者的组合进行。例如:
[] |
不捕获任何内容 |
[&] |
所有捕获通过引用进行 |
[=] |
所有捕获通过值进行 |
[&A, =B] |
通过引用捕获 A ,通过值捕获 B |
[=, &A] |
通过引用捕获 A ,通过值捕获其他所有内容 |
[*this] |
捕获‘this ’(C++17 仍未广泛支持) |
C++14 允许您创建更通用的 lambda,您无需指定类型,编译器就可以自行推导。(请注意,我们不是在谈论模板 lambda,而是指一个不带严格类型但可以与不同类型调用它的 lambda。)
auto gene_lambda = [] (auto arg) { return arg + arg; };
std::cout << gene_lambda(5) << std::endl;
std::cout << gene_lambda(3.1416) << std::endl;
上面的代码将编译而不会出现任何错误,编译器将自行推断返回类型,分别是 int
和 double
。
多态函数包装器 (std::function)
函数在 C++ 中不仅仅是一个单一的构造,而是一个完整的概念。它可以指向一个简单的函数、一个函数对象(重载了 ()
运算符)或一个 lambda 表达式。这让我回到了可调用(callable)的概念(任何可以像函数一样调用的东西),有时会引起困惑,即当我们称某物为函数时,我们到底指的是什么?是简单的函数、函数对象还是 lambda?为了解决这个问题,C++ 在 C++11 标准中加入了 std::function
,它是一个 STL 模板类提供的方便的包装器。请看语法。
std::function<signature(args)> f
其中 signature
是函数的返回类型。它可以是
int(int, int)
或
double(double, double)
而 f
可以是任何可调用的对象。
std::function<>
具有值语义,因此任何匹配的签名都可以存储在其中。这意味着 std::function<>
为调用可调用对象提供了统一的语法,并且可以在所有情况下使用,即简单的函数、函数对象或 lambda。让我们尝试以这种方式实现我们的求和 lambda 表达式。
std::function<double(double, double)> sum
= [](double A, double B) { return A + B; };
std::cout << sum(4.6, 5.9) << std::endl;
以下是使用函数对象实现相同功能的另一种方法:
int sum (int x, int y) {
return x+ y;
}
class Add {
public:
int operator() (int x, int y) const {
return x+y;
}
};
int main() {
std::function<int(int, int)> func;
func = sum;
std::cout << func(5, 7) << std::endl;
return 0;
}
正如我们之前讨论过的,函数对象使用重载的 ()
运算符。我们的 Add
类定义了重载的运算符。我们还定义了 sum 函数,该函数连同重载的 ()
运算符;将用于添加两个整数。好吧,这种方式基本上是函数式和面向对象方式的结合,如果您不喜欢它也没关系。在此展示它的唯一目的是说明 lambda 的使用如何使我们的代码更紧凑、更美观,并且 std::function<>
也让我们的生活更轻松。
高阶函数
高阶函数将其他函数作为参数或返回它们。所有支持 FP 的编程语言都提供 map
、filter
和 fold
函数。C++ 中这类函数的一些例子是 for_each
、transform
、remove_if
。让我们实现其中一些,看看它们是如何工作的。
让我们先尝试看看 transform
是如何工作的(map
函数)。
std::vector<int> v {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
std::transform(v.begin(), v.end(), v.begin(),
[] (int n) {return n + (n*2);}
);
在这里,我们想将向量中的值加倍。我们从向量的开头开始操作到结尾,并且我们希望我们的值从开头开始进行转换。最后但同样重要的是;看看 lambda 在我们的例子中工作得多么漂亮。
让我们尝试使用 for_each
打印向量中的值。
std::vector<int> v{ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
std::for_each(v.begin(), v.end(),
[](const int&x) {std::cout << x << std::endl; });
记住!您不需要指定 x
是 int
,您也可以使用 auto
关键字,代码也能正常工作。我们还可以使用 remove_if
根据提供的条件过滤(filter 函数)结果。看下面的例子:
std::vector<int> v {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
std::remove_if (v.begin(), v.end (),
[] (int n) {return n%2 !=0; }
);
std::remove_if
将删除满足条件的元素。我们也可以按如下方式复制指定的元素:
std::vector<int> v {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
std::vector<int> v1;
std::copy_if (v.begin(), v.end (),
std::back_inserter(v1),
[] (int n) {return n%2 !=0; }
);
现在轮到 fold 了。C++ 的 accumulate
等同于 FP 的 fold。
std::vector<int> v = { 2, 9, -4, 2 };
auto sum = std::accumulate(begin(v), end(v), 0);
前向调用包装器 (std::bind)
高阶函数为我们提供了转换调用约定的能力。请看以下内容:
int func(int x, float y, std::string const& z);
我们可能想要转换函数参数列表,即,将某些参数绑定到特定值。为了方便开发者,C++ 在标准库中添加了 std::bind
。std::bind
也是一个模板函数,它返回一个 std::function
对象,该对象将参数绑定到函数。
std::bind(f, args)
这是 std::bind
最简单的语法。返回类型未指定,但它是可调用的,因此该对象称为绑定器(binder)。
此函数每个参数最多可以有四种类型:
- 如果
f
是成员函数,则第一个参数将是类实例的引用或指针。std::bind(&x::f, this)
- 占位符(Placeholders):表示传递给绑定器的参数。
_1, _2, _3, …
- 引用包装器(Reference wrappers):通过引用或 const 引用绑定参数。
std::ref, std::cref
- 其他参数使用值语义传递。
以下示例解释了 std::bind()
的用法:
int func(int x, float y, std::string const& z);
绑定参数的值语义
auto f1 = std::bind(&func, 5, _1, _2);
f1(5.4, “some string”);
上面的表达式等同于函数调用 func(5, 5.4, “string”)
。
绑定引用包装器
std::string input = “something”
auto f2 = std::bind(&func, _1, _2, std::cref(input));
f2(2, 2.3);
上面的调用评估为 func(2, 2.3, input)
。
使用 std::bind()
可以做的另一件有趣的事情是使用占位符重新排序参数,我们仍然可以成功调用函数。观察以下内容:
auto f3 = std::bind(&func, _3, _1, _2);
f3(“something”, 256, 3.1416);
它将评估为 f(256, 3.1416, “something”)
纯函数
纯函数在给定相同参数时始终产生相同的结果,这意味着返回值仅取决于输入。此外,它们没有任何副作用,并且不变性适用,状态不会发生任何改变。纯函数不与外部世界通信。例如,数学函数如 pow
、abs
、sqrt
等是纯函数的示例。考虑以下代码片段。
std::cout << "Absolute value of +0.025 is " << abs(+0.025) << std::endl;
std::cout << "Absolute value of -1.62 is " << abs(-1.62) << std::endl;
std::cout << "Square root of 25 is " << sqrt(25) << std::endl;
std::cout << "square of 10 is " << pow(10, 2) << std::endl;
这些只是标准库中的一些预定义数学函数。C++ 也允许您定义自己的函数。考虑以下:
__attribute__ _((pure)) int square(int l)
{
return l*l;
}
您可以看到输出仅取决于作为参数传递的值,而不取决于任何全局状态,因此该函数是纯函数
。
纯函数
之所以特别重要,是因为它们是无副作用的,这使得单元测试更容易(我们不需要任何外部设置来进行测试)。由于依赖性少或有时没有依赖性,代码更容易理解和调试。此外,纯函数
使得程序的并行执行成为可能,而无需任何额外代码(例如处理锁等)。
不可变性
不变性(Immutability)是 FP 中一个强大而简单的概念,其故事几乎与纯度相同。基本上,不变性是指对象创建后其状态不会改变。类也一样。不可变对象极大地简化了并发编程。它解决了同步问题,因为线程访问的对象的[:]状态不会改变,因此不再需要同步。
现在我们来讨论 C++ 如何使字段或变量不可变。如果满足以下条件,则可以实现:
const
关键字用于确保对象或字段不会被赋值或更改。- 引用的对象的类是不可变的(没有
public
字段,没有可以更改内部数据的函数)。
好吧,我不会深入研究 const
,因为我们一直在到处使用它。再看一遍文章前面解释过的表达式:
int func(int x, float y, std::string const& z);
在这里,我们通过使用 const
使字符串字面量不可变。然而,C++11 中引入并由 C++14 改进的另一个关键字是constexpr
。它的意思是常量表达式。
让我们看看如何实现这个概念。
constexpr int Fibonacci (int x)
{
return (x <= 1)? x : Fibonacci(x-1) + Fibonacci(x-2);
}
int main ()
{
const int series = Fibonacci(10);
std::cout << series << std::endl;
return 0;
}
但是,const
和 constexpr
之间不应该有混淆。const
实际上用于声明对象为不可变的。与 const
一样,constexpr
可以应用于变量,如果任何代码尝试修改该值,编译器将发出错误。const
只能与非 static
成员函数一起使用,但 constexpr
也可以应用于函数和类构造函数,只要参数和返回类型是字面量的。constexpr
表明值或返回值是常量,将在编译时计算,从而大大提高性能。constexpr
整型值可以在需要 const
整数的任何地方使用,例如在模板参数和数组声明中。
递归
正如我们之前讨论过的,FP 要求没有可变数据,因此它们通常使用递归而不是常规循环。就像我们在前面的示例中所做的那样(尽管是间接递归)
constexpr int Fibonacci (int x)
{
return (x <= 1)? x : Fibonacci(x-1) + fibonacci(x-2);
}
递归(直接递归;函数调用自身)的另一个最常见的例子是阶乘示例。
int factorial(int x)
{
if (x == 0) return 1;
return x*factorial(x-1);
}
好吧,当递归与列表和模式匹配一起使用时,您可以创建一个强大的函数,但这仅部分适用于 C++。像 C++ 这样的语言,迭代是可取的,因为递归是有时间和空间成本的。调用函数的第一个步骤是从堆栈分配函数参数和局部变量的内存,而这正是问题出现的地方。许多程序在启动时;会从堆栈分配一大块内存。由于递归(因为它需要更多空间),它们有时(经常,但不总是)会耗尽内存,因此程序会因堆栈溢出而崩溃。我鼓励您阅读这篇Wikipedia 帖子,以了解更多有关问题的信息。
然而,这个问题也有解决方案,称为尾递归(tail recursion)。它是递归的一种特殊情况,在递归调用后,调用函数不再进行任何计算。看这里。
factorial_TR(int x, int y)
{
if (x == 0)
{return y;};
return Factorial_TR (x-1, x*y);
}
int factorial(int x)
{
return factorial_TR (x, 1);
}
这里的想法是在提供的额外参数中累积阶乘值,当达到零时,它将返回累积的阶乘值。这使我们更接近尾递归(因为最后的指令是递归调用)并且更有效。而且,现在我们知道一旦递归调用完成就会返回,所以我们不需要调用堆栈来压入返回地址,这节省了空间,从而避免了堆栈溢出。
惰性求值
惰性(Laziness)是函数式编程语言中的常见特性之一。它指的是仅在必要时才对表达式进行求值的概念,而不是在声明时进行求值,从而提高了效率。它也是一种强大的代码结构工具,因为它允许您颠倒代码并颠倒控制流。C++ 中最常见和特殊的惰性情况之一是短路求值,如果第一个参数为 true
,则 ||
运算符的第二个参数不会被求值。让我们看看它在 C++ 中是如何工作的。
#include <iostream>
void func1() {
std::cout << "This is an implemented function" << std::endl;
}
void func2();
int main(){
func1();
return 0;
}
请注意,我们只声明了 func2
而未定义它,也没有调用它,这完全没问题。编译器将接受上述程序而不会出现任何错误,因为我没有调用 func2
,所以我不需要定义。
现在,如果您有兴趣实现通用的惰性求值,这里有一些内容供您参考。
结束语
C++ 不仅仅是面向对象编程语言,它还有更多强大的功能。本文旨在让您从 C++ 的角度看到函数式编程的美妙之处。正如 John Carmack 所说:
“无论您使用哪种语言,以函数式风格编程都会带来好处。
在方便的时候应该这样做,在不方便的时候应该认真考虑这个决定。”