函数式编程的基本原理

发布于
函数式编程的基本原理

本文旨在阐述函数式编程的一些基本原理,同时主要涉及到它们在 JavaScript 中的应用。简单介绍了函数 Functions 和 过程 Procedures,声明式 Declarative 编程与命令式 Imperative 编程,函数的输入和输出 等概念。

内容翻译自 Animesh Pandey 的 Fundamentals of Functional JavaScript


1. 什么是函数 Functions

就像任何一门编程入门课都会告诉你,函数是 一段可重用的代码,在执行时完成一些任务。 虽然这个定义很合理,但它忽略了一个重要的角度,那就是函数的核心,因为它适用于函数式编程 Functional Programming

让我们试着用一个非常基本的数学例子来更完整地理解 函数 Functions

你可能还记得在学校里读到过 f(x) ,或者 y=f(x) 这个方程。 我们假设方程 f(x)=x² - 1 ,这意味着什么?把这个方程画成图是什么意思?这就是图形。

f(x)=x² - 1

这相当于:

function f(x) {
   return Math.pow(x,2) - 1;
}

你可以注意到的是,对于任何一个 x 的值,比如说 1 ,如果你把它输入方程,就会得到 0,不过 0 是什么呢?它是 f(x) 函数的返回值,我们前面说过它代表一个 y 值。

在数学中,函数总是接受输入,并总是给出一个输出。在 FP(函数式编程) 中,你会经常听到的一个术语是 态射 (morphism) ;这是一种描述一组值的花哨方式,它映射到另一组值,就像一个函数的输入与该函数的输出有关。

然而,在我们的代码中,我们可以定义具有各种输入和输出的函数,尽管它们很少会被解释为图形上的视觉曲线。

因此,更完整的函数定义是:

函数是输入和计算输出之间的语义关系。

同时注意本文中对 「函数」 和 函数 的使用。函数 是我们讨论的概念,而 「函数」 只是 JavaScript 的关键字「function」。

从本质上讲,函数式编程 Functional Programming 就是把 「函数」 作为这种 数学意义上的函数 来使用。


2. 函数 Functions过程 Procedures

术语 函数 Functions过程 Procedures 经常被互换使用,但它们实际上意味着不同的东西。

一个 过程 Procedures 是一个任意的功能集合,它可能有输入,也可能没有。它可能有输入,也可能没有。它可能有一个输出(作为一个返回 return 值),也可能没有。

函数 Functions 需要输入,并且肯定总是有一个返回值。

对于 函数式编程,我们尽可能的使用函数,并尽量避免使用过程。你所有的函数都应该接受输入并返回输出。

基于这些知识,让我们考虑以下的例子。

// 例 1: 函数 还是 过程?

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

试评估 「函数」 addPokémoncountPokémon函数 还是 过程

以下是一些基本观察。

  • addPokémon 有一个定义的输入,但没有通过 return 指定输出。它应该是一个 过程
  • countPokémon 有一个定义的输入和一个定义的返回 return ,所以它应该是一个 函数

我们认为 addPokémon 是一个过程是正确的,但是 countPokémon 也是一个 过程,而不是一个 函数 ,因为它在自身内部调用了一个 过程

总的来说:

请记住,在自身内部调用 过程 的 「函数」 也是一个 过程 。一个 过程“不纯” impurity ——这个概念将在下面解释——蔓延并”污染”了所有直接或间接调用它的人。

———

现在,我们可能想了解如何将上一个例子的 过程 转换为 函数

根据上一节中提到的比较完整的函数定义,试着对上一个例子进行修改,然后再去寻找前面众多可能的解决方案之一。对于这个例子,应该是很直接的。

// 例 2: 将 过程 转换为 函数 ?

function addPokémon(team1 = 0, team2 = 0, team3 = 0) {
    var total = team1 + team2 + team3;
    return total;
    // 我们没有记录一个值,而是返回它。
    // 所以现在有一个正确的输出/返回。
}

function countPokémon(currentTeam = 6, ...args) {
    return addPokémon(currentTeam, ...args);
    // 现在,返回的是对函数的调用,而不是过程的调用。
}

console.log(countPokémon());
// Output : 6

console.log(countPokémon(6, 5, 6));
// Output : 17

———

我们再来看一个区分 函数过程 的例子。

