JavaScript 中的函数式编程原则
在长期学习和从事面向对象编程之后,我退一步思考系统复杂性。
“复杂性是任何使软件难以理解或修改的因素。”——约翰·奥特豪特
经过一番研究,我发现了一些函数式编程概念,例如不变性和纯函数。这些概念对于构建无副作用的函数非常有利,因此系统更容易维护,还有其他一些好处。
在这篇文章中,我将通过大量的代码示例,向您详细介绍函数式编程及其一些重要概念。使用 JavaScript!
什么是函数式编程?
函数式编程是一种编程范式——一种构建计算机程序结构和元素的风格——它将计算视为数学函数的评估,并避免改变状态和可变数据——维基百科
纯函数
当我们想要理解函数式编程时,我们学习的第一个基本概念是纯函数。但这究竟意味着什么?是什么让函数变得纯粹?
那么我们如何知道一个函数是否是pure
纯度函数呢?这里有一个非常严格的纯度定义:
-
如果给定相同的参数,它将返回相同的结果(也称为
deterministic
) -
它不会引起任何可观察到的副作用
如果给定相同的参数,它将返回相同的结果
假设我们要实现一个计算圆面积的函数。一个非纯函数将接收radius
作为参数,然后计算radius * radius * PI
:
const PI = 3.14;
function calculateArea(radius) {
return radius * radius * PI;
}
calculateArea(10); // returns 314.0
为什么这是一个非纯函数?很简单,因为它使用了一个全局对象,而这个对象并没有作为参数传递给函数。
现在想象一些数学家争论说该PI
值实际上是42
并改变全局对象的值。
我们的非纯函数现在会返回10 * 10 * 42
= 4200
。对于相同的参数 ( radius = 10
) ,我们得到了不同的结果。让我们来修复它!
const PI = 3.14;
function calculateArea(radius, pi) {
return radius * radius * pi;
}
calculateArea(10, PI); // returns 314.0
太棒了🎉!现在我们总是将PI
值作为参数传递给函数。所以现在我们只是访问传递给函数的参数。没有external object
。
-
对于参数
radius = 10
&PI = 3.14
,我们总是会得到相同的结果:314.0
-
对于参数
radius = 10
&PI = 42
,我们总是会得到相同的结果:4200
读取文件
如果我们的函数读取外部文件,它就不是纯函数——文件的内容可能会发生变化。
function charactersCounter(text) {
return `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。
观察:函数式编程不鼓励可变性。
我们正在修改全局对象。但是该怎么做呢pure
?只需返回增加 1 的值即可。就这么简单。
let counter = 1;
function increaseCounter(value) {
return value + 1;
}
increaseCounter(counter); // 2
console.log(counter); // 1
可以看到,我们的纯函数increaseCounter
返回了 2,但counter
值仍然保持不变。该函数返回的是增加后的值,而不会改变变量的值。
如果我们遵循这两个简单的规则,我们的程序就会更容易理解。现在每个函数都被隔离了,无法影响系统的其他部分。
纯函数稳定、一致且可预测。给定相同的参数,纯函数始终会返回相同的结果。我们无需考虑相同参数产生不同结果的情况——因为这种情况永远不会发生。
纯函数的好处
代码测试起来绝对更容易。我们不需要模拟任何东西。所以我们可以在不同的上下文中对纯函数进行单元测试:
-
给定一个参数
A
→期望函数返回值B
-
给定一个参数
C
→期望函数返回值D
一个简单的例子是,一个函数接收一个数字集合,并期望它增加该集合中的每个元素。
let list = [1, 2, 3, 4, 5];
function incrementNumbers(list) {
return list.map(number => number + 1);
}
我们接收numbers
数组,使用map
递增的每个数字,并返回一个新的递增数字列表。
incrementNumbers(list); // [2, 3, 4, 5, 6]
对于input
[1, 2, 3, 4, 5]
,预期output
为[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
和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
一个接收数值向量的函数。该函数不断调用自身,直到列表为空(我们的递归base case
)。每次“迭代”后,我们都会将值添加到total
累加器中。
不可变。list
和accumulator
变量不会改变。它保持相同的值。
观察:是的!我们可以用它reduce
来实现这个功能。我们将在Higher Order Functions
主题中讨论这一点。
构建对象的最终状态url slug
也很常见。假设我们有一个字符串,我们想将其转换为。
在 Ruby 的 OOP 中,我们会创建一个类,比如说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
过程中想要做什么——首先将字母小写,然后删除无用的空格,最后用连字符替换剩余的空格。
但我们在这个过程中改变了输入状态。
我们可以通过函数组合或函数链来处理这种变化。换句话说,一个函数的结果将用作下一个函数的输入,而不会修改原始输入字符串。
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
andjoin
:用给定字符串中的替换项替换匹配的所有实例
我们将这 4 个函数结合起来,就可以得到"slugify"
我们的字符串。
引用透明度
让我们实现一个square function
:
function square(n) {
return n * n;
}
对于相同的输入,这个纯函数总是会有相同的输出。
square(2); // 4
square(2); // 4
square(2); // 4
// ...
将2
作为参数传递的square function
将始终返回 4。所以现在我们可以将 替换square(2)
为 4。就是这样!我们的函数是referentially transparent
。
基本上,如果一个函数对于相同的输入始终产生相同的结果,那么它就是引用透明的。
纯函数 + 不可变数据 = 引用透明性
有了这个概念,我们可以做一件很酷的事情,那就是记忆函数。假设我们有这个函数:
function sum(a, b) {
return a + b;
}
我们用这些参数来调用它:
sum(3, sum(5, 8));
相等。此函数sum(5, 8)
的13
结果总是13
。因此我们可以这样做:
sum(3, 13);
并且此表达式的结果总是16
。我们可以用数值常数替换整个表达式,并记住它。
函数作为一等实体
函数作为一流实体的想法是函数也被视为值并用作数据。
作为一等实体的函数可以:
-
从常量和变量中引用它
-
将其作为参数传递给其他函数
-
将其作为其他函数的结果返回
其理念是将函数视为值,并将函数作为数据传递。这样,我们可以组合不同的函数来创建具有新行为的新函数。
假设我们有一个函数,它将两个值相加,然后将结果翻倍。像这样:
function doubleSum(a, b) {
return (a + b) * 2;
}
现在有一个函数可以减去值并返回双精度值:
function doubleSubtraction(a, b) {
return (a - b) * 2;
}
这些函数的逻辑相似,但区别在于运算符函数。如果我们可以将函数视为值并将其作为参数传递,那么我们就可以构建一个接收运算符函数并在函数内部使用它的函数。让我们开始构建吧!
function sum(a, b) {
return a + b;
}
function subtraction(a, b) {
return a - b;
}
function doubleOperator(f, a, b) {
return f(a, b) * 2;
}
doubleOperator(sum, 3, 1); // 8
doubleOperator(subtraction, 3, 1); // 4
完成了!现在我们有了一个f
参数,并用它来处理a
和b
。我们传入sum
和subtraction
函数,与 函数组合doubleOperator
,从而创建一个新的行为。
高阶函数
当我们谈论高阶函数时,我们指的是以下函数:
-
接受一个或多个函数作为参数,或者
-
返回函数作为其结果
我们上面实现的函数doubleOperator
是一个高阶函数,因为它接受一个运算符函数作为参数并使用它。
你可能已经听说过filter
、map
和reduce
。让我们来看看这些。
筛选
给定一个集合,我们希望按属性进行过滤。过滤函数需要一个true
或false
值来决定该元素是否应该包含在结果集合中。基本上,如果回调表达式为true
,过滤函数就会将该元素包含在结果集合中。否则,则不会。
一个简单的例子是,当我们有一个整数集合并且我们只想要偶数时。
命令式方法
使用 Javascript 执行此操作的必要方法是:
-
创建一个空数组
evenNumbers
-
遍历
numbers
数组 -
将偶数推送到
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
函数,并返回偶数列表:
function even(number) {
return number % 2 == 0;
}
let listOfNumbers = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
listOfNumbers.filter(even); // [0, 2, 4, 6, 8, 10]
我在Hacker Rank FP Path 上解决的一个有趣的问题是过滤数组问题。这个问题的思路是过滤给定的整数数组,只输出小于指定值的值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
声明式方法
但是我们希望采用一种更具声明性的方法来解决这个问题,并且filter
同时使用高阶函数。
声明式 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]
this
在函数中使用smaller
乍一看似乎有点奇怪,但很容易理解。
this
将是函数中的第二个参数filter
。在本例中,3
表示x
为this
。就是这样。
我们也可以用地图来实现这一点。假设我们有一张包含人物及其name
和 的地图age
。
let people = [
{ name: "TK", age: 26 },
{ name: "Kaio", age: 10 },
{ name: "Kazumi", age: 30 }
];
我们只想筛选出年龄超过特定值的人,在这个例子中,年龄超过 21 岁的人。
function olderThan21(person) {
return person.age > 21;
}
function overAge(people) {
return people.filter(olderThan21);
}
overAge(people); // [{ name: 'TK', age: 26 }, { name: 'Kazumi', age: 30 }]
代码摘要:
-
我们有一份人员名单(包括
name
和age
)。 -
我们有一个函数
olderThan21
。在本例中,对于 people 数组中的每个人,我们想要访问 ,age
并查看其年龄是否大于 21 岁。 -
我们根据此功能过滤所有人。
地图
map 的思想就是对一个集合进行转换。
map 方法通过将函数应用于集合的所有元素并从返回的值构建新集合来转换集合。
让我们获取people
上面相同的集合。我们现在不想按“超龄”进行筛选。我们只想要一个字符串列表,类似于TK is 26 years old
。因此,最终的字符串可能是 :name is :age years old
where :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 方式来说,它将是:
function makeSentence(person) {
return `${person.name} is ${person.age} years old`;
}
function peopleSentences(people) {
return 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
。
一个简单的解决方案是对每个集合值进行就地更新。
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
我们希望将每个值转换为正值(绝对值)。
现在我们知道了如何absolute
对一个值进行操作,我们可以用这个函数作为参数传递给map
函数。你还记得 ahigher order function
可以接收一个函数作为参数并使用它吗?没错,map 可以做到!
let values = [1, 2, 3, -4, 5];
function updateListMap(values) {
return values.map(Math.abs);
}
updateListMap(values); // [1, 2, 3, 4, 5]
哇!太美了!😍
减少
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
,我们可以构建一个函数来处理amount 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;
function getTotalAmount(shoppingCart) {
return shoppingCart.reduce(sumAmount, 0);
}
getTotalAmount(shoppingCart); // 120
这里我们有,接收当前 的shoppingCart
函数 ,以及它们的对象。sumAmount
currentTotalAmount
order
sum
该getTotalAmount
函数用于reduce
通过shoppingCart
使用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
产品对象并仅返回其amount
值。所以,我们这里得到的是[10, 30, 20, 60]
。然后,reduce
通过求和将所有项目合并起来。太棒了!
我们了解了每个高阶函数的工作原理。我想用一个简单的例子来展示如何组合这三个函数。
说到shopping cart
,假设我们的订单中有以下产品清单:
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 }
]
我们想要知道购物车里所有书籍的总金额。就这么简单。算法呢?
-
按书籍类型过滤
-
使用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
完成!🎉
资源
我整理了一些我阅读和研究过的资源,分享其中我觉得非常有趣的。更多资源请访问我的函数式编程 Github 仓库。
介绍
纯函数
不可变数据
高阶函数
声明式编程
就是这样!
大家好,希望大家读这篇文章玩得开心,也希望大家学到了很多东西!我只是想分享一下我的学习心得。
这是包含本文所有代码的存储库。
来跟我学习吧。我在这个学习函数式编程的仓库里分享资源和代码。
我也写了一篇FP 帖子,但主要使用 Clojure ❤。
希望你在这里看到的内容对你有用。下次再见!:)
传统的知識。
文章来源:https://dev.to/teekay/functional-programming-principles-in-javascript-26g7