Skip to content

Latest commit

 

History

History
424 lines (313 loc) · 22.6 KB

ch-05.md

File metadata and controls

424 lines (313 loc) · 22.6 KB

在第二张,我们讨论了函数为何能在return某值之外还能有别的输出。到现在为止,你应该对函数的函数式定义非常熟悉了,所以像这样的输出——副作用——你也应该有所耳闻了。

我们将会研究各种形式的副作用,看看它们为什么对我们代码的质量和可读性有害。

在开始之前,我想在这里首先强调一点,本章的中心观点是:写一个没有副作用的程序是不可能的。嗯,也不是不可能,你当然可以,但是这样的程序不会做任何有用的或是者任何可观察到的事情。假如你编写了一个没有副作用的程序,你将无法区分它与一个被删除或者空程序的区别。

函数式编程者们从不消除所有副作用,我们的目的是尽可能的限制它们。为了做到这一点,我们首先需要充分的了解它们。

Effects On The Side, Please

因果关系:人类可以对我们周围的世界做出最基本最直觉的观察。将一本书从桌子边缘推下去,它就会掉到地上。你并不需要物理学位就能知道,因为你推了书,所以产生了书在重力影响下掉落于地的结果。这是非常清晰和直接的关系。

在程序中,我们同样也是完全处在因果关系中。假如你调用了某个函数(因),它在屏幕中打印出了一条消息(果)。

在阅读程序的时候,读者能否清晰的识别出每个因果关系是非常重要的。在某种程度上,假如不能轻易的看出代码中的因果关系,那么这段程序的可读性就比较低。

思考:

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

var y = foo( 3 );

在这个很普通的程序中,我们立即就能清楚的知道,调用带着3这个值调用foo(因),就会返回6,并且它会被赋值给y(果)。这里没有任何歧义。

但是这个片段:

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

var y;

foo( 3 );

这个程序的结果和上面的完全相同,但是却有一个很大的区别,这里的因与果是不相交的,结果是间接达到的。以这种方式设定y的值,我们就称之为副作用

当函数对外部变量进行引用时, 这个变量称为自由变量。并非所有的自由变量引用都是不好的, 但我们要非常小心。

假如我给你一个引用来调用函数bar(..),并且你并不能看到它的内部代码。但是我告诉你,它没有这样的间接副作用,只有一个显式的return,你会怎样?

bar( 4 );			// 42

因为你知道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发生了什么。这样并不需要花费多少额外的精力,代码的可读性明显更高。

隐藏的原因

输出和状态的改变是最常见的副作用的表现形式,但是还有另一种对可读性有害的实践,有人喜欢把这种做法称为副因子(side causes,与副作用side effects相对,我实在想不出更好翻译。)。思考:

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(..)的显式参数来输入。这样代码的读者才会清晰的看到这之间的因果关系。

固定状态

为了避免副因子,这意味着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变量的值在别的函数中被覆盖的可能性,但我在这段代码中并没有这样做,这也并不是我或惯例的常见做法。对于所有的意图和目的,我的函数总是常量(绝不重新赋值)。

思考:

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值——因为它是一个const,它不能够被重新赋值——所以PI在这里仅仅是为了可读性/可维护性的目的。这个值也可以是内联的,并且也不会改变程序的行为。

我的结论:PI在这里并没有违反最小化/避免副作用(或副因子)的精神,之前代码片段中的bar(x)调用也是一样的。

在这两种情况下,PIbar并不是程序状态的一部分,它们是固定的,不可重新赋值(常量)的引用。如果在整个程序流程中没有改变,那我们就不必分心去跟踪这些状态的变化。因此,它们并不会损害我们的可读性。有一些错误通常是由于变量出乎意料的被改变而造成的,使用常量将会避免成为这种错误的源头。

在我看来,`PI`没有造成副作用的原因并不是因为我们使用了`const`,因为假如我们使用`var PI`也可以得出同样的结论。问题的关键在于,我们没有对`PI`重新赋值,而不是我们不能对其重新赋值。我们将会在之后的章节中详细的讨论`const`。

随机性

