函数式编程意味着使用函数来创建干净且可维护的软件的最佳效果。 本文通过 JavaScript 和 Java 中的实际示例说明函数范式背后的概念。
函数式编程从一开始就一直是软件开发的弄潮儿,但在现代赋予了新的涵义。 本文着眼于函数式编程背后的概念,并通过 JavaScript 和 Java 中的示例提供实际理解。
函数式编程定义
函数是代码组织的基础; 它们存在于所有高阶编程语言中。 一般来说,函数式编程意味着使用函数来创建干净且可维护的软件的最佳效果。 更具体地说,函数式编程是一组编码方法,通常被描述为一种编程范式。
函数式编程有时被定义为与面向对象编程 (OOP) 和过程式编程相对立。 这是一种误导,因为这些方法并不是相互排斥的,而且大多数系统倾向于同时使用这三种方法。
函数式编程在某些情况下提供了明显的好处,它在许多语言和框架中被大量使用,并且在当前的软件趋势中很突出。 它是一个有用且强大的工具,应该成为每个开发人员的概念和语法工具包的一部分。
纯函数
函数式编程的理想是所谓的纯函数。 纯函数是一种结果仅取决于输入参数的函数,并且其操作不会引发副作用,即除了返回值之外不产生任何外部影响。
纯函数的美妙之处在于其架构的简单性。 因为一个纯函数被简化为只有参数和返回值(即它的 API),所以它可以被视为一个复杂性的终点:它与它运行的外部系统的唯一交互是通过定义的 API。
这与 OOP 形成对比,在 OOP 中,对象方法被设计为与对象的状态(对象成员)交互,与过程式代码形成对比,后者通常从函数内部操纵外部状态。
然而,在实际实践中,函数最终往往需要与更广泛的上下文进行交互,正如 React 的 useEffect 钩子所证明的那样。
不变性
函数式编程哲学的另一个原则是不在函数外修改数据。实际上,这意味着避免修改函数的输入参数。相反,函数的返回值应该反映完成的工作。这是一种避免副作用的方法。当函数在更大的系统中运行时,它可以更容易地推断函数的影响。
First Class Functions
除了纯函数特点之外,在实际编码实践中,函数式编程取决于First Class Functions. First Class Functions是被视为“事物本身”的函数,能够独立存在并被独立处理。函数式编程试图利用语言支持将函数用作变量、参数和返回值来创建优雅的代码。
因为First Class Functions是如此灵活和有用,即使是像 Java 和 C# 这样的强 OOP 语言也已经转向合并 First Class Functions 支持。这就是 Java 8 支持 Lambda 表达式背后的推动力。
另一种描述First Class Functions的方法是将函数作为数据。也就是说,可以像任何其他数据一样将First Class Functions分配给变量。当您编写 let myFunc = function(){} 时,您正在使用函数作为数据。
高阶函数
接受函数作为参数或返回函数的函数称为高阶函数——对函数进行运算的函数。
近年来,JavaScipt 和 Java 都添加了改进的函数语法。 Java 添加了箭头运算符和双冒号运算符。 JavaScript 添加了箭头运算符。 这些运算符旨在使定义和使用函数更容易,尤其是作为匿名函数内联。 匿名函数是在没有给定引用变量的情况下定义和使用的函数。
函数式编程示例:集合
也许函数式编程最突出的例子是处理集合。 这是因为能够针对集合中的元素应用多种不同的功能函数,这是纯函数思想的自然契合。
考虑清单 1,它利用 JavaScript map() 函数将数组中的字母大写。
Listing 1. Using map() and an anonymous function in JavaScript
let letters = ["a", "b", "c"];
console.info( letters.map((x) => x.toUpperCase()) ); // outputs ["A", "B", "C"]
这种语法的美妙之处在于代码非常集中。 不需要命令式管道,例如循环和数组操作。 这段代码清楚地表达了正在做的事情的思考过程。
使用 Java 的箭头运算符也可以实现相同的效果,如清单 2 所示。
Listing 2. Using map() and an anonymous function in Java
import java.util.*;
import java.util.stream.Collectors;
import static java.util.stream.Collectors.toList;
//...
List lower = Arrays.asList("a","b","c");
System.out.println(lower.stream().map(s -> s.toUpperCase()).collect(toList())); // outputs ["A", "B", "C"]
清单 2 使用 Java 8 的stream库来执行转换为大写字母列表的相同任务。 请注意,核心箭头运算符语法实际上与 JavaScript 相同,它们做同样的事情,即创建一个接受参数、执行逻辑并返回值的函数。 (需要注意的是,如果这样定义的函数体周围缺少大括号,则自动给出返回值。)
继续使用 Java,考虑清单 3 中的双冒号运算符。该运算符允许您引用类上的方法:在本例中,是 String 类上的 toUpperCase 方法。 清单 3 的作用与清单 2 相同。不同的语法适用于不同的场景。
Listing 3. Java Double Colon Operator
// ...
List upper = lower.stream().map(String::toUpperCase).collect(toList());
在上面的所有三个示例中,您可以看到高阶函数在起作用。 两种语言中的 map() 函数都接受一个函数作为参数。
换句话说,您可以将函数传递给其他函数(在 Array API 中或以其他方式)作为函数接口。 提供者函数(使用参数函数)是通用逻辑的插件。
这看起来很像 OOP 中的策略模式(实际上,在 Java 中,在幕后生成了具有单个方法的接口),但是函数的紧凑性使得组件协议非常紧凑。
作为另一个示例,请考虑清单 4,它在 Node.js 的 Express 框架中定义了一个路由处理程序。
Listing 4. Functional route handler in Express
var express = require(‘express‘);
var app = express();
app.get(‘/‘, function (req, res) {
res.send(‘One Love!‘);
});
清单 4 是函数式编程的一个很好的例子,因为它允许对映射路由和处理请求和响应所需的确切定义进行清晰的定义——尽管可能有人认为在函数体内操作响应对象是一种副作用 .
Curried 函数
现在考虑返回值是函数的函数式编程概念。 与作为参数的函数相比,这种情况不太常见。 清单 5 有一个来自常见 React 模式的示例,其中链接了粗箭头语法。
Listing 5. A curried function in React
handleChange = field => e => {
e.preventDefault();
// Handle event
}
上述代码的目的是创建一个事件处理程序,它将接受一个字段为参数,然后是事件。 这很有用,因为您可以将相同的 handleChange 应用于多个字段。 简而言之,同一个处理程序可用于多个字段。
清单 5 是一个柯里化函数的示例。 “Curried函数”这个名字有点令人沮丧。 它是为了纪念一个人,这很好,但它没有描述这个概念,这会引发困惑。 无论如何,我们的想法是,当您有返回函数的函数时,您可以将调用链接在一起,这比创建具有多个参数的单个函数更灵活。
在调用这些类型的函数时,您会遇到独特的“链式括号”语法:handleChange(field)(event)。
大范围编程
前面的示例提供了在重点上下文中对函数式编程的动手理解,但函数式编程旨在为大型编程带来更大的好处。 换句话说,函数式编程旨在创建更紧凑、更具弹性的大型系统。
很难提供这方面的例子,但一个真实的例子是 React 推广功能组件的举措。 React 团队已经注意到,组件的更简洁的功能风格提供了界面架构变得更大而组合的好处。
另一个大量使用函数式编程的系统是 ReactiveX。 建立在 ReactiveX 使用的那种事件流上的大型系统可以从解耦的软件组件交互中受益。 Angular 全面采用 ReactiveX (RxJS) 作为对这种能力的认可。
变量范围和上下文
最后,作为范式不一定是函数式编程的一部分,但在进行函数式编程时需要注意的一个问题是变量范围和上下文。
在 JavaScript 中,上下文特指 this 关键字解析的内容。 在 JavaScript 箭头运算符的情况下, this 指的是封闭上下文。 使用传统语法定义的函数接收其自己的上下文。 DOM 对象上的事件处理程序可以利用这一事实来确保 this 关键字引用正在处理的元素。
范围是指变量的范围,即哪些变量是可见的。 对于所有 JavaScript 函数(胖箭头函数和传统函数)以及 Java 箭头定义的匿名函数,作用域是封闭函数体的作用域——尽管在 Java 中,只有那些实际上是 final 的变量可以是 访问。 这就是为什么这些函数被称为闭包。 该术语意味着函数被包含在其包含范围内。
记住这一点很重要:这样的匿名函数可以完全访问作用域中的变量。 内部函数可以对外部函数的变量进行操作。 这可以被认为是非纯函数的副作用。