原文: Functional Programming Principles in Javascript
做者:TK
译者:博轩
通过很长一段时间的学习和面向对象编程的工做,我退后一步,开始思考系统的复杂性。javascript
“复杂性是任何使软件难以理解或修改的东西。” - John Outerhout
作了一些研究,我发现了函数式编程概念,如不变性和纯函数。 这些概念使你可以构建无反作用的功能,而函数式编程的一些优势,也使得系统变得更加容易维护。java
在这篇文章中,我将经过 JavaScript
中的大量代码示例向您详细介绍函数式编程和一些重要概念。算法
函数式编程是一种编程范式,一种构建计算机程序结构和元素的方式,将计算视为数学函数的评估并避免改变状态和可变数据 -- 维基百科
当咱们想要理解函数式编程时,咱们学到的第一个基本概念是纯函数。 那么咱们怎么知道函数是否纯粹呢? 这是一个很是严格的纯度定义:数组
咱们想要实现一个计算圆的面积的函数。 不纯的函数将接收半径:radius
做为参数,而后计算 radius * radius * PI
:性能优化
const PI = 3.14; const calculateArea = (radius) => radius * radius * PI; calculateArea(10); // returns 314
为何这是一个不纯的功能? 仅仅由于它使用的是未做为参数传递给函数的全局对象。dom
想象一下,数学家认为 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。
在 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
的功能了。
若是表达式能够替换为其相应的值而不更改程序的行为,则该表达式称为引用透明。这要求表达式是纯粹的,也就是说相同输入的表达式值必须相同,而且其评估必须没有反作用。-- 维基百科
让咱们实现一个计算平方的方法:
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
)。
想象一下咱们拥有这样一个函数:
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
函数进行组合并建立一个新行为。
当咱们谈论高阶函数时,一般是指一个函数同时具备:
咱们上面实现的 doubleOperator
函数是一个高阶函数,由于它将一个运算符函数做为参数并使用它。
您可能已经据说过 filter
,map
和 reduce
。 咱们来看看这些。
给定一个集合,咱们但愿按照属性进行过滤。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上解决的一个有趣问题是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
方法会将集合传入函数,并根据返回的值构建新集合。
让咱们使用刚才的 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)。
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
函数的概念是,接收一个函数和一个集合,而后组合他们来建立返回值。
一个常见的例子是得到订单的总金额。想象一下,你正在一个购物网站购物。你增长了 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!