图片 5

JavaScript 函数式编程导论

近年来,函数式编程(Functional
Programming)已经成为了JavaScript社区中炙手可热的主题之一,无论你是否欣赏这种编程理念,相信你都已经对它有所了解。即使是前几年函数式编程尚未流行的时候,我已经在很多的大型应用代码库中发现了不少对于函数式编程理念的深度实践。函数式编程即是在软件开发的工程中避免使用共享状态(Shared
State)、可变状态(Mutable Data)以及副作用(Side
Effects)。函数式编程中整个应用由数据驱动,应用的状态在不同纯函数之间流动。与偏向命令式编程的面向对象编程而言,函数式编程其更偏向于声明式编程,代码更加简洁明了、更可预测,并且可测试性也更好。。函数式编程本质上也是一种编程范式(Programming
Paradigm),其代表了一系列用于构建软件系统的基本定义准则。其他编程范式还包括面向对象编程(Object
Oriented Programming)与过程程序设计(Procedural Programming)。

编程范式

编程范式是:解决编程中的问题的过程中使用到的一种模式,体现在思考问题的方式和代码风格上。这点很像语言,语言本身会体现出不同国家的人的思考方式和行为模式。

常见的编程范式有下面几种:

  • 命令式编程
  • 面向对象编程
  • 函数式编程

除了这三个之外,我们还会接触到其他的编程范式,如:声明式。

编程范式之间不是互斥关系,而是可以结合在一起使用的。我们往往需要结合各种编程范式来完成一个程序功能。

在学习写代码的过程中,我们一般先接触命令式编程,然后学习面向对象编程,面向对象编程可以让我们很方便地处理更复杂的问题。这篇文章里,我们会介绍函数式编程。

面向对象编程和面向过程编程都是编程范式,函数式编程也是一种编程范式,意味着它们都是软件构建的思维方式。与命令式或面向对象代码相比,函数式代码倾向于更简洁、更可预测以及更易于测试。

纯函数

顾名思义,纯函数往往指那些仅根据输入参数决定输出并且不会产生任何副作用的函数。纯函数最优秀的特性之一在于其结果的可预测性:

var z = 10;
function add(x, y) {
    return x + y;
}
console.log(add(1, 2)); // prints 3
console.log(add(1, 2)); // still prints 3
console.log(add(1, 2)); // WILL ALWAYS print 3

add函数中并没有操作z变量,即没有读取z的数值也没有修改z的值。它仅仅根据参数输入的xy变量然后返回二者相加的和。这个add函数就是典型的纯函数,而如果在add函数中涉及到了读取或者修改z变量,那么它就失去了纯洁性。我们再来看另一个函数:

function justTen() {
    return 10;
}

对于这样并没有任何输入参数的函数,如果它要保持为纯函数,那么该函数的返回值就必须为常量。不过像这种固定返回为常量的函数还不如定义为某个常量呢,就没必要大材小用用函数了,因此我们可以认为绝大部分的有用的纯函数至少允许一个输入参数。再看看下面这个函数:

function addNoReturn(x, y) {
    var z = x + y
}

注意这个函数并没有返回任何值,它确实拥有两个输入参数xy,然后将这两个变量相加赋值给z,因此这样的函数也可以认为是无意义的。这里我们可以说,绝大部分有用的纯函数必须要有返回值。总结而言,纯函数应该具有以下几个特效:

  • 绝大部分纯函数应该拥有一或多个参数值。
  • 纯函数必须要有返回值。
  • 相同输入的纯函数的返回值必须一致。
  • 纯函数不能够产生任何的副作用。

不同的编程范式有不同的代码表现

图片 1

image.png

比如从来没有坐过电梯的人,第一次坐电梯,电梯在 10 楼,人在 1
楼,他会按下,让电梯下来。按错按钮是因为他用了祈使语,而不是把自己的想法提交出去。

相似地,你写的代码就像电梯的按钮界面,是让自己或者他人阅读的。只有达成了相同的共识才能更好地理解。通过这次文章可以让大家更好地理解函数式编程。

下面是几种编程范式的代码片段:

const app = 'github';
const greeting = 'Hi, this is ';
console.log(greeting + app);

这是命令式编程,通过调用 constconsole.log 进行赋值和输出。

const Program = function() {
    this.app = 'github';
    this.greeting = function() {
        console.log('Hi, this is ' + this.app);
    };
};
const program = new Program();
program.greeting();

这是面向对象编程,我们把整个程序抽象成现实生活中的一个对象,这个对象会包含属性和方法。通过类的概念,我们有了生成对象的一个工厂。使用
new
关键字创建一个对象,最后调用对象的方法,也能完成刚才我们用命令式编程完成的程序功能。

