本篇會将談談函數程式設計中一個很重要的細節 —— “副作用”。
維基上關于副作用的解釋:
函數内部有隐式(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 ) 的結果卻不一樣,又增大了閱讀的負擔。相信我,這是個最簡單抽象的例子,實際的影響将遠大于此。
避免副作用?
- const
以上面的例子來說:這樣寫,foo( 1 ) 的結果當然是确定的,因為用到了 const 來固定外部變量。
const y = 5;
// ..
foo( 1 );
- I/O
一個沒有 I/O 的程式是完全沒有意義的,因為它的工作不能以任何方式被觀察到。一個有用的程式必須最少有一個輸出,并且也需要輸入。輸入會産生輸出。
還記得 foo(..) 函數片段 2 嗎?沒有輸出 return,這是不太可取的。
// 片段 2
function foo(x) {
y = x * 2;
}
var y;
foo( 3 );
- 明确依賴
我們經常會由于函數的異步問題導緻資料出錯;一個函數引用了另外一個函數的回調結果,當我們作這種引用時要特别注意。
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 需要前者的回調;
寫出有副作用/效果的代碼是很正常的, 但我們需要謹慎和刻意地避免産生有副作用的代碼。
- 運用幂等
這是一個很新但重要的概念!
從數學的角度來看,幂等指的是在第一次調用後,如果你将該輸出一次又一次地輸入到操作中,其輸出永遠不會改變的操作。
一個典型的數學例子是 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(..) 方法等,看起來安全,但實際上會修改數組本身。我們需要複制一個變量來解耦(深拷貝)。
函數的純度是和自信是有關的。函數越純潔越好。制作純函數時越努力,當您閱讀使用它的代碼時,你的自信就會越高,這将使代碼更加可讀。
其實,關于函數純度還有更多有意思的點:
思考一個問題,如果我們把函數和外部變量再封裝為一個函數,外界無法直接通路其内部,這樣,内部的函數算不算是一個純函數?
階段小結
- 我們反複強調:開發人員喜歡顯式輸入輸出而不是隐式輸入輸出。
- 如果有隐式的輸入輸出,那麼就有可能産生副作用。
- 有副作用的代碼讓我們的代碼了解起來更加費勁!
- 解決副作用的方法有:定義常量、明确 I/O、明确依賴、運用幂等......
- 在 js 運用幂等是一個新事物,我們需要逐漸熟悉它。
- 沒有副作用的函數就是純函數,純函數是我們追求編寫的!
- 将一個不純的函數重構為純函數是首選。但是,如果無法重構,嘗試封裝副作用。(假如一棵樹在森林裡倒下而沒有人在附近聽見,它有沒有發出聲音?—— 有沒有其實已經不重要了,反正聽不到)