[CoffeeScript]咖啡,动手实践



简介

本系列文章将探讨 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
15
                 
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
2
                
$ 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
31
                 
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 和 thePrimesthePrimes 方法使用一个数组过滤器来删除 @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。

你可能感兴趣的:([CoffeeScript]咖啡,动手实践)