const greet = function(app) {
    return 'Hi, this is ' + app;
};
console.log(greet('github'));

这是简单的函数式编程,通过函数的调用完成程序的功能。但是一般情况下的函数式编程会更复杂一些,会包含函数的组合。

什么是函数式编程 (通常简称为 FP)

是指通过复合纯函数来构建软件的过程,它避免了共享的状态易变的数据、以及副作用。函数式编程是声明式而不是命令式,并且应用程序状态通过纯函数流转。

共享状态与副作用

共享状态(Shared State)可以是存在于共享作用域(全局作用域与闭包作用域)或者作为传递到不同作用域的对象属性的任何变量、对象或者内存空间。在面向对象编程中,我们常常是通过添加属性到其他对象的方式共享某个对象。共享状态问题在于,如果开发者想要理解某个函数的作用,必须去详细了解该函数可能对于每个共享变量造成的影响。譬如我们现在需要将客户端生成的用户对象保存到服务端,可以利用saveUser()函数向服务端发起请求,将用户信息编码传递过去并且等待服务端响应。而就在你发起请求的同时,用户修改了个人头像,触发了另一个函数updateAvatar()以及另一次saveUser()请求。正常来说,服务端会先响应第一个请求,并且根据第二个请求中用户参数的变更对于存储在内存或者数据库中的用户信息作相应的修改。不过某些意外情况下,可能第二个请求会比第一个请求先到达服务端,这样用户选定的新的头像反而会被第一个请求中的旧头像覆写。这里存放在服务端的用户信息就是所谓的共享状态,而因为多个并发请求导致的数据一致性错乱也就是所谓的竞态条件(Race
Condition),也是共享状态导致的典型问题之一。另一个共享状态的常见问题在于不同的调用顺序可能会触发未知的错误,这是因为对于共享状态的操作往往是时序依赖的。

const x = {
  val: 2
};

const x1 = () => x.val += 1;

const x2 = () => x.val *= 2;

x1();
x2();

console.log(x.val); // 6

const y = {
  val: 2
};

const y1 = () => y.val += 1;

const y2 = () => y.val *= 2;

// 交换了函数调用顺序
y2();
y1();

// 最后的结果也受到了影响
console.log(y.val); // 5

副作用指那些在函数调用过程中没有通过返回值表现的任何可观测的应用状态变化,常见的副作用包括但不限于:

  • 修改任何外部变量或者外部对象属性
  • 在控制台中输出日志
  • 写入文件
  • 发起网络通信
  • 触发任何外部进程事件
  • 调用任何其他具有副作用的函数

在函数式编程中我们会尽可能地规避副作用,保证程序更易于理解与测试。Haskell或者其他函数式编程语言通常会使用Monads来隔离与封装副作用。在绝大部分真实的应用场景进行编程开始时,我们不可能保证系统中的全部函数都是纯函数,但是我们应该尽可能地增加纯函数的数目并且将有副作用的部分与纯函数剥离开来,特别是将业务逻辑抽象为纯函数,来保证软件更易于扩展、重构、调试、测试与维护。这也是很多前端框架鼓励开发者将用户的状态管理与组件渲染相隔离,构建松耦合模块的原因。

不同的编程范式适用的场景不同

  • 命令式编程:流程顺序
  • 面向对象编程:物体
  • 函数式语言:数据处理

我们往往会在不同场景下使用不同的编程范式,通过编程范式的结合来实现一个程序。

我们通过命令式编程去让程序按步骤地执行操作。

面向对象编程则是把程序抽象成和现实生活中的物体一样的对象,对象上有属性和方法,通过对象之间的修改属性和调用方法来完成程序设计。

而函数式编程则适用于数据运算和处理。

再仔细看下之前的代码,我们就会发现这些编程范式往往是要结合起来使用的。

const app = 'github';
const greeting = 'Hi, this is ';
console.log(greeting + app);

这个例子里面,除了命令式之外,我们还可以把前两句语句赋值解读成声明式编程。

const Program = function(app) {
    this.app = app;
    this.greeting = function() {
        console.log('Hi, this is ' + this.app);
    };
};
const program = new Program('github');
program.greeting();

这里例子里面,我们看到在类的 greeting 方法里面也用到了命令式的
console.log。在最后的执行过程中的 program.greeing() 也是命令式的。

const greet = function(app) {
    return 'Hi, this is ' + app;
};
console.log(greet('github'));