// 例 3: 确定 过程 和 函数

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 的近邻的 Pokédex ID,给定了它自己的 ID。

显然,neighbouringPokémonID 是一个 函数 ,因为它有一个输入,并根据它 返回 一个输出。

另外,generateNeighboursForTeam 是一个 过程 ,因为它不 返回 任何东西。

再一次,我们可以修改这个例子,使两者都是 函数

// 例 4: 将 过程 转换为 函数
function neighbouringPokémonID(x) {
    x = Number(x);
    return [x - 1, x + 1];
}

function generateNeighboursForTeam(team) {
    var teamIDs = Object.keys(team);
    var neighbourIDs = [];
    // 使用一个临时数组来存储计算结果
    teamIDs.forEach(element =>
        neighbourIDs.push(neighbouringPokémonID(element)));
    return neighbourIDs;
}

var myTeam = {
    25: "Pikachu",
    155: "Cyndaquil"
};

generateNeighboursForTeam(myTeam);
// Output :
// [[24, 26],[154, 156]]

3. 声明式 Declarative 编程与命令式 Imperative 编程

另一个需要熟悉的基本概念是,声明式 Declarative 和 命令式 Imperative 两种编码风格的区别,说实话,这两种风格的含义有点相对。

没有一种风格是绝对的 声明式 或绝对的 命令式它本身就是一个光谱

说到这里,我们先来介绍一个常见的、简单的定义。

“命令式编程就像你如何做某事 how ,而声明式编程更像你在做什么 what。“

这有点模棱两可,开放式的,我们举个小例子。

假设,你是想帮助你的小弟学习最新的 Pokémon 游戏的基础知识。具体来说,就是关于捕捉野生 Pokémon 的事情。

声明式:在 pokémon 虚弱的时候扔出一个 pokéball 。( 做什么 what )

命令式:当 pokémon 的健康条低于30%时,按 X 键投掷一个 pokéball 。( 具体如何做 how )

一般来说,明确地把所有的步骤一一列出来,就是 命令式。它的理解比较机器人化,需要逐条逐条的去理解。

而利用某种程度的抽象和可信的帮助函数,将步骤列出来,它只呈现基本的思想,是 声明式 。它更容易理解,因为我们不需要去管某件事情是如何发生的 how,而是管什么在发生 what

由于 whathow 是相当主观的,所以我们无法围绕什么是声明式或命令式划出一个硬性的界限。

例如,对于一个用机器语言编程的人来说,这是超级命令式的,Java 可能看起来相当声明式。或者对于一个从事纯功能语言的人来说,比如 Haskell 或者 Clojure ,即使是JavaScript 中的函数式实现 functional implementations ,也会让人觉得相当的命令式。

我们目前关心的问题,就是要奠定 函数式编程函数式 JavaScript 的基础,我们需要明白,我们应该通过利用 函数,让我们的代码尽可能的 声明化

接着,我们再来了解一下 函数 的输入和输出。


4. 函数输入 Function Inputs

本节主要介绍函数输入的更多方面:

  • Arguments(实际参数,实参)和 Parameters(形式参数,形参)
  • 默认参数 Parameters
  • 输入的数量
  • 参数 Arguments 数组
  • 参数 Parameter 解构
  • 声明式风格的好处
  • 命名参数 Arguments
  • 无序参数 Parameters

让我们开始吧。


a. Arguments(实参)和 Parameters(形参)

人们经常会对 Arguments(实参)和 Parameters(形参)之间的区别有一点混淆。

简单地说,Arguments 是你传递到 「函数」 中的值,而 Parameters 则是 「函数」 中接收这些值的命名变量。

注意:在JavaScript中,Arguments 的数量 不需要与 Parameters 的数量 相匹配。如果你传递的 arguments 数量超过了你声明的 parameters 接收数量,那么数值传递进来就好了。
这些额外的 arguments 可以通过几种方式来访问,包括 args 对象。如果你传递的 arguments 较少,每个未匹配的 parameter 都会被分配为未定义 undefined


b. 默认参数 Parameters

Parameters 可以声明默认值。在没有传递该 parameter 的 argument,或者传递的值未定义 undefined 的情况下,用默认赋值表达式代替。

function f(x = 10) {
    console.log(x);
}

