翻译连载 | JavaScript 轻量级函数式编程-第5章:减小反作用 |《你不知道的JS》姊妹篇

关于译者:这是一个流淌着沪江血液的纯粹工程:认真,是 HTML 最坚实的梁柱;分享,是 CSS 里最闪耀的一瞥;总结,是 JavaScript 中最严谨的逻辑。通过捶打磨练,成就了本书的中文版。本书包含了函数式编程之精髓,但愿能够帮助你们在学习函数式编程的道路上走的更顺畅。比心。前端

译者团队(排名不分前后):阿希bluekenbrucechamcfanlifedailkyoko-dfl3velilinsLittlePineappleMatildaJin冬青pobusamaCherry萝卜vavd317vivaxy萌萌zhouyaogit

第 5 章:减小反作用

在第 2 章,咱们讨论了一个函数除了它的返回值以外还有什么输出。如今你应该很熟悉用函数式编程的方法定义一个函数了,因此对于函数式编程的反作用你应该有所了解。程序员

咱们将检查各类各样不一样的反作用而且要看看他们为何会对咱们的代码质量和可读性形成损害。github

这一章的要点是:编写出没有反作用的程序是不可能的。固然,也不是不可能,你固然能够编写出没有反作用的程序。可是这样的话程序就不会作任何有用和明显的事情。若是你编写出来一个零反作用的程序,你就没法区分它和一个被删除的或者空程序的区别。ajax

函数式编程者并无消除全部的反作用。实际上,咱们的目标是尽量地限制他们。要作到这一点,咱们首先须要彻底理解函数式编程的反作用。算法

什么是反作用

因果关系:举一个咱们人类对周围世界影响的最基本、最直观的例子,推一下放在桌子边沿上的一本书,书会掉落。不须要你拥有一个物理学的学位你也会知道,这是由于你刚刚推了书而且书掉落是由于地心引力,这是一个明确并直接的关系。编程

在编程中,咱们也彻底会处理因果关系。若是你调用了一个函数(原由),就会在屏幕上输出一条消息(结果)。api

当咱们在阅读程序的时候,可以清晰明确的识别每个原由和每个结果是很是重要的。在某种程度上,通读程序但不能看到因果的直接关系,程序的可读性就会下降。数组

思考一下:浏览器

function foo(x) {
    return x * 2;
}

var y = foo( 3 );

在这段代码中,有很直接的因果关系,调用值为 3 的 foo 将具备返回值 6 的效果,调用函数 foo() 是原由,而后将其赋值给 y 是结果。这里没有歧义,传入参数为 3 将会返回 6,将函数结果赋值给变量 y 是结果。

可是如今:

function foo(x) {
    y = x * 2;
}

var y;

foo( 3 );

这段代码有相同的输出,可是却有很大的差别,这里的因果是没有联系的。这个影响是间接的。这种方式设置 y 就是咱们所说的反作用。

注意: 当函数引用外部变量时,这个变量就称为自由变量。并非全部的自由变量引用都是很差的,可是咱们要对它们很是当心。

假使给你一个引用来调用函数 bar(..),你看不到代码,可是我告诉你这段代码并无间接的反作用,只有一个显式的 return 值会怎么样?

bar( 4 );            // 42

由于你知道 bar(..) 的内部结构不会有反作用,你能够像这样直接地调用 bar(..)。可是若是你不知道 bar(..) 没有反作用,为了理解调用这个函数的结果,你必须去阅读和分析它的逻辑。这对读者来讲是额外的负担。

有反作用的函数可读性更低,由于它须要更多的阅读来理解程序。

可是程序每每比这个要复杂,思考一下:

var x = 1;

foo();

console.log( x );

bar();

console.log( x );

baz();

console.log( x );

你能肯定每次 console.log(x) 的值都是你想要的吗?

答案是否认的。若是你不肯定函数 foo()bar()baz() 是否有反作用,你就不能保证每一步的 x 将会是什么,除非你检查每一个步骤的实现,而后从第一行开始跟踪程序,跟踪全部状态的改变。

换句话说,console.log(x) 最后的结果是不能分析和预测的,除非你已经在内心将整个程序执行到这里了。

猜猜谁擅长运行你的程序?JS 引擎。猜猜谁不擅长运行你的程序?你代码的读者。然而,若是你选择在一个或多个函数调用中编写带有(潜在)反作用的代码,那么这意味着你已经使你的读者必须将你的程序完整地执行到某一行,以便他们理解这一行。

若是 foo()bar()、和 baz() 都没有反作用的话,它们就不会影响到 x,这就意味着咱们不须要在内心默默地执行它们而且跟踪 x 的变化。这在精力上负担更小,而且使得代码更加地可读。

潜在的缘由

输出和状态的变化,是最常被引用的反作用的表现。可是另外一个有损可读性的实践是一些被认为的侧因,思考一下:

function foo(x) {
    return x + y;
}

var y = 3;

foo( 1 );            // 4

y 不会随着 foo(..) 改变,因此这和咱们以前看到的反作用有所不一样。可是如今,对函数 foo(..) 的调用实际上取决于 y 当前的状态。以后咱们若是这样作:

y = 5;

// ..

foo( 1 );            // 6

咱们可能会感到惊讶两次调用 foo(1) 返回的结果不同。

foo(..) 对可读性有一个间接的破坏性。若是没有对函数 foo(..) 进行仔细检查,使用者可能不会知道致使这个输出的缘由。这看起来仅仅像是参数 1 的缘由,但却不是这样的。

