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

Fundebug發表於2018-12-29

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

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

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

本系列的其它篇:

引用透明 (Referential Transparency)

引用透明是一個富有想象力的優秀術語,它是用來描述純函式可以被它的表示式安全的替換,通過下例來幫助我們理解。

在代數中,有一個如下的公式:

y = x + 10
複製程式碼

接著:

 x = 3
複製程式碼

然後帶入表示式:

y = 3 + 10
複製程式碼

注意這個方程仍然是有效的,我們可以利用純函式做一些相同型別的替換。

下面是一個 JavaScript 的方法,在傳入的字串兩邊加上單引號:

function quote (str) {
  retrun "'" + str + "'"
}
複製程式碼

下面是呼叫它:

   function findError (key) {
     return "不能找到 " + quote(key)
   }
複製程式碼

當查詢 key 值失敗時,findError 返回一個報錯資訊。

因為 quote 是純函式,我們可以簡單地將 quote 函式體(這裡僅僅只是個表示式)替換掉在findError中的方法呼叫:

   function findError (key) {
     return "不能找到 " + "'" + str + "'"
   }
複製程式碼

這個就是通常所說的**“反向重構”**(它對我而言有更多的意義),可以用來幫程式設計師或者程式(例如編譯器和測試程式)推理程式碼的過程一個很好的方法。如,這在推導遞迴函式時尤其有用的。

執行順序 (Execution Order)

大多數程式都是單執行緒的,即一次只執行一段程式碼。即使你有一個多執行緒程式,大多數執行緒都被阻塞等待I/O完成,例如檔案,網路等等。

這也是當我們編寫程式碼的時候,我們很自然考慮按次序來編寫程式碼:

1. 拿到麵包 
2. 把2片面包放入烤麵包機 
3. 選擇加熱時間 
4. 按下開始按鈕 
5. 等待麵包片彈出 
6. 取出烤麵包 
7. 拿黃油 
8. 拿黃油刀 
9. 製作黃油麵包 
複製程式碼

在這個例子中,有兩個獨立的操作:拿黃油以及 加熱麵包。它們在 步驟9 時開始變得相互依賴。

我們可以將 步驟7步驟8步驟1步驟6 同時執行,因為它們彼此獨立。當我們開始做的時候,事情開始複雜了:

執行緒一
--------------------------
1. 拿到麵包 
2. 把2片面包放入烤麵包機 
3. 選擇加熱時間 
4. 按下開始按鈕 
5. 等待麵包片彈出 
6. 取出烤麵包 

執行緒二
-------------------------
1. 拿黃油 
2. 拿黃油刀 
3. 等待執行緒1完成 
4. 取出烤麵包 
複製程式碼

果執行緒1失敗,執行緒2怎麼辦? 怎麼協調這兩個執行緒? 烤麵包這一步驟在哪個執行緒執行:執行緒1,執行緒2或者兩者?

不考慮這些複雜性,讓我們的程式保持單執行緒會更容易。但是,只要能夠提升我們程式的效率,要付出努力來寫好多執行緒程式,這是值得的。

然而,多執行緒有兩個主要問題:

  • 多執行緒程式難於編寫、讀取、解釋、測試和除錯。
  • 一些語言,例如JavaScript,並不支援多執行緒,就算有些語言支援多執行緒,對它的支援也很弱。

但是,如果順序無關緊要,所有事情都是並行執行的呢?

儘管這聽起來有些瘋狂,但其實並不像聽起來那麼混亂。讓我們來看一下 Elm 的程式碼來形象的理解它:

buildMessage message value =
    let
        upperMessage =
            String.toUpper message
        quotedValue =
            "'" ++ value ++ "'"
    in
        upperMessage ++ ": " ++ quotedValue
複製程式碼

這裡的 buildMessage 接受引數 messagevalue,然後,生成大寫的 message和 帶有引號的 value

