1. 程式人生 > >學會JavaScript函數語言程式設計(第1部分)

學會JavaScript函數語言程式設計(第1部分)

摘要: JS函數語言程式設計入門。

Fundebug經授權轉載,版權歸原作者所有。

在這篇由多部分組成的文章中,接下來將介紹函數語言程式設計的一些概念,這些概念對你學習函數語言程式設計有所幫助。如果你已經懂了什麼是函數語言程式設計,這可以加深你的理解。

請不要著急。從這一點開始,花點時間閱讀並理解程式碼示例。你甚至可能想在每節課結束後停止閱讀,以便讓你的觀點深入理解,然後再回來完成。

最重要的是你要理解。

純函式(Purity)

所謂純函式,就是指這樣一個函式,對於相同的輸入,永遠得到相同的輸出,它不依賴外部環境,也不會改變外部環境。如果不滿足以上幾個條件那就是非純函式。

下面是Javascript中的一個純函式示例:

var z = 10;
function add(x, y) {
    return x + y;
}

注意,add 函式不涉及z變數。它不從z讀取,也不從z寫入,它只讀取xy,然後返回它們相加的結果。這是一個純函式。如果 add 函式確實訪問了變數z,那麼它就不再是純函數了。

請思考一下下面這個函式:

function justTen() {
    return 10;
}

如果函式justTen是純的,那麼它只能返回一個常量, 為什麼?

因為我們沒有給它任何引數。 而且,既然是純函式的,除了自己的輸入之外它不能訪問任何東西,它唯一可以返回的就是常量。

由於不帶引數的純函式不起作用,所以它們不是很有用。所以justTen被定義為一個常數會更好。

大多數有用的純函式必須至少帶一個引數。

考慮一下這個函式:

function addNoReturn(x, y) {
    var z = x + y
}

注意這個函式是不返回任何值。它只是把變數xy相加賦給變數z,但並沒有返回。

這個也是一個純函式,因為它只處理輸入。它確實對輸入的變數進行操作,但是由於它不返回結果,所以它是無用的。

所有有用的純函式都必須返回一些我們期望的結果。

讓我們再次考慮第一個add函式:

注意 add(1, 2) 的返回結果總是 3。這不是奇怪的事情,只是因為 add

函式是純的。如果 add 函式使用了一些外部值,那麼你永遠無法預測它的行為。

在給定相同輸入的情況下,純函式總是返回相同的結果。

由於純函式不能改變任何外部變數,所以下面的函式都不是純函式:

writeFile(fileName);
updateDatabaseTable(sqlCmd);            
sendAjaxRequest(ajaxRequest);
openSocket(ipAddress);

所有這些功能都有副作用。當你呼叫它們時,它們會更改檔案和資料庫表、將資料傳送到伺服器或呼叫作業系統以獲取套接字。它們不僅對輸入操作同時也對輸出進行操作,因此,你永遠無法預測這些函式將返回什麼。

純函式沒有副作用。

在Javascript、Java 和 c# 等指令式程式設計語言中,副作用無處不在。這使得除錯非常困難,因為變數可以在程式的任何地方更改。所以,當你有一個錯誤,因為一個變數在錯誤的時間被更改為錯誤的值,這不是很好。

此時,你可能會想,“我怎麼可能只使用純函式呢?”

函數語言程式設計不能消除副作用,只能限制副作用。由於程式必須與真實環境相連線,所以每個程式的某些部分肯定是不純的。函數語言程式設計的目標是儘量寫更多的純函式,並將其與程式的其他部分隔離開來。

不可變性 (Immutability)

你還記得你第一次看到下面的程式碼是什麼時候嗎?

var x = 1;
x = x + 1;

教你初中數學的老師看到以上程式碼,可能會問你,你忘記我給你教的數學了嗎? 因為在數學中,x 永遠不能等於x + 1。

但在指令式程式設計中,它的意思是,取x的當前值加1,然後把結果放回x中。

在函數語言程式設計中,x = x + 1是非法的。所以這裡你可以用數學的邏輯還記得在數式程式設計中這樣寫是不對的!

函數語言程式設計中沒有變數。

由於歷史原因,儲存值的變數仍然被稱為變數,但它們是常量,也就是說,一旦x取值,這個常量就是x返回的值。別擔心,x 通常是一個區域性變數,所以它的生命週期通常很短。但只要它還沒被銷燬,它的值就永遠不會改變。

下面是Elm中的常量變數示例,Elm是一種用於Web開發的純函數語言程式設計語言:

addOneToSum y z =
    let
        x = 1
    in
        x + y + z

如果你不熟悉ml風格的語法,讓我解釋一下。addOneToSum 是一個函式,有兩個引數分別為yz

let塊中,x被繫結到1的值上,也就是說,它在函式的生命週期內都等於1。當函式退出時,它的生命週期結束,或者更準確地說,當let塊被求值時,它的生命週期就結束了。

