XDM,JS如何函数式编程?看这就够了!(四)

3,592 阅读6分钟

不知不觉,我们已经来到了《JS如何函数式编程》系列的【第四篇】。

前三篇传送门:

经过前几篇的历练,本瓜相信你的心中一定对函数编程有了基本的蓝图。

本篇会将这个蓝图再具象一下,谈谈函数编程中一个很重要的细节 —— “副作用”

  • 点赞富三代👍👍👍评论美一生🎉🎉🎉

维基上关于副作用的解释:

函数内部有隐式(Implicit)的数据流,这种情况叫做副作用(Side Effect)。

咱们前文也提到过:开发人员喜欢显式输入输出而不是隐式输入输出。

所以我们将细致的看看副作用中【隐式】和【显式】的区别!

何为副作用?

先来个小例子作开胃菜:

// 片段 1
function foo(x) {
    return x * 2;
}

var y = foo( 3 );

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

var y;

foo( 3 );

片段 1 和片段 2 实现的最终效果是一致的,即 y = 3 * 2 ,但是片段 1 是显示的,片段 2 是隐式的。

原因是:片段 2 在函数内引用了外部变量 y。

片段 2 ,当我们调用 foo( 3 ) 时,并不知道其内部是否会修改外部变量 y。它的修改是隐式的,即产生了副作用!

有副作用的函数可读性更低,我们需要更多的阅读来理解程序。

再举一例:

var x = 1;

foo();

console.log( x );

bar();

console.log( x );

baz();

console.log( x );

如果每个函数内都引用了 x ,有可能对其赋值修改,那么我们很难知道每一步 x 的值是怎样的,要每一步去追踪!

选择在一个或多个函数调用中编写带有(潜在)副作用的代码,那么这意味着你代码的读者必须将你的程序完整地执行到某一行,逐步理解。

如果 foo()bar()、和 baz() 这三个函数没有(潜在)副作用,x 的值一眼可见!

一定是修改外部变量才是产生副作用了吗?

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

var y = 3;

foo( 1 ); 

这段代码中,我们没有修改外部变量 y ,但是引用了它,也是会产生副作用的。

y = 5;

// ..

foo( 1 );   

两次 foo( 1 ) 的结果却不一样,又增大了阅读的负担。相信我,这是个最简单抽象的例子,实际的影响将远大于此。

避免副作用?

  1. const

以上面的例子来说:这样写,foo( 1 ) 的结果当然是确定的,因为用到了 const 来固定外部变量。

const y = 5;

// ..

foo( 1 );
  1. I/O

一个没有 I/O 的程序是完全没有意义的,因为它的工作不能以任何方式被观察到。一个有用的程序必须最少有一个输出,并且也需要输入。输入会产生输出。

还记得 foo(..) 函数片段 2 吗?没有输出 return,这是不太可取的。

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

var y;

foo( 3 );
  1. 明确依赖

我们经常会由于函数的异步问题导致数据出错;一个函数引用了另外一个函数的回调结果,当我们作这种引用时要特别注意。

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];
        }
    } );
}

fetchUserData(..) 应该在 fetchOrders(..) 之前执行,因为后者设置 latestOrder 需要前者的回调;

写出有副作用/效果的代码是很正常的, 但我们需要谨慎和刻意地避免产生有副作用的代码。

  1. 运用幂等

这是一个很新但重要的概念!

从数学的角度来看,幂等指的是在第一次调用后,如果你将该输出一次又一次地输入到操作中,其输出永远不会改变的操作。

一个典型的数学例子是 Math.abs(..)(取绝对值)。Math.abs(-2) 的结果是 2,和 Math.abs(Math.abs(Math.abs(Math.abs(-2)))) 的结果相同。

幂等在 js 中的表现:

// 例 1
var x = 42, y = "hello";

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

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

// 例 2
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

// 例 3
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

实际上,我们在 js 函数式编程中幂等有更加宽泛的概念,即只用要求:f(x) === f(f(x))

// 幂等的:
obj.count = 2; // 这里的幂等性的概念是每一个幂等运算(比如 obj.count = 2)可以重复多次
person.name = upper( person.name );

// 非幂等的:
obj.count++;
person.lastUpdated = Date.now();

// 幂等的:
var hist = document.getElementById( "orderHistory" );
hist.innerHTML = order.historyText;

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

我们不会一直用幂等的方式去定义数据,但如果能做到,这肯定会减少意外情况下产生的副作用。这需要时间去体会,我们就先记住它。

纯函数

你应该听说过纯函数的大名,我们把没有副作用的函数称为纯函数。

例 1:

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

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

例 2:

const PI = 3.141592;

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

circleArea 也是纯函数。虽然它调用了外部变量 PI ,但是 PI 是 const 定义的常量,引用常量不会产生副作用;

例 3:

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

unary 也是纯函数。

表达一个函数的纯度的另一种常用方法是:给定相同的输入(一个或多个),它总是产生相同的输出。

不纯的函数是不受欢迎的!因为我们需要更多的精力去判断它的输出结果!

写纯函数需要更多耐心,比如我们操作数组的 push(..) 方法,或 reverse(..) 方法等,看起来安全,但实际上会修改数组本身。我们需要复制一个变量来解耦(深拷贝)。

函数的纯度是和自信是有关的。函数越纯洁越好。制作纯函数时越努力,当您阅读使用它的代码时,你的自信就会越高,这将使代码更加可读。

其实,关于函数纯度还有更多有意思的点:

思考一个问题,如果我们把函数和外部变量再封装为一个函数,外界无法直接访问其内部,这样,内部的函数算不算是一个纯函数?

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

阶段小结

  1. 我们反复强调:开发人员喜欢显式输入输出而不是隐式输入输出。

  2. 如果有隐式的输入输出,那么就有可能产生副作用。

  3. 有副作用的代码让我们的代码理解起来更加费劲!

  4. 解决副作用的方法有:定义常量、明确 I/O、明确依赖、运用幂等......

  5. 在 js 运用幂等是一个新事物,我们需要逐渐熟悉它。

  6. 没有副作用的函数就是纯函数,纯函数是我们追求编写的!

  7. 将一个不纯的函数重构为纯函数是首选。但是,如果无法重构,尝试封装副作用。(假如一棵树在森林里倒下而没有人在附近听见,它有没有发出声音?—— 有没有其实已经不重要了,反正听不到)

以上,便是本次关于 JS 函数式编程 副作用 这个细节的讲解。

这个细节,真的很重要!

我是掘金安东尼,公众号【掘金安东尼】,输出暴露输入,技术洞见生活!