注意到 upperMessagequotedValue 是獨立的。我們怎麼知道的呢?

在上面的程式碼示例中,upperMessagequotedValue 兩者都是純的並且沒有一個需要依賴其它的輸出。

如果它們不純,我們就永遠不知道它們是獨立的。在這種情況下,我們必須依賴程式中呼叫它們的順序來確定它們的執行順序。這就是所有命令式語言的工作方式。

第二點必須滿足的就是一個函式的輸出值不能作為其它函式的輸入值。如果存在這種情況,那麼我們不得不等待其中一個完成才能執行下一個。

在本例中,upperMessagequotedValue 都是純的並且沒有一個需要依賴其它的輸出,因此,這兩個函式可以以任何順序執行。

編譯器可以在不需要程式設計師幫助的情況下做出這個決定。這隻有在純函式式語言中才有可能,因為很難(如果不是不可能的話)確定副作用的後果。

在純函式語言中,執行的順序可以由編譯器決定。

考慮到 CPU 無法一再的加快速度,這種做法非常有利的。別一方面,生產商也不斷增加CPU核心晶片的數量,這意味著程式碼可以在硬體層面上並行執行。使用純函式語言,就有希望在不改變任何程式碼的情況下充分地發揮 CPU 晶片的功能並取得良好成效。

型別註釋 (Type Annotations)

在靜態型別語言中,型別是內聯定義的。以下是 Java 程式碼:

public static String quote(String str) {
    return "'" + str + "'";
}
複製程式碼

注意型別是如何同函式定義內聯在一起的。當有泛型時,它變的更糟:

private final Map<Integer, String> getPerson(Map<String, String> people, Integer personId) {
   // ...
}
複製程式碼

這裡使用粗體標出了使它們使用的型別,但它們仍然會讓函式可讀性降低,你必須仔細閱讀才能找到變數的名稱。

對於動態型別語言,這不是問題。在 Javascript 中,可以編寫如下程式碼:

var getPerson = function(people, personId) {
    // ...
};
複製程式碼

這樣沒有任何的的型別資訊更易於閱讀,唯一的問題就是放棄了型別檢測的安全特性。這樣能夠很簡單的傳入這些引數,例如,一個 Number 型別的 people 以及一個 Objec t型別的 personId

動態型別要等到程式執行後才能知道哪裡問題,這可能是在釋出的幾個月後。在 Java 中不會出現這種情況,因為它不能被編譯。

但是,假如我們能同時擁有這兩者的優異點呢? JavaScript 的語法簡單性以及 Java 的安全性。

事實證明我們可以。下面是 Elm 中的一個帶有型別註釋的函式:

add : Int -> Int -> Int
add x y =
    x + y
複製程式碼

請注意型別資訊是在單獨的程式碼行上面的,而正是這樣的分割使得其有所不同。

現在你可能認為型別註釋有錯訓。 第一次見到它的時候。 大都認為第一個 -> 應該是一個逗號。可以加上隱含的括號,程式碼就清晰多了:

add : Int -> (Int -> Int)
複製程式碼

上例 add 是一個函式,它接受型別為 Int 的單個引數,並返回一個函式,該函式接受單個引數 Int型別 並返回一個 Int 型別的結果。

以下是一個帶括號型別註釋的程式碼:

doSomething : String -> (Int -> (String -> String)) 
doSomething prefix value suffix = 
prefix ++ (toString value) ++ suffix
複製程式碼

這裡 doSomething 是一個函式,它接受 String 型別的單個引數,然後返回一個函式,該函式接受 Int 型別的單個引數,然後返回一個函式,該函式接受 String 型別的單個引數,並返回一個字串。

注意為什麼每個方法都只接受一個引數呢? 這是因為每個方法在 Elm 裡面都是柯里化。

因為括號總是指向右邊,它們是不必要的,簡寫如下:

doSomething : String -> Int -> String -> String
複製程式碼

