函数式 JavaScript 基础知识
介绍
函数式编程是指任何利用函数的代码风格吗?要是真这么简单就好了!函数确实是函数式编程
的核心,但真正让我们的实现真正具有函数式性的关键在于我们如何使用这些函数。
本文旨在阐述函数式编程的一些基础知识,主要涉及其应用JavaScript
,以帮助您理解:
- 什么是函数?
- 函数 与 过程
- 声明式编程 与 命令式编程
- 理解函数输入和输出
如果本文有帮助的话,这些基础知识将极大地帮助您掌握函数式 JavaScript的更多概念,这些概念将在以后的文章中介绍。
下一篇文章将介绍:
- 函数纯度(纯函数与非纯函数)
- 副作用
- 提取及含杂
- 所有这些如何共同定义函数 式编程,以及它为何被使用
- 是
JavaScript
一种函数式编程语言吗? - 为什么要考虑为你的代码采用函数式编程风格?
敬请关注!
1.什么是函数?
好吧,就像任何入门编程课程都会告诉你的那样,函数是一段可重用的代码,它在执行时执行某些任务。虽然这个定义是合理的,但它忽略了一个重要的视角,那就是函数在函数式编程中的核心。
让我们尝试以非常基础的数学为例,更全面地理解函数。
你可能还记得在学校读到过f(x)
,或者方程式y = f(x)
。
假设方程式是。这是什么意思?画出这个方程式的图意味着什么?如下图所示:f(x) = x2 - 1
它相当于:
function f(x) {
return Math.pow(x,2) - 1;
}
你会注意到,对于任意 的值x
,比如1
,如果你把它代入方程,你会得到0
。0
那么 是什么呢?它是函数的返回值f(x)
,我们之前说过它代表一个y
值。
在数学中,函数总是接受输入,并给出输出。在函数式编程中,你经常听到的一个术语是“态射”;这是一种描述一组值映射到另一组值的一种奇特方式,例如一个函数的输入与其输出之间存在关联。
然而,在我们的代码中,我们可以定义具有各种输入和输出的函数,即使它们很少被解释为图形上绘制的曲线。
因此,函数的更完整定义是:
函数是输入和计算输出之间的语义关系。
另请注意本文中
function
和函数的用法。虽然我们正在讨论函数的概念,function
但它只是JavaScript keyword
。
本质上,函数式编程就是在数学意义上functions
使用函数。
2. 函数与过程?
函数和过程这两个术语经常互换使用,但它们实际上意味着不同的东西。
过程是任意功能的集合。它可能有输入,也可能没有。它可能有输出(以return
值的形式),也可能没有。
然而,函数接受输入并且肯定总是有一个return
值。
对于函数式编程,我们尽可能使用函数,并尽量避免使用过程。所有函数都应该接受输入并返回输出。
基于这些知识,让我们考虑以下示例:
// Example 1: Function or Procedure?
function addPokémon(team1 = 0, team2 = 0, team3 = 0) {
var total = team1 + team2 + team3;
console.log(total);
}
function countPokémon(currentTeam = 6, ...args) {
return addPokémon(currentTeam, ...args);
}
countPokémon();
// Output : 6
countPokémon(6, 5, 6);
// Output : 17
尝试评估function
addPokémon
和是否countPokémon
是函数或程序?
以下是一些基本观察结果:
addPokémon
有定义的输入,但没有指定的输出return
。它应该是一个过程。countPokémon
有一个定义的输入和一个定义return
,所以它应该是一个函数?
我们正确地认为addPokémon
它是一个过程,但countPokémon
它也是一个过程,而不是一个函数,因为它调用了其自身内部的过程。
总之 :
请注意,如果在其内部
function
调用了过程,那么 也是一个过程。过程的“杂质” ——这个概念将在下文中解释——会逐渐渗透并“污染”所有直接或间接调用它的人。
现在,我们可能想了解如何将最后一个例子的过程转换为函数?
基于上一节中提到的更完整的函数定义,尝试对上一个示例进行修改,然后再考虑其他可能的解决方案。对于这个示例来说,修改应该相当简单。
// Example 2: Converting Procedures to Functions?
function addPokémon(team1 = 0, team2 = 0, team3 = 0) {
var total = team1 + team2 + team3;
return total;
// Instead of logging a value, we returned it,
// so there's a proper output/return now.
}
function countPokémon(currentTeam = 6, ...args) {
return addPokémon(currentTeam, ...args);
// Now, a call to a function, not a procedure, is returned
}
console.log(countPokémon());
// Output : 6
console.log(countPokémon(6, 5, 6));
// Output : 17
让我们再看一个区分过程和函数的例子。
// Example 3. Identifying functions and procedures
function neighbouringPokémonID(x) {
x = Number(x);
return [x - 1, x + 1];
}
function generateNeighboursForTeam(team) {
var teamIDs = Object.keys(team);
teamIDs.forEach(element =>
console.log(neighbouringPokémonID(element)));
}
var myTeam = {
25: "Pikachu",
155: "Cyndaquil"
};
generateNeighboursForTeam(myTeam);
// Output :
// [24, 26]
// [154, 156]
此代码片段有效地返回了神奇宝贝 (Pokémon) 的近邻的神奇宝贝图鉴 ID (根据其自己的 ID)。
显然,neighbouringPokémonID
是一个函数,因为它有一个输入和return
一个基于该输入的输出。
此外,generateNeighboursForTeam
这是一个过程,因为它不做return
任何事情。
再次,我们可以修改这个例子,使两者都是函数。
// Example 4. Converting the procedure to a function
function neighbouringPokémonID(x) {
x = Number(x);
return [x - 1, x + 1];
}
function generateNeighboursForTeam(team) {
var teamIDs = Object.keys(team);
var neighbourIDs = [];
// Use a temporary array to store computation
teamIDs.forEach(element =>
neighbourIDs.push(neighbouringPokémonID(element)));
return neighbourIDs;
}
var myTeam = {
25: "Pikachu",
155: "Cyndaquil"
};
generateNeighboursForTeam(myTeam);
// Output :
// [[24, 26],[154, 156]]
3.声明式编程与命令式编程?
另一个需要熟悉的基本概念是声明式和命令式编码风格之间的区别,老实说,其含义有点相对。
没有一种风格是绝对的声明式或 绝对的命令式。它本身就是一个范围。
话虽如此,让我们来介绍一个常见的、简单的定义。
“命令式编程就像你如何做某事,而声明式编程更像是你做什么。”
有点模棱两可,开放式,所以我们举一个小例子。
假设你正在尝试帮助你的弟弟学习最新宝可梦游戏的基础知识。具体来说,是如何捕捉野生宝可梦。
陈述句:当宝可梦虚弱时,扔一个精灵球。(该怎么做)
强制:当宝可梦的生命值低于30%时,按下X键即可扔出精灵球。(具体操作方法)
一般来说,明确地逐一列出所有步骤是必要的。理解起来相当机械,需要一行一行地看。
声明式的方法是利用一定程度的抽象和可信任的辅助函数,以一种只呈现基本思想的方式列出步骤。它更容易理解,因为我们不需要关心事情是如何发生的,而是关心发生了什么。
由于“什么”和“如何”可能相当主观,我们无法对声明性或命令性划出严格的界限。
例如,对于使用机器语言(命令式编程)的人来说,Java 可能显得相当声明式。或者对于使用纯函数式语言(例如 Haskell 或 Clojure)的人来说,即使是JavaScript 中的函数式实现,也可能感觉相当命令式。
我们目前关注的是奠定函数式编程和函数式 JavaScript的基础,我们需要明白,我们应该通过利用函数,使我们的代码尽可能具有声明性。
接下来,让我们进一步了解函数输入和输出。
4. 函数输入
本节涵盖了函数输入的更多方面,主要是:
- 参数和形参
- 默认参数
- 计数输入
- 参数数组
- 参数解构
- 声明式风格的好处
- 命名参数
- 无序参数
让我们开始吧。
a. 参数和形参
人们常常对参数和形参之间的区别感到有些困惑。
简而言之,参数是传递给的值function
,而参数function
是接收这些值的内部命名变量。
注意:在 JavaScript 中,参数的数量不必与形参的数量一致。如果传递的参数数量大于声明的接收参数数量,则值可以正常传递。
这些额外的参数可以通过几种方式访问,包括
args
对象。如果传递的参数较少,则每个未匹配的参数都会被赋值undefined
。
b. 默认参数
参数可以声明默认值。如果未传递该参数的实参,或者传递了值undefined
,则使用默认赋值表达式进行替换。
function f(x = 10) {
console.log(x);
}
f(); // Output : 10
f(undefined); // Output : 10
f(null); // Output : null
f(0); // Output : 0
考虑任何可以提高函数可用性的默认情况始终是一个好习惯。
c. 元数,即输入的数量
“期望”的参数数量function
由声明的参数数量决定。
function f(x,y,z,w) {
// something
}
f.length;
// Output :
// 4
f(..)
需要4
参数,因为它已经4
声明了参数。这个计数有一个特殊术语:Arity,即声明中参数的数量function
。的arityf(..)
是4
。
此外,元数function
为1 的运算符也称为一元运算符,元数为2 的运算符也称为二元运算符,元数为3 或更高的运算符称为多元运算符。function
function
length
该引用的属性返回function
其元数。
虽然这听起来简单,但其影响却深远。
在执行期间确定元数的一个原因是,如果一段代码从多个来源接收函数引用,并且必须根据每个来源的元数发送不同的值。
例如,假设一个fn
函数引用可能需要一个、两个或三个参数,但您总是希望x
在最后一个位置传递一个变量:
// `fn` is set to some function reference
// `x` exists with some value
if (fn.length == 1) {
fn(x);
}
else if (fn.length == 2) {
fn(undefined, x);
}
else if (fn.length == 3) {
fn(undefined, undefined, x);
}
提示:函数的属性
length
是只读的,并且在声明函数时确定。它本质上应该被视为一种元数据,用于描述函数的预期用途。需要注意的一个问题是,某些类型的参数列表变化可能会导致
length
函数的属性报告与您预期不同的内容:
function foo(x,y = 2) {
// something
}
function bar(x,...args) {
// something
}
function baz( {a,b} ) {
// something
}
foo.length; // Output : 1
bar.length; // Output : 1
baz.length; // Output : 1
那么如何计算当前函数调用接收到的参数数量呢?这曾经很简单,但现在情况稍微复杂了一些。每个函数都有一个arguments
可用的对象(类似数组),它保存着对每个传入参数的引用。然后你可以检查length
的属性arguments
来确定实际传递了多少个参数:
function f(x,y,z) {
console.log(arguments.length);
}
f(3, 4);
// Output :
// 2
从 ES5(特别是严格模式)开始,arguments
有些人认为 已被弃用;许多人尽可能避免使用它。但是,arguments.length
只有在需要关注传递参数数量的情况下, (并且只有 )才可以继续使用。
接受不确定数量参数的函数签名称为可变参数函数。
假设你确实需要以类似位置数组的方式访问参数,可能是因为你正在访问的参数在该位置没有正式的形参。我们该怎么做呢?
ES6 来帮忙!让我们用...
操作符(称为“spread”、“rest”或“gather”)来声明我们的函数:
function f(x,y,z,...args) {
// something
}
参数列表中的 是 ES6 声明式形式...args
,它告诉引擎收集所有未分配给命名参数的剩余参数(如果有),并将它们放入一个名为 的实际数组中args
。args
即使它为空,它也始终是一个数组。但它不会包含分配给x
、y
和z
参数的值,只会包含除前三个值之外传入的任何其他值。
function f(x,y,z,...args) {
console.log(x, y, z, args);
}
f(); // undefined undefined undefined []
f(1, 2, 3); // 1 2 3 []
f(1, 2, 3, 4); // 1 2 3 [ 4 ]
f(1, 2, 3, 4, 5); // 1 2 3 [ 4, 5 ]
因此,如果您想设计一个可以考虑任意数量参数的函数,请使用...args
。
...
即使没有声明其他形式参数,您也可以在参数列表中使用该运算符。
function (...args) {
// something
}
args
现在将是完整的参数数组,无论它们是什么,您都可以使用它args.length
来准确知道传入了多少个参数。
d. 参数数组
如果您想将一个值数组作为参数传递给函数调用,该怎么办?
function f(...args) {
console.log(args[3]);
}
var arr = [1, 2, 3, 4, 5];
f(...arr);
// Output :
// 4
我们的新朋友,...
运算符在这里使用,但现在不仅仅在参数列表中;它还在调用站点的参数列表中使用。
在这个上下文中,它的行为正好相反。
在参数列表中,我们说它将参数聚集在一起。在实参列表中,它将它们分散开来。因此, 的内容arr
实际上是作为调用的单个参数分散开来的f(..)
。
...
此外,可以根据需要交织多个值和扩展:
var arr = [2];
f(1, ...arr, 3, ...[4,5]);
// Output :
// 4
从对称意义上思考
...
:在值列表位置,它会扩展。在赋值位置——比如参数列表,因为参数被赋值给参数——它会聚集。
...
使得处理参数数组变得更加容易。
e. 参数解构
考虑上一节中的可变参数f(..)
:
function f(...args) {
// something
}
f( ...[1,2,3]);
如果我们想改变这种交互方式,让函数调用者传入一个数组,而不是单个参数,该怎么办?只需删除以下两种...
用法:
function f(args) {
// something
}
f([1,2,3]);
很简单。但是,如果我们现在想为传入数组的前两个值分别赋予一个参数名称,该怎么办?我们不再声明单独的参数,所以似乎失去了这种能力。
值得庆幸的是,ES6解构就是答案。解构是一种声明模式的方法,用于定义你期望看到的结构类型(对象、数组等),以及如何处理其各个部分的分解(赋值)。
考虑:
function f([x,y,...args] = []) {
// something
}
f([1,2,3]);
你[ .. ]
现在看到参数列表外面的括号了吗?这叫做数组参数解构。
在这个例子中,解构告诉引擎,在这个赋值位置(也就是参数)需要一个数组。模式的意思是,取出该数组的第一个值,并将其赋值给一个名为 的局部参数变量x
,将第二个值赋值给y
,剩下的值则被收集到 中args
。
f.声明式风格的好处
考虑到我们刚刚看到的解构f(..)
,我们可以手动处理参数:
function f(params) {
var x = params[0];
var y = params[1];
var args = params.slice(2);
// something
}
但这里我们强调一个原则,即声明式代码比命令式代码的通信更有效。
声明性代码(例如,前面代码片段中的解构f(..)
,或...
运算符的使用)关注的是一段代码的结果应该是什么。
命令式代码(例如后一段代码中的手动赋值)更侧重于如何获得结果。结果虽然写在那里,但由于充斥着各种实现细节,因此显得不够清晰。
前者f(..)
被认为更具可读性,因为解构隐藏了如何管理参数输入的不必要的细节。
只要有可能,我们就应该努力实现声明式的、不言自明的代码。
g. 命名参数
正如我们可以解构数组参数一样,我们也可以解构对象参数:
function f({x,y} = {}) {
console.log(x, y);
}
f({
y: 3
});
// Output :
// undefined 3
我们传入一个对象作为单个参数,并将其解构为两个单独的参数变量x
和y
,并为其分配传入对象中相应属性名称的值。x
属性是否在对象上并不重要;它最终会像undefined
您期望的那样成为一个变量。
对于像 这样的普通调用站点f(undefined,3)
,位置用于从参数映射到参数;我们将 放在3
第二个位置以将其分配给y
参数。
但是在涉及参数解构的这个调用站点,一个简单的对象属性y
指示应该将参数值3
分配给哪个参数( )。
有些语言对此有一个明确的功能:命名参数。换句话说,在调用处标记输入值以指示它映射到哪个参数。JavaScript 没有命名参数,但参数对象解构是仅次于命名参数的最佳选择。
h. 无序参数
另一个关键好处是,由于命名参数被指定为对象属性,因此它们本质上是无序的。这意味着我们可以按任意顺序指定输入:
function f({x,y} = {}) {
console.log(x, y);
}
f({
y: 3
});
// Output :
// undefined 3
调用站点不再因undefined
跳过参数等有序占位符而变得混乱。
函数输出
本节涵盖了函数输出的更多方面。
在 JavaScript 中,functions
始终return
是一个值。以下三个函数具有相同的return
行为:
function foo() {}
function bar() {
return;
}
function baz() {
return undefined;
}
如果没有或者只有空的,则该undefined
值是隐式的。returned
return
return;
但是尽可能地遵循函数式编程函数定义的精神——使用函数而不是过程——我们的函数应该始终有输出,这意味着它们应该明确地有return
一个值,并且通常不是undefined
。
一个return
语句只能返回一个值。因此,如果你的函数需要返回多个值,那么唯一可行的选择就是将它们收集到一个复合值中,例如数组或对象:
function f() {
var retValue1 = 1;
var retValue2 = 3;
return [retValue1, retValue2];
}
然后,我们将从返回的数组中的两个相应项中分配x
和:y
f()
var [x, y] = f();
console.log(x + y);
// Output : 4
将多个值收集到一个数组(或对象)中返回,然后将这些值解构回不同的赋值,这是一种透明地表达函数多个输出的方法。
让我们介绍一些与函数输出相关的概念,主要是:
- 提前回报
- 未
return
编辑输出 - 高阶函数(HOF 或函数的函数)
a. 提前返回
该return
语句不仅仅是从 返回一个值function
。它也是一个流控制结构;它在该点结束 的执行function
。
function
因此,具有多个语句的Areturn
具有多个可能的出口点,这意味着如果有许多路径可以产生该输出,则可能更难阅读函数以理解其输出行为。
考虑:
function f(x) {
if (x > 10) return x + 1;
var y = x / 2;
if (y > 3) {
if (x % 2 == 0) return x;
}
if (y > 1) return y;
return x;
}
f(2); // Output : 2
f(4); // Output : 2
f(8); // Output : 8
f(12); // Output : 13
首先,f(x)
它非常难以理解,也很难理解。在脑子里模拟运行这段代码非常繁琐。这是因为我们return
不仅要使用它来返回不同的值,还要将其用作流程控制结构,以便在某些情况下提前退出函数的执行。
考虑这个版本的代码:
function f(x) {
var retValue;
if (retValue == undefined && x > 10) {
retValue = x + 1;
}
var y = x / 2;
if (y > 3) {
if (retValue == undefined && x % 2 == 0) {
retValue = x;
}
}
if (retValue == undefined && y > 1) {
retValue = y;
}
if (retValue == undefined) {
retValue = x;
}
return retValue;
}
这个版本无疑更加冗长。但它的逻辑更容易理解,因为每个retValue
可以设置的分支都受到检查该分支是否已被设置的条件的保护。
return
我们没有提前从函数中执行 ,而是使用了正常的流程控制(逻辑if
)来确定retValue
的赋值。最后,我们简单地return retValue
。
return
总而言之,末尾只使用一个单数更易读。尝试找出最清晰的方式来表达逻辑。
b. 未return
编辑的输出
您可能在编写的大多数代码中使用过一种技术,甚至可能没有考虑太多,那就是让函数通过简单地更改其自身外部的变量来输出其部分或全部值。
还记得我们之前的函数吗?我们可以在 JS 中像这样定义它:f(x) = x2 - 1
var y;
function f(x) {
y = (2 * Math.pow( x, 2 )) + 3;
}
我们可以轻松地获得该值,而不是在函数内部return
设置它:y
function f(x) {
return (2 * Math.pow( x, 2 )) + 3;
}
这两个函数完成相同的任务,那么我们有什么理由选择一个版本而不是另一个版本呢?
解释这种差异的一种方法是,return
后一个版本中的表示显式输出,而y
前一个版本中的赋值是隐式输出。
y
但是,像我们在内部赋值那样,在外部作用域中更改变量f(..)
只是实现隐式输出的一种方式。一个更微妙的例子是通过引用更改非局部值。
考虑:
function sum(list) {
var total = 0;
for (let i = 0; i < list.length; i++) {
if (!list[i]) list[i] = 0;
total = total + list[i];
}
return total;
}
var nums = [ 1, 3, 9, 27, , 84 ];
sum(nums);
// Output :
// 124
这个函数最明显的输出就是124
我们明确给出的 sum 值。但是,位置 处的 slotreturn
不再是一个空值,而是出现了一个。undefined
4
0
尽管我们对局部参数变量进行操作,但看似无害的list[i] = 0
操作最终会影响外部的数组值。list
为什么?因为list
保存的是引用的引用副本nums
,而不是数组值的值副本[1,3,9,..]
。JavaScript 对数组、对象和函数使用引用和引用副本,因此我们很容易在函数中创建意外的输出。
这种隐式函数输出在函数式编程的世界里有一个特殊的名字:副作用 (Side Effects) 。而没有副作用的函数也有一个特殊的名字:纯函数 (Pure Function )。这两个概念将在下一篇文章中介绍。
c.高阶函数(HOF或函数的函数)
函数可以接收和返回任何类型的值。接收或返回一个或多个其他函数值的函数有一个特殊名称:高阶函数。
考虑:
function forEach(list,fn) {
for (let v of list) {
fn( v );
}
}
forEach( [1,2,3,4,5], function each(val){
console.log( val );
} );
// Output :
// 1 2 3 4 5
forEach(..)
是一个高阶函数,因为它接收一个函数作为参数。
高阶函数还可以输出另一个函数,例如:
function f() {
return function upper(x){
return x.toUpperCase();
};
}
var g = f();
g("Hello!");
// Output :
// HELLO!
return
并不是“输出”内部函数的唯一方法:
function f() {
return g(function upper(x){
return x.toUpperCase();
} );
}
function g(func) {
return func("Hello!");
}
f();
// Output :
// HELLO!
根据定义,将其他函数视为值的函数是高阶函数。这对于函数式编程至关重要!
概括
我们在本文中介绍了以下概念:
- 什么是函数?
- 函数 与 过程
- 声明式编程 与 命令式编程
- 函数输入
- 参数和形参
- 默认参数
- 计数输入
- 参数数组
- 参数解构
- 声明式风格的好处
- 命名参数
- 无序参数
- 函数输出
- 提前回报
- 未
return
编辑输出 - 高阶函数(HOF 或函数的函数)
下一篇文章将介绍:
- 函数纯度(纯函数与非纯函数)
- 副作用
- 提取及含杂
- 所有这些如何共同定义函数 式编程,以及它为何被使用
- 是
JavaScript
一种函数式编程语言吗? - 为什么要考虑为你的代码采用函数式编程风格?
致谢
- Kyle Simpson 撰写的《Functional-Light JS》一书启发了本文的灵感,以及,
- 前端大师:轻量级函数式 JavaScript
非常感谢你的阅读!❤️
Dev.to | Twitter | Hashnode | Medium | GitHub | LinkedIn |请我喝杯咖啡