为了帮助可读性,全部决定 foo(..) 输出的缘由应该被设置的直接并明显。函数的使用者将会直接看到缘由和结果。

使用固定的状态

避免反作用就意味着函数 foo(..) 不能引用自由变量了吗?

思考下这段代码:

function foo(x) {
    return x + bar( x );
}

function bar(x) {
    return x * 2;
}

foo( 3 );            // 9

很明显,对于函数 foo(..) 和函数 bar(..),惟一和直接的缘由就是参数 x。可是 bar(x) 被称为何呢?bar 仅仅只是一个标识符,在 JS 中,默认状况下,它甚至不是一个常量(不可从新分配的变量)。foo(..) 函数依赖于 bar 的值,bar 做为一个自由变量被第二个函数引用。

因此说这个函数还依赖于其余的缘由吗?

我认为不。虽然能够用其余的函数来重写 bar 这个变量,可是在代码中我没有这样作,这也不是个人惯例或先例。不管出于什么意图和目的,个人函数都是常量(从不从新分配)。

思考一下:

const PI = 3.141592;

function foo(x) {
    return x * PI;
}

foo( 3 );            // 9.424776000000001

注意: JavaScript 有内置的 Math.PI 属性,因此咱们在本文中仅仅是用 PI 作一个方便的说明。在实践中,老是使用 Math.PI 而不是你本身定义的。

上面的代码怎么样呢?PI 是函数 foo(..) 的一个反作用吗?

两个观察结果将会合理地帮助咱们回答这个问题:

  1. 想一下是否每次调用 foo(3),都将会返回 9.424..答案是确定的。 若是每一次都给一个相同的输入(x),那么都将会返回相同的输出。
  2. 你能用 PI 的当前值来代替每个 PI 吗,而且程序可以和以前同样正确地的运行吗?是的。 程序没有任何一部分依赖于 PI 值的改变,由于 PI 的类型是 const,它是不能再分配的,因此变量 PI 在这里只是为了便于阅读和维护。它的值能够在不改变程序行为的状况下内联。

个人结论是:这里的 PI 并不违反减小或避免反作用的精神。在以前的代码也没有调用 bar(x)

在这两种状况下,PIbar 都不是程序状态的一部分。它们是固定的,不可从新分配的(“常量”)的引用。若是他们在整个程序中都不改变,那么咱们就不须要担忧将他们做为变化的状态追踪他们。一样的,他们不会损害程序的可读性。并且它们也不会由于变量以不可预测的方式变化,而成为错误的源头。

注意: 在我看来,使用 const 并不能说明 PI 不是反作用;使用 var PI 也会是一样的结果。PI 没有被从新分配是问题的关键,而不是使用 const。咱们将在后面的章节讨论 const

随机性

你之前可能历来没有考虑过,可是随机性是不纯的。一个使用 Math.random() 的函数永远都不是纯的,由于你不能根据它的输入来保证和预测它的输出。因此任何生成惟一随机的 ID 等都须要依靠程序的其余缘由。

在计算中,咱们使用的是伪随机算法。事实证实,真正的随机是很是难的,因此咱们只是用复杂的算法来模拟它,产生的值看起来是随机的。这些算法计算很长的一串数字,但秘密是,若是你知道起始点,实际上这个序列是能够预测的。这个起点被称之为种子。

一些语言容许你指定生成随机数的种子。若是你老是指定了相同的种子,那么你将始终从后续的“随机数”中获得相同的输出序列。这对于测试是很是有用的,可是在真正的应用中使用也是很是危险的。

在 JS 中,Math.random() 的随机性计算是基于间接输入,由于你不能明确种子。所以,咱们必须将内建的随机数生成视为不纯的一方。

I/O 效果

这可能不太明显,可是最多见(而且本质上不可避免)的反作用就是 I/O(输入/输出)。一个没有 I/O 的程序是彻底没有意义的,由于它的工做不能以任何方式被观察到。一个有用的程序必须最少有一个输出,而且也须要输入。输入会产生输出。

用户事件(鼠标、键盘)是 JS 编程者在浏览器中使用的典型的输入,而输出的则是 DOM。若是你使用 Node.js 比较多,你更有可能接收到和输出到文件系统、网络系统和/或者 stdin / stdout(标准输入流/标准输出流)的输入和输出。

事实上,这些来源既能够是输入也能够是输出,是因也是果。以 DOM 为例,咱们更新(产生反作用的结果)一个 DOM 元素为了给用户展现文字或图片信息,可是 DOM 的当前状态是对这些操做的隐式输入(产生反作用的缘由)。

其余的错误

在程序运行期间反作用可能致使的错误是多种多样的。让咱们来看一个场景来讲明这些危害,但愿它们能帮助咱们辨认出在咱们本身的程序中相似的错误。

思考一下:

var users = {};
var userOrders = {};

function fetchUserData(userId) {
    ajax( "http://some.api/user/" + userId, function onUserData(userData){
        users[userId] = userData;
    } );
}

function fetchOrders(userId) {
    ajax( "http://some.api/orders/" + userId, function onOrders(orders){
        for (let i = 0; i < orders.length; i++) {
                // 对每一个用户的最新订单保持引用
            users[userId].latestOrder = orders[i];
            userOrders[orders[i].orderId] = orders[i];
        }
    } );
}