理解函数式编程中核心概念

  • 纯函数(Pure functions)
  • 函数复合(Function composition)
  • 避免共享状态(Avoid shared state)
  • 避免改变状态(Avoid mutating state)
  • 避免副作用(Avoid side effects)
  • 声明式与命令式(Declarative and Imperative)

不变性

不可变对象(Immutable
Object)指那些创建之后无法再被修改的对象,与之相对的可变对象(Mutable
Object)指那些创建之后仍然可以被修改的对象。不可变性(Immutability)是函数式编程的核心思想之一,保证了程序运行中数据流的无损性。如果我们忽略或者抛弃了状态变化的历史,那么我们很难去捕获或者复现一些奇怪的小概率问题。使用不可变对象的优势在于你在程序的任何地方访问任何的变量,你都只有只读权限,也就意味着我们不用再担心意外的非法修改的情况。另一方面,特别是在多线程编程中,每个线程访问的变量都是常量,因此能从根本上保证线程的安全性。总结而言,不可变对象能够帮助我们构建简单而更加安全的代码。
在JavaScript中,我们需要搞清楚const与不可变性之间的区别。const声明的变量名会绑定到某个内存空间而不可以被二次分配,其并没有创建真正的不可变对象。你可以不修改变量的指向,但是可以修改该对象的某个属性值,因此const创建的还是可变对象。JavaScript中最方便的创建不可变对象的方法就是调用Object.freeze()函数,其可以创建一层不可变对象:

const a = Object.freeze({
  foo: 'Hello',
  bar: 'world',
  baz: '!'
});

a.foo = 'Goodbye';
// Error: Cannot assign to read only property 'foo' of object Object

不过这种对象并不是彻底的不可变数据,譬如如下的对象就是可变的:

const a = Object.freeze({
  foo: { greeting: 'Hello' },
  bar: 'world',
  baz: '!'
});

a.foo.greeting = 'Goodbye';

console.log(`${ a.foo.greeting }, ${ a.bar }${a.baz}`);

如上所见,顶层的基础类型属性是不可以改变的,不过如果对象类型的属性,譬如数组等,仍然是可以变化的。在很多函数式编程语言中,会提供特殊的不可变数据结构Trie
Data
Structures来实现真正的不可变数据结构,任何层次的属性都不可以被改变。Tries还可以利用结构共享(Structural
Sharing)的方式来在新旧对象之间共享未改变的对象属性值,从而减少内存占用并且显著提升某些操作的性能。JavaScript中虽然语言本身并没有提供给我们这个特性,但是可以通过Immutable.js与Mori这些辅助库来利用Tries的特性。我个人两个库都使用过,不过在大型项目中会更倾向于使用Immutable.js。估计到这边,很多习惯了命令式编程的同学都会大吼一句:在没有变量的世界里我又该如何编程呢?不要担心,现在我们考虑下我们何时需要去修改变量值:譬如修改某个对象的属性值,或者在循环中修改某个循环计数器的值。而函数式编程中与直接修改原变量值相对应的就是创建原值的一个副本并且将其修改之后赋予给变量。而对于另一个常见的循环场景,譬如我们所熟知的for,while,do,repeat这些关键字,我们在函数式编程中可以使用递归来实现原本的循环需求:

// 简单的循环构造
var acc = 0;
for (var i = 1; i <= 10; ++i)
    acc += i;
console.log(acc); // prints 55
// 递归方式实现
function sumRange(start, end, acc) {
    if (start > end)
        return acc;
    return sumRange(start + 1, end, acc + start)
}
console.log(sumRange(1, 10, 0)); // prints 55

注意在递归中,与变量i相对应的即是start变量,每次将该值加1,并且将acc+start作为当前和值传递给下一轮递归操作。在递归中,并没有修改任何的旧的变量值,而是根据旧值计算出新值并且进行返回。不过如果真的让你把所有的迭代全部转变成递归写法,估计得疯掉,这个不可避免地会受到JavaScript语言本身的混乱性所影响,并且迭代式的思维也不是那么容易理解的。而在Elm这种专门面向函数式编程的语言中,语法会简化很多:

sumRange start end acc =
    if start > end then
        acc
    else
        sumRange (start + 1) end (acc + start)

其每一次的迭代记录如下:

