簡介
本系列文章將探討 CoffeeScript,這是構建於 JavaScript 基礎之上的一種全新程式語言,它提供了非常乾淨的語法。CoffeeScript 可編譯為高效的 JavaScript。除了在 Web 瀏覽器中執行 JavaScript 之外,您還可以將它與伺服器應用程式的 Node.js 等技術一起使用。在 第 1 部分 中,學習瞭如何設定 CoffeeScript 編譯器,並使用它建立了隨時可在瀏覽器或伺服器中執行的程式碼。
在本文中,我們將通過解決 Project Euler 中的幾個程式設計問題來探討 CoffeeScript 語言(有關 Project Euler 的更多資訊,請參見 參考資料 部分)。文中的示例將指導您使用 CoffeeScript 的函式、作用域、解析 (comprehension)、塊語句、陣列和一些物件導向的方面。
下載 本文中使用的原始碼。
函式、作用域和解析
您要解決的第一個 Project Euler 問題就是第 6 題(請參見 參考資料 部分),這道題要求您計算前幾個自然數的平方和,然後獲得這些數字的和的平方,隨後算出兩者的差值。對於這道題,您將使用 CoffeeScript Read-Evaluate-Print-Loop (REPL)。清單 1展示了 REPL 會話的程式碼和對應輸出。
清單 1. REPL 第 6 題
1 2 3 4 5 6 7 8 |
coffee> square = (x) -> x*x [Function] coffee> sum = (nums) -> nums.reduce (a,b) -> a+b [Function] coffee> diff = (nums) -> (square sum nums) - (sum nums.map square) [Function] coffee> diff [1..100] 25164150 |
詳解:
1. 在 REPL 中,定義一個函式。(如 第 1 部分 所述,CoffeeScript 延續了 JavaScript 的函數語言程式設計特色,摒棄了 JavaScript 中大多數類似於 C 語言的語法,因為這些語法加大了實現優雅的函數語言程式設計的難度。)清單 1 中的示例定義了一個名為 square
的函式,並宣告它將獲取一個引數,返回該引數自身與自身的乘積(即求其平方)。隨後,REPL 告訴您已經定義了一個函式。
2. 定義另外一個名為 sum
的函式,該函式也接受一個引數:一個陣列。隨後 sum
函式對該陣列呼叫 reduce
方法。reduce
方法並不是在 CoffeeScript 中新增的,而是 JavaScript 自有的一部分(在 JavaScript 1.8 中新增)。reduce
方法類似於 Python 中的 reduce 函式,或者 Haskell 或 Scala 中的 fold 函式。它獲取一個函式,從左至右地遍歷陣列,將該函式應用於此前由 fold 返回的值和陣列中的下一個值。CoffeeScript 緊湊的函式語法使得 reduce
更易於使用。在本例中,傳遞給 reduce 的函式是由 (a,b) -> a + b
指定的。這個函式將獲取兩個值,將兩值相加,隨後將陣列中的所有元素相加。
3. 建立一個名為 diff
的函式,該函式將獲取一個數字陣列,並計算兩個子表示式,隨後將其相減。第一個子表示式將陣列傳遞給 sum 函式,隨後獲取結果,並將其傳遞給 square
函式。CoffeeScript 允許您在很多情況下忽略圓括號,以避免產生混淆。舉例來說,square sum nums
等同於 square(sum(nums))
。第二個子表示式呼叫陣列的 map
方法,這也是一個 JavaScript 1.8 方法,以另一個函式作為其輸入。隨後它會將該函式應用於陣列的各成員,根據結果建立一個新陣列。清單 1 中的示例使用 square 函式作為 map 的輸入引數,為您提供一個使用輸入陣列元素的平方構成的陣列。隨後,只需將此傳遞給 sum 函式,即可獲得平方和。
4. 將恰當的數字陣列傳遞到 diff
函式之中,使用作用域 [1..100]
來解答第 6 題。這個作用域等同於全部由從 1 到 100(包括 1 和 100 在內)的數字構成的陣列。如果您希望將 1 和 100 排除在外,那麼可以使用 [1...100]
,使用三個圓點,而非兩個。將此傳遞給 diff
函式即可給出第 6 題的解。
讓我們回過頭來看看 Project Euler 的第一題(請參見 參考資料 部分),這道題要求您算出 1000 以內可以被 3 或 5 整除的所有整數的和。您可能會認為,這是 Project Euler 中最簡單的問題。可以使用函式和作用域來輕鬆解決此問題,就像解答第 6 題一樣。不過,在使用 CoffeeScript 的解析特性時,您可以建立如 清單 2 所示的優雅的解決方案。
清單 2. 使用解析解決第 1 題
1 |
coffee> (n for n in [1..999] when n % 3 == 0 or n % 5 == 0).reduce (x,y) -> x+y 233168 |
僅通過一行程式碼便可解決問題是最好不過,CoffeeScript 簡明的語法使之能夠通過單行方式解決問題。清單 2 中的解決方案使用解析建立了是 3 或 5 的倍數的所有整數的列表。首先從作用域 [1..999]
開始生成,但僅使用可被 3 或 5 整除的值。隨後使用另外一個reduce
來求取這些值的和。REPL 將計算這一行程式碼的結果,並輸出問題的解。
下一節將處理略微有些複雜的問題,進一步探討 CoffeeScript。
塊語句、陣列和檔案
Project Euler 第 4 題(請參見 參考資料 部分)要求您找出兩個三位數相乘能得到的最大回文數。解決這個問題的方法有許多種,清單 3 展示了其中的一種方法。
清單 3. 測試迴文數
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
isPal = (n) -> digits = [] while n > 0 digits.push(n % 10) n = Math.floor(n / 10) len = digits.length comps = [0..len/2].map (i) -> digits[i] == digits[len-i-1] comps.reduce (a,b)-> a && b vals = [] vals.push(x*y) for x in [100..999] for y in [100..999] pals = vals.filter (n) -> isPal(n) biggest = pals.reduce (a,b) -> Math.max(a,b) console.log biggest |
1.定義一個名為 isPal
的函式,用它來測試一個數字是否是迴文數。此函式比之前定義的函式要略微複雜一些。函式體內共有七行程式碼。您很可能會注意到,CoffeeScript 並未使用大括號 ({ }) 或其他任何顯式機制來註明函式的開始和結束位置。它使用了空格(縮排),這與 Python 極為相似,均使用表示函式的相同表示法:引數列表後接大於號箭頭 (->)。隨後,您將為數字的位數建立一個空陣列,並開始一個 while 迴圈。此迴圈類似於 JavaScript(以及 C 和 Java 等)中的 while 迴圈。謂詞 (n > 0
) 兩側不需要帶圓括號。迴圈體進一步縮排,以表明它是迴圈的一部分。在迴圈內,您將獲取數字的最後一位,將它放在陣列的前面,然後將該數字除以 10,捨去餘數。此結果將是原數字的各位的陣列。您還可以直接使用 digits = new String n
取代迴圈,將 n
轉為字串。其餘程式碼按原樣工作。
2. 獲得了各位數字的陣列之後,需要建立一個陣列,使該陣列的長度是本陣列的一半。使用 map
函式,將此陣列的各元素轉為一個布林值,用該值表示從陣列開始到陣列結束的各位數字的距離是否相同。如果所有該值均為真,則表示這是一個迴文數。要測試所得到的結果,只需使用另外一個 reduce 函式,這次是將布林值相加。
3. 既然您已經定義了 isPal
函式,那麼接下來就可以使用它來測試迴文數。測試作為兩個三位數乘積的所有數字。
a. 建立兩個解析,其範圍均為從最小的三位數 (100) 到最大的三位數 (999)。
b. 每個解析均獲取乘積,並將該乘積值放入一個陣列中。
c. 使用另外一個 reduce
函式,查詢陣列中的最大元素。最後,此元素將使用 console.log 列印出來。
將此儲存到一個檔案之中。
清單 4 展示瞭如何執行解決方案並進行計時。
清單 4. 執行第 4 題的解決方案
1 |
$ time coffee p4.coffee 906609 real 0m2.132s user 0m2.106s sys 0m0.022s |
清單 4 中的指令碼在速度較快的計算機中用了兩秒鐘的時間,獲得這樣的速度很大程度上是由於複合解析生成了 899*899 = 808,201 個值來進行測試(其中許多是重複的)。作為一項額外的練習,您可以優化 清單 3 中的程式碼。(提示:實際上,將數字轉為字串將顯著提高速度。)
Project Euler 第 22 題(請參見 參考資料 部分)要求您對一個字串列表執行復合計算。您需要從一個檔案讀取列表,將內容解析為列表,對其進行排序,將每個字串轉為一個數字,隨後求得各數字的乘積及其在列表中的位置。第 22 題使您能夠看到檔案在 CoffeeScript 的工作方式。此外,還有一些字串操縱和許多陣列方面的技巧。清單 5 展示了相關的解決方案。
清單 5. 在 CoffeeScript 中處理檔案
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
path = "/path/on/your/computer/names.txt" fs = require "fs" contents = fs.readFileSync path, "utf8" strs = contents.split(",") names = strs.map (str) -> str[1 .. str.length - 2] names = names.sort() vals = names.map (name) -> name.split("") vals = vals.map (list) -> list.map (ch) -> 1 + ch.charCodeAt(0) - 'A'.charCodeAt(0) vals = vals.map (list) -> list.reduce (a,b) -> a+b vals = ((i+1)*vals[i] for i in [0...vals.length]) total = vals.reduce (a,b) -> a+b console.log total |
1. 儲存檔案。問題描述中提供了一個連結,此外您也可以使用本文包含的 原始碼。將路徑變數的值更改為該檔案在您的計算機上的絕對路徑。CoffeeScript 不包含任何專門用於處理檔案的特殊庫。而是利用 node.js 及其 fs
模組。可以使用 require
函式來載入 fs
模組。
2. 使用 fs.readFileSync
讀取檔案的內容。檔案的內容將包括 “MARY”、”PATRICIA” 等。它最初是單獨一個字串,因此可以使用 split
方法,用逗號將其分隔來開。每個字串將仍然帶有一對雙引號 (“),分別位於字串的開始處和結束處。要刪除這些雙引號,請使用 map
函式,使用一個切片取代各字串。如果 str
是字串,那麼 str[1 .. str.length -2]
就是一個子字串,從第二個字元開始,到緊接最後一個字元之前的字元處結束。該程式碼將準確地刪除第一個字元和最後一個字元,也就是那些麻煩的雙引號。切片字串使用起來非常方便。
3. 獲得無雙引號的字串列表之後,即可開始排序。使用陣列的 sort
方法。您需要將字串轉為一個數字,其中的每一個字元都將替換為該字元在字母表中的位置(A -> 1、B -> 2、C -> 3 等)。
a. 再次使用 split
方法,將各字串轉為一個字元陣列。
b. 使用 charCodeAt
方法,將各字元轉為一個數字值。
c. 使用另外一個 reduce
操作,將這些數字值相加。
字串列表轉為一個數字列表。
4. 將各數字與其在列表中的位置相乘,並使用另一個解析將其相加。建立一個新陣列,其中的各元素是通過將上一個陣列中的元素與其位置相乘而生成的。再次使用 reduce
操作,將這個陣列中的元素相加,並列印出總和。
同樣,您也可以將結果儲存到一個檔案中,隨後執行操作並對其計時,如 清單 6 所示:
清單 6. 為第 22 題的執行計時
1 |
$ time coffee p22.coffee 871198282 real 0m0.133s user 0m0.115s sys 0m0.013s |
執行第 22 題的解決方案只用了不到 0.2 秒的時間。描述所計算的內容時所用的英文行數幾乎與之前執行計算的程式碼行數相同。這是展現 CoffeeScript 的簡明語法的一個很好的例子。可想而知,本示例在其他程式語言中會使用比這多得多的程式碼行。
在這一節中,您利用 CoffeeScript 解決了更為困難的問題。下一節將介紹 CoffeeScript 中的一項關鍵特性:物件導向的程式設計。
物件導向的 CoffeeScript
如 初步瞭解 CoffeeScript,第 1 部分:入門 所述,JavaScript 的一個主要難題就在於它 “不同尋常的” 物件導向程式設計 (OOP) 風格。之所以不同尋常,是因為基於類的 OOP 極為常見。CoffeeScript 採用了基於類的 OOP。
您的下一項挑戰就是使用 CoffeeScript 的 OOP 來解決 Project Euler 的第 35 題(請參見 參考資料 部分)。第 35 題說明,迴圈質數即一種特殊型別的質數,有著可以隨意迴圈排列其各位數字,同時仍然得到一個質數的特點。清單 7 中的程式碼使用 OOP 來計算小於 100 萬的迴圈質數的數量。
清單 7. 迴圈質數計數
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
class PrimeSieve constructor: (@max) -> @nums = [2..@max] for p in @nums d = 2*p while p != 0 and d <= @max @nums[d-2] = 0 d += p isPrime: (n) -> @nums[n-2] != 0 thePrimes: -> @nums.filter (n) -> n != 0 class CircularPrimeGenerator extends PrimeSieve genPerms = (num) -> s = new String num x = (for i in [0 ... s.length] s[i+1 ... s.length].concat s[0..i]) x.map (a) -> parseInt(a) isCircularPrime : (n) -> perms = genPerms(n) len = perms.length primePerms = perms.filter (p) => @isPrime(p) len == primePerms.length theCircularPrimes: -> (p for p in @thePrimes() when @isCircularPrime(p)) max = process.argv[2] generator = new CircularPrimeGenerator max console.log "Number of circular primes less than #{max} is #{generator.theCircularPrimes().length}" |
1. 建立一個名為 PrimeSieve
的類,它實現了經典的 Erasthones 篩選演算法,計算所有小於特定值的質數。@
標記表明一個類的屬性,也是 'this.'
的速記形式。因此,@nums = [2..@max]
就等於 this.nums = [2 .. this.max]
。
類方法的標識方法是名稱後接分號和函式定義。第一個方法名為 constructor
,它是該類的構造方法。例如,新的PrimeSieve(100)
會使構造方法得到呼叫,而 100 將作為 max
傳入其中,並指派給 this.max
。我們的構造方法將構造篩子 (sieve),並將質數儲存在 @nums
成員變數之中。隨後它將宣告另外兩個方法:isPrime
和 thePrimes
。thePrimes
方法使用一個陣列過濾器來刪除 @nums
中的合數。
2. 宣告 PrimeSieve
的一個稱為 CircularPrimeGenerator
的子類。CoffeeScript 使用 class ... extends
語法,這與許多流行的 OOP 語言相似。此類將繼承 PrimeSieve
的構造方法、成員變數和方法。它擁有:
a. genPerms
方法,用於迴圈排列給定數字的排位,從而生成給定數字的所有排列。
b. isCircularPrime
方法,用於生成給定數字的所有排列,並刪除排列列表中不屬於質數的所有數字。如果過濾後的列表包含的所有元素均與未過濾的列表相同,那麼該數字必然是一個迴圈質數。
c. theCircularPrimes
方法,通過解析生成所有迴圈質數的列表。
請注意,您將能夠如何使用超類中定義的 @thePrimes
方法,隨後過濾掉不屬於順換質數的質數。定義了兩個類之後,您就可以使用它們來解決問題。
3. 清單 7 可接受一個命令列引數,該引數即計算質數和迴圈質數時所使用的 max 值。所有命令列引數均可使用 process.argv
訪問。此陣列中的前兩個值是命令和指令碼,因此 process.argv[2]
中包含指令碼將使用的第一個引數。使用傳入指令碼的 max 值建立 CircularPrimeGenerator
的一個例項。
列印出使用 console.log 找到的迴圈質數的數量。
在本例中,我們使用了 CoffeeScript 提供的另一項便捷的特性:字串插值,我們利用這項特性建立了傳遞給 console.log 的字串。
結束語
在本文中,我們探索了 CoffeeScript 的許多特性,其簡明的語法和函數語言程式設計特性使您能夠優雅地實現許多常用演算法。與此同時,CoffeeScript 還提供了簡化的物件導向程式設計。無論您需要解決哪種型別的問題,CoffeeScript 的語法都能簡化您的任務。
請繼續關注本 系列 的下一部分,我們將更加註重實效,對 Web 應用程式中的客戶端使用 CoffeeScript。