function deleteOrder(orderId) {
    var user = users[ userOrders[orderId].userId ];
    var isLatestOrder = (userOrders[orderId] == user.latestOrder);
    
    // 删除用户的最新订单?
    if (isLatestOrder) {
        hideLatestOrderDisplay();
    }

    ajax( "http://some.api/delete/order/" + orderId, function onDelete(success){
        if (success) {
                // 删除用户的最新订单?
            if (isLatestOrder) {
                user.latestOrder = null;
            }

            userOrders[orderId] = null;
        }
        else if (isLatestOrder) {
            showLatestOrderDisplay();
        }
    } );
}

我敢打赌,一些读者显然会发现其中潜在的错误。若是回调 onOrders(..) 在回调 onUserData(..) 以前运行,它会给一个还没有设置的值(users[userId]userData 对象)添加一个 latestOrder 属性

所以,这种依赖于因果关系的“错误”是在两种不一样操做(是否异步)紊乱状况下发生的,咱们指望以肯定的顺序运行,但在某些状况下,可能会以不一样的顺序运行。有一些策略能够确保操做的顺序,很明显,在这种状况下顺序是相当重要的。

这里还有另外一个细小的错误,你发现了吗?

思考下这个调用顺序:

fetchUserData( 123 );
onUserData(..);
fetchOrders( 123 );
onOrders(..);

// later

fetchOrders( 123 );
deleteOrder( 456 );
onOrders(..);
onDelete(..);

你发现每一对 fetchOrders(..) / onOrders(..)deleteOrder(..) / onDelete(..) 都是交替出现了吗?这个潜在的排序会伴随着咱们状态管理的侧因/反作用暴露出一个古怪的状态。

在设置 isLatestOrder 标志和使用它来决定是否应该清空 users 中的用户数据对象的 latestOrder 属性时,会有一个延迟(由于回调)。在此延迟期间,若是 onOrders(..) 销毁,它能够潜在地改变用户的 latestOrder 引用的顺序值。当 onDelete(..) 在销毁以后,它会假定它仍然须要从新引用 latestOrder

错误:数据(状态)可能不一样步。当进入 onOrders(..) 时,latestOrder 可能仍然指向一个较新的顺序,这样 latestOrder 就会被重置。

这种错误最糟糕的是你不能和其余错误同样获得程序崩溃的异常。咱们只是有一个不正确的状态,同时咱们的应用程序“默默地”崩溃。

fetchUserData(..)fetchOrders(..) 的序列依赖是至关明显的,而且被直截了当地处理。可是,在 fetchOrders(..)deleteOrder(..) 之间存在潜在的序列依赖关系,就不太清楚了。这两个彷佛更加独立。而且确保他们的顺序被保留是比较棘手的,由于你事先不知道(在 fetchOrders(..) 产生结果以前)是否必需要按照这样的顺序执行。

是的,一旦 deleteOrder(..) 销毁,你就能从新计算 isLatestOrder 标志。可是如今你有另外一个问题:你的 UI 状态可能不一样步。

若是你以前已经调用过 hideLatestOrderDisplay(),如今你须要调用 showLatestOrderDisplay(),可是若是一个新的 latestOrder 已经被设置好了,你将要跟踪至少三个状态:被删除的状态是否原本是“最新的”、是不是“最新”设置的,和这两个顺序有什么不一样吗?这些都是能够解决的问题,但不管如何都是不明显的。

全部这些麻烦都是由于咱们决定在一组共享的状态下构造出有反作用的代码。

函数式编程人员讨厌这类因果的错误,由于这有损咱们的阅读、推理、验证和最终相信代码的能力。这就是为何他们要如此严肃地对待避免反作用的缘由。

有不少避免/修复反作用的策略。咱们将在本章后面和后面的章节中讨论。我要说一个肯定的事情:写出有反作用/效果的代码是很正常的, 因此咱们须要谨慎和刻意地避免产生有反作用的代码。

一次就好

若是你必需要使用反作用来改变状态,那么一种对限制潜在问题有用的操做是幂等。若是你的值的更新是幂次的,那么数据将会适应你可能有不一样反作用来源的多个此类更新的状况。

幂等的定义有点让人困惑,同时数学家和程序员使用幂等的含义稍有不一样。然而,这两种观点对于函数式编程人员都是有用的。

首先,让咱们给出一个计数器的例子,它既不是数学上的,也不是程序上的幂等:

function updateCounter(obj) {
    if (obj.count < 10) {
        obj.count++;
        return true;
    }

    return false;
}

这个函数经过引用递增 obj.count 来该改变一个对象,因此对这个对象产生了反作用。当 o.count 小于 10 时,若是 updateCounter(o) 被屡次调用,即程序状态每次都要更改。另外,updateCounter(..) 的输出是一个布尔值,这不适合返回到 updateCounter(..) 的后续调用。

数学中的幂等

从数学的角度来看,幂等指的是在第一次调用后,若是你将该输出一次又一次地输入到操做中,其输出永远不会改变的操做。换句话说,foo(x) 将产生与 foo(foo(x))foo(foo(foo(x))) 等相同的输出。

一个典型的数学例子是 Math.abs(..)(取绝对值)。Math.abs(-2) 的结果是 2,和 Math.abs(Math.abs(Math.abs(Math.abs(-2)))) 的结果相同。像Math.min(..)Math.max(..)Math.round(..)Math.floor(..)Math.ceil(..)这些工具函数都是幂等的。

咱们能够用一样的特征来定义一些数学运算:

function toPower0(x) {
    return Math.pow( x, 0 );
}

function snapUp3(x) {
    return x - (x % 3) + (x % 3 > 0 && 3);
}

