摘要:但在開始之前應該心中有數值的不可變性并不是說我們不能在程序編寫時不改變某個值。這些都是對值的不可變這個概念的誤解。程序的其他部分不會影響的賦值。
原文地址:Functional-Light-JS
原文作者:Kyle Simpson-《You-Dont-Know-JS》作者
第 6 章:值的不可變性關于譯者:這是一個流淌著滬江血液的純粹工程:認真,是 HTML 最堅實的梁柱;分享,是 CSS 里最閃耀的一瞥;總結,是 JavaScript 中最嚴謹的邏輯。經過捶打磨練,成就了本書的中文版。本書包含了函數式編程之精髓,希望可以幫助大家在學習函數式編程的道路上走的更順暢。比心。
譯者團隊(排名不分先后):阿希、blueken、brucecham、cfanlife、dail、kyoko-df、l3ve、lilins、LittlePineapple、MatildaJin、冬青、pobusama、Cherry、蘿卜、vavd317、vivaxy、萌萌、zhouyao
在第 5 章中,我們探討了減少副作用的重要性:副作用是引起程序意外狀態改變的原因,同時也可能會帶來意想不到的驚喜(bugs)。這樣的暗雷在程序中出現的越少,開發者對程序的信心無疑就會越強,同時代碼的可讀性也會越高。本章的主題,將繼續朝減少程序副作用的方向努力。
如果編程風格冪等性是指定義一個數據變更操作以便只影響一次程序狀態,那么現在我們將注意力轉向將這個影響次數從 1 降為 0。
現在我們開始探索值的不可變性,即只在我們的程序中使用不可被改變的數據。
原始值的不可變性原始數據類型(number、string、boolean、null 和 undefined)本身就是不可變的;無論如何你都沒辦法改變它們。
// 無效,且毫無意義 2 = 2.5;
然而 JS 確實有一個特性,使得看起來允許我們改變原始數據類型的值, 即“boxing”特性。當你訪問原始類型數據時 —— 特別是 number、string 和 boolean —— 在這種情況下,JS 會自動的把它們包裹(或者說“包裝”)成這個值對應的對象(分別是 Number、String 以及 Boolean)。
思考下面的代碼:
var x = 2; x.length = 4; x; // 2 x.length; // undefined
數值本身并沒有可用的 length 屬性,因此 x.length = 4 這個賦值操作正試圖添加一個新的屬性,不過它靜默地失敗了(也可以說是這個操作被忽略了或被拋棄了,這取決于你怎么看);變量 x 繼續承載那個簡單的原始類型數據 —— 數值 2。
但是 JS 允許 x.length = 4 這條語句正常執行的事實著實令人困惑。如果這種現象真的無緣無故出現,那么代碼的閱讀者無疑會摸不著頭腦。好消息是,如果你使用了嚴格模式("use strict";),那么這條語句就會拋出異常了。
那么如果嘗試改變那些明確被包裝成對象的值呢?
var x = new Number( 2 ); // 沒問題 x.length = 4;
這段代碼中的 x 保存了一個對象的引用,因此可以正常地添加或修改自定義屬性。
像 number 這樣的原始數型,值的不可變性看起來相當明顯,但字符串呢?JS 開發者有個共同的誤解 —— 字符串和數組很像,所以應該是可變的。JS 使用 [] 訪問字符串成員的語法甚至還暗示字符串真的就像數組。不過,字符串的確是不可變的:
var s = "hello"; s[1]; // "e" s[1] = "E"; s.length = 10; s; // "hello"
盡管可以使用 s[1] 來像訪問數組元素一樣訪問字符串成員,JS 字符串也并不是真的數組。s[1] = "E" 和 s.length = 10 這兩個賦值操作都是失敗的,就像剛剛的 x.length = 4 一樣。在嚴格模式下,這些賦值都會拋出異常,因為 1 和 length 這兩個屬性在原始數據類型字符串中都是只讀的。
有趣的是,即便是包裝后的 String 對象,其值也會(在大部分情況下)表現的和非包裝字符串一樣 —— 在嚴格模式下如果改變已存在的屬性,就會拋出異常:
"use strict"; var s = new String( "hello" ); s[1] = "E"; // error s.length = 10; // error s[42] = "?"; // OK s; // "hello"從值到值
我們將在本節詳細展開從值到值這個概念。但在開始之前應該心中有數:值的不可變性并不是說我們不能在程序編寫時不改變某個值。如果一個程序的內部狀態從始至終都保持不變,那么這個程序肯定相當無趣!它同樣不是指變量不能承載不同的值。這些都是對值的不可變這個概念的誤解。
值的不可變性是指當需要改變程序中的狀態時,我們不能改變已存在的數據,而是必須創建和跟蹤一個新的數據。
例如:
function addValue(arr) { var newArr = [ ...arr, 4 ]; return newArr; } addValue( [1,2,3] ); // [1,2,3,4]
注意我們沒有改變數組 arr 的引用,而是創建了一個新的數組(newArr),這個新數組包含數組 arr 中已存在的值,并且新增了一個新值 4。
使用我們在第 5 章討論的副作用的相關概念來分析 addValue(..)。它是純的嗎?它是否具有引用透明性?給定相同的數組作為輸入,它會永遠返回相同的輸出嗎?它無副作用嗎?答案是肯定的。
設想這個數組 [1, 2, 3], 它是由先前的操作產生,并被我們保存在一個變量中,它代表著程序當前的狀態。我們想要計算出程序的下一個狀態,因此調用了 addValue(..)。但是我們希望下一個狀態計算的行為是直接的和明確的,所以 addValue(..) 操作簡單的接收一個直接輸入,返回一個直接輸出,并通過不改變 arr 引用的原始數組來避免副作用。
這就意味著我們既可以計算出新狀態 [1, 2, 3, 4],也可以掌控程序的狀態變換。程序不會出現過早的過渡到這個狀態或完全轉變到另一個狀態(如 [1, 2, 3, 5])這樣的意外情況。通過規范我們的值并把它視為不可變的,我們大幅減少了程序錯誤,使我們的程序更易于閱讀和推導,最終使程序更加可信賴。
arr 所引用的數組是可變的,只是我們選擇不去改變他,我們實踐了值不可變的這一精神。
同樣的,可以將“以拷貝代替改變”這樣的策略應用于對象,思考下面的代碼:
function updateLastLogin(user) { var newUserRecord = Object.assign( {}, user ); newUserRecord.lastLogin = Date.now(); return newUserRecord; } var user = { // .. }; user = updateLastLogin( user );消除本地影響
下面的代碼能夠體現不可變性的重要性:
var arr = [1,2,3]; foo( arr ); console.log( arr[0] );
從表面上講,你可能認為 arr[0] 的值仍然為 1。但事實是否如此不得而知,因為 foo(..) 可能會改變你傳入其中的 arr 所引用的數組。
在之前的章節中,我們已經見到過用下面這種帶有欺騙性質的方法來避免意外:
var arr = [1,2,3]; foo( arr.slice() ); // 哈!一個數組副本! console.log( arr[0] ); // 1
當然,使得這個斷言成立的前提是 foo 函數不會忽略我們傳入的參數而直接通過相同的 arr 這個自由變量詞法引用來訪問源數組。
對于防止數據變化負面影響,稍后我們會討論另一種策略。
重新賦值在進入下一個段落之前先思考一個問題 —— 你如何描述“常量”?
…
你可能會脫口而出“一個不能改變的值就是常量”,“一個不能被改變的變量”等等。這些回答都只能說接近正確答案,但卻并不是正確答案。對于常量,我們可以給出一個簡潔的定義:一個無法進行重新賦值(reassignment)的變量。
我們剛剛在“常量”概念上的吹毛求疵其實是很有必要的,因為它澄清了常量與值無關的事實。無論常量承載何值,該變量都不能使用其他的值被進行重新賦值。但它與值的本質無關。
思考下面的代碼:
var x = 2;
我們剛剛討論過,數據 2 是一個不可變的原始值。如果將上面的代碼改為:
const x = 2;
const 關鍵字的出現,作為“常量聲明”被大家熟知,事實上根本沒有改變 2 的本質,因為它本身就已經不可改變了。
下面這行代碼會拋出錯誤,這無可厚非:
// 嘗試改變 x,祝我好運! x = 3; // 拋出錯誤!
但再次重申,我們并不是要改變這個數據,而是要對變量 x 進行重新賦值。數據被卷進來純屬偶然。
為了證明 const 和值的本質無關,思考下面的代碼:
const x = [ 2 ];
這個數組是一個常量嗎?并不是。 x 是一個常量,因為它無法被重新賦值。但下面的操作是完全可行的:
x[0] = 3;
為何?因為盡管 x 是一個常量,數組卻是可變的。
關于 const 關鍵字和“常量”只涉及賦值而不涉及數據語義的特性是個又臭又長的故事。幾乎所有語言的高級開發者都踩 const 地雷。事實上,Java 最終不贊成使用 const 并引入了一個全新的關鍵詞 final 來區分“常量”這個語義。
拋開混亂之后開始思考,如果 const 并不能創建一個不可變的值,那么它對于函數式編程者來說又還有什么重要的呢?
意圖const 關鍵字可以用來告知閱讀你代碼的讀者該變量不會被重新賦值。作為一個表達意圖的標識,const 被加入 JavaScript 不僅常常受到稱贊,也普遍提高了代碼可讀性。
在我看來,這是夸大其詞,這些說法并沒有太大的實際意義。我只看到了使用這種方法來表明意圖的微薄好處。如果使用這種方法來聲明值的不可變性,與已使用幾十年的傳統方式相比,const 簡直太弱了。
為了證明我的說法,讓我們來做一個實踐。const 創建了一個在塊級作用域內的變量,這意味著該變量只能在其所在的代碼塊中被訪問:
// 大量代碼 { const x = 2; // 少數幾行代碼 } // 大量代碼
通常來說,代碼塊的最佳實踐是用于僅包裹少數幾行代碼的場景。如果你有一個包含了超過 10 行的代碼塊,那么大多數開發者會建議你重構這一段代碼。因此 const x = 2 只作用于下面的9行代碼。
程序的其他部分不會影響 x 的賦值。
我要說的是:上述程序的可讀性與下面這樣基本相同:
// 大量代碼 { let x = 2; // 少數幾行代碼 } // 大量代碼
其實只要查看一下在 let x = 2; 之后的幾行代碼,就可以判斷出 x 這個變量是否被重新賦值過了。對我來說,“實際上不進行重新賦值”相對“使用容易迷惑人的 const 關鍵字告訴讀者‘不要重新賦值’”是一個更明確的信號。
此外,讓我們思考一下,乍看這段代碼起來可能給讀者傳達什么:
const magicNums = [1,2,3,4]; // ..
讀者可能會(錯誤地)認為,這里使用 const 的用意是你永遠不會修改這個數組 —— 這樣的推斷對我來說合情合理。想象一下,如果你的確允許 magicNums 這個變量所引用的數組被修改,那么這個 const 關鍵詞就極具混淆性了 —— 的很確容易發生意外,不是嗎?
更糟糕的是,如果你在某處故意修改了 magicNums,但對讀者而言不夠明顯呢?讀者會在后面的代碼里(再次錯誤地)認為 magicNums 的值仍然是 [1, 2, 3, 4]。因為他們猜測你之前使用 const 的目的就是“這個變量不會改變”。
我認為你應該使用 var 或 let 來聲明那些你會去改變的變量,它們確實相比 const 來說是一個更明確的信號。
const 所帶來的問題還沒講完。還記得我們在本章開頭所說的嗎?值的不可變性是指當需要改變某個數據時,我們不應該直接改變它,而是應該使用一個全新的數據。那么當新數組創建出來后,你會怎么處理它?如果你使用 const 聲明變量來保存引用嗎,這個變量的確沒法被重新賦值了,那么……然后呢?
從這方面來講,我認為 const 反而增加了函數式編程的困難度。我的結論是:const 并不是那么有用。它不僅造成了不必要的混亂,也以一種很不方便的形式限制了我們。我只用 const 來聲明簡單的常量,例如:
const PI = 3.141592;
3.141592 這個值本身就已經是不可變的,并且我也清楚地表示說“PI 標識符將始終被用于代表這個字面量的占位符”。對我來說,這才是 const 所擅長的。坦白講,我在編碼時并不會使用很多這樣的聲明。
我寫過很多,也閱讀過很多 JavaScript 代碼,我認為由于重新賦值導致大量的 bug 這只是個想象中的問題,實際并不存在。
我們應該擔心的,并不是變量是否被重新賦值,而是值是否會發生改變。為什么?因為值是可被攜帶的,但詞法賦值并不是。你可以向函數中傳入一個數組,這個數組可能會在你沒意識到的情況下被改變。但是你的其他代碼在預期之外重新給變量賦值,這是不可能發生的。
凍結這是一種簡單廉價的(勉強)將像對象、數組、函數這樣的可變的數據轉為“不可變數據”的方式:
var x = Object.freeze( [2] );
Object.freeze(..) 方法遍歷對象或數組的每個屬性和索引,將它們設置為只讀以使之不會被重新賦值,事實上這和使用 const 聲明屬性相差無幾。Object.freeze(..) 也會將屬性標記為“不可配置(non-reconfigurable)”,并且使對象或數組本身不可擴展(即不會被添加新屬性)。實際上,而就可以將對象的頂層設為不可變。
注意,僅僅是頂層不可變!
var x = Object.freeze( [ 2, 3, [4, 5] ] ); // 不允許改變: x[0] = 42; // oops,仍然允許改變: x[2][0] = 42;
Object.freeze(..) 提供淺層的、初級的不可變性約束。如果你希望更深層的不可變約束,那么你就得手動遍歷整個對象或數組結構來為所有后代成員應用 Object.freeze(..)。
與 const 相反,Object.freeze(..) 并不會誤導你,讓你得到一個“你以為”不可變的值,而是真真確確給了你一個不可變的值。
回顧剛剛的例子:
var arr = Object.freeze( [1,2,3] ); foo( arr ); console.log( arr[0] ); // 1
可以非常確定 arr[0] 就是 1。
這是非常重要的,因為這可以使我們更容易的理解代碼,當我們將值傳遞到我們看不到或者不能控制的地方,我們依然能夠相信這個值不會改變。
性能每當我們開始創建一個新值(數組、對象等)取代修改已經存在的值時,很明顯迎面而來的問題就是:這對性能有什么影響?
如果每次想要往數組中添加內容時,我們都必須創建一個全新的數組,這不僅占用 CPU 時間并且消耗額外的內存。不再存在任何引用的舊數據將會被垃圾回收機制回收;更多的 CPU 資源消耗。
這樣的取舍能接受嗎?視情況而定。對代碼性能的優化和討論都應該有個上下文。
如果在你的程序中,只會發生一次或幾次單一的狀態變化,那么扔掉一個舊對象或舊數組完全沒必要擔心。性能損失會非常非常小 —— 頂多只有幾微秒 —— 對你的應用程序影響甚小。追蹤和修復由于數據改變引起的 bug 可能會花費你幾分鐘甚至幾小時的時間,這么看來那幾微秒簡直沒有可比性。
但是,如果頻繁的進行這樣的操作,或者這樣的操作出現在應用程序的核心邏輯中,那么性能問題 —— 即性能和內存 —— 就有必要仔細考慮一下了。
以數組這樣一個特定的數據結構來說,我們想要在每次操作這個數組時使每個更改都隱式地進行,就像結果是一個新數組一樣,但除了每次都真的創建一個數組之外,還有什么其他辦法來完成這個任務呢?像數組這樣的數據結構,我們期望除了能夠保存其最原始的數據,然后能追蹤其每次改變并根據之前的版本創建一個分支。
在內部,它可能就像一個對象引用的鏈表樹,樹中的每個節點都表示原始值的改變。從概念上來說,這和 git 的版本控制原理類似。
想象一下使用這個假設的、專門處理數組的數據結構:
var state = specialArray( 1, 2, 3, 4 ); var newState = state.set( 42, "meaning of life" ); state === newState; // false state.get( 2 ); // 3 state.get( 42 ); // undefined newState.get( 2 ); // 3 newState.get( 42 ); // "meaning of life" newState.slice( 1, 3 ); // [2,3]
specialArray(..) 這個數據結構會在內部追蹤每個數據更新操作(例如 set(..)),類似 diff,因此不必要為原始的那些值(1、2、3 和 4)重新分配內存,而是簡單的將 "meaning of life" 這個值加入列表。重要的是,state 和 newState 分別指向兩個“不同版本”的數組,因此值的不變性這個語義得以保留。
發明你自己的性能優化數據結構是個有趣的挑戰。但從實用性來講,找一個現成的庫會是個更好的選擇。Immutable.js(http://facebook.github.io/imm...) 是一個很棒的選擇,它提供多種數據結構,包括 List(類似數組)和 Map(類似普通對象)。
思考下面的 specialArray 示例,這次使用 Immutable.List:
var state = Immutable.List.of( 1, 2, 3, 4 ); var newState = state.set( 42, "meaning of life" ); state === newState; // false state.get( 2 ); // 3 state.get( 42 ); // undefined newState.get( 2 ); // 3 newState.get( 42 ); // "meaning of life" newState.toArray().slice( 1, 3 ); // [2,3]
像 Immutable.js 這樣強大的庫一般會采用非常成熟的性能優化。如果不使用庫而是手動去處理那些細枝末節,開發的難度會相當大。
當改變值這樣的場景出現的較少且不用太關心性能時,我推薦使用更輕量級的解決方案,例如我們之前提到過的內置的 Object.freeze(..)。
以不可變的眼光看待數據如果我們從函數中接收了一個數據,但不確定這個數據是可變的還是不可變的,此時該怎么辦?去修改它試試看嗎?不要這樣做。 就像在本章最開始的時候所討論的,不論實際上接收到的值是否可變,我們都應以它們是不可變的來對待,以此來避免副作用并使函數保持純度。
回顧一下之前的例子:
function updateLastLogin(user) { var newUserRecord = Object.assign( {}, user ); newUserRecord.lastLogin = Date.now(); return newUserRecord; }
該實現將 user 看做一個不應該被改變的數據來對待;user 是否真的不可變完全不會影響這段代碼的閱讀。對比一下下面的實現:
function updateLastLogin(user) { user.lastLogin = Date.now(); return user; }
這個版本更容易實現,性能也會更好一些。但這不僅讓 updateLastLogin(..) 變得不純,這種方式改變的值使閱讀該代碼,以及使用它的地方變得更加復雜。
應當總是將 user 看做不可變的值,這樣我們就沒必要知道數據從哪里來,也沒必要擔心數據改變會引發潛在問題。
JavaScript 中內置的數組方法就是一些很好的例子,例如 concat(..) 和 slice(..) 等:
var arr = [1,2,3,4,5]; var arr2 = arr.concat( 6 ); arr; // [1,2,3,4,5] arr2; // [1,2,3,4,5,6] var arr3 = arr2.slice( 1 ); arr2; // [1,2,3,4,5,6] arr3; // [2,3,4,5,6]
其他一些將參數看做不可變數據且返回新數組的原型方法還有:map(..) 和 filter(..) 等。reduce(..) / reduceRight(..) 方法也會盡量避免改變參數,盡管它們并不默認返回新數組。
不幸的是,由于歷史問題,也有一部分不純的數組原型方法:splice(..)、pop(..)、push(..)、shift(..)、unshift(..)、reverse(..) 以及 fill(..)。
有些人建議禁止使用這些不純的方法,但我不這么認為。因為一些性能面的原因,某些場景下你仍然可能會用到它們。不過你也應當注意,如果一個數組沒有被本地化在當前函數的作用域內,那么不應當使用這些方法,避免它們所產生的副作用影響到代碼的其他部分。
不論一個數據是否是可變的,永遠將他們看做不可變。遵守這樣的約定,你程序的可讀性和可信賴度將會大大提升。
總結值的不可變性并不是不改變值。它是指在程序狀態改變時,不直接修改當前數據,而是創建并追蹤一個新數據。這使得我們在讀代碼時更有信心,因為我們限制了狀態改變的場景,狀態不會在意料之外或不易觀察的地方發生改變。
由于其自身的信號和意圖,const 關鍵字聲明的常量通常被誤認為是強制規定數據不可被改變。事實上,const 和值的不可變性聲明無關,而且使用它所帶來的困惑似乎比它解決的問題還要大。另一種思路,內置的 Object.freeze(..) 方法提供了頂層值的不可變性設定。大多數情況下,使用它就足夠了。
對于程序中性能敏感的部分,或者變化頻繁發生的地方,處于對計算和存儲空間的考量,每次都創建新的數據或對象(特別是在數組或對象包含很多數據時)是非常不可取的。遇到這種情況,通過類似 Immutable.js 的庫使用不可變數據結構或許是個很棒的主意。
值不變在代碼可讀性上的意義,不在于不改變數據,而在于以不可變的眼光看待數據這樣的約束。
【上一章】翻譯連載 | JavaScript輕量級函數式編程-第5章:減少副作用 |《你不知道的JS》姊妹篇
iKcamp原創新書《移動Web前端高效開發實戰》已在亞馬遜、京東、當當開售。
>> 滬江Web前端上海團隊招聘【Web前端架構師】,有意者簡歷至:zhouyao@hujiang.com <<
文章版權歸作者所有,未經允許請勿轉載,若此文章存在違規行為,您可以聯系管理員刪除。
轉載請注明本文地址:http://specialneedsforspecialkids.com/yun/88381.html
摘要:我稱之為輕量級函數式編程。序眾所周知,我是一個函數式編程迷。函數式編程有很多種定義。本書是你開啟函數式編程旅途的絕佳起點。事實上,已經有很多從頭到尾正確的方式介紹函數式編程的書了。 原文地址:Functional-Light-JS 原文作者:Kyle Simpson?。 禮ou-Dont-Know-JS》作者 譯者團隊(排名不分先后):阿希、blueken、brucecham、...
摘要:本書主要探索函數式編程的核心思想。我們在中應用的僅僅是一套基本的函數式編程概念的子集。我稱之為輕量級函數式編程。通常來說,關于函數式編程的書籍都熱衷于拓展閱讀者的知識面,并企圖覆蓋更多的知識點。,本書統稱為函數式編程者。 原文地址:Functional-Light-JS 原文作者:Kyle Simpson?。 禮ou-Dont-Know-JS》作者 譯者團隊(排名不分先后)...
摘要:相像閉包和對象之間的關系可能不是那么明顯。一個沒有對象的編程語言可以用閉包來模擬對象。事實上,表達一個對象為閉包形式,或閉包為對象形式是相當簡單的。簡而言之,閉包和對象是狀態的同構表示及其相關功能。 原文地址:Functional-Light-JS 原文作者:Kyle Simpson-《You-Dont-Know-JS》作者 關于譯者:這是一個流淌著滬江血液的純粹工程:認真,...
摘要:一旦我們滿足了基本條件值為,我們將不再調用遞歸函數,只是有效地執行了。遞歸深諳函數式編程之精髓,最被廣泛引證的原因是,在調用棧中,遞歸把大部分顯式狀態跟蹤換為了隱式狀態。 原文地址:Functional-Light-JS 原文作者:Kyle Simpson-《You-Dont-Know-JS》作者 關于譯者:這是一個流淌著滬江血液的純粹工程:認真,是 HTML 最堅實的梁柱;...
摘要:所以我覺得函數式編程領域更像學者的領域。函數式編程的原則是完善的,經過了深入的研究和審查,并且可以被驗證。函數式編程是編寫可讀代碼的最有效工具之一可能還有其他。我知道很多函數式編程編程者會認為形式主義本身有助于學習。 原文地址:Functional-Light-JS 原文作者:Kyle Simpson?。 禮ou-Dont-Know-JS》作者 關于譯者:這是一個流淌著滬江血液...
閱讀 1865·2019-08-30 15:53
閱讀 3193·2019-08-30 15:44
閱讀 2806·2019-08-26 13:31
閱讀 1949·2019-08-26 12:10
閱讀 792·2019-08-26 11:01
閱讀 2120·2019-08-23 15:32
閱讀 1585·2019-08-23 13:43
閱讀 2529·2019-08-23 11:58