你以前可能从来没有考虑过,但随机性并不纯粹。一个使用了Math.random()的函数永远都不会是纯净的。因为你并不能根据输入来确保/预料到你的输出。所以任何生成唯一随机ID/等的代码都被认为依赖于程序的副因子。

在计算机中,我们使用所谓的伪随机算法来进行生成工作。就结果而言,真正的随机是非常困难的,所以我们只能用复杂的算法来伪造它,这些算法能产生看起来像是随机的值。这些算法将会计算很长的数字流,但是这里有个秘密,如果你知道这个起始点,这个序列实际上是可以预测的。这个起始点被称作种子。

有些语言允许你为随机数生成指定种子的值,如果你始终指定相同的种子,那么你随后获得的“随机数”序列也将会是相同的。这对于测试来说非常有用,但对于现实世界的应用程序来说则是非常危险的。

在 JS 中,Math.random()的随机性计算基于间接的输入,因为你无法指定种子。因此,我们必须将内置的这个随机数生成函数视作不纯的副因子。

I/O Effects

这可能并不是特别明显的,但最常见(也是根本无法避免)的副作用的形式是 I/O(输入/输出 input/output)。没有I/O的程序是完全没有意义的,因为这样的话它的工作就不可能被任何方式所观察到。有用的程序必须至少有输出,有些还需要输入。输入是副因子,而输出则是副作用。

对于浏览器端的JS程序员而言,用户事件(鼠标、键盘)是典型的输入而输出是 DOM。假如你的工作更多是面对Node.js,你接触到的输入和输出则可能是文件系统、网络连接或者是stdin/stdout流。

事实上,这些东西可以既是输入也是输出,既是因也是果。我们以 DOM 为例,我们更新(副作用)DOM 元素来给用户展示文字或者是图像,但 DOM 的当前状态也是这些操作的隐式输入(副因子)。

Side Bugs

副因子和副作用可能导致错误的情景和存在的程序一样数不胜数。但是我们先来看一个场景来说明这些危害,希望他们能够帮助我们识别我们自己程序中类似的错误。