toPower0( 3 ) == toPower0( toPower0( 3 ) );            // true

snapUp3( 3.14 ) == snapUp3( snapUp3( 3.14 ) );        // true

数学上的幂等仅限于数学运算。咱们还能够用 JavaScript 的原始类型来讲明幂等的另外一种形式:

var x = 42, y = "hello";

String( x ) === String( String( x ) );                // true

Boolean( y ) === Boolean( Boolean( y ) );            // true

在本文的前面,咱们探究了一种常见的函数式编程工具,它能够实现这种形式的幂等:

identity( 3 ) === identity( identity( 3 ) );    // true

某些字符串操做天然也是幂等的,例如:

function upper(x) {
    return x.toUpperCase();
}

function lower(x) {
    return x.toLowerCase();
}

var str = "Hello World";

upper( str ) == upper( upper( str ) );                // true

lower( str ) == lower( lower( str ) );                // true

咱们甚至能够以一种幂等方式设计更复杂的字符串格式操做,好比:

function currency(val) {
    var num = parseFloat(
        String( val ).replace( /[^\d.-]+/g, "" )
    );
    var sign = (num < 0) ? "-" : "";
    return `${sign}$${Math.abs( num ).toFixed( 2 )}`;
}

currency( -3.1 );                                    // "-$3.10"

currency( -3.1 ) == currency( currency( -3.1 ) );    // true

currency(..) 举例说明了一个重要的技巧:在某些状况下,开发人员能够采起额外的步骤来规范化输入/输出操做,以确保操做是幂等的来避免意外的发生。

在任何可能的状况下经过幂等的操做限制反作用要比不作限制的更新要好得多。

编程中的幂等

幂等的面向程序的定义也是相似的,但不太正式。编程中的幂等仅仅是 f(x); 的结果与 f(x); f(x) 相同而不是要求 f(x) === f(f(x))。换句话说,以后每一次调用 f(x) 的结果和第一次调用 f(x) 的结果没有任何改变。

这种观点更符合咱们对反作用的观察。由于这更像是一个 f(..) 建立了一个幂等的反作用而不是必需要返回一个幂等的输出值。

这种幂等性的方式常常被用于 HTTP 操做(动词),例如 GET 或 PUT。若是 HTTP REST API 正确地遵循了幂等的规范指导,那么 PUT 被定义为一个更新操做,它能够彻底替换资源。一样的,客户端能够一次或屡次发送 PUT 请求(使用相同的数据),而服务器不管如何都将具备相同的结果状态。

让咱们用更具体的编程方法来考虑这个问题,来检查一下使用幂等和没有使用幂等是否产生反作用:

// 幂等的:
obj.count = 2;
a[a.length - 1] = 42;
person.name = upper( person.name );

// 非幂等的:
obj.count++;
a[a.length] = 42;
person.lastUpdated = Date.now();

记住:这里的幂等性的概念是每个幂等运算(好比 obj.count = 2)能够重复屡次,而不是在第一次更新后改变程序操做。非幂等操做每次都改变状态。

那么更新 DOM 呢?

var hist = document.getElementById( "orderHistory" );

// 幂等的:
hist.innerHTML = order.historyText;

// 非幂等的:
var update = document.createTextNode( order.latestUpdate );
hist.appendChild( update );

这里的关键区别在于,幂等的更新替换了 DOM 元素的内容。DOM 元素的当前状态是独立的,由于它是无条件覆盖的。非幂等的操做将内容添加到元素中;隐式地,DOM 元素的当前状态是计算下一个状态的一部分。

咱们将不会一直用幂等的方式去定义你的数据,但若是你能作到,这确定会减小你的反作用在你最意想不到的时候忽然出现的可能性。

纯粹的快乐

没有反作用的函数称为纯函数。在编程的意义上,纯函数是一种幂等函数,由于它不可能有任何反作用。思考一下:

function add(x,y) {
    return x + y;
}

全部输入(xy)和输出(return ..)都是直接的,没有引用自由变量。调用 add(3,4) 屡次和调用一次是没有区别的。add(..) 是纯粹的编程风格的幂等。

然而,并非全部的纯函数都是数学概念上的幂等,由于它们返回的值不必定适合做为再次调用它们时的输入。思考一下:

function calculateAverage(list) {
    var sum = 0;
    for (let i = 0; i < list.length; i++) {
        sum += list[i];
    }
    return sum / list.length;
}

calculateAverage( [1,2,4,7,11,16,22] );            // 9

输出的 9 并非一个数组,因此你不能在 calculateAverage(calculateAverage(..)) 中将其传入。

正如咱们前面所讨论的,一个纯函数能够引用自由变量,只要这些自由变量不是侧因。

例如:

const PI = 3.141592;

function circleArea(radius) {
    return PI * radius * radius;
}

function cylinderVolume(radius,height) {
    return height * circleArea( radius );
}

circleArea(..) 中引用了自由变量 PI,可是这是一个常量因此不是一个侧因。cylinderVolume(..) 引用了自由变量 circleArea,这也不是一个侧因,由于这个程序把它看成一个常量引用它的函数值。这两个函数都是纯的。

另外一个例子,一个函数仍然能够是纯的,但引用的自由变量是闭包:

function unary(fn) {
    return function onlyOneArg(arg){
        return fn( arg );
    };
}

unary(..) 自己显然是纯函数 —— 它惟一的输入是 fn,而且它惟一的输出是返回的函数,可是闭合了自由变量 fn 的内部函数 onlyOneArg(..) 是否是纯的呢?

