從JS對象開始,談一談究竟什麼是“不可變數據”和函數式編程
作為前端開發者,你會感受到JS中對象(Object)這個概念的強大。我們說“JS中一切皆對象”。最核心的特性,例如從String,到數組,再到瀏覽器的APIs,對象這個概念無處不在。這裏你可以了解到JS Objects中的一切。
同時,隨著React的強勢崛起,不管你有沒有關注過這個框架,也一定聽說過一個概念—不可變數據(immutable.js)。究竟什麼是不可變數據?這篇文章會從JS源頭—對象談起,讓你逐漸了解這個函數式編程裏的重要概念。
JS中的對象是那麼美妙:我們可以隨意複製他們,改變並刪除他們的某項屬性等。但是要記住一句話:
“伴隨著特權,隨之而來的是更大的責任。”
(With great power comes great responsibility)
的確,JS Objects裏概念太多了,我們切不可隨意使用對象。下麵,我就從基本對象說起,聊一聊不可變數據和JS的一切。
這篇文章緣起於Daniel Leite在本月16日的文章:Things you should know about Objects and Immutability in JavaScript,我進行了翻譯,並改寫了用到的例子,以及進行了大量更多的擴展。
可變和共享是萬惡之源
不可變數據其實是函數式編程相關的重要概念。相對的,函數式編程中認為可變性是萬惡之源。但是,為什麼會有這樣的結論呢?
這個問題可能很多程序員都會有。其實,如果你的代碼邏輯可變,這並不是“政治錯誤”的。比如JS中的數組操作,很對都會對原數組進行直接改變,這當然並沒有什麼問題。比如:
let arr = [1, 2, 3, 4, 5];
arr.splice(1, 1); // 返回[2];
console.log(arr); // [1, 3, 4, 5];
這是我們常用的“刪除數組某一項”的操作。好吧,他一點問題也沒有。
問題其實出現在“濫用”可變性上,這樣會給你的程序帶來“副作用”。先不必關心什麼是“副作用”,他又是一個函數式編程的概念。
我們先來看一下代碼實例:
const student1 = {
school: 'Baidu',
name: 'HOU Ce',
birthdate: '1995-12-15',
}
const changeStudent = (student, newName, newBday) => {
const newStudent = student;
newStudent.name = newName;
newStudent.birthdate = newBday;
return newStudent;
}
const student2 = changeStudent(student1, 'YAN Haijing', '1990-11-10');
// both students will have the name properties
console.log(student1, student2);
// Object {school: "Baidu", name: "YAN Haijing", birthdate: "1990-11-10"}
// Object {school: "Baidu", name: "YAN Haijing", birthdate: "1990-11-10"}
我們發現,盡管創建了一個新的對象student2,但是老的對象student1也被改動了。這是因為JS對象中的賦值是“引用賦值”,即在賦值過程中,傳遞的是在內存中的引用(memory reference)。具體說就是“棧存儲”和“堆存儲”的問題。具體圖我就不畫了,理解不了可以單找我。
不可變數據的強大和實現
我們說的“不可變”,其實是指保持一個對象狀態不變。這樣做的好處是使得開發更加簡單,可回溯,測試友好,減少了任何可能的副作用。
函數式編程認為:
隻有純的沒有副作用的函數,才是合格的函數。
好吧,現在開始解釋下“副作用”(Side effect):在計算機科學中,函數副作用指當調用函數時,除了返回函數值之外,還對主調用函數產生附加的影響。例如修改全局變量(函數外的變量)或修改參數。
函數副作用會給程序設計帶來不必要的麻煩,給程序帶來十分難以查找的錯誤,並降低程序的可讀性。嚴格的函數式語言要求函數必須無副作用。
那麼我們避免副作用,創建不可變數據的主要實現思路就是:一次更新過程中,不應該改變原有對象,隻需要新創建一個對象用來承載新的數據狀態。
我們使用純函數(pure functions)來實現不可變性。純函數指無副作用的函數。
那麼,具體怎麼構造一個純函數呢?我們可以看一下代碼實現,我對上例進行改造:
const student1 = {
school: "Baidu",
name: 'HOU Ce',
birthdate: '1995-12-15',
}
const changeStudent = (student, newName, newBday) => {
return {
...student, // 使用解構
name: newName, // 覆蓋name屬性
birthdate: newBday // 覆蓋birthdate屬性
}
}
const student2 = changeStudent(student1, 'YAN Haijing', '1990-11-10');
// both students will have the name properties
console.log(student1, student2);
// Object {school: "Baidu", name: "HOU Ce", birthdate: "1995-12-15"}
// Object {school: "Baidu", name: "YAN Haijing", birthdate: "1990-11-10"}
需要注意的是,我使用了ES6中的解構(destructuring)賦值。
這樣,我們達到了想要的效果:根據參數,產生了一個新對象,並正確賦值,最重要的就是並沒有改變原對象。
創建純函數,過濾副作用
現在,我們知道了“不可變”到底指的是什麼。接下來,我們就要分析一下純函數應該如何實現,進而生產不可變數據。
其實創建不可變數據方式有很多,在使用原生JS的基礎上,我推薦的方法是使用現有的Objects API和ES6當中的解構賦值(上例已經演示)。現在看一下Objects.assign的實現方式:
const student1 = {
school: "Baidu",
name: 'HOU Ce',
birthdate: '1995-12-15',
}
const changeStudent = (student, newName, newBday) => Object.assign({}, student, {name: newName, birthdate: newBday})
const student2 = changeStudent(student1, 'YAN Haijing', '1990-11-10');
// both students will have the name properties
console.log(student1, student2);
// Object {school: "Baidu", name: "HOU Ce", birthdate: "1995-12-15"};
// Object {school: "Baidu", name: "YAN Haijing", birthdate: "1990-11-10"};
同樣,如果是處理數組相關的內容,我們可以使用:.map, .filter或者.reduce去達成目標。這些APIs的共同特點就是不會改變原數組,而是產生並返回一個新數組。這和純函數的思想不謀而合。
但是,再說回來,使用Object.assign請務必注意以下幾點:
- 1)他的複製,是將所有可枚舉屬性,複製到目標對象。換句話說,不可枚舉屬性是無法完成複製的。
- 2)對象中如果包含undefined和null類型內容,會報錯。
- 3)最重要的一點:Object.assign方法實行的是淺拷貝,而不是深拷貝。
第三點很重要,也就是說,如果源對象某個屬性的值是對象,那麼目標對象拷貝得到的是這個對象的引用。這也就意味著,當對象存在嵌套時,還是有問題的。比如下麵代碼:
const student1 = {
school: "Baidu",
name: 'HOU Ce',
birthdate: '1995-12-15',
friends: {
friend1: 'ZHAO Wenlin',
friend2: 'CHENG Wen'
}
}
const changeStudent = (student, newName, newBday, friends) => Object.assign({}, student, {name: newName, birthdate: newBday})
const student2 = changeStudent(student1, 'YAN Haijing', '1990-11-10');
// both students will have the name properties
console.log(student1, student2);
// Object {school: "Baidu", name: "HOU Ce", birthdate: "1995-12-15", friends: Object}
// Object {school: "Baidu", name: "YAN Haijing", birthdate: "1990-11-10", friends: Object}
student2.friends.friend1 = 'MA xiao';
console.log(student1.friends.friend1); // "MA xiao"
對student2 friends列表當中的friend1的修改,同時也影響了student1 friends列表當中的friend1。
JS本身的蒼白無力和強大的不可變數據類庫
以上,我們分析了純JS如何實現不可變數據。這樣處理帶來的一個負麵影響在於:一些經典APIs都是shallow處理,比如上文提到的Object.assign。如果遇到嵌套很深的結構,我們就需要手動遞歸。這樣做呢,又會存在性能上的問題。
比如我自己動手用遞歸實現一個深拷貝,需要考慮循環引用的“死環”問題,另外,當使用大規模數據結構時,性能劣勢盡顯無疑。我們熟悉的jquery extends方法,某一版本(最新版本情況我不太了解)的實現是進行了三層拷貝,也沒有達到完備的deep copy。
總之,實現不可變數據,我們必然要關心性能問題。針對於此,我推薦一款已經“大名鼎鼎”的——immutable.js類庫來處理不可變數據。
他的實現原理很有意思,下麵這段話,我摘自camsong前輩的文章:
Immutable實現的原理是Persistent Data Structure(持久化數據結構),也就是使用舊數據創建新數據時,要保證舊數據同時可用且不變。
同時為了避免deepCopy把所有節點都複製一遍帶來的性能損耗,Immutable使用了Structural Sharing(結構共享),即如果對象樹中一個節點發生變化,隻修改這個節點和受它影響的父節點,其它節點則進行共享。
感興趣的讀者可以深入研究下,這是很有意思的。
總結
我們使用JavaScript操縱對象,這樣的方式很簡單便捷。但是,這樣操控的基礎是在JavaScript靈活的對象機製的熟練掌握上。不然很容易使你“頭大”。
在我開發的百度某部門私信項目中,因為使用了React+Redux技術棧,並且數據結構較為負責,也采用了immutable.js實現。
最後,在前端開發中,函數式編程越來越熱,並且在某種程度上已經取代了“過程式”編程和麵向對象思想。
我的感想是在某些特定的場景下,不要畏懼變化,擁抱未來。
就像我很喜歡的葡萄牙詩人安德拉德一首詩中那樣說的:
我同樣不知道什麼是海,
赤腳站在沙灘上,
急切地等待著黎明的到來。
最後更新:2017-06-23 23:35:28