摘要:函數式編程者并沒有消除所有的副作用。我的結論是這里的并不違反減少或避免副作用的精神。一些語言允許你指定生成隨機數的種子。因此,我們必須將內建的隨機數生成視為不純的一方。其他的錯誤在程序運行期間副作用可能導致的錯誤是多種多樣的。
原文地址:Functional-Light-JS
原文作者:Kyle Simpson-《You-Dont-Know-JS》作者
第 5 章:減少副作用關于譯者:這是一個流淌著滬江血液的純粹工程:認真,是 HTML 最堅實的梁柱;分享,是 CSS 里最閃耀的一瞥;總結,是 JavaScript 中最嚴謹的邏輯。經過捶打磨練,成就了本書的中文版。本書包含了函數式編程之精髓,希望可以幫助大家在學習函數式編程的道路上走的更順暢。比心。
譯者團隊(排名不分先后):阿希、blueken、brucecham、cfanlife、dail、kyoko-df、l3ve、lilins、LittlePineapple、MatildaJin、冬青、pobusama、Cherry、蘿卜、vavd317、vivaxy、萌萌、zhouyao
在第 2 章,我們討論了一個函數除了它的返回值之外還有什么輸出。現在你應該很熟悉用函數式編程的方法定義一個函數了,所以對于函數式編程的副作用你應該有所了解。
我們將檢查各種各樣不同的副作用并且要看看他們為什么會對我們的代碼質量和可讀性造成損害。
這一章的要點是:編寫出沒有副作用的程序是不可能的。當然,也不是不可能,你當然可以編寫出沒有副作用的程序。但是這樣的話程序就不會做任何有用和明顯的事情。如果你編寫出來一個零副作用的程序,你就無法區分它和一個被刪除的或者空程序的區別。
函數式編程者并沒有消除所有的副作用。實際上,我們的目標是盡可能地限制他們。要做到這一點,我們首先需要完全理解函數式編程的副作用。
什么是副作用因果關系:舉一個我們人類對周圍世界影響的最基本、最直觀的例子,推一下放在桌子邊沿上的一本書,書會掉落。不需要你擁有一個物理學的學位你也會知道,這是因為你剛剛推了書并且書掉落是因為地心引力,這是一個明確并直接的關系。
在編程中,我們也完全會處理因果關系。如果你調用了一個函數(起因),就會在屏幕上輸出一條消息(結果)。
當我們在閱讀程序的時候,能夠清晰明確的識別每一個起因和每一個結果是非常重要的。在某種程度上,通讀程序但不能看到因果的直接關系,程序的可讀性就會降低。
思考一下:
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(..) 的一個副作用嗎?
兩個觀察結果將會合理地幫助我們回答這個問題:
想一下是否每次調用 foo(3),都將會返回 9.424..?答案是肯定的。 如果每一次都給一個相同的輸入(x),那么都將會返回相同的輸出。
你能用 PI 的當前值來代替每一個 PI 嗎,并且程序能夠和之前一樣正確地的運行嗎?是的。 程序沒有任何一部分依賴于 PI 值的改變,因為 PI 的類型是 const,它是不能再分配的,所以變量 PI 在這里只是為了便于閱讀和維護。它的值可以在不改變程序行為的情況下內聯。
我的結論是:這里的 PI 并不違反減少或避免副作用的精神。在之前的代碼也沒有調用 bar(x)。
在這兩種情況下,PI 和 bar 都不是程序狀態的一部分。它們是固定的,不可重新分配的(“常量”)的引用。如果他們在整個程序中都不改變,那么我們就不需要擔心將他們作為變化的狀態追蹤他們。同樣的,他們不會損害程序的可讀性。而且它們也不會因為變量以不可預測的方式變化,而成為錯誤的源頭。
注意: 在我看來,使用 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; }
所有輸入(x 和 y)和輸出(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(..) 是不純的。
首先,我們對純度的斷言是基于數組的值(通過 list 和 nums 引用)一直不改變:
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; } ); } }
userId 和 users 都是原始的的 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 ); } }
蠻力的策略是,在我們程序的其余部分使用此通用程序時隔離副作用的方法時創建一個接口函數,執行以下步驟:
捕獲受影響的當前狀態
設置初始輸入狀態
運行不純的函數
捕獲副作用狀態
恢復原來的狀態
返回捕獲的副作用狀態
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前端高效開發實戰》已在亞馬遜、京東、當當開售。
文章版權歸作者所有,未經允許請勿轉載,若此文章存在違規行為,您可以聯系管理員刪除。
轉載請注明本文地址:http://specialneedsforspecialkids.com/yun/85188.html
摘要:我稱之為輕量級函數式編程。序眾所周知,我是一個函數式編程迷。函數式編程有很多種定義。本書是你開啟函數式編程旅途的絕佳起點。事實上,已經有很多從頭到尾正確的方式介紹函數式編程的書了。 原文地址:Functional-Light-JS 原文作者:Kyle Simpson - 《You-Dont-Know-JS》作者 譯者團隊(排名不分先后):阿希、blueken、brucecham、...
摘要:所以我覺得函數式編程領域更像學者的領域。函數式編程的原則是完善的,經過了深入的研究和審查,并且可以被驗證。函數式編程是編寫可讀代碼的最有效工具之一可能還有其他。我知道很多函數式編程編程者會認為形式主義本身有助于學習。 原文地址:Functional-Light-JS 原文作者:Kyle Simpson - 《You-Dont-Know-JS》作者 關于譯者:這是一個流淌著滬江血液...
摘要:但在開始之前應該心中有數值的不可變性并不是說我們不能在程序編寫時不改變某個值。這些都是對值的不可變這個概念的誤解。程序的其他部分不會影響的賦值。 原文地址:Functional-Light-JS 原文作者:Kyle Simpson-《You-Dont-Know-JS》作者 關于譯者:這是一個流淌著滬江血液的純粹工程:認真,是 HTML 最堅實的梁柱;分享,是 CSS 里最閃...
摘要:一旦我們滿足了基本條件值為,我們將不再調用遞歸函數,只是有效地執行了。遞歸深諳函數式編程之精髓,最被廣泛引證的原因是,在調用棧中,遞歸把大部分顯式狀態跟蹤換為了隱式狀態。 原文地址:Functional-Light-JS 原文作者:Kyle Simpson-《You-Dont-Know-JS》作者 關于譯者:這是一個流淌著滬江血液的純粹工程:認真,是 HTML 最堅實的梁柱;...
摘要:相像閉包和對象之間的關系可能不是那么明顯。一個沒有對象的編程語言可以用閉包來模擬對象。事實上,表達一個對象為閉包形式,或閉包為對象形式是相當簡單的。簡而言之,閉包和對象是狀態的同構表示及其相關功能。 原文地址:Functional-Light-JS 原文作者:Kyle Simpson-《You-Dont-Know-JS》作者 關于譯者:這是一個流淌著滬江血液的純粹工程:認真,...
摘要:這就是積極的函數式編程。上一章翻譯連載第章遞歸下輕量級函數式編程你不知道的姊妹篇原創新書移動前端高效開發實戰已在亞馬遜京東當當開售。 原文地址:Functional-Light-JS 原文作者:Kyle Simpson-《You-Dont-Know-JS》作者 關于譯者:這是一個流淌著滬江血液的純粹工程:認真,是 HTML 最堅實的梁柱;分享,是 CSS 里最閃耀的一瞥;總...
閱讀 1303·2021-11-11 10:57
閱讀 3718·2021-09-07 10:10
閱讀 3442·2021-08-03 14:03
閱讀 3067·2019-08-30 13:45
閱讀 681·2019-08-29 11:19
閱讀 1039·2019-08-28 18:07
閱讀 3100·2019-08-26 13:55
閱讀 809·2019-08-26 12:17