它仍然是纯的,由于 fn 永远不变。事实上,咱们对这一事实有充分的自信,由于从词法上讲,这几行是惟一可能从新分配 fn 的代码。

注意: fn 是一个函数对象的引用,它默认是一个可变的值。在程序的其余地方可能为这个函数对象添加一个属性,这在技术上“改变”这个值(改变,而不是从新分配)。然而,由于咱们除了调用 fn,不依赖 fn 之外的任何事情,而且不可能影响函数值的可调用性,所以 fn 在最后的结果中仍然是有效的不变的;它不多是一个侧因。

表达一个函数的纯度的另外一种经常使用方法是:给定相同的输入(一个或多个),它老是产生相同的输出。 若是你把 3 传给 circleArea(..) 它老是输出相同的结果(28.274328)。

若是一个函数每次在给予相同的输入时,可能产生不一样的输出,那么它是不纯的。即便这样的函数老是返回相同的值,只要它产生间接输出反作用,而且程序状态每次被调用时都会被改变,那么这就是不纯的。

不纯的函数是不受欢迎的,由于它们使得全部的调用都变得更加难以理解。纯的函数的调用是彻底可预测的。当有人阅读代码时,看到多个 circleArea(3) 调用,他们不须要花费额外的精力来计算每次的输出结果。

相对的纯粹

当咱们讨论一个函数是纯的时,咱们必须很是当心。JavaScript 的动态值特性使其很容易产生不明显的反作用。

思考一下:

function rememberNumbers(nums) {
    return function caller(fn){
        return fn( nums );
    };
}

var list = [1,2,3,4,5];

var simpleList = rememberNumbers( list );

simpleList(..) 看起来是一个纯函数,由于它只涉及内部的 caller(..) 函数,它仅仅是闭合了自由变量 nums。然而,有不少方法证实 simpleList(..) 是不纯的。

首先,咱们对纯度的断言是基于数组的值(经过 listnums 引用)一直不改变:

function median(nums) {
    return (nums[0] + nums[nums.length - 1]) / 2;
}

simpleList( median );        // 3

// ..

list.push( 6 );

// ..

simpleList( median );        // 3.5

当咱们改变数组时,simpleList(..) 的调用改变它的输出。因此,simpleList(..) 是纯的仍是不纯的呢?这就取决于你的视角。对于给定的一组假设来讲,它是纯函数。在任何没有 list.push(6) 的状况下是纯的。

咱们能够经过改变 rememberNumbers(..) 的定义来修改这种不纯。一种方法是复制 nums 数组:

function rememberNumbers(nums) {
        // 复制一个数组
    nums = nums.slice();

    return function caller(fn){
        return fn( nums );
    };
}

但这可能会隐含一个更棘手的反作用:

var list = [1,2,3,4,5];

// 把 list[0] 做为一个有反作用的接收者
Object.defineProperty(
    list,
    0,
    {
        get: function(){
            console.log( "[0] was accessed!" );
            return 1;
        }
    }
);

var simpleList = rememberNumbers( list );

// [0] 已经被使用!

一个更粗鲁的选择是更改 rememberNumbers(..) 的参数。首先,不要接收数组,而是把数字做为单独的参数:

function rememberNumbers(...nums) {
    return function caller(fn){
        return fn( nums );
    };
}

var simpleList = rememberNumbers( ...list );

// [0] 已经被使用!

这两个 ... 的做用是将列表复制到 nums 中,而不是经过引用来传递。

注意: 控制台消息的反作用不是来自于 rememberNumbers(..),而是 ...list 的扩展中。所以,在这种状况下,rememberNumbers(..)simpleList(..) 是纯的。

可是若是这种突变动难被发现呢?纯函数和不纯的函数的合成老是产生不纯的函数。若是咱们将一个不纯的函数传递到另外一个纯函数 simpleList(..) 中,那么这个函数就是不纯的:

// 是的,一个愚蠢的人为的例子 :)
function firstValue(nums) {
    return nums[0];
}

function lastValue(nums) {
    return firstValue( nums.reverse() );
}

simpleList( lastValue );    // 5

list;                        // [1,2,3,4,5] -- OK!

simpleList( lastValue );    // 1

注意: 无论 reverse() 看起来多安全(就像 JS 中的其余数组方法同样),它返回一个反向数组,实际上它对数组进行了修改,而不是建立一个新的数组。

咱们须要对 rememberNumbers(..) 下一个更斩钉截铁的定义来防止 fn(..) 改变它的闭合的 nums 变量的引用。

function rememberNumbers(...nums) {
    return function caller(fn){
            // 提交一个副本!
        return fn( nums.slice() );
    };
}

