【譯】你的程式語言能做到這個嗎?(為什麼要學函數語言程式設計)

serialcoder發表於2019-01-25

譯者序

今天偶然發現公司裡有同事釋出了一個函式式深度學習框架,剛好最近我也在入門深度學習,所以去了解了一下函數語言程式設計在深度學習裡面的應用。在查資料時我找到了今天要翻譯的這篇文章。

這篇文章作者是 Joel Spolsky。他是 Trello 的聯合創始人,Stack Overflow 的聯合創始人和現任 CEO。

正文

某天,你在瀏覽你寫的程式碼時發現了兩塊程式碼幾乎長得一模一樣。比如:

alert("I'd like some Spaghetti!")
alert("I'd like some Chocolate Moose!")
複製程式碼

這兩行程式碼唯一的不同就是 'Spaghetti' 和 'Chocolate Moose'。他們是用 JavaScript 寫的,但是你不用懂 JS 也能知道這些程式碼在幹嘛。這兩行程式碼當然看起來不對勁,你可以建立一個函式來優化下:

function SwedishChef(food) {
  alert("I'd like some " + food + '!')
}

SwedishChef('Spaghetti')
SwedishChef('Chocolate Moose')
複製程式碼

這個例子太過於簡單了,不過你可以擴充套件想象,當程式碼過於複雜時,這種寫法能帶來的好處。你可能已經知道這些好處了,比如易讀,易維護。抽象就是好!

然後你又發現有兩塊程式碼幾乎長得一模一樣,區別就是一塊程式碼反覆呼叫一個叫 BoomBoom 的函式,而另一塊程式碼反覆呼叫一個叫 PutInPot 的函式:

alert('get the lobster')
PutInPot('lobster')
PutInPot('water')

alert('get the chicken')
BoomBoom('chicken')
BoomBoom('coconut')
複製程式碼

現在你需要把一個函式傳給另一個函式來讓上面的程式碼好看點。函式接受函式為引數是程式語言的一個很重要的能力,它能幫你把程式碼中重複的部分抽離到一個函式中去:

function Cook(i1, i2, f) {
  alert('get the ' + i1)
  f(i1)
  f(i2)
}

Cook('lobster', 'water', PutInPot)
Cook('chicken', 'coconut', BoomBoom)
複製程式碼

看!我們把函式作為引數傳給另一個函式。

你的程式語言能做到嗎?

等等……假設你還沒有定義 PutInPotBoomBoom,我們要是能直接把這兩個函式行內傳入,而不是先在別處定義這兩個函式,不是很棒嗎?像這樣:

Cook('lobster', 'water', function(x) {
  alert('pot ' + x)
})

Cook('chicken', 'coconut', function(x) {
  alert('boom ' + x)
})
複製程式碼

這真是太方便了。我隨意寫個函式就塞給另一個函式,都不用給入參函式命名。

一旦你開始思考把匿名函式當做引數傳遞,你可能會意識到處處可見的某種程式碼,比如,對陣列的每一個元素進行操作:

var a = [1, 2, 3]

for (i = 0; i < a.length; i++) {
  a[i] = a[i] * 2
}

for (i = 0; i < a.length; i++) {
  alert(a[i])
}
複製程式碼

運算元組的每個元素是個很常用的操作,你可以寫個函式來幫你幹這事:

function map(fn, a) {
  for (i = 0; i < a.length; i++) {
    a[i] = fn(a[i])
  }
}
複製程式碼

然後你可以這樣重構上面的陣列操作程式碼:

map(function(x) {
  return x * 2
}, a)
map(alert, a)
複製程式碼

另一個常用的陣列操作是把陣列的每個元素按某種方式連線起來:

function sum(a) {
  var s = 0
  for (i = 0; i < a.length; i++) s += a[i]

  return s
}

function join(a) {
  var s = ''
  for (i = 0; i < a.length; i++) s += a[i]

  return s
}

alert(sum([1, 2, 3]))
alert(join(['a', 'b', 'c']))
複製程式碼

sumjoin 長得太像了,你可能想把它們的本質部分(把一個陣列的所有元素按某種方式連線成一個值)抽象到一個通用函式裡面去:

function reduce(fn, a, init) {
  var s = init
  for (i = 0; i < a.length; i++) s = fn(s, a[i])

  return s
}

function sum(a) {
  return reduce(
    function(a, b) {
      return a + b
    },
    a,
    0
  )
}

function join(a) {
  return reduce(
    function(a, b) {
      return a + b
    },
    a,
    ''
  )
}
複製程式碼

很多老的程式語言根本就沒辦法做到上面展示的這些程式抽象。另外一些語言允許你這樣幹,但是很難做到(例如,C 語言有函式指標,但是你必須把函式宣告和定義在其它地方)。物件導向程式語言沒有被完全說服,開發者應該用函式來做任何事情。

Java 要求你先建立一個叫函子的帶有單一方法的完整物件,然後才能把函式當做一等物件。(譯者注:原文發表於 2006 年,當時 Java 8 還沒有釋出,lambda 表示式在 Java 中還不存在)。另外,很多物件導向語言要求你為每一個類建立一個檔案,很快你的程式碼就變得笨拙臃腫。如果你的程式語言要求你寫個函子才能實現函式一等物件,你就沒有得到現代程式設計環境帶來的一些好處。

就寫個能幫你遍歷陣列的每個元素的函式而已,能給你帶來什麼好處?