當我們將函式作為引數傳遞時,括號是必要的。如果沒有它們,型別註釋將是不明確的。例如:

takes2Params : Int -> Int -> String
takes2Params num1 num2 =
    -- do something
複製程式碼

非常不同於:

takes1Param : (Int -> Int) -> String
takes1Param f =
    -- do something
複製程式碼

takes2Param 函式需要兩個引數,一個 Int 和另一個 Int,而takes1Param 函式需要一個引數,這個引數為函式, 這個函式需要接受兩個 Int 型別引數。

下面是 map 的型別註釋:

map : (a -> b) -> List a -> List b
map f list =
    // ...
複製程式碼

這裡需要括號,因為 f 的型別是(a -> b),也就是說,函式接受型別 a 的單個引數並返回型別 b 的某個函式。

這裡型別 a 是任何型別。當型別為大寫形式時,它是顯式型別,例如 String。當一個型別是小寫時,它可以是任何型別。這裡 a 可以是字串,也可以是 Int

如果你看到 (a -> a) 那就是說輸入型別和輸出型別必須是相同的。它們是什麼並不重要,但必須匹配。

但在 map 這一示例中,有這樣一段 (a -> b)。這意味著它既能返回一個不同的型別,也能返回一個相同的型別。

但是一旦 a 的型別確定了,a 在整段程式碼中就必須為這個型別。例如,如果 a 是一個 Int,b 是一個 String,那麼這段程式碼就相當於:

(Int -> String) -> List Int -> List String
複製程式碼

這裡所有的 a 都換成了 Int,所有的 b 都換成了 String

List Int 型別意味著一個值都為 Int 型別的列表, List String 意味著一個值都為 String 型別的列表。如果你已經在 Java 或者其他的語言中使用過泛型,那麼這個概念你應該是熟悉的

函式式 JavaScript

JavaScript 擁有很多類函式式的特性但它沒有純性,但是我們可以設法得到一些不變數和純函式,甚至可以藉助一些庫。

但這並不是理想的解決方法。如果你不得不使用純特性,為何不直接考慮函式式語言?

這並不理想,但如果你必須使用它,為什麼不從函式式語言中獲得一些好處呢?

不可變性(Immutability)

首先要考慮的是不變性。在ES2015或ES6中,有一個新的關鍵詞叫const,這意味著一旦一個變數被設定,它就不能被重置:

const a = 1;
a = 2; // 這將在Chrome、Firefox或 Node中丟擲一個型別錯誤,但在Safari中則不會
複製程式碼

在這裡,a 被定義為一個常量,因此一旦設定就不能更改。這就是為什麼 a = 2 丟擲異常。

const 的缺陷在於它不夠嚴格,我們來看個例子:

const a = {
    x: 1,
    y: 2
};
a.x = 2; // 沒有異常
a = {}; // 報錯
複製程式碼

注意到 a.x = 2 沒有丟擲異常。const 關鍵字唯一不變的是變數 a, a 所指向的物件是可變的。

那麼Javascript中如何獲得不變性呢?

不幸的是,我們只能通過一個名為 Immutable.js 的庫來實現。這可能會給我們帶來更好的不變性,但遺憾的是,這種不變性使我們的程式碼看起來更像 Java 而不是 Javascript。

柯里化與組合 (curring and composition)

在本系列的前面,我們學習瞭如何編寫柯里化函式,這裡有一個更復雜的例子:

const f = a => b => c => d => a + b + c + d
複製程式碼

我們得手寫上述柯里化的過程,如下:

console.log(f(1)(2)(3)(4)); // prints 10
複製程式碼

括號如此之多,但這已經足夠讓Lisp程式設計師哭了。有許多庫可以簡化這個過程,我最喜歡的是 Ramda

使用 Ramda 簡化如下:

const f = R.curry((a, b, c, d) => a + b + c + d);
console.log(f(1, 2, 3, 4)); // prints 10
console.log(f(1, 2)(3, 4)); // also prints 10
console.log(f(1)(2)(3, 4)); // also prints 10
複製程式碼

函式的定義並沒有好多少,但是我們已經消除了對那些括號的需要。注意,呼叫 f 時,可以指定任意引數。

重寫一下之前的 mult5AfterAdd10 函式:

const add = R.curry((x, y) => x + y);
const mult5 = value => value * 5;
const mult5AfterAdd10 = R.compose(mult5, add(10));
複製程式碼

事實上 Ramda 提供了很多輔助函式來做些簡單常見的運算,比如R.add以及R.multiply。以上程式碼我們還可以簡化:

const mult5AfterAdd10 = R.compose(R.multiply(5), R.add(10));
複製程式碼

Map, Filter and Reduce

Ramda 也有自己的 mapfilterreduce 版本。雖然這些函式存在於陣列中。這幾個函式是在 Array.prototype 物件中的,而在 Ramda 中它們是柯里化的

const isOdd = R.flip(R.modulo)(2);
const onlyOdd = R.filter(isOdd);
const isEven = R.complement(isOdd);
const onlyEven = R.filter(isEven);
const numbers = [1, 2, 3, 4, 5, 6, 7, 8];
console.log(onlyEven(numbers)); // prints [2, 4, 6, 8]
console.log(onlyOdd(numbers)); // prints [1, 3, 5, 7]
複製程式碼

R.modulo 接受2個引數,被除數和除數。

isOdd 函式表示一個數除 2 的餘數。若餘數為 0,則返回 false,即不是奇數;若餘數為 1,則返回 true,是奇數。用 R.filp 置換一下 R.modulo 函式兩個引數順序,使得 2 作為除數。

isEven 函式是 isOdd 函式的補集。

onlyOdd 函式是由 isOdd 函式進行斷言的過濾函式。當它傳入最後一個引數,一個陣列,它就會被執行。

同理,onlyEven 函式是由 isEven 函式進行斷言的過濾函式。

當我們給函式 onlyEvenonlyOd 傳入 numbersisEvenisOdd 獲得了最後的引數,然後執行最終返回我們期望的數字。

Javascript的缺點

所有的庫和語言增強都已經得到了Javascript 的發展,但它仍然面臨著這樣一個事實:它是一種強制性的語言,它試圖為所有人提供所有的東西。

大多數前端開發人員都不得不使用 Javascript,因為這旨瀏覽器也識別的語言。相反,它們使用不同的語言編寫,然後編譯,或者更準確地說,是把其它語言轉換成 Javascript。

CoffeeScript 是這類語言中最早的一批。目前,TypeScript 已經被 Angular2 採用,Babel可以將這類語言編譯成 JavaScript,越來越多的開發者在專案中採用這種方式。

但是這些語言都是從 Javascript 開始的,並且只稍微改進了一點。為什麼不直接從純函式語言轉換到Javascript呢?

未來期盼

我們不可能知道未來會怎樣,但我們可以做一些有根據的猜測。以下是作者的一些看法:

  1. 能轉換成 JavaScript 這類語言會有更加豐富及健壯。
  2. 已有40多年曆史的函數語言程式設計思想將被重新發現,以解決我們當前的軟體複雜性問題。
  3. 目前的硬體,比如廉價的記憶體,快速的處理器,使得函式式技術普及成為可能。
  4. PU不會變快,但是核心的數量會持續增加。
  5. 可變狀態將被認為是複雜系統中最大的問題之一。

希望這系列文章能幫助你更好容易更好幫助你理解函數語言程式設計及優勢,作者相信函數語言程式設計是未來趨勢,大家有時間可以多多瞭解,接著提升你們的技能,然後未來有更好的出路。

原文:

  1. medium.com/@cscalfani.…
  2. medium.com/@cscalfani.…

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

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

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

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

相關文章