思考:

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++) {
			// keep a reference to latest order for each user
			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);

	// deleting the latest order for a user?
	if (isLatestOrder) {
		hideLatestOrderDisplay();
	}

	ajax( "http://some.api/delete/order/" + orderId, function onDelete(success){
		if (success) {
			// deleted the latest order for a user?
			if (isLatestOrder) {
				user.latestOrder = null;
			}

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

我敢和读者们打个赌,这里的代码中明显隐藏着一个错误。假如回调onOrders(..)onUserData(..)之前运行了,它将会给值(在users[userId]上的userData对象)增加一个latestOrder属性,而此时这个值(即users[userId])还不存在。

因此,对于这种形式的“错误”,它发生的逻辑是这样的,它依赖于两个不同且处于竞争状态的操作(异步或者其他)而导致的副作用,我们希望能以某种顺序来运行这两个操作,但在某些情况下它们运行的顺序是不同的。我们也有确保操作顺序的策略,而且在这种情况下,它们的顺序是非常明显的。

在这里还有另一个微妙的错误,你发现了吗?

思考下列的调用顺序:

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

// later

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

你看到交错出现的fetchOrders(..)/onOrders(..)以及deleteOrder(..)/onDelete(..)了吗?这种潜在的顺序会导致状态管理的副作用暴露出一个奇怪的状况。

在我们设置isLatestOrder标志位时,以及在我们使用它来决定是否应该清空用户数据对象中的latestOrder属性时,在这两个状态之间是有一定的时间延迟的(因为是回调)。在延迟器件如果onOrders(..)被触发,它可能会改变用户的latestOrder引用值。随后当onDelete(..)被触发时,它将会假设这里仍然需要取消latestOrder的引用。

错误:数据(状态)现在可能是非同步的。onOrders(..)被调用的时候,latestOrder有可能已经指向了一个新的值,此时它应该保持这个值,然而它却被清空了。

这类错误最糟糕的部分在于,你不会得到像其他错误那样被程序抛出的异常。我们只有一个不正确的状态,我们程序的行为是“静默”的。

fetchUserData(..)fetchOrders(..)之间的顺序相关性是相当明显的,并且我们可以直截了当地解决它。但是fetchOrders(..)deleteOrder(..)之间存在潜在的顺序依赖关系,这一点就不是那么清楚明了了。它们看起来似乎要更独立一些,并且要确定它们之间的顺序要更为困难,因为你并不是提前知道(在fetchOrders(..)运行之前)是否必须执行排序。

是的,一旦deleteOrder(..)被触发了,你可以重新计算isLatestOrder标志位。但是你现在又有了另一个问题:你的 UI 状态可能是不同步的。

如果你在之前已经调用了hideLatestOrderDisplay(..),现在需要调用showLatestOrderDisplay(..),但是这必须要设置一个新的isLatestOrder。因此你需要跟踪至少三个状态:原来被删除的最新的订单(order),现在被设置的最新的订单,这两者是否不同?当然,这些都是可以解决的问题,但是它们并不那么明显。

所有的这些麻烦,都是因为我们决定在一组共享的状态下构建我们的代码,所造成的副作用。

函数式程序员厌恶这些各种各样的副作用错误,因为它让我们对代码的读取、推理、验证变得更加困难,并最终影响了我们对代码的信任。这就是为什么他们采取了这样的原则,以避免严重的副作用的影响。

有很多不同的策略来避免/解决副作用,我们将会在本章的后面以及后面的章节中讨论这些内容。我可以肯定的说:写出了带有副作用的代码是很正常的,所以想要避免它们需要你仔细且专门的努力。

一次就足够了,谢谢

如果你必须对状态进行有副作用的修改,它与对限制潜在问题的有效操作是幂等的。如果你对值的更新是幂等的,即便你可能有多个副作用来源的值,数据也是可以适应这些情况的。

幂等的定义有点混乱,数学家与程序员使用的定义通常略有不同。但是这两个视角对于函数式程序员来说都是有用的。

首先,我们给出一个例子,它既不是数学中的幂等也不是程序定义的幂等:

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

	return false;
}

这个函数对obj.count实行了递增操作,这样就使得对象的引用产生了变化,所以它对输入的对象产生了副作用。假如updateCounter(o)被调用了数次——而o.count一直小于10——那就意味着程序的状态每次都会被改变。并且,updateCounter(..)的输出是一个布尔值,它并不适合反馈到updateCounter(..)的后续调用中。

数学意义上的幂等

从数学的角度来看,幂等是指第一次调用之后,即便是将输出再反复输入到操作中,输出也不会发生变化的操作。换句话说,foo(x)foo(foo(x))甚至foo(foo(foo(x)))等等的输出都是一样的。

一个典型的数学上的例子是Math.abs(..)(求绝对值)。Math.abs(-2)的结果是2ath.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(f(x)),而仅仅是要求f(x);的结果与f(x); f(x);将会导致相同的程序行为。换句话说,在第一次调用f(x)之后,随后的调用结果均不会发生改变。

这种观点更符合我们对副作用的观察,因为这样的f(..)操作更有可能产生幂等副作用,而不是必须返回幂等的输出值。

这种幂等风格经常被应用在 HTTP 操作中,例如 GET 和 PUT。如果 HTTP REST API 正确的遵循了幂等规范指南,则 PUT 会被定义为完全替代资源的更新操作。因此不管客户端发送多少次 PUT 请求(具有相同数据),服务器的结果状态都是相同的。

为了更具体的思考这个问题,我们来写几段代码,检查一下一些含有副作用的操作的幂等性(或者不是):

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

// non-idempotent:
obj.count++;
a[a.length] = 42;
person.lastUpdated = Date.now();

请记住:这里的这个幂等的概念是, 每个幂等运算(如 obj.count = 2)可以多次重复, 而不会在第一次更新之后更改程序状态。非幂等的操作每次都会更改状态。

DOM 的更新又怎么样呢?

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

// idempotent:
hist.innerHTML = order.historyText;

// non-idempotent:
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,并且也只输出了return后面的函数——但是,对于其内部的onlyOneArg函数,它引用了这个闭包之中的自由变量fn,它是否是纯函数呢?

它仍然是纯函数,因为fn绝不会改变。事实上,我们完全相信这个试试,因为在词法作用域的规则中,也只有在这么几行代码中可能对fn进行重新赋值。

百分比:49% 已翻译:23559 总字符:47346