f();                // Output : 10
f(undefined);       // Output : 10
f(null);            // Output : null
f(0);               // Output : 0

思考任何默认情况都是一个很好的做法,可以帮助你的函数的可用性。


c. Arity,或输入的数量

一个「函数」 “期望 “的 arguments 数量由声明的 parameters 数量决定。

function f(x,y,z,w) {
    // something
}

f.length;
// Output :
// 4

f(..) 需要 4 个 arguments,因为它有 4 个声明 parameters。这个数量有一个特殊的术语 Arity, 是指 「函数」 声明中 parameters 的数量。 f(..)arity4

此外, arity 为 1 的 「函数」 称为一元函数 unaryarity 为 2 的 「函数」 称为二元函数 binaryarity 为 1 的 「函数」 称为多元函数 n-ary

该 「函数」 长度 length 属性的引用会返回其 arity

虽然这听起来很简单,但其意义却很深远。

在执行过程中确定 arity 的一个原因是,如果一段代码从多个源头接收到一个函数引用,并且要根据每个函数的 arity 发送不同的值。

例如,假设一个 fn 函数引用可能期望有一个、两个或三个 arguments,但你总是希望只在最后一个位置传递一个变量 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 属性是只读的,它是在你声明函数时确定的。它应该被认为是描述函数用途的元数据。
需要注意的一个问题是,某些类型的 parameter 列表变化会使函数的长度 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 数量呢?这曾经是小事一桩,但现在情况稍微复杂了一些。每个函数都有一个 arguments 对象( 类数组 array-like )可用,它保存着对每个传递进来的 arguments 的引用。然后,你可以检查 arguments 的长度属性,以弄清到底传递了多少个:

function f(x,y,z) {
    console.log(arguments.length);
}

f(3, 4);   
// Output : 
// 2

从 ES5 开始 (特别是严格模式), arguments 被一些人认为是被废弃的;许多人尽可能避免使用它。然而,arguments.length ,也只有这个,对于那些你需要关心传递的arguments 数量的情况,可以继续使用。

一个接受不确定数量的 arguments 的函数签名被称为 变量函数 ( variadic function )。

假设你确实需要以特定位置的类数组方式访问 arguments ,可能是因为你正在访问一个在该位置没有形参 formal parameter 的 argument 。我们该怎么做呢?

ES6 来拯救我们! 让我们用 ... 操作符来声明我们的函数,称为 “spread” 展开、“rest” 剩余 或 “gather” 聚集。

function f(x,y,z,...args) {
    // something
}

parameter 列表中的 ...args 是一种 ES6 声明形式,它告诉引擎收集所有未分配给命名 parameters 的剩余 arguments(如果有的话),并将它们放在一个名为 args 的真实数组中。 args 将始终是一个数组,即使它是空的。但它 不包括 分配给 xyz parameters 的值,只包括前三个值之外的其他内容才会被传递进来。

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 ]

所以,如果你想设计一个可以容纳任意数量 arguments 的函数,可以使用 ...args

即使没有声明其他形参 formal parameters ,也可以在参数列表中使用 ... 操作符。

function (...args) {
    // something
}

...args 现在将是完整的 arguments 数组,不管它们是什么,你可以使用 args.length 来了解到底有多少个 arguments 被传递进来。


d. 参数 Arguments 数组

如果你想把一个数组的值作为 arguments 传递给一个函数调用,你会怎么做?

function f(...args) {
    console.log(args[3]);
}

var arr = [1, 2, 3, 4, 5];
f(...arr);  
// Output :                    
// 4

我们的新朋友,... 操作符在这里被使用,但现在不只是在 parameter 列表中使用,它也在调用端 call-site 的 argument 列表中使用。

在这个语境中,它的行为正好相反。

在 parameter 列表中,我们说它把参数 聚集 ( gathered ) 在一起。 在 argument 列表中,它把它们 展开 ( spreads ) 开来。所以 arr 的内容实际上是作为单个 arguments 分散到 f(...) 调用中的。

另外,根据需要,多个值和 ... 展开 可以根据需要,交织在一起。

var arr = [2];

f(1, ...arr, 3, ...[4,5]);  
// Output :   
// 4

在这种对称的意义上思考 ... :在值列表 value-list 的位置,它 展开 spreads。在赋值 assignment 的位置 —— 就像 parameter 列表一样,因为 arguments 会被 赋值到 assigned to parameters 上 —— 它就会 聚集 gathers