我們還是回到前面提到的 map 函式。當你需要對陣列裡面的每個元素做某種操作時,這些操作的順序可能並不重要。那麼,假如你有兩個 CPU,那你就可以寫段程式碼讓每個 CPU 計算一半的陣列,這樣 map 執行速度就兩倍快了。

再假如,你有分佈在全球各個資料中心的幾十萬個伺服器,然後你有一個超級大陣列,這個大陣列包含了整個網際網路的內容。那現在你就可以在這幾十萬臺計算機上執行 map 函式了,每臺計算機解決陣列的一小塊部分。

現在,搜尋整個網際網路的內容就簡單到執行一個 map 函式,並給 map 傳一個查詢字串就行了。

我希望你注意到的真正有趣的事情是,一旦你意識到 mapreduce 函式是每個開發者都能用的,你就只需要找個超級天才幫你寫段比較難寫的程式碼,讓 mapreduce 執行在一個大型的平行計算機叢集上。然後你實現的這種分散式計算會比之前用 for 迴圈寫的一次完成所有任務的老程式碼快無數倍。

讓我再重複一遍。把迴圈這個概念從你的程式碼中抽象出去之後,你可以用任何方式來實現迴圈,包括用上面提到的可利用多餘硬體來靈活伸縮的分散式計算。

現在,你應該明白了我為什麼之前會抱怨現在的 CS 專業學生只學 Java:

如果你不懂函數語言程式設計,你是不可能發明出 MapReduce 的(谷歌的高可伸縮搜尋演算法 )。Map 和 Reduce 這兩個術語源自 Lisp 和函數語言程式設計。如果你在學習 CS 6.001 時就學到了純函式程式由於沒有副作用,所以可以很簡單完成平行計算,你是很容易理解 MapReduce 的(譯者注:作者在抱怨現在 CS 教育缺失了函數語言程式設計)。谷歌發明出了 MapReduce,而微軟沒有,說明了為什麼微軟現在還在試圖弄出可行的搜尋演算法來趕上谷歌;與此同時,谷歌已經開始研發 Skynet 這個世界最大的並行超級計算機去解決下一個問題了。我認為微軟還沒搞清楚他們落後了谷歌多少。

(譯者注:谷歌把 MapReduce 演算法的論文開放了,你能在這裡讀到)

好啦,現在我希望我已經說服了你,為什麼支援一等函式的程式語言能讓你找到更多程式抽象的機會,這意味著你的程式碼會變得更輕量,更緊湊,更易複用,和更可伸縮。很多谷歌的應用都用到了 MapReduce 演算法。當有人優化 MapReduce 或者修復它的某些 bug 的時候,所有這些應用都受益。

現在我要變得感性一點了。我認為最有生產力的程式設計環境必須是那些允許你建立不同層級的抽象的語言。老而難用的 FORTRAN 根本不讓你寫函式。C 有函式指標,但你必須把函式宣告和定義在其它地方,這樣寫太醜陋了。Java 逼著你用函子,更醜陋。(見前譯註)

原文糾錯:

上次我使用 FORTRAN 還是 27 年前了。很明顯它是支援函式的。我寫到這裡的時候肯定想到的是 GW-BASIC。

譯者後記

JavaScript 是我入門程式設計的第一門語言,也是我目前掌握最熟練的語言。我一開始以為一等公民函式就是一個很普通的特徵,其它語言應該也有,但直到最近我才知道它來自 Lisp,在主流程式語言裡面還比較小眾,目前只有一部分程式語言才在最近加入 lambda 表示式(我知道的只有 Java 和 Python)。

我不明白為什麼有那麼多開發者認為 JavaScript 垃圾。Lexical scoping(我不知道這個術語對應的中文翻譯是什麼)和一等公民函式的語言特性已經足夠讓你寫出強大而複雜的應用,而這些強大的特性並不是所有主流語言都支援的,JS 怎麼就垃圾了?

同樣我也不明白為什麼有人認為 JavaScript 不適合函數語言程式設計。這段時間比較火的 “計算機之子” 對 JS 有這樣的評價:

用 JS 做函數語言程式設計並不靠譜,Map/Reduce/Redux/Hooks 等並不是函數語言程式設計,只是長得像而已。

Hooks 借鑑了 Algebraic Effect,有些 FP 的影子,但太雜糅了。Redux 是直接從 Elm 借鑑過來的,不知怎麼就不是函式式。而 map 和 reduce 剛剛已經說得很清楚了,是函數語言程式設計的核心概念,原理上也和 Lisp 一致,怎麼就不函式式了?

JavaScript 是支援多個程式設計正規化的。Vue 的成功已經證明了 JS 在物件導向程式設計上的潛力,但這並沒有證明 JS 不適合寫函式式程式碼。React 比較函式式,但為了照顧開發者的接受程度,做出太多妥協,它本可以更函式式。

用時下開發者的接受程度來判斷一個程式語言是否具有某些特性顯然是荒唐的。真這樣的話,React 不會探索出這麼多新的可能。如果你理解你在幹什麼,你只需要 JS 提供給你的一些核心能力就實現程式功能。你並不需要使用 Proxy(也不需要 defineProperties), generators, iterators 等新功能,你甚至都不需要原型鏈繼承。而一等公民函式,是提供給你這些能力的核心特性。(我只是說理論上你不需要,沒有鼓勵你和整個開發生態為敵)


相關文章