in塊中,計算可以包含在let塊中定義的值,即 x,返回計算結果 x + y + z,或者更準確地說,返回 1 + y + z,因為 x = 1。

你可能又會想 :“我怎麼能在沒有變數的情況下做任何事情呢?”

我們想一下什麼時候需要修改變數。通常會想到兩種情況:多值更改(例如修改或記錄物件中的單個值)和單值更改(例如迴圈計數器)。

函數語言程式設計使用引數儲存狀態,最好的例子就是遞迴。是的,是沒有迴圈。“什麼沒有變數,現在又沒有迴圈? ”我討厭你! ! !”

哈哈,這並不是說我們不能做迴圈,只是沒有特定的迴圈結構,比如for, while, do, repeat等等。

函數語言程式設計使用遞迴進行迴圈。

這裡有兩種方法可以在Javascript中執行迴圈:

注意,遞迴是一種函式式方法,它通過使用一個結束條件 start (start + 1) 和呼叫自己 accumulator (acc + start) 來實現與 for 迴圈相同的功能。它不會修改舊的值。相反,它使用從舊值計算的新值。

不幸的是,這在 Javascript中 很難想懂,需要你花點時間研究它,原因有二。第一,Javascript的語法相對其它高階語言比較亂,其次,你可能還不習慣遞迴思維。

在Elm,它更容易閱讀,如下:

sumRange start end acc =
    if start > end then
        acc
    else
        sumRange (start + 1) end (acc + start) 
    

它是這樣執行的:

你可能認為 for 迴圈更容易理解。雖然這是有爭議的,而且更可能是一個熟悉的問題,但非遞迴迴圈需要可變性,這是不好的。

在這裡,我還沒有完全解釋不變性的好處,但是請檢視全域性可變狀態部分,即為什麼程式設計師需要限制來了解更多。

我還沒有完全解釋不可變性(Immutability)在這裡的好處,但請檢視 為什麼程式設計師需要限制的全域性可變狀態部分 以瞭解更多資訊。

不可變性的好處是,你讀取訪問程式中的某個值,但只有讀許可權的,這意味著不用害怕其他人更改該值使自己讀取到的值是錯誤。

不可變性的還有一個好處是,如果你的程式是多執行緒的,那麼就沒有其他執行緒可以更改你執行緒中的值,因為該值是不可變,所以另一個執行緒想要更改它,它只能從舊執行緒建立一個新值。

不變性可以建立更簡單、更安全的程式碼。

重構

讓我們考慮一下重構,下面是一些Javascript程式碼:

我們以前可能都寫過這樣的程式碼,隨著時間的推移,開始意識到這兩個函式實際上是相同的,函式名稱,列印結果不太一樣而已。

我們不應該複製 validateSsn 來建立 validatePhone,而是應該建立一個函式(共同的部分),通過引數形式實現我們想要的結果。

重構後的程式碼如下:

舊程式碼引數中 ssnphone 現在用 value 表示,正則表示式 /^\d{3}-\d{2}-\d{4}$/ and /^(\d{3})\d{3}-\d{4}$/ 由變數 regex. 表示。最後,訊息“SSN”“電話號碼” 由變數 type 表示。

這個有類似的函式都可以使用這個函式來實現,這樣可以保持程式碼的整潔和可維護性。

高階函式

許多語言不支援將函式作為引數傳遞,有些會支援但並不容易。

在函數語言程式設計中,函式是一級公民。換句話說,函式通常是另一個函式的值。

由於函式只是值,我們可以將它們作為引數傳遞。即使Javascript不是純函式語言,也可以使用它進行一些功能性的操作。 所以這裡將上面的兩個函式重構為單個函式,方法是將驗證合法性的函式作為函式 parseFunc 的引數:

function validateValueWithFunc(value, parseFunc, type) {
  if (parseFunc(value))
    console.log('Invalid ' + type);
  else
    console.log('Valid ' + type);
}

像函式 parseFunc 接收一個或多個函式作為輸入的函式,稱為 高階函式

高階函式要麼接受函式作為引數,要麼返回函式,要麼兩者兼而有之。

現在可以呼叫高階函式(這在Javascript中有效,因為Regex.exec在找到匹配時返回一個truthy值):

validateValueWithFunc('123-45-6789', /^\d{3}-\d{2}-\d{4}$/.exec, 'SSN');
validateValueWithFunc('(123)456-7890', /^\(\d{3}\)\d{3}-\d{4}$/.exec, 'Phone');
validateValueWithFunc('123 Main St.', parseAddress, 'Address');
validateValueWithFunc('Joe Mama', parseName, 'Name');

這比有四個幾乎相同的函式要好得多。

但是請注意正則表示式,這裡有點冗長了。簡化一下:

