译者: 博轩
经过很长一段时间的学习和面向对象编程的工作, 我退后一步, 开始思考系统的复杂性.
"复杂性是任何使软件难以理解或修改的东西." - John Outerhout
做了一些研究, 我发现了函数式编程概念, 如不变性和纯函数. 这些概念使你能够构建无副作用的功能, 而函数式编程的一些优点, 也使得系统变得更加容易维护.
在这篇文章中, 我将通过 JavaScript 中的大量代码示例向您详细介绍函数式编程和一些重要概念.
什么是函数式编程?
维基百科: Functional programming
函数式编程是一种编程范式 - 一种构建计算机程序结构和元素的方式 - 将计算视为数学函数的评估并避免改变状态和可变数据 - Wikipedia
纯函数
当我们想要理解函数式编程时, 我们学到的第一个基本概念是纯函数. 那么我们怎么知道函数是否纯粹呢? 这是一个非常严格的纯度定义:
如果给出相同的参数, 它返回相同的结果(它也称为确定性)
它不会引起任何可观察到的副作用
如果给出相同的参数, 它返回相同的结果
我们想要实现一个计算圆的面积的函数. 不纯的函数将接收半径: radius 作为参数, 然后计算 radius * radius * PI :
- const PI = 3.14;
- const calculateArea = (radius) => radius * radius * PI;
- calculateArea(10); // returns 314
为什么这是一个不纯的功能? 仅仅因为它使用的是未作为参数传递给函数的全局对象.
想象一下, 数学家认为 PI 值实际上是 42, 并且改变了全局对象的值.
不纯的函数现在将导致 10 * 10 * 42 = 4200 . 对于相同的参数(radius= 10), 我们得到不同的结果.
我们来解决它吧!
- const PI = 3.14;
- const calculateArea = (radius, pi) => radius * radius * pi;
- calculateArea(10, PI); // returns 314
现在我们将 PI 的值作为参数传递给函数. 所以现在我们只是访问传递给函数的参数. 没有外部对象(参数).
对于参数 radius = 10 和 PI = 3.14, 我们将始终具有相同的结果:
314
对于参数 radius = 10 和 PI = 42, 我们将始终具有相同的结果:
4200
读取文件 (Node.JS)
如果我们的函数读取外部文件, 它也不是纯函数 - 文件的内容可以更改:
- const fs = require('fs');
- const charactersCounter = (text) => `Character count: ${text.length}`;
- function analyzeFile(filepath) {
- let fileContent = fs.readFileSync(filepath);
- return charactersCounter(fileContent);
- }
生成随机数
任何依赖于随机数生成器的函数都不可能是纯函数:
- function yearEndEvaluation() {
- if (Math.random()> 0.5) {
- return "You get a raise!";
- } else {
- return "Better luck next year!";
- }
- }
它不会引起任何可观察到的副作用
什么是可观察副作用呢? 其中一种示例, 就是在函数内修改全局的对象, 或者参数.
现在我们要实现一个函数, 来接收一个整数值并返回增加 1 的值.
- let counter = 1;
- function increaseCounter(value) {
- counter = value + 1;
- }
- increaseCounter(counter);
- console.log(counter); // 2
我们首先定义了变量 counter . 然后使用不纯的函数接收该值并重新为 counter 赋值, 使其值增加 1 .
注意: 在函数式编程中不鼓励可变性.
上面的例子中, 我们修改了全局对象. 但是我们如何才能让函数变得纯净呢? 只需返回增加 1 的值.
- let counter = 1;
- const increaseCounter = (value) => value + 1;
- increaseCounter(counter); // 2
- console.log(counter); // 1
可以看到我们的纯函数 increaseCounter 返回 2 , 但是 counter 还保持之前的值. 该函数会使返回的数字递增, 而且不更改变量的值.
如果我们遵循这两个简单的规则, 就会使我们的程序更加容易理解. 每个功能都是孤立的, 无法影响到我们的系统.
纯函数是稳定, 一致并且可预测的. 给定相同的参数, 纯函数将始终返回相同的结果. 我们不需要考虑, 相同的参数会产生不同的结果, 因为它永远不会发生.
纯函数的好处
容易测试
纯函数的代码更加容易测试. 我们不需要模拟任何执行的上下文. 我们可以使用不同的上下文对纯函数进行单元测试:
给定参数 A -> 期望函数返回 B
给定参数 C -> 期望函数返回 D
一个简单的例子, 函数接收一个数字集合, 并期望数字集合每个元素递增.
- let list = [1, 2, 3, 4, 5];
- const incrementNumbers = (list) => list.map(number => number + 1);
我们接收到数字数组, 使用 map 递增每个数字, 并返回一个新的递增数字列表.
incrementNumbers(list); // [2, 3, 4, 5, 6]
对于输入 [1, 2, 3, 4, 5], 预期输出将是 [2, 3, 4, 5, 6].
不变性
随着时间的推移不变, 或无法改变
当数据具有不可变性时, 它的状态在创建之后, 就不能改变了. 你不能去更改一个不可变的对象, 但是你可以使用新值去创建一个新的对象.
在 JavaScript 中, 我们常使用 for 循环. 下面这个 for 循环有一些可变的变量.
- var values = [1, 2, 3, 4, 5];
- var sumOfValues = 0;
- for (var i = 0; i <values.length; i++) {
- sumOfValues += values[i];
- }
- sumOfValues // 15
对于每次迭代, 我们都在改变变量 i 和 sumOfValues 的状态. 但是我们要如何处理迭代中的可变性? 使用递归
- let list = [1, 2, 3, 4, 5];
- let accumulator = 0;
- function sum(list, accumulator) {
- if (list.length == 0) {
- return accumulator;
- }
- // 移除数组第一项, 并做累加
- return sum(list.slice(1), accumulator + list[0]);
- }
- sum(list, accumulator); // 15
- list; // [1, 2, 3, 4, 5]
- accumulator; // 0
所以这里我们有 sum 函数接收数值向量. 该函数调用自身, 直到我们将列表清空. 对于每个 "迭代", 我们会将该值添加到总累加器.
使用递归, 我们可以保持变量的不可变性. 列表和累加器变量不会更改, 会保持相同的值.
注意: 我们可以使用 reduce 来实现这个功能. 我们将在高阶函数主题中介绍这个话题.
构建对象的最终状态也很常见. 想象一下, 我们有一个字符串, 我们想将这个字符串转换为 url slug https://prettylinks.com/2018/03/url-slugs/ .
在 Ruby 中的面向对象编程中, 我们将创建一个类, 比方说, UrlSlugify. 这个类将有一个 slugify 方法将字符串输入转换为 url slug .
- class UrlSlugify
- attr_reader :text
- def initialize(text)
- @text = text
- end
- def slugify!
- text.downcase!
- text.strip!
- text.gsub!('','-')
- end
- end
- UrlSlugify.new('I will be a url slug').slugify! # "i-will-be-a-url-slug"
他已经实现了!(It's implemented!)
这里我们使用命令式编程, 准确的说明我们想要在 函数实现的过程中 (slugify) 每一步要做什么: 首先是转换成小写, 然后移除无用的空格, 最后用连字符替换剩余的空格.
但是, 在这个过程中, 函数改变了输入的参数.
我们可以通过执行函数组合或函数链来处理这种变异. 换句话说, 函数的结果将用作下一个函数的输入, 而不修改原始输入字符串.
- let string = "I will be a url slug";
- function slugify(string) {
- return string.toLowerCase()
- .trim()
- .split(" ")
- .join("-");
- }
- slugify(string); // i-will-be-a-url-slug
这里我们:
toLowerCase: 将字符串转换为全部小写
trim: 从字符串的两端删除空格
split 和 join : 用给定字符串中的替换替换所有匹配实例
我们将所有这四个功能结合起来, 就可以实现 slugify 的功能了.
参考透明度
维基百科: Referential transparency
如果表达式可以替换为其相应的值而不更改程序的行为, 则该表达式称为引用透明. 这要求表达式是纯粹的, 也就是说相同输入的表达式值必须相同, 并且其评估必须没有副作用.-- 维基百科
让我们实现一个计算平方的方法:
const square = (n) => n * n;
在给定相同输入的情况下, 此纯函数将始终具有相同的输出.
- square(2); // 4
- square(2); // 4
- square(2); // 4
- // ...
把 2 传递给 square 方法将始终返回 4. 所以, 现在我们可以使用 4 来替换 square(2). 我们的函数是引用透明的.
基本上, 如果函数对同一输入始终产生相同的结果, 则引用透明.
- pure functions + immutable data =
- referential transparency
纯函数 + 不可变数据 = 参照透明度
有了这个概念, 我们可以做一件很 cool 的事情, 就是使这个函数拥有记忆(memoize https://en.wikipedia.org/wiki/Memoization ).
想象一下我们拥有这样一个函数:
const sum = (a, b) => a + b;
我们用这些参数调用它:
sum(3, sum(5, 8));
sum(5, 8) 等于 13. 这个函数总是返回 13. 因此, 我们可以这样做:
sum(3, 13);
这个表达式总是会返回 16 . 我们可以用一个数值常量替换整个表达式, 并记住它.
这里推荐一篇淘宝 FED 关于 memoize 的文章: 性能优化: memoization
函数是一等公民
函数作为一等公民, 意味着函数也可以视为值处理, 并当做数据来使用.
函数作为一等公民有如下特性:
可以当做常量, 或者变量来引用
将函数当做参数传递给其他函数
将函数作为其他函数的返回值
我们的想法是函数视为值并将它们作为参数传递. 这样我们就可以组合不同的函数来创建具有新行为的新函数.
想象一下, 我们有一个函数可以将两个值相加, 然后将该值加倍:
const doubleSum = (a, b) => (a + b) * 2;
现在是一个, 将两值相减, 并返回该值加倍的函数:
const doubleSubtraction = (a, b) => (a - b) * 2;
这些函数具有相似的逻辑, 但是计算时的运算符不同. 如果我们可以将函数视为值并将它们作为参数传递, 我们可以构建一个函数来接收运算符函数并在函数中使用它.
- const sum = (a, b) => a + b;
- const subtraction = (a, b) => a - b;
- const doubleOperator = (f, a, b) => f(a, b) * 2;
- doubleOperator(sum, 3, 1); // 8
- doubleOperator(subtraction, 3, 1); // 4
现在我们有一个函数参数: f, 并用它来处理 a 和 b . 我们传递了 sum 和 subtraction 函数以使用 doubleOperator 函数进行组合并创建一个新行为.
高阶函数
维基百科: Higher-order function
当我们谈论高阶函数时, 通常是指一个函数同时具有:
将一个或多个函数作为参数, 或
返回一个函数作为结果
我们上面实现的 doubleOperator 函数是一个高阶函数, 因为它将一个运算符函数作为参数并使用它.
您可能已经听说过 filter,map 和 reduce . 我们来看看这些.
Filter
给定一个集合, 我们希望按照属性进行过滤. filter 函数需要 true 或者 false 值来确定元素是否应该包含在结果集合中. 基本上, 如果回调表达式返回的是 true ,filter 函数返回的结果会包含该元素. 否则, 就不会包含该元素.
一个简单的例子是当我们有一个整数集合时, 我们只想要过滤偶数.
命令式方法
使用 JavaScript 来实现时, 需要如下操作:
创建一个空数组 evenNumbers
迭代数字数组
将偶数推到 evenNumbers 数组
- var numbers = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
- var evenNumbers = [];
- for (var i = 0; i <numbers.length; i++) {
- if (numbers[i] % 2 == 0) {
- evenNumbers.push(numbers[i]);
- }
- }
- console.log(evenNumbers); // (6) [0, 2, 4, 6, 8, 10]
我们还可以使用 filter 高阶函数来接收 even 函数, 并返回偶数列表:
- const even = n => n % 2 == 0;
- const listOfNumbers = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
- listOfNumbers.filter(even); // [0, 2, 4, 6, 8, 10]
我在 Hacker Rank FP https://www.hackerrank.com/ 上解决的一个有趣问题是 Filter Array 问题. 问题的想法是过滤给定的整数数组, 并仅输出那些小于指定值 X 的值.
针对此问题, 命令式 JavaScript 解决方案如下:
- var filterArray = function(x, coll) {
- var resultArray = [];
- for (var i = 0; i <coll.length; i++) {
- if (coll[i] < x) {
- resultArray.push(coll[i]);
- }
- }
- return resultArray;
- }
- console.log(filterArray(3, [10, 9, 8, 2, 7, 5, 1, 3, 0])); // (3) [2, 1, 0]
我们的函数会做如下的事情 - 迭代集合, 将集合当前项与 x 进行比较, 如果它符合条件, 则将此元素推送到 resultArray.
声明性处理
但我们想要一种更具声明性的方法来解决这个问题, 并使用过滤器高阶函数.
声明性 JavaScript 解决方案将是这样的:
- function smaller(number) {
- return number < this;
- }
- function filterArray(x, listOfNumbers) {
- return listOfNumbers.filter(smaller, x);
- }
- let numbers = [10, 9, 8, 2, 7, 5, 1, 3, 0];
- filterArray(3, numbers); // [2, 1, 0]
在 smaller 函数中使用 this 首先看起来有点奇怪, 但很容易理解.
this 将作为第二个参数传给 filter 方法. 在这个示例中, 3(x)代表 this.
这样的操作也可以用于集合. 想象一下, 我们有一个人物集合, 包含了 name, age 属性.
- let people = [
- { name: "TK", age: 26 },
- { name: "Kaio", age: 10 },
- { name: "Kazumi", age: 30 }
- ];
我们希望仅过滤指定年龄值的人, 在此示例中, 年龄超过 18 岁的人.
- const olderThan18 = person => person.age> 18;
- const overAge = people => people.filter(olderThan18);
- overAge(people); // [{
- name: 'TK', age: 26
- }, {
- name: 'Kazumi', age: 30
- }]
代码摘要:
我们有一份人员名单(姓名和年龄).
我们有一个函数 oldThan18. 在这种情况下, 对于 people 数组中的每个人, 我们想要访问年龄并查看它是否超过 18 岁.
我们根据此功能过滤所有人.
Map
map 的概念是转换一个集合.
map 方法会将集合传入函数, 并根据返回的值构建新集合.
让我们使用刚才的 people 集合. 我们现在不想过滤年龄了. 我们只想得到一个列表, 元素就像: TK is 26 years old. 所以最后的字符串可能是 :name is:age years old 其中 :name 和 :age 是 people 集合中每个元素的属性.
下面是使用命令式 JavaScript 编码的示例:
- var people = [
- { name: "TK", age: 26 },
- { name: "Kaio", age: 10 },
- { name: "Kazumi", age: 30 }
- ];
- var peopleSentences = [];
- for (var i = 0; i <people.length; i++) {
- var sentence = people[i].name + "is" + people[i].age + "years old";
- peopleSentences.push(sentence);
- }
- console.log(peopleSentences); // ['TK is 26 years old', 'Kaio is 10 years old', 'Kazumi is 30 years old']
下面是使用声明式 JavaScript 编码的示例:
- const makeSentence = (person) => `${person.name} is ${person.age} years old`;
- const peopleSentences = (people) => people.map(makeSentence);
- peopleSentences(people);
- // ['TK is 26 years old', 'Kaio is 10 years old', 'Kazumi is 30 years old']
要做的事情是将给定数组转换为新数组.
另一个有趣的 Hacker Rank 问题是更新列表问题. 我们只想用它们的绝对值更新给定数组的值.
例如, 输入 [1,2,3-4,5] 需要输出为 [1,2,3,4,5] . -4 的绝对值是 4.
一种简单的解决方案是将每个集合的值进行就地更新 (in-place https://en.wikipedia.org/wiki/In-place_algorithm ).
- var values = [1, 2, 3, -4, 5];
- for (var i = 0; i <values.length; i++) {
- values[i] = Math.abs(values[i]);
- }
- console.log(values); // [1, 2, 3, 4, 5]
我们使用 Math.abs 函数将值转换为其绝对值, 并进行就地更新.
这不是一个函数式的解决方案.
首先, 我们了解了不变性. 我们知道不可变性对于使我们的函数更加一致和可预测非常重要. 我们的想法是建立一个具有所有绝对值的新集合.
第二, 为什么不在这里使用 map 来转换所有数据?
我的第一个想法是测试 Math.abs 函数只处理一个值.
- Math.abs(-1); // 1
- Math.abs(1); // 1
- Math.abs(-2); // 2
- Math.abs(2); // 2
我们希望将每个值转换为正值(绝对值).
现在我们知道如何对一个值进行取绝对值的操作, 我们可以将这个函数通过参数的方式传递给 map . 你还记得高阶函数可以接收函数作为参数并使用它吗? 是的, map 可以.
- let values = [1, 2, 3, -4, 5];
- const updateListMap = (values) => values.map(Math.abs);
- updateListMap(values); // [1, 2, 3, 4, 5]
Wow, 鹅妹子嘤!
Reduce
reduce 函数的概念是, 接收一个函数和一个集合, 然后组合他们来创建返回值.
一个常见的例子是获得订单的总金额. 想象一下, 你正在一个购物网站购物. 你增加了 Product 1,Product 2,Product 3,Product 4 到你的购物车. 现在我们要计算购物车的总金额.
使用命令式编程的方式, 我们将迭代订单列表并将每个产品金额与总金额相加.
- var orders = [
- { productTitle: "Product 1", amount: 10 },
- { productTitle: "Product 2", amount: 30 },
- { productTitle: "Product 3", amount: 20 },
- { productTitle: "Product 4", amount: 60 }
- ];
- var totalAmount = 0;
- for (var i = 0; i <orders.length; i++) {
- totalAmount += orders[i].amount;
- }
- console.log(totalAmount); // 120
使用 reduce , 我们可以创建一个用来处理累加的函数, 并将其作为参数传给 reduce 函数.
- let shoppingCart = [
- { productTitle: "Product 1", amount: 10 },
- { productTitle: "Product 2", amount: 30 },
- { productTitle: "Product 3", amount: 20 },
- { productTitle: "Product 4", amount: 60 }
- ];
- const sumAmount = (currentTotalAmount, order) => currentTotalAmount + order.amount;
- const getTotalAmount = (cart) => cart.reduce(sumAmount, 0);
- getTotalAmount(shoppingCart); // 120
这里我们有 shoppingCart,sumAmount 函数接收当前的 currentTotalAmount , 对所有订单进行累加.
getTotalAmount 函数会接收 sumAmount 函数 从 0 开始累加购物车的值.
获得总金额的另一种方法是组合使用 map 和 reduce. 那是什么意思? 我们可以使用 map 将 shoppingCart 转换为 amount 值的集合, 然后只使用 reduce 函数和 sumAmount 函数.
- const getAmount = (order) => order.amount;
- const sumAmount = (acc, amount) => acc + amount;
- function getTotalAmount(shoppingCart) {
- return shoppingCart
- .map(getAmount)
- .reduce(sumAmount, 0);
- }
- getTotalAmount(shoppingCart); // 120
getAmount 函数接收产品对象并仅返回金额值. 所以我们这里有 [10,30,20,60] . 然后, 通过 reduce 累加所有金额. Nice~
我们看了每个高阶函数的工作原理. 我想向您展示一个示例, 说明如何在一个简单的示例中组合所有三个函数.
还是购物车, 想象一下在我们的订单中有一个产品列表:
- let shoppingCart = [
- { productTitle: "Functional Programming", type: "books", amount: 10 },
- { productTitle: "Kindle", type: "eletronics", amount: 30 },
- { productTitle: "Shoes", type: "fashion", amount: 20 },
- { productTitle: "Clean Code", type: "books", amount: 60 }
- ]
我们想要购物车中所有图书的总金额. 就那么简单, 需要怎样编写算法?
使用 filter 函数过滤书籍类型
使用 map 函数将购物车转换为数量的集合
使用 reduce 函数累加所有项目
- let shoppingCart = [
- { productTitle: "Functional Programming", type: "books", amount: 10 },
- { productTitle: "Kindle", type: "eletronics", amount: 30 },
- { productTitle: "Shoes", type: "fashion", amount: 20 },
- { productTitle: "Clean Code", type: "books", amount: 60 }
- ]
- const byBooks = (order) => order.type == "books";
- const getAmount = (order) => order.amount;
- const sumAmount = (acc, amount) => acc + amount;
- function getTotalAmount(shoppingCart) {
- return shoppingCart
- .filter(byBooks)
- .map(getAmount)
- .reduce(sumAmount, 0);
- }
- getTotalAmount(shoppingCart); // 70
- Done!
来源: https://segmentfault.com/a/1190000019052188