因此 simpleList(..) 是可靠的纯函数吗!?不。 :(

咱们只防范咱们能够控制的反作用(经过引用改变)。咱们传递的任何带有反作用的函数,都将会污染 simpleList(..) 的纯度:

simpleList( function impureIO(nums){
    console.log( nums.length );
} );

事实上,没有办法定义 rememberNumbers(..) 去产生一个完美纯粹的 simpleList(..) 函数。

纯度是和自信是有关的。但咱们不得不认可,在不少状况下,咱们所感觉到的自信其实是与咱们程序的上下文和咱们对程序了解有关的。在实践中(在 JavaScript 中),函数纯度的问题不是纯粹的纯粹性,而是关于其纯度的一系列信心。

越纯洁越好。制做纯函数时越努力,当您阅读使用它的代码时,你的自信就会越高,这将使代码的一部分更加可读。

有或者无

到目前为止,咱们已经将函数纯度定义为一个没有反作用的函数,而且做为这样一个函数,给定相同的输入,老是产生相同的输出。这只是看待相同特征的两种不一样方式。

可是,第三种看待函数纯性的方法,也许是广为接受的定义,即纯函数具备引用透明性。

引用透明性是指一个函数调用能够被它的输出值所代替,而且整个程序的行为不会改变。换句话说,不可能从程序的执行中分辨出函数调用是被执行的,仍是它的返回值是在函数调用的位置上内联的。

从引用透明的角度来看,这两个程序都有彻底相同的行为由于它们都是用纯粹的函数构建的:

function calculateAverage(list) {
    var sum = 0;
    for (let i = 0; i < list.length; i++) {
        sum += list[i];
    }
    return sum / list.length;
}

var nums = [1,2,4,7,11,16,22];

var avg = calculateAverage( nums );

console.log( "The average is:", avg );        // The average is: 9
function calculateAverage(list) {
    var sum = 0;
    for (let i = 0; i < list.length; i++) {
        sum += list[i];
    }
    return sum / list.length;
}

var nums = [1,2,4,7,11,16,22];

var avg = 9;

console.log( "The average is:", avg );        // The average is: 9

这两个片断之间的惟一区别在于,在后者中,咱们跳过了调用 calculateAverage(nums) 并内联。由于程序的其余部分的行为是相同的,calculateAverage(..) 是引用透明的,所以是一个纯粹的函数。

思考上的透明

一个引用透明的纯函数可能会被它的输出替代,这并不意味着它应该被替换。远非如此。

咱们用在程序中使用函数而不是使用预先计算好的常量的缘由不只仅是应对变化的数据,也是和可读性和适当的抽象等有关。调用函数去计算一列数字的平均值让这部分程序比只是使用肯定的值更具备可读性。它向读者讲述了 avg 从何而来,它意味着什么,等等。

咱们真正建议使用引用透明是当你阅读程序,一旦你已经在心里计算出纯函数调用输出的是什么的时候,当你看到它的代码的时候不须要再去思考确切的函数调用是作什么,特别是若是它出现不少次。

这个结果有一点像你在内心面定义一个 const,当你阅读的时候,你能够直接跳过而且不须要花更多的精力去计算。

咱们但愿纯函数的这种特性的重要性是显而易见的。咱们正在努力使咱们的程序更容易读懂。咱们能作的一种方法是给读者较少的工做,经过提供帮助来跳过没必要要的东西,这样他们就能够把注意力集中在重要的事情上。

读者不须要从新计算一些不会改变(也不须要改变)的结果。若是用引用透明定义一个纯函数,读者就没必要这样作了。

不够透明?

那么若是一个有反作用的函数,而且这个反作用在程序的其余地方没有被观察到或者依赖会怎么样?这个功能还具备引用透明性吗?

这里有一个例子:

function calculateAverage(list) {
    sum = 0;
    for (let i = 0; i < list.length; i++) {
        sum += list[i];
    }
    return sum / list.length;
}

var sum, nums = [1,2,4,7,11,16,22];

var avg = calculateAverage( nums );

你发现了吗?

sum 是一个 calculateAverage(..) 使用的外部自由变量。可是,每次咱们使用相同的列表调用 calculateAverage(..),咱们将获得 9 做为输出。而且这个程序没法和使用参数 9 调用 calculateAverage(nums) 在行为上区分开来。程序的其余部分和 sum 变量有关,因此这是一个不可观察的反作用。
这是一个像这棵树同样不能观察到的反作用吗?

假如一棵树在森林里倒下而没有人在附近听见,它有没有发出声音?

经过引用透明的狭义的定义,我想你必定会说 calculateAverage(..) 仍然是一个纯函数。可是,由于在咱们的学习中不只仅是学习学术,并且与实用主义相平衡,我认为这个结论须要更多的观点。让咱们探索一下。

性能影响

你常常会发现这些不易观察的反作用被用于性能优化的操做。例如:

var cache = [];

function specialNumber(n) {
        // 若是咱们已经计算过这个特殊的数,
    // 跳过这个操做,而后从缓存中返回
    if (cache[n] !== undefined) {
        return cache[n];
    }

    var x = 1, y = 1;

    for (let i = 1; i <= n; i++) {
        x += i % 2;
        y += i % 3;
    }

    cache[n] = (x * y) / (n + 1);

    return cache[n];
}

specialNumber( 6 );                // 4
specialNumber( 42 );            // 22
specialNumber( 1E6 );            // 500001
specialNumber( 987654321 );        // 493827162

这个愚蠢的 specialNumber(..) 算法是肯定性的,而且,纯函数从定义来讲,它老是为相同的输入提供相同的输出。从引用透明的角度来看 —— 用 22 替换对 specialNumber(42) 的任何调用,程序的最终结果是相同的。

可是,这个函数必须作一些工做来计算一些较大的数字,特别是输入像 987654321 这样的数字。若是咱们须要在咱们的程序中屡次得到特定的特殊号码,那么结果的缓存意味着后续的调用效率会更高。

注意: 思考一个有趣的事情:CPU 在执行任何给定操做时产生的热量,即便是最纯粹的函数 / 程序,也是不可避免的反作用吗?那么 CPU 的时间延迟,由于它花时间在一个纯操做上,而后再执行另外一个操做,是否也算做反作用?

不要这么快地作出假设,你仅仅运行 specialNumber(987654321) 计算一次,并手动将该结果粘贴到一些变量 / 常量中。程序一般是高度模块化的而且全局可访问的做用域并非一般你想要在这些独立部分之间分享状态的方式。让specialNumber(..) 使用本身的缓存(即便它刚好是使用一个全局变量来实现这一点)是对状态共享更好的抽象。

关键是,若是 specialNumber(..) 只是程序访问和更新 cache 反作用的惟一部分,那么引用透明的观点显然能够适用,这能够被看做是能够接受的实际的“欺骗”的纯函数思想。

可是真的应该这样吗?

典型的,这种性能优化方面的反作用是经过隐藏缓存结果产生的,所以它们不能被程序的任何其余部分所观察到。这个过程被称为记忆化。我一直称这个词是 “记忆化”,我不知道这个想法是从哪里来的,但它确实有助于我更好地理解这个概念。

思考一下:

var specialNumber = (function memoization(){
    var cache = [];

    return function specialNumber(n){
            // 若是咱们已经计算过这个特殊的数,
            // 跳过这个操做,而后从缓存中返回
        if (cache[n] !== undefined) {
            return cache[n];
        }

        var x = 1, y = 1;

        for (let i = 1; i <= n; i++) {
            x += i % 2;
            y += i % 3;
        }

        cache[n] = (x * y) / (n + 1);

        return cache[n];
    };
})();

咱们已经遏制 memoization() 内部 specialNumber(..) IIFE 范围内的 cache 的反作用,因此如今咱们肯定程序任何的部分都不能观察到它们,而不只仅是观察它们。

最后一句话彷佛是一个的微妙观点,但实际上我认为这多是整章中最重要的一点。 再读一遍。

回到这个哲学理论:

假如一棵树在森林里倒下而没有人在附近听见,它有没有发出声音?

经过这个暗喻,我所获得的是:不管是否产生声音,若是咱们从不创造一个当树落下时周围没有人的情景会更好一些。当树落下时,咱们老是会听到声音。

减小反作用的目的并非他们在程序中不能被观察到,而是设计一个程序,让反作用尽量的少,由于这使代码更容易理解。一个没有观察到的发生的反作用的程序在这个目标上并不像一个不能观察它们的程序那么有效。

若是反作用可能发生,做者和读者必须尽可能应对它们。使它们不发生,做者和读者都要对任何可能或不可能发生的事情更有自信。

纯化

若是你有不纯的函数,且你没法将其重构为纯函数,此时你能作些什么?

您须要肯定该函数有什么样的反作用。反作用来自不一样的地方,多是因为词法自由变量、引用变化,甚至是 this 的绑定。咱们将研究解决这些状况的方法。

封闭的影响

若是反作用的本质是使用词法自由变量,而且您能够选择修改周围的代码,那么您可使用做用域来封装它们。

回忆一下:

var users = {};

function fetchUserData(userId) {
    ajax( "http://some.api/user/" + userId, function onUserData(userData){
        users[userId] = userData;
    } );
}

纯化此代码的一个方法是在变量和不纯的函数周围建立一个容器。本质上,容器必须接收全部的输入。

function safer_fetchUserData(userId,users) {
        // 简单的、原生的 ES6 + 浅拷贝,也能够
    // 用不一样的库或框架
    users = Object.assign( {}, users );

    fetchUserData( userId );

        // 返回拷贝过的状态 
    return users;


    // ***********************

        // 原始的没被改变的纯函数:
    function fetchUserData(userId) {
        ajax( "http://some.api/user/" + userId, function onUserData(userData){
            users[userId] = userData;
        } );
    }
}

userIdusers 都是原始的的 fetchUserData 的输入,users 也是输出。safer_fetchUserData(..) 取出他们的输入,并返回 users。为了确保在 users 被改变时咱们不会在外部建立反作用,咱们制做一个 users 本地副本。

这种技术的有效性有限,主要是由于若是你不能将函数自己改成纯的,你也几乎不可能修改其周围的代码。然而,若是可能,探索它是有帮助的,由于它是全部修复方法中最简单的。

不管这是不是重构纯函数的一个实际方法,最重要的是函数的纯度仅仅须要深刻到皮肤。也就是说,函数的纯度是从外部判断的, 无论内部是什么。只要一个函数的使用表现为纯的,它就是纯的。在纯函数的内部,因为各类缘由,包括最多见的性能方面,能够适度的使用不纯的技术。正如他们所说的“世界是一只驮着一只一直驮下去的乌龟群”。

不过要当心。程序的任何部分都是不纯的,即便它仅仅是用纯函数包裹的,也是代码错误和困惑读者的潜在的根源。整体目标是尽量减小反作用,而不只仅是隐藏它们。

覆盖效果

不少时候,你没法在容器函数的内部为了封装词法自由变量来修改代码。例如,不纯的函数可能位于一个你没法控制的第三方库文件中,其中包括:

var nums = [];
var smallCount = 0;
var largeCount = 0;

function generateMoreRandoms(count) {
    for (let i = 0; i < count; i++) {
        let num = Math.random();

        if (num >= 0.5) {
            largeCount++;
        }
        else {
            smallCount++;
        }

        nums.push( num );
    }
}

蛮力的策略是,在咱们程序的其他部分使用此通用程序时隔离反作用的方法时建立一个接口函数,执行如下步骤:

  1. 捕获受影响的当前状态
  2. 设置初始输入状态
  3. 运行不纯的函数
  4. 捕获反作用状态
  5. 恢复原来的状态
  6. 返回捕获的反作用状态
function safer_generateMoreRandoms(count,initial) {
        // (1) 保存原始状态
    var orig = {
        nums,
        smallCount,
        largeCount
    };

        // (2) 设置初始反作用状态
    nums = initial.nums.slice();
    smallCount = initial.smallCount;
    largeCount = initial.largeCount;

        // (3) 小心杂质!
    generateMoreRandoms( count );

        // (4) 捕获反作用状态
    var sides = {
        nums,
        smallCount,
        largeCount
    };

        // (5) 从新存储原始状态
    nums = orig.nums;
    smallCount = orig.smallCount;
    largeCount = orig.largeCount;

        // (6) 做为输出直接暴露反作用状态
    return sides;
}

而且使用 safer_generateMoreRandoms(..)

var initialStates = {
    nums: [0.3, 0.4, 0.5],
    smallCount: 2,
    largeCount: 1
};

safer_generateMoreRandoms( 5, initialStates );
// { nums: [0.3,0.4,0.5,0.8510024448959794,0.04206799238...

nums;            // []
smallCount;        // 0
largeCount;        // 0

这须要大量的手动操做来避免一些反作用,若是咱们一开始就没有它们,那就容易多了。但若是咱们别无选择,那么这种额外的努力是值得的,以免咱们的项目出现意外。

注意: 这种技术只有在处理同步代码时才有用。异步代码不能可靠地使用这种方法被管理,由于若是程序的其余部分在期间也在访问 / 修改状态变量,它就没法防止意外。

回避影响

当要处理的反作用的本质是直接输入值(对象、数组等)的突变时,咱们能够再次建立一个接口函数来替代原始的不纯的函数去交互。

考虑一下:

function handleInactiveUsers(userList,dateCutoff) {
    for (let i = 0; i < userList.length; i++) {
        if (userList[i].lastLogin == null) {
                // 将 user 从 list 中删除
            userList.splice( i, 1 );
            i--;
        }
        else if (userList[i].lastLogin < dateCutoff) {
            userList[i].inactive = true;
        }
    }
}

userList 数组自己,加上其中的对象,都发生了改变。防护这些反作用的一种策略是先作一个深拷贝(不是浅拷贝):

function safer_handleInactiveUsers(userList,dateCutoff) {
        // 拷贝列表和其中 `user` 的对象
    let copiedUserList = userList.map( function mapper(user){
            // 拷贝 user 对象
        return Object.assign( {}, user );
    } );

        // 使用拷贝过的对象调用最初的函数
    handleInactiveUsers( copiedUserList, dateCutoff );
         
    // 将突变的 list 做为直接的输出暴露出来
    return copiedUserList;
}

这个技术的成功将取决于你所作的复制的深度。使用 userList.slice() 在这里不起做用,由于这只会建立一个 userList 数组自己的浅拷贝。数组的每一个元素都是一个须要复制的对象,因此咱们须要格外当心。固然,若是这些对象在它们以内有对象(可能会这样),则复制须要更加完善。

再看一下 this

另外一个参数变化的反作用是和 this 有关的,咱们应该意识到 this 是函数隐式的输入。查看第 2 章中的“什么是This”获取更多的信息,为何 this 关键字对函数式编程者是不肯定的。

思考一下:

var ids = {
    prefix: "_",
    generate() {
        return this.prefix + Math.random();
    }
};

咱们的策略相似于上一节的讨论:建立一个接口函数,强制 generate() 函数使用可预测的 this 上下文:

function safer_generate(context) {
    return ids.generate.call( context );
}

// *********************

safer_generate( { prefix: "foo" } );
// "foo0.8988802158307285"

这些策略绝对不是愚蠢的,对反作用的最安全的保护是不要产生它们。可是,若是您想提升程序的可读性和你对程序的自信,不管在什么状况下尽量减小反作用 / 效果是巨大的进步。

本质上,咱们并无真正消除反作用,而是克制和限制它们,以便咱们的代码更加的可验证和可靠。若是咱们后来遇到程序错误,咱们就知道代码仍然产生反作用的部分最有多是罪魁祸首。

总结

反作用对代码的可读性和质量都有害,由于它们使您的代码难以理解。反作用也是程序中最多见的错误缘由之一,由于很难应对他们。幂等是经过本质上建立仅有一次的操做来限制反作用的一种策略。

避免反作用的最优方法是使用纯函数。纯函数给定相同输入时总返回相同输出,而且没有反作用。引用透明更近一步的状态是 —— 更多的是一种脑力运动而不是文字行为 —— 纯函数的调用是能够用它的输出来代替,而且程序的行为不会被改变。

将一个不纯的函数重构为纯函数是首选。可是,若是没法重构,尝试封装反作用,或者建立一个纯粹的接口来解决问题。

没有程序能够彻底没有反作用。可是在实际状况中的不少地方更喜欢纯函数。尽量地收集纯函数的反作用,这样当错误发生时更加容易识别和审查出最像罪魁祸首的错误。

【上一章】翻译连载 | JavaScript轻量级函数式编程-第4章:组合函数 |《你不知道的JS》姊妹篇

【下一章】翻译连载 | JavaScript轻量级函数式编程-第6章:值的不可变性 |《你不知道的JS》姊妹篇

iKcamp原创新书《移动Web前端高效开发实战》已在亚马逊、京东、当当开售。