var parseSsn = /^\d{3}-\d{2}-\d{4}$/.exec;
var parsePhone = /^\(\d{3}\)\d{3}-\d{4}$/.exec;
validateValueWithFunc('123-45-6789', parseSsn, 'SSN');
validateValueWithFunc('(123)456-7890', parsePhone, 'Phone');
validateValueWithFunc('123 Main St.', parseAddress, 'Address');
validateValueWithFunc('Joe Mama', parseName, 'Name');

現在看起來好多了。現在,當要驗證一個電話號碼時,不需要複製和貼上正則表示式了。

但是假設我們有更多的正則表示式需要解析,而不僅僅是 parseSsnparsePhone。每次建立正則表示式解析器時,我們都必須記住在末尾新增 .exec,這很容易被忘記。

可以通過建立一個返回exec 的高階函式來防止這種情況:

function makeRegexParser(regex) {
    return regex.exec;
}
var parseSsn = makeRegexParser(/^\d{3}-\d{2}-\d{4}$/);
var parsePhone = makeRegexParser(/^\(\d{3}\)\d{3}-\d{4}$/);
validateValueWithFunc('123-45-6789', parseSsn, 'SSN');
validateValueWithFunc('(123)456-7890', parsePhone, 'Phone');
validateValueWithFunc('123 Main St.', parseAddress, 'Address');
validateValueWithFunc('Joe Mama', parseName, 'Name');

這裡,makeRegexParser採用正則表示式並返回exec函式,該函式接受一個字串。validateValueWithFunc 將字串 value 傳遞給 parse 函式,即exec。

parseSsnparsePhone 實際上與以前一樣,是正則表示式的 exec 函式。

當然,這是一個微小的改進,但是這裡給出了一個返回函式的高階函式示例。但是,如果makeRegexParser 要複雜得多,這種更改的好處是很大的。

下面是另一個返回函式的高階函式示例:

function makeAdder(constantValue) {
    return function adder(value) {
        return constantValue + value;
    };
}

函式 makeAdder,接受引數 constantValue 並返回函式 adder,這個函式返回 constantValue 與它傳入引數相加結果。

下面是它的用法:

var add10 = makeAdder(10);
console.log(add10(20)); // 列印 30
console.log(add10(30)); // 列印 40
console.log(add10(40)); // 列印 50

我們通過將常量10傳遞給 makeAdder 來建立一個函式 add10, makeAdder 返回一個函式,該函式將向返回的結果都加 10。

注意,即使在 makeAddr 返回之後,函式 adder 也可以訪問變數 constantValue。 這裡能訪問到 constantValue 是因為存在閉包。

閉包機制非常重要,因為如果沒有它 ,返回函式的函式就不會有很大作用。所以必須瞭解它們是如何工作。

閉包

下面是一個使用閉包的函式的示例:

function grandParent(g1, g2) {
    var g3 = 3;
    return function parent(p1, p2) {
        var p3 = 33;
        return function child(c1, c2) {
            var c3 = 333;
            return g1 + g2 + g3 + p1 + p2 + p3 + c1 + c2 + c3;
        };
    };
}

在這個例子中,child 函式可以訪問它自身的變數,函式 parent 函式可以訪問它的自身變數和函式 grandParent 的變數。而函式 grandParent 只能訪問自身的變數。

下面是它的一個使用例子:

var parentFunc = grandParent(1, 2); // returns parent()
var childFunc = parentFunc(11, 22); // returns child()
console.log(childFunc(111, 222)); // prints 738
// 1 + 2 + 3 + 11 + 22 + 33 + 111 + 222 + 333 == 738

在這裡,parentFunc 保留了 parent 的作用域,因為 grandParent 返回 parent

類似地,childFunc 保留了 child 的作用域,因為 parentFunc 保留了 parent 的作用域,而 parent 的作用域 保留了child 的作用域。

當一個函式被建立時,它在建立時作用域中的所有變數在函式的生命週期內都是可訪問的。一個函式只要還有對它的引用就存在。例如,只要childFunc 還引用 child 的作用域,child 的作用域就存在。

閉包具體還看看之前整理的一篇文章:我從來不理解JavaScript閉包,直到有人這樣向我解釋它...

原文:
1、https://medium.com/@cscalfani...
2、https://medium.com/@cscalfani...

編輯中可能存在的bug沒法實時知道,事後為了解決這些bug,花了大量的時間進行log 除錯,這邊順便給大家推薦一個好用的BUG監控工具Fundebug。

你的點贊是我持續分享好東西的動力,歡迎點贊!

一個笨笨的碼農,我的世界只能終身學習!

更多內容請關注公眾號《大遷世界》!

關於Fundebug

Fundebug專注於JavaScript、微信小程式、微信小遊戲、支付寶小程式、React Native、Node.js和Java實時BUG監控。 自從2016年雙十一正式上線,Fundebug累計處理了9億+錯誤事件,得到了Google、360、金山軟體、百姓網等眾多知名使用者的認可。歡迎免費試用!

版權宣告

轉載時請註明作者Fundebug以及本文地址:
https://blog.fundebug.com/2018/12/27/to-be-a-functional-programmer-part-1/