... 使得处理 arguments 数组的工作变得更加容易。


e. 参数 Parameter 解构

考虑上一节中可变的 variadic f(..)

function f(...args) {
    // something
}

f( ...[1,2,3]);

如果我们想改变这种交互方式,让函数的调用者传入一个数组而不是单个 argument 值呢?只要去掉两个 ... 的用法。

function f(args) {
    // something
}

f([1,2,3]);

很简单 但如果现在我们想给传入数组中的前两个值各取一个 parameter 名呢?我们不再声明单个参数了,所以似乎我们失去了这个能力。

值得庆幸的是,ES6 的解构 destructuring 就是答案。解构 是一种 为您希望看到的结构类型(对象、数组等)声明一个模式 pattern 的方法,以及应该如何处理其各个部分的分解( 分配 assignment )。

考虑一下:

function f([x,y,...args] = []) {
    // something
}

f([1,2,3]);

你现在发现 parameter 列表周围的 [ .. ] 括号了吗?这就是所谓的 数组 parameter 解构

在这个例子中,destructuring 告诉引擎,在这个赋值位置(也就是 parameter )预计会有一个数组。这个模式 pattern 说,取该数组的第一个值,并将其分配给一个名为 x 的本地参数变量,第二个值分配给 y ,剩下的任何值都会被 聚集 gathered 到 args 中。


f. 声明式风格的好处

考虑到我们刚刚看到的解构的 f(..) ,我们可以手动处理 parameters 。

function f(params) {
    var x = params[0];
    var y = params[1];
    var args = params.slice(2);
    // something
}

但这里我们强调一个原则,声明式代码 比 命令式代码 能更有效地传达。

声明式代码 (比如前一个 f(..) 片段中的 解构,或者 ... 操作符的用法)关注的是一段代码的结果应该是什么。

命令式代码 (比如后一个代码段中的手动赋值)更多关注的是如何获得结果。结果是 coded 在那里的,但它并不那么清晰,因为它被我们 如何 how 到达那里的细节所挤占。

早期的 f(..) 被认为是更可读的,因为 解构 隐藏了 如何 how 管理 parameter 输入的不必要的细节。

只要有可能,我们就应该争取使用声明式的、自明的代码


g. 命名参数 Arguments

就像我们可以解构数组参数一样,我们也可以解构对象参数。

function f({x,y} = {}) {
    console.log(x, y);
}

f({
    y: 3
});
// Output :                                      
// undefined 3

我们传入一个对象作为单一 argument,它被解构成两个独立的 parameter 变量 xy ,并从传入的对象中分配那些对应的属性名的值。 x 属性不在对象上也没有关系,只是像你所期望的那样,最后变成了一个带有 undefined 的变量。

在像 f(undefined,3) 这样的正常调用端,位置 position 是用于从 argument 到 parameter 的映射;我们把 3 放在第二个位置,让它分配到 y parameter 上。

但是在这个涉及到 parameter 解构的调用端,一个简单的对象属性 object-property 就能指示出 argument 值 3 应该分配到哪个 parameter(y)。

有些语言对此有一个明确的功能:命名参数 argument 。换句话说,在调用端,给一个输入值打上标签,以表明它映射到哪个 parameter 。JavaScript 没有命名参数,但 parameter 对象重构是退而求其次之策。


h. 无序参数 Parameters

另一个关键的好处是,命名的 arguments 由于是作为对象属性指定的,所以从根本上来说是没有顺序的。这意味着我们可以以任何我们想要的顺序来指定输入。

function f({x,y} = {}) {
    console.log(x, y);
}

f({
    y: 3
});  
// Output :                  
// undefined 3

调用端不再被有序的占位符 ordered-placeholders 所干扰,比如跳过一个参数的 undefined


5. 函数输出 Function Outputs

本节介绍了 函数输出 的一些方面。

在JavaScript中,「函数」总是返回 return 一个值。这三个函数都有相同的返回 return 行为。

function foo() {}

function bar() {
    return;
}

function baz() {
    return undefined;
}

如果你没有返回 return,或者你只是有一个空的返回 return,那么 undefined 的值就会被隐式返回 return;

