65.9K
CodeProject 正在变化。 阅读更多。
Home

C++ 函数式编程

starIconstarIconstarIconstarIconstarIcon

5.00/5 (30投票s)

2018年11月23日

CPOL

14分钟阅读

viewsIcon

65864

downloadIcon

481

本文将向您展示一种使用 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;

上面的代码将编译而不会出现任何错误,编译器将自行推断返回类型,分别是 intdouble

多态函数包装器 (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 的编程语言都提供 mapfilterfold 函数。C++ 中这类函数的一些例子是 for_eachtransformremove_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; });

记住!您不需要指定 xint,您也可以使用 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::bindstd::bind 也是一个模板函数,它返回一个 std::function 对象,该对象将参数绑定到函数。

std::bind(f,  args)

这是 std::bind 最简单的语法。返回类型未指定,但它是可调用的,因此该对象称为绑定器(binder)

此函数每个参数最多可以有四种类型:

  1. 如果 f 是成员函数,则第一个参数将是类实例的引用或指针。
    std::bind(&x::f, this)
  2. 占位符(Placeholders):表示传递给绑定器的参数。
     _1, _2, _3, …
  3. 引用包装器(Reference wrappers):通过引用或 const 引用绑定参数。
    std::ref, std::cref
  4. 其他参数使用值语义传递。

以下示例解释了 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”)

纯函数

纯函数在给定相同参数时始终产生相同的结果,这意味着返回值仅取决于输入。此外,它们没有任何副作用,并且不变性适用,状态不会发生任何改变。纯函数不与外部世界通信。例如,数学函数如 powabssqrt 等是纯函数的示例。考虑以下代码片段。

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;
}

但是,constconstexpr 之间不应该有混淆。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 所说:

“无论您使用哪种语言,以函数式风格编程都会带来好处。

在方便的时候应该这样做,在不方便的时候应该认真考虑这个决定。”

© . All rights reserved.