摘要: 理解函数式编程。
- 作者:前端小智
- 原文:JS中函数式编程基本原理简介
Fundebug经授权转载,版权归原作者所有。
在长时间学习和使用面向对象编程之后,咱们退一步来考虑系统复杂性。
在做了一些研究之后,我发现了函数式编程的概念,比如不变性和纯函数。这些概念使你能够构建无副作用的函数,因此更容易维护具有其他优点的系统。
在这篇文章中,将通大量代码示例来详细介绍函数式编程和一些相关重要概念。
什么是函数式编程
函数式编程是一种编程范式,是一种构建计算机程序结构和元素的风格,它把计算看作是对数学函数的评估,避免了状态的变化和数据的可变。
纯函数
当我们想要理解函数式编程时,需要知道的第一个基本概念是纯函数,但纯函数又是什么鬼?
咱们怎么知道一个函数是否是纯函数?这里有一个非常严格的定义:
- 如果给定相同的参数,则返回相同的结果(也称为确定性)。
- 它不会引起任何副作用。
如果给定相同的参数,则得到相同的结果
如果给出相同的参数,它返回相同的结果。 想象一下,我们想要实现一个计算圆的面积的函数。
不是纯函数会这样做,接收radius
作为参数,然后计算radius * radius * PI
:
let PI = 3.14;
const calculateArea = (radius) => radius * radius * PI;
calculateArea(10); // returns 314.0
为什么这是一个不纯函数?原因很简单,因为它使用了一个没有作为参数传递给函数的全局对象。
现在,想象一些数学家认为圆周率的值实际上是42
并且修改了全局对象的值。
不纯函数得到10 * 10 * 42 = 4200
。对于相同的参数(radius = 10
),我们得到了不同的结果。
修复它:
let PI = 3.14;
const calculateArea = (radius, pi) => radius * radius * pi;
calculateArea(10, PI); // returns 314.0
现在把 PI
的值作为参数传递给函数,这样就没有外部对象引入。
- 对于参数
radius = 10
和PI = 3.14
,始终都会得到相同的结果:314.0
。 - 对于
radius = 10
和PI = 42
,总是得到相同的结果:4200
读取文件
下面函数读取外部文件,它不是纯函数,文件的内容随时可能都不一样。
const charactersCounter = (text) => `Character count: ${text.length}`;
function analyzeFile(filename) {
let fileContent = open(filename);
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
,使其值增加1
。
函数式编程不鼓励可变性。我们修改全局对象,但是要怎么做才能让它变得纯函数呢?只需返回增加1
的值。
let counter = 1;
const increaseCounter = (value) => value + 1;
increaseCounter(counter); // 2
console.log(counter); // 1
纯函数increaseCounter
返回2
,但是counter
值仍然是相同的。函数返回递增的值,而不改变变量的值。
如果我们遵循这两条简单的规则,就会更容易理解我们的程序。现在每个函数都是孤立的,不能影响系统的其他部分。
纯函数是稳定的、一致的和可预测的。给定相同的参数,纯函数总是返回相同的结果。
咱们不需要考虑相同参数有不同结果的情况,因为它永远不会发生。
纯函数的好处
纯函数代码肯定更容易测试,不需要 mock 任何东西,因此,我们可以使用不同的上下文对纯函数进行单元测试:
- 给定一个参数
A
,期望函数返回值B
- 给定一个参数
C
,期望函数返回值D
一个简单的例子是接收一组数字,并对每个数进行加 1
这种沙雕的操作。
let list = [1, 2, 3, 4, 5];
const incrementNumbers = (list) => list.map(number => number + 1);
接收numbers
数组,使用map
递增每个数字,并返回一个新的递增数字列表。
incrementNumbers(list); // [2, 3, 4, 5, 6]
对于输入[1,2,3,4,5]
,预期输出是[2,3,4,5,6]
。
不可变性
尽管时间变或者不变,纯函数大佬都是不变的。
当数据是不可变的时,它的状态在创建后不能更改。
咱们不能更改不可变对象,如果非要来硬的,刚需要深拷贝一个副本,然后操作这个副本。
在JS中,我们通常使用for
循环,for
的每次遍历 i
是个可变变量。
var values = [1, 2, 3, 4, 5];
var sumOfValues = 0;
for (var i = 0; i < values.length; i++) {
sumOfValues += values[i];
}
sumOfValues // 15
对于每次遍历,都在更改i
和sumOfValue
状态,但是我们如何在遍历中处理可变性呢? 答案就是使用递归。
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
函数,它接收一个数值向量。函数调用自身,直到 list
为空退出递归。对于每次“遍历”,我们将把值添加到总accumulator
中。
使用递归,咱们保持变量不变。不会更改list
和accumulator
变量。它保持相同的值。
观察:我们可以使用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"
上面使用的有命令式编程方式,首先用小写字母表示我们想在每个slugify
进程中做什么,然后删除无用的空格,最后用连字符替换剩余的空格。
这种方式在整个过程中改变了输入状态,显然不符合纯函数的概念。
这边可以通过函数组合或函数链来来优化。换句话说,函数的结果将用作下一个函数的输入,而不修改原始输入字符串。
const string = " I will be a url slug ";
const slugify = string =>
string
.toLowerCase()
.trim()
.split(" ")
.join("-");
slugify(string); // i-will-be-a-url-slug
上述代码主要做了这几件事:
toLowerCase
:将字符串转换为所有小写字母。- trim:删除字符串两端的空白。
split
和join
:用给定字符串中的替换替换所有匹配实例
代码部署后可能存在的BUG没法实时知道,事后为了解决这些BUG,花了大量的时间进行log 调试,这边顺便给大家推荐一个好用的BUG监控工具 Fundebug。
引用透明性
接着实现一个square
函数:
const square = (n) => n * n;
给定相同的输入,这个纯函数总是有相同的输出。
square(2); // 4
square(2); // 4
square(2); // 4
// ...
将2
作为square函数的参数传递始终会返回4
。这样咱们可以把square(2)
换成4
,我们的函数就是引用透明的。
基本上,如果一个函数对于相同的输入始终产生相同的结果,那么它可以看作透明的。
有了这个概念,咱们可以做的一件很酷的事情就是记住这个函数。假设有这样的函数
const sum = (a, b) => a + b;
用这些参数来调用它
sum(3, sum(5, 8));
sum(5, 8)
总等于13
,所以可以做些骚操作:
sum(3, 13);
这个表达式总是得到16
,咱们可以用一个数值常数替换整个表达式,并把它记下来。
函数是 JS 中的一级公民
函数作为 JS 中的一级公民,很风骚,函数也可以被看作成值并用作数据使用。
- 从常量和变量中引用它。
- 将其作为参数传递给其他函数。
- 作为其他函数的结果返回它。
其思想是将函数视为值,并将函数作为数据传递。通过这种方式,我们可以组合不同的函数来创建具有新行为的新函数。
假如我们有一个函数,它对两个值求和,然后将值加倍,如下所示:
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
都是高阶函数,Look see see。
Filter
对于给定的集合,我们希望根据属性进行筛选。filter
函数期望一个true
或false
值来决定元素是否应该包含在结果集合中。
如果回调表达式为真,过滤器函数将在结果集合中包含元素,否则,它不会。
一个简单的例子是,当我们有一个整数集合,我们只想要偶数。
命令式
使用命令式方式来获取数组中所有的偶数,通常会这样做:
- 创建一个空数组
evenNumbers
- 遍历数组
numbers
- 将偶数 push 到
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
高阶函数来接收偶函数并返回一个偶数列表:
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][2] 上解决的一个有趣问题是[Filter Array问题][3]。 问题是过滤给定的整数数组,并仅输出小于指定值X
的那些值。
命令式做法通常是这样的:
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]
声明式方式
对于上面的总是,我们更想要一种更声明性的方法来解决这个问题,如下所示:
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
,一开始看起来有点奇怪,但是很容易理解。
filter
函数中的第二个参数表示上面 this
, 也就是 x
值。
我们也可以用map
方法做到这一点。想象一下,有一组信息
let people = [
{ name: "TK", age: 26 },
{ name: "Kaio", age: 10 },
{ name: "Kazumi", age: 30 }
]
我们希望过滤 age
大于 21 岁的人,用 filter
方式
const olderThan21 = person => person.age > 21;
const overAge = people => people.filter(olderThan21);
overAge(people); // [{ name: 'TK', age: 26 }, { name: 'Kazumi', age: 30 }]
map
map
函数的主要思路是转换集合。
map
方法通过将函数应用于其所有元素并根据返回的值构建新集合来转换集合。
假如我们不想过滤年龄大于 21 的人,我们想做的是显示类似这样的:TK is 26 years old.
使用命令式,我们通常会这样做:
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']
声明式会这样做:
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']
整个思想是将一个给定的数组转换成一个新的数组。
另一个有趣的HackerRank问题是[更新列表问题][3]。我们想要用一个数组的绝对值来更新它的值。
例如,输入[1,2,3,- 4,5]
需要输出为[1,2,3,4,5]
,-4
的绝对值是4
。
一个简单的解决方案是每个集合中值的就地更新,很危险的作法
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]
Reduce
reduce
函数的思想是接收一个函数和一个集合,并返回通过组合这些项创建的值。
常见的的一个例子是获取订单的总金额。
假设你在一个购物网站,已经将产品1、产品2、产品3和产品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
,我们可以构建一个函数来处理量计算sum
并将其作为参数传递给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 = (shoppingCart) => shoppingCart.reduce(sumAmount, 0);
getTotalAmount(shoppingCart); // 120
这里有shoppingCart
,接收当前currentTotalAmount
的函数sumAmount
,以及对它们求和的order
对象。
咱们也可以使用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
接收product
对象并只返回amount
值,即[10,30,20,60]
,然后,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 }
]
假如相要想要购物车里类型为 books
的总数,通常会这样做:
- 过滤 type 为 books的
- 使用
map
将购物车转换为amount
集合。 - 用
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
代码部署后可能存在的BUG没法实时知道,事后为了解决这些BUG,花了大量的时间进行log 调试,这边顺便给大家推荐一个好用的BUG监控工具 Fundebug。