但是,尽可能地保持 函数式编程函数 定义的精神 —— 使用函数而不是过程 —— 我们的函数应该总是有输出,这意味着它们应该显式地返回一个值,而且通常不是未定义的 undefined

一个返回语句只能返回一个值。所以如果你的函数需要返回多个值,你唯一可行的选择就是把它们收集成一个复合值,比如一个数组或一个对象。

function f() {
    var retValue1 = 1;
    var retValue2 = 3;
    return [retValue1, retValue2];
}

然后,我们将从 f() 返回的数组中分别赋值 xy 两项。

var [x, y] = f();
console.log(x + y);
// Output : 4

将多个值收集到一个数组(或对象)中返回,然后将这些值解构回不同的赋值,是透明地表达一个函数的多个输出的方法。

让我们来介绍一些与函数输出相关的概念,主要有:

  • 提前返回
  • 返回 的输出
  • 高阶函数 ( HOFs 或 函数的函数 )

a. 提前返回

return 语句不只是从「函数」中返回一个值。它也是一个流程控制结构;它在该点结束了「函数」的执行。

因此,一个有多个返回语句的「函数」有多个可能的退出点,这意味着如果有很多路径可以产生该输出,那么阅读一个函数可能更难理解它的输出行为。

考虑

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 的分支都有条件保护,检查它是否已经被设置。

我们没有提前从函数中 返回 ,而是使用正常的流程控制( if 逻辑)来确定 retValue 的赋值。在最后,我们只是返回 retValue

总而言之,在最后只进行一次返回,这样更容易读懂。试着找出最明确的逻辑表达方式。


b. 无 返回 的输出

你可能在你写的大多数代码中都使用过一种技术,也许你根本就没有多想,那就是通过简单地改变自身外部的变量,让一个函数输出部分或全部的值。

还记得我们之前的 f(x) = x² - 1 函数吗?我们可以在 JS 中这样定义它。

var y;
function f(x) {
    y = (2 * Math.pow( x, 2 )) + 3;
}

我们可以很容易地从函数中 返回 值,而不是将其设置为 y

function f(x) {
    return (2 * Math.pow( x, 2 )) + 3;
}

两个函数都完成了同样的任务,那么我们有什么理由选择一个版本而不是另一个版本吗?

解释这种区别的一种方法是,后一个版本中的 返回 是一个 显式输出 的信号,而前一个版本中的 y 赋值是一个 隐式输出

但是在外部作用域中改变一个变量,就像我们在 f(...) 里面的 y 赋值一样,只是实现隐式输出的一种方式。一个更微妙的例子是通过引用对非局部值进行更改。

考虑一下

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 。但是在第 4 位没有出现 undefined 的空槽值,现在出现了一个0。

看似无害的 list[i]=0 操作最终还是影响了外部的数组值,尽管我们操作的是一个局部的 list parameter 变量。

为什么呢?因为 list 拥有 nums 引用的副本,而不是 [1,3,9,...] 数组值的值副本。JavaScript 在数组、对象和函数中使用了引用 references 和引用副本 reference-copies,所以我们可能会很容易地从我们的函数中创建一个意外的输出。

这种隐式函数输出在 FP 世界里有一个特殊的名字。副作用 Side Effects. 而一个没有副作用的函数也有一个特殊的名字:纯函数 Pure Function 。


c. 高阶函数 ( HOFs 或 函数的函数 )

函数可以接收和返回任何类型的值。接收或返回一个或多个其他函数值的函数有一个特殊的名称: 高阶函数 higher-order function

请考虑:

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!

根据定义,将其他函数作为值的函数是高阶函数。这些函数对于函数式编程来说是非常关键的。


小节:

我们在这篇文章中介绍了以下概念。

  1. 什么是函数 Functions

  2. 函数 Functions过程 Procedures

  3. 声明式 Declarative 编程与命令式 Imperative 编程

  4. 函数输入 Function Inputs

  • Arguments(实际参数,实参)和 Parameters(形式参数,形参)
  • 默认参数 Parameters
  • 输入的数量
  • 参数 Arguments 数组
  • 参数 Parameter 解构
  • 声明式风格的好处
  • 命名参数 Arguments
  • 无序参数 Parameters
  1. 函数输入 Function Inputs
  • 提前返回
  • 返回 的输出
  • 高阶函数 ( HOFs 或 函数的函数 )