sumRange 1 10 0 =      -- sumRange (1 + 1)  10 (0 + 1)
sumRange 2 10 1 =      -- sumRange (2 + 1)  10 (1 + 2)
sumRange 3 10 3 =      -- sumRange (3 + 1)  10 (3 + 3)
sumRange 4 10 6 =      -- sumRange (4 + 1)  10 (6 + 4)
sumRange 5 10 10 =     -- sumRange (5 + 1)  10 (10 + 5)
sumRange 6 10 15 =     -- sumRange (6 + 1)  10 (15 + 6)
sumRange 7 10 21 =     -- sumRange (7 + 1)  10 (21 + 7)
sumRange 8 10 28 =     -- sumRange (8 + 1)  10 (28 + 8)
sumRange 9 10 36 =     -- sumRange (9 + 1)  10 (36 + 9)
sumRange 10 10 45 =    -- sumRange (10 + 1) 10 (45 + 10)
sumRange 11 10 55 =    -- 11 > 10 => 55
55

函数式编程

使用函数式编程可以大大提高代码的可读性。

纯函数

纯函数是满足如下条件的函数:

  • 相同输入总是会返回相同的输出
  • 不产生副作用
  • 不依赖于外部状态

JS中纯函数的例子:

String.prototype.toUpperCase  
Array.prototype.map
Function.prototype.bind

JS中非纯函数的例子:

Date.now
Math.random
Array.prototype.sort
document.body.appendChild

纯函数的好处:

  1. 易于测试(上下文无关)
  2. 可并行计算(时序无关)
  3. bug 自限性(问题不会扩散)

高阶函数

函数式编程倾向于重用一系列公共的纯函数来处理数据,而面向对象编程则是将方法与数据封装到对象内。这些被封装起来的方法复用性不强,只能作用于某些类型的数据,往往只能处理所属对象的实例这种数据类型。而函数式编程中,任何类型的数据则是被一视同仁,譬如map()函数允许开发者传入函数参数,保证其能够作用于对象、字符串、数字,以及任何其他类型。JavaScript中函数同样是一等公民,即我们可以像其他类型一样处理函数,将其赋予变量、传递给其他函数或者作为函数返回值。而高阶函数(Higher
Order
Function)则是能够接受函数作为参数,能够返回某个函数作为返回值的函数。高阶函数经常用在如下场景:

  • 利用回调函数、Promise或者Monad来抽象或者隔离动作、作用以及任何的异步控制流
  • 构建能够作用于泛数据类型的工具函数
  • 函数重用或者创建柯里函数
  • 将输入的多个函数并且返回这些函数复合而来的复合函数

典型的高阶函数的应用就是复合函数,作为开发者,我们天性不希望一遍一遍地重复构建、测试与部分相同的代码,我们一直在寻找合适的只需要写一遍代码的方法以及如何将其重用于其他模块。代码重用听上去非常诱人,不过其在很多情况下是难以实现的。如果你编写过于偏向具体业务的代码,那么就会难以重用。而如果你把每一段代码都编写的过于泛化,那么你就很难将这些代码应用于具体的有业务场景,而需要编写额外的连接代码。而我们真正追寻的就是在具体与泛化之间寻求一个平衡点,能够方便地编写短小精悍而可复用的代码片,并且能够将这些小的代码片快速组合而解决复杂的功能需求。在函数式编程中,函数就是我们能够面向的最基础代码块,而在函数式编程中,对于基础块的组合就是所谓的函数复合(Function
Composition)。我们以如下两个简单的JavaScript函数为例:

var add10 = function(value) {
    return value + 10;
};
var mult5 = function(value) {
    return value * 5;
};

如果你习惯了使用ES6,那么可以用Arrow Function重构上述代码:

var add10 = value => value + 10; 
var mult5 = value => value * 5;

现在看上去清爽多了吧,下面我们考虑面对一个新的函数需求,我们需要构建一个函数,首先将输入参数加10然后乘以5,我们可以创建一个新函数如下:

var mult5AfterAdd10 = value => 5 * (value + 10)

尽管上面这个函数也很简单,我们还是要避免任何函数都从零开始写,这样也会让我们做很多重复性的工作。我们可以基于上文的add10与mult5这两个函数来构建新的函数:

var mult5AfterAdd10 = value => mult5(add10(value));

在mult5AfterAdd10函数中,我们已经站在了add10与mult5这两个函数的基础上,不过我们可以用更优雅的方式来实现这个需求。在数学中,我们认为f ∘ g是所谓的Function
Composition,因此`f ∘ g可以认为等价于f(g(x)),我们同样可以基于这种思想重构上面的mult5AfterAdd10。不过JavaScript中并没有原生的Function
Composition支持,在Elm中我们可以用如下写法:

add10 value =
    value + 10
mult5 value =
    value * 5
mult5AfterAdd10 value =
    (mult5 << add10) value

这里的<<操作符也就指明了在Elm中是如何组合函数的,同时也较为直观的展示出了数据的流向。首先value会被赋予给add10,然后add10的结果会流向mult5。另一个需要注意的是,(mult5 << add10)中的中括号是为了保证函数组合会在函数调用之前。你也可以组合更多的函数:

f x =
   (g << h << s << r << t) x

如果在JavaScript中,你可能需要以如下的递归调用来实现该功能:

g(h(s(r(t(x)))))

函数式编程的学习曲线

你编写的每一行代码之后都要有人来维护,这个人可能是你的团队成员,也可能是未来的你。如果代码写的太过复杂,那么无论谁来维护都会对你炫技式的故作聪明的做法倍感压力。

对于复杂计算的场景下,使用函数式编程相比于命令式编程有更好的可读性。

图片 2

image.png

从命令式的编程到函数式编程转换的道路上,可读性会变低,但是一旦度过了一个坎,也就是在你大量使用函数式编程时,可读性便会大大提升。可是我们往往会被这个坎阻挠,在发现可读性下降后放弃学习函数式编程。

因此除了学习函数式编程本身的知识之外,我们还需要明白学习可能经历的过程和最终的结果。

函数复合

函数复合是结合两个或多个函数,从而产生一个新函数或进行某些计算的过程。

在 JavaScript 中相当于执行 f(g(x))。

函数式编程定义

函数是第一公民。

JavaScript 是一门在设计之处就完全支持函数式编程的语言,在 JavaScript
里面,函数可以用 function
声明,作为全局变量,也就是这里说的“第一公民”。我们不再使用
varconst 或者 let
等关键字声明函数。我们也会大大减少变量的声明,通过函数的形参来替代变量的声明。

函数式编程大量通过函数的组合进行逻辑处理,因此我们在后面会看到很多辅助函数。通过这些辅助函数,我们可以更方便得修改和组合函数。

共享状态

共享状态
的意思是:任意变量、对象或者内存空间存在于共享作用域(包括全局作用域和闭包作用域)下,或者作为对象的属性在各个作用域之间被传递。

通常,在面向对象编程中,对象以添加属性到其他对象上的方式在作用域之间共享。与面向对象编程不同,函数式编程避免共享状态,它依赖于不可变数据结构和纯粹的计算过程来从已存在的数据中派生出新的数据。
共享状态的一个常见问题是改变函数调用次序函数调用的次序可能会改变函数调用的结果,进而可能导致一连串的错误:

const x = {
  val: 2
};
const x1 = () => x.val += 1;
const x2 = () => x.val *= 2;
x1(); // -> 3
x2(); // -> 6

下面的例子与上面的相同,除了函数调用的次序不同:

const x = {
  val: 2
};
const x1 = () => x.val += 1;
const x2 = () => x.val *= 2;
x2(); // -> 4
x1(); // -> 5

如果避免共享状态,就不会改变函数内容,或者改变函数调用的时序不会波及和破坏程序的其他部分:

const x = {
  val: 2
};
const x1 = x => Object.assign({}, x, { val: x.val + 1});
const x2 = x => Object.assign({}, x, { val: x.val * 2});

x1(x); // -> 3
x2(x); // -> 4

/**
x2(x); // -> 4
x1(x); // -> 3
*/

什么是函数?

一个函数就是包含输入和输出的方程。数据流方向是从输入到输出。

在数学里面我们学到的函数是这样的:

y = f(x)

在 JavaScript 里面,函数是这样表示的:

function(x) { return y; }

代码中的函数和数学意义上的函数概念是一样的。

不修改状态

在其他类型的语言中,变量往往用来保存”状态”。而函数式编程只是返回新的值,不修改系统变量,即是无破坏性的数据转换。

函数和程序的区别

  • 程序是任意功能的合集,可以没有输入值,可以没有输出值。
  • 函数必须有输入值和输出值。

副作用

副作用是指除了函数返回值以外,任何在函数调用之外观察到的应用程序状态改变。

副作用包括:

  • 改变了任何外部变量或对象属性
  • 写文件
  • 发网络请求
  • 在屏幕输出
  • 调用另一个有副作用的函数

在函数式编程中,副作用被尽可能避免。

函数适合的场景

函数适合:数学运算。不适合:与真实世界互动。

实际的编程需要修改硬盘等。如果不改变东西,等于什么都没做。也就没办法完成任务了。

声明式与命令式

  • 命令式:程序花费大量代码来描述用来达成期望结果的特定步骤,即”How to
    do”
  • 声明式:程序抽象了控制流过程,花费大量代码描述的是数据流,即”What to
    do”

函数式编程是一个声明式范式,意思是说程序逻辑不需要通过明确描述控制流程来表达。
命令式:

let list = [1, 2, 3, 4];
let map1 = [];
for (let i = 0; i < list.length; i++) {
  map1.push(list[i] * 2);
}

声明式:

let list = [1, 2, 3, 4];
let map2 = list.map(x => 2 * x);

再来看声明式例子中引出的两个重要概念:
[图片上传失败…(image-f8878f-1512358902667)]
在讲容器前不得不提什么是范畴:

彼此之间存在某种关系的概念、事物、对象等等,都构成”范畴”。

范畴的数学模型简单理解就是:”集合 + 函数”。

  • 容器(Container):可以把”范畴”想象成是一个容器,里面包含:值和值的变形(函数)
  • 函子(Functor):是一个有接口的容器,能够遍历其中的值。能够将容器里面的每一个值,映射到另一个容器。

JavaScript 和函数式编程

JavaScript 支持函数式编程。使用 JavaScript 进行函数式编程时,我们要使用
JavaScript 的子集。不使用 for 循环, Math.random, Date,
不修改数据,来避免副作用,做到函数式编程。

下面,面向 JavaScript 开发者,介绍在 JavaScript
函数式编程中用到的一些概念。

函数式编程的应用

在函数式编程中,通常使用functors以及使用高阶函数抽象来创建通用功能函数,以处理任意数值或不同类型的数据。

高阶函数

高阶函数是由一个或多个函数作为输入的函数,或者是输出一个函数的函数。

[1, 2, 3].map(function(item, index, array) {
    return item * 2;
});

[1, 2, 3].reduce(function(accumulator, currentValue, currentIndex, array) {
    return accumulator + currentValue;
}, 0);

mapreduce 是高阶函数,它接收一个函数作为参数。

高阶函数

高阶函数指的是一个函数以函数为参数,或以函数为返回值,或者既以函数为参数又以函数为返回值。

高阶函数常用于:

  • 部分应用于函数参数(偏函数应用)或创建一个柯里化的函数,用于复用或函数复合
  • 接受一个函数列表并返回一些由这个列表中的函数组成的复合函数。
    面向对象编程倾向于把方法和数据集中到对象上。那些被集中的方法通常只能用来操作包含在特定对象实例上的数据。而函数式编程倾向于复用一组通用的函数功能来处理数据。

纯函数

纯函数有两个特点:

  • 纯函数是幂等的
  • 纯函数没有副作用

纯函数是可靠的,可预测结果。带来了可读性和可维护性。

偏函数

通过指定部分参数来产生一个新定制函数的形式就是偏函数。

const isType = function (type) {
  return function (obj) {
    return toString.call(obj) == '[object' + type + ']';
  };
};

const isString = isType('string');
const isFunction = isType('function');

幂等

幂等是指函数任意多次执行所产生的影响和一次执行的影响相同。函数的输入和输出都需要幂等。

function add(a, b) {
    return a + b;
}

上面的函数是幂等的。

function add(a) {
    return a + Math.random();
}

上面使用了随机数,每次执行得到的结果不同,所以这个函数不幂等。

var a = 1;
function add(b) {
    return a + b;
}

上面使用到函数外部的数据,当外部数据变化时,函数执行的结果不再相同,所以这个函数不幂等。

var c = 1;
function add(a, b) {
    c++;
    return a + b;
}

上面的函数修改了函数外部的数据,所以也不幂等。

柯里化

柯里化是将一个多参数函数转换成多个单参数函数。

// 柯里化之前
function add(x, y) {
  return x + y;
}

add(1, 2) // 3

// 柯里化之后
function addX(y) {
  return function (x) {
    return x + y;
  };
}

addX(2)(1) // 3

副作用

副作用是当调用函数时,除了返回函数值之外,还对主调用函数产生附加的影响。

最常见的副作用是
I/O(输入/输出)。对于前端来说,用户事件(鼠标、键盘)是 JS
编程者在浏览器中使用的典型的输入,而输出的则是 DOM。如果你使用 Node.js
比较多,你更有可能接触到和输出到文件系统、网络系统和/或者 stdin /
stdout(标准输入流/标准输出流)的输入和输出。

函数的复合

如果一个值要经过多个函数,才能变成另外一个值,就可以把所有中间步骤合并成一个函数,这叫做”函数的复合”。

一个简单的函数的复合例子:

const compose = function (f, g) {
  return function (x) {
    return f(g(x));
  };
}

实现一个高阶函数用来减少非纯函数:

function batch (fn) {
  return function (target, ...args) {
    if (target.length >= 0) {
      return Array.from(target).map(item => fn.apply(this, [item, ...args]));
    } else {
      return fn.apply(this, [target, ...args]);
    }
  }
}

例如:两个非纯函数
-> batch(fn) ->
一个非纯函数

纯函数

一个纯函数需要满足下面两个条件:

  • 纯函数是幂等的
  • 纯函数没有副作用

结论

函数式编程偏好:

  • 使用表达式替代语句
  • 让可变数据成为不可变的
  • 用函数复合替代命令控制流
  • 使用声明式而不是命令式代码
  • 使用纯函数而不是使用共享状态和副作用
  • 使用容器与高阶函数替代多态
  • 使用高阶函数来操作许多数据类型,创建通用、可复用功能取代只是操作集中的数据的方法

不可变数据

不可变数据是指保持一个对象状态不变。

值的不可变性并不是不改变值。它是指在程序状态改变时,不直接修改当前数据,而是创建并追踪一个新数据。这使得我们在读代码时更有信心,因为我们限制了状态改变的场景,状态不会在意料之外或不易观察的地方发生改变。

在函数式和非函数式编程中,不可变数据对我们都有帮助。

使用不可变数据的准则

  • 使用 const,不使用 let
  • 不使用 splicepoppushshiftunshiftreverse 以及
    fill 修改数组
  • 不修改对象属性或方法

使用不可变数据的弊端

不可变数据有更多内存开销。

图片 3

image.png

修改数据的情况下,直接替换了变量的值,内存开销不变。

图片 4

image.png

使用不可变数据后,我们复制了一个对象,内存开销翻倍。

图片 5

image.png

使用 immutableJS 等辅助库后,可以更好地利用之前的数据,优化了内存开销。

闭包 vs 对象

闭包和对象是一样东西的两种表达方式。一个没有闭包的编程语言可以用对象来模拟闭包;一个没有对象的编程语言可以用闭包来模拟对象。两者都可以用来维护数据。

var obj = {
    one: 1,
    two: 2
};

function run() {
    return this.one + this.two;
}

var three = run.bind(obj);

three();        // => 3

function getRun() {
    var one = 1;
    var two = 2;

    return function run(){
        return one + two;
    };
}

var three = getRun();

three();            // => 3

上面两种方式都可以用来完成程序功能,对象和函数之间可以转换。

常见的辅助函数

  • unary
  • reverseArgs
  • curry
  • uncurry
  • compose
  • pipe
  • asyncPipe

unaryreverseArgscurryuncurry
是用来进行参数操作的。composepipeasyncPipe
是用来进行函数组合的。

unary

unary 是用来限制某个函数只接收一个参数的。常见的使用场景是处理
parseInt 函数:

['1', '2', '3'].map(parseInt);
// => [1, NaN, NaN]

['1', '2', '3'].map(unary(parseInt));
// => [1, 2, 3]

unary 的实现方式可以是:

const unary = (fn) => {
    return (arg) => {
        return fn(arg);
    };
};

reverseArgs

reverseArgs 是用来讲函数参数反转的,实现方式如下:

const reverseArgs = (fn) => {
    return (...args) => {
        return fn(...args.reverse());
    };
};

curry

curry
是用来把函数执行滞后的,让我们可以逐步把参数传入这个函数,当参数完整之后,目标函数才会执行。常见的用法如下:

function add(a, b) {
    return a + b;
}
function add10(a) {
    return add(10, a);
}
add10(1); // => 11

通过 curry 函数,可以把上面的代码优化一下:

function add(a, b) {
    return a + b;
}
const curriedAdd = curry(add);
const add10 = curriedAdd(10);
add10(1); // => 11

curry 的实现思路如下:

args,保存起来,每个 curried
函数接受一个参数,将参数拼在之前的参数后面。

const curry = (fn) => {
    const curried = (curArg) => {
        const args = [...prevArgs, curArg];
        return curried;
    };
    return curried;
};

修改成用闭包保存参数。

const curry = (fn) => {
    return (curArg) => {
        const args = [...prevArgs, curArg];
        return nextCurried(...args);
    };
};

递归调用 nextCurried,第一次柯里化的函数不传入参数。

const curry = (fn) => {
    const nextCurried = (...prevArgs) => {
        return (curArg) => {
            const args = [...prevArgs, curArg];
            return nextCurried(...args);
        };
    };
    return nextCurried();
};

最后补全 arity
参数,来定义目标函数的参数数量。这样,我们可以定义柯里化后的参数数量,如果传入的参数数量到了函数需要的数量,则直接执行函数,并传入所有的参数。

const curry = (fn, arity = fn.length) => {
    const nextCurried = (...prevArgs) => {
        return (curArgs) => {
            const args = [...prevArgs, curArgs];
            if (args.length >= arity) {
                return fn(...args);
            }
            return nextCurried(...args);
        };
    };
    return nextCurried();
};

或者我们可以实现一个支持传入多个参数的柯里化函数:

const curry = (fn, arity = fn.length) => {
    const nextCurried = (...prevArgs) => {
        return (...curArgs) => {
            const args = [...prevArgs, ...curArgs];
            if (args.length >= arity) {
                return fn(...args);
            }
            return nextCurried(...args);
        };
    };
    return nextCurried();
};

compose

compose 用来串联执行函数,执行顺序是从后向前的。与之对应的是 pipe
函数,同样是串联执行函数,但是执行顺序是从前向后的。

compose 的用法:

function add10(value) {
    return value + 10;
}
function multiple10(value) {
    return value * 10;
}
const add10AndMultiple10 = compose(multiple10, add10);
add10AndMultiple10(1); // => 110

compose 的实现:

const compose = (...fns) => {
    return fns.reduce((a, b) => {
        return (...args) => {
            return a(b(...args));
        };
    });
};

或者通过 reduceRight
简单地从右边向左执行,这是更好理解的一种实现,但是有参数个数的限制。

const compose = (...fns) => {
    return (input) => {
        return fns.reduceRight((value, fn) => {
            return fn(value);
        }, input);
    };
};

pipe

pipe 也是用来组合函数的,串联执行的顺序是从前向后,与 compose
相反。pipe 的实现可以是:

const pipe = (...fns) => {
    return fns.reduceRight((a, b) => {
        return (...args) => {
            return a(b(...args));
        }
    });
};

pipe 的用法如下:

const addA = (value) => {
    return value + 'A';
};
const addB = (value) => {
    return value + 'B';
};
pipe(addA, addB)('1') // => 1AB

asyncPipe

对于异步函数来说,如果我们要串联执行,可以使用 asyncPipe。实现可以是:

const asyncPipe =  (...fns) => {
    return fns.reduceRight((next, fn) => {
        return (...args) => {
            fn(...args, next);
        };
    }, () => {});
};

用法是:

const addA = (value, next) => {
    next(value + 'A', 'a');
};
const addB = (value, anotherValue, next) => {
    console.log(anotherValue);                      // => a
    next(value + 'B');
};
const consoleLog = (value, next) => {
    console.log(value);
};
asyncPipe(addA, addB, consoleLog)('1');              // => 1AB

函数式编程在数据结构上的运用

实现链表

主要思路是用函数闭包代替对象保存数据。

const createNode = (value, next) => {
    return (x) => {
        if (x) {
            return value;
        }
        return next;
    };
};

const getValue = (node) => {
    return node(true);
};
const getNext = (node) => {
    return node(false);
};

const append = (next, value) => {
    if (next === null) {
        return createNode(value, null);
    }
    return createNode(getValue(next), append(getNext(next), value));
};
const reverse = (linkedList) => {
    if (linkedList === null) {
        return null;
    }
    return append(reverse(getNext(linkedList)), getValue(linkedList));
};

const linkedList1 = createNode(1, createNode(2, createNode(3, null)));
const linkedList2 = reverse(linkedList1);
getValue(linkedList1);                      // => 1
getValue(getNext(linkedList1));             // => 2
getValue(getNext(getNext(linkedList1)));    // => 3
getValue(linkedList2);                      // => 3
getValue(getNext(linkedList2));             // => 2
getValue(getNext(getNext(linkedList2)));    // => 1

同样可以用函数式编程实现二叉树。

总结

希望大家能够通过学习函数式编程范式,加深对软件研发的理解,开拓视野,找到更多组织代码方式。

函数式编程能够更好地组织业务代码中的数据处理,更多地复用了函数,减少了中间变量。

但是函数式编程也有缺点,它增加了学习成本,需要大家理解高阶函数。

参考资料

  • Anjana Vakil: Learning Functional Programming with JavaScript –
    JSUnconf
    2016
  • Anjana Vakil: Immutable data structures for functional JS – JSConf
    EU
    2017
  • JavaScript
    轻量级函数式编程
  • Douglas Crockford: Monads and Gonads (YUIConf Evening
    Keynote)
  • A Brief Intro to Functional
    Programming
  • JavaScript
    中的函数式编程

发表评论

电子邮件地址不会被公开。 必填项已用*标注