Ruby-示例-全-
Ruby 示例(全)
原文:Ruby by Example
译者:飞龙
简介:什么是 Ruby?
Ruby 是“一种注重简洁和高效的开源编程语言。它拥有优雅的语法,易于阅读和编写。”^([1)] 它由 Yukihiro “Matz” Matsumoto 在 1995 年发布。它通常被描述为一种非常高级的语言或脚本语言,这取决于你问的是谁。因此,它不需要程序员指定计算机如何实现你的决策的细节。像其他高级语言一样,Ruby 通常用于文本处理应用,包括越来越多的网络应用。我希望一旦你更熟悉这门语言,你会同意它很好地帮助你摆脱障碍,让你简单地完成工作。
Ruby 拥有一个非常有趣的血统。Matz 本身曾经说过,对 Ruby 设计影响最大的两种语言是 Common Lisp 和 Smalltalk——它们的影响如此之大,以至于他戏称 Ruby 为 MatzLisp。另一方面,一些 Ruby 爱好者强调 Ruby 与 Smalltalk 和 Perl 的相似性,正如 Rails 的创造者 David Heinemeier Hansson 在 2006 年 6 月的 Linux Journal 采访中所说。Hansson 还将 Ruby 描述为“一种让程序员快乐的编写美观代码的语言。”我完全同意。^([2)]
注意
如果你对 Ruby 的遗产感兴趣,请参阅附录中 Ruby 与其他语言的比较。
获取和配置 Ruby
但历史就到这里吧——让我们把这些问题放一边,实际上安装 Ruby 吧。Ruby 是灵活的、富有表现力的,并且遵循免费软件/开源许可协议。(许可协议可在www.ruby-lang.org/en/about/license.txt在线获取。)
在 Unix 或类似 Unix 的系统上
使用类似 Unix 的操作系统(如 Mac OS X、BSD 和 GNU/Linux 变体)的用户会容易一些。许多这些系统要么预先安装了 Ruby,要么将其作为非常方便的软件包提供。
如果 Ruby 在您的计算机上预先安装,它可能包括我们在下一章中将要使用的交互式 Ruby Shell (irb)。如果您使用包管理器安装了 Ruby,irb 可能会作为一个单独的包提供,可能包含在包名中的特定版本号。
如果你的包管理器不包括 Ruby 或者你想使用比包管理器提供的更新版本的 Ruby,你只需浏览到 www.ruby-lang.org 并点击下载 Ruby 链接。下载当前的稳定版本(在撰写本文时为 1.8.4),这是一个 .tar.gz 文件。然后以超级用户(也称为 root)的身份输入以下命令。(我将假设你使用的是 1.8.4 版本,尽管你下载 Ruby 时可能是一个更晚的版本。)
cp ruby-1.8.4.tar.gz /usr/local/src/
cd /usr/local/src
tar -xzf ruby-1.8.4.tar.gz
cd ruby-1.8.4
然后按照 README 文件中的说明操作。安装的常用命令集如下。
./configure
make
make install
您现在应该有一个可工作的 Ruby 版本。您可以通过执行以下命令来测试:
ruby --version
如果它报告 ruby 1.8.4 (2005-12-24) [i486-linux] 或您下载的版本以及您的系统,则一切正常.^([3])
在 Windows 系统上
如果您使用的是 Windows 系统,可以在 rubyinstaller.rubyforge.org/wiki/wiki.pl 找到一键 Ruby 安装器。只需按照那里的说明下载适合您系统的 Ruby。这是一个综合性的包——请访问网站查看其内容的最新列表。在撰写本文时,它包括了基础语言和各种流行的扩展,包括 SciTE(一个语法高亮文本编辑器)、FreeRIDE(一个 Ruby 开发环境)、包含 Dave Thomas 的书 Programming Ruby(也称为 The Pickaxe)的帮助文件,以及 RubyGems 包安装器。它还包含 irb,我们将在 第一章 中探讨。
^([1]) 根据 ruby-lang.org。
^([2]) 关于 Ruby 的起源,请参阅 Ruby-Talk 存档 (blade.nagaokaut.ac.jp/cgi-bin/scat.rb/ruby/ruby-talk/179642) 和 O’Reilly 对 Matz 的采访 (www.linuxdevcenter.com/pub/a/linux/2001/11/29/ruby.html)。
^([3]) Ruby,像大多数开源语言一样,正在不断开发中。本书中的代码使用的是 Ruby 版本 1.8.4,这是我编写本书中的脚本时的稳定版本。Ruby 版本 1.8.6 在本书出版前稍早发布。
书籍的动机
本书旨在在短期内实用,在长期内提供信息。这些目标对本书的组织方式产生了深远的影响。
它也旨在让新手易于使用,但重点在于编程范式及其对语言设计和语言使用的影响——这些是学术编程书籍中常见的主题。如今,您可以使用任何流行的语言来完成大多数任务,但这并不意味着解决特定问题在每种语言中都会同样轻松。没有一种语言是孤立存在的,讨论像 Ruby 这样的语言时,应该承认其设计中所做的决策。您会发现它是一个非常灵活的语言,可以让您以强大的方式结合不同的方法。生物学家认识到杂交活力;Matz 在创建 Ruby 时也是如此。
注意
当我提到编程范式时,我指的是三种主要类型:命令式、面向对象和函数式。广义而言,命令式语言告诉计算机这样做,然后那样做,然后做下一件事。面向对象语言定义对象(事物类型),它们知道如何执行方法(特定动作)。函数式语言将编程问题视为数学关系。Ruby 非常灵活,这意味着你可以用任何这些风格进行编程;然而,它主要是面向对象的,同时受到一些强烈的函数式影响。本书在 Ruby 的函数式方面比其他一些书籍略为侧重*。
习惯用法
本书使用几种约定来区分不同类型的信息。当你第一次遇到一个新术语时,它将以斜体显示。由于这是一本编程书,你经常会看到以代码字体显示的小型代码示例。代码列表将如下所示:
puts "Hi, I'm code!"
章节总结
下面是关于章节内容的简要介绍:
第一章
本章描述了交互式 Ruby(irb),并介绍了一些关键的 Ruby 概念。
第二章
本章介绍了我们的第一个独立程序(或脚本),在完成简单任务的同时继续引入关键 Ruby 概念。
第三章
本章包含对开发者有用的工具,这些工具以库文件的形式提供,供其他程序使用。
第四章
本章专注于处理文本。
第五章
本章主要关注数值数据,包括纯数学和递归。
第六章
本章对函数式编程给予了高度重视,这在前面章节中已有暗示。
第七章
本章详细介绍了测试、分析和优化你的程序。
第八章
本章包含专门用于标记的文本处理子集,如 HTML 和 XML。
第九章
本章讨论了通用网关接口(CGI)以及如何在网页文档中嵌入 Ruby 代码。
第十二章
本章向您展示如何使用 RubyGems,Ruby 的包管理器,并使用该系统安装 Rails,Ruby 的主要 Web 开发框架。
第十三章
本章包含一个示例 Rails 应用程序,通过它来讨论对任何 Rails 开发者都很有用的关键设计问题。
现在,让我们深入探讨并开始使用 Ruby 来完成一些有趣的任务。但在我们开始创建单独的程序文件之前,我们将探索 Ruby 如何与交互式 Ruby 环境协同工作。
第一章. 交互式 Ruby 和 Ruby 环境

在 Ruby 中,就像在大多数编程语言中一样,我们通常会存储程序在外部文件中,并一次性执行它们作为一个单元。然而,Ruby 也允许您逐行输入程序的代码,并在进行过程中查看结果,使用交互式 Ruby(irb);irb 是一个 shell,类似于 Unix 或类似 Unix 系统中的 bash 或 Windows 中的命令提示符。使用 irb 将让您了解 Ruby 如何处理信息,这也有助于您在编写程序之前就理解 Ruby 的基础知识。
谁应该阅读这一章?如果您已经使用过 Ruby,并且已经知道 表达式、irb、流程控制、变量、函数、方法 和 常量 这些术语的含义,您可能只需浏览这一章。(如果您以后遇到任何不熟悉的内容,您总是可以回来。)如果您以前从未编程过,您应该仔细阅读这一章。如果您已经使用过具有交互式环境的语言,如 Lisp 或 Python,您可能只需查看 irb 会话,以了解 Ruby 与您已知的语言有何不同——它可能在某些关键方面有所不同。
irb 程序是一个 读取-评估-打印循环(REPL)环境的例子。这个想法来自 Ruby 的祖先 Lisp。这意味着正如其名所说:它 读取 一行,评估 该行,打印 评估的结果,然后 循环,等待读取另一行。shell 会立即对您输入的每一行给出反馈,这是学习一种语言语法的理想方式。
启动 irb
启动 irb 非常简单。在 Unix 或类似 Unix 的机器上(例如 GNU/Linux 或 Mac OS X),你只需在 shell 提示符下输入 irb。这应该会给你以下结果:
$ irb
irb(main):001:0>
在 Windows 机器上,您可以选择 开始 ▸ 运行,输入 irb,然后点击 确定。您也可以直接从命令行运行 irb 命令。
使用 irb
现在您已经启动了它,irb 正在等待您输入第一行。行 由一个或多个表达式组成。
表达式
就 Ruby 而言,一个 表达式 只是一段有值的代码。按照精细的计算机编程传统,让我们看看 irb 对表达式 “Hello, world!” 的反应。
irb(main):001:0> "Hello, world!"
=> "Hello, world!"
注意
此列表显示了您需要输入的行以及 irb 的响应。请注意,irb 还会在每行的开头显示行号。我偶尔会引用这些数字,以及。
发生了什么?您输入了 “Hello, world!”,irb 高兴地将其直接返回给您。有趣的部分在于不明确的部分。您输入的表达式在 Ruby 中有一个值,因此也在 irb 中。“Hello, world!” 是一个 字符串,它是一系列字符,通常用单引号或双引号括起来。让我们来证明这一点。
一切都是对象
在 Ruby 中,就像它的祖先 Smalltalk 一样,一切都是一个对象,它只是一个类的实例。“Hello, world!”是字符串类的一个实例。
让我们在 irb 中验证一下:
irb(main):002:0> "Hello, world!".class
=> String
对象有方法(在对象上调用时为some_object.some_method),这些方法只是对象可以执行的操作。调用class方法只是简单地报告某个事物属于哪个类;换句话说,它是哪种类型的东西。由于“Hello, world!”是一个字符串,所以当在“Hello, world!”上调用class方法时,它报告的就是这个。当然,除了字符串之外,还有其他类型的对象。
注意
本书假设你已经熟悉面向对象。如果你不熟悉,这里有一个简要的描述。对象是一个东西。它可以是任何类型的东西。每个对象都是类的实例;例如,对象格拉斯哥、开罗和布法罗都是类City的实例。这些对象彼此不同,但它们是同一类型的东西。蒙提·派森和儿童在厅里都是类Comedy Troupe的实例,等等。在 Ruby 中,你通常会使用全部小写字母命名实例,并在空格处使用下划线;你将使用驼峰命名法命名类。在实际的 Ruby 代码中,类ComedyTroupe将具有名为monty_python和kids_in_the_hall*的实例(对象)**。
整数、Fixnums 和 Bignums
另一种类型的对象(或类)是整数,它是指可以被 1 整除的任何数字。这些应该对你来说很熟悉:0,1,-5,27 等等。让我们在 irb 中输入一个整数。
irb(main):003:0> 100
=> 100
注意
如果你在整数上调用class方法,它将报告 Fixnum 或 Bignum,而不是 Integer。这源于 Ruby 内部存储数字的方式。计算机在不需要浪费空间的情况下可以更快地运行,所以它们必须担心数字占用多少空间。然而,计算机还需要能够处理非常大的数字。因此,它们做出了妥协,存储小的数字以节省空间,但同时也存储非常大的数字,这些数字不可避免地会占用更多的空间。像 Ruby 这样的高级语言会自动在这些不同类型的数字之间进行转换,所以你只需处理数字,无需担心这些具体的细节。这不是很方便吗?例如,100.class返回Fixnum,而(100 ** 100).class返回Bignum。这是因为 100 足够小,可以放入 Fixnum 中,但(100 ** 100)的值只能放入 Bignum 中——它太大,不能放入 Fixnum 中。
我们可以看到,数字 100 在 irb 中的值是 100,正如你所期望的那样。但我们都想能够做更多的事情,而不仅仅是看到我们输入的内容,所以让我们用我们的数字 100 来做点什么。让我们把它加到 100 上。
irb(main):004:0> 100 + 100
=> 200
你可以看到 irb 已经正确地添加了这些数字,并显示了结果。在 Ruby 中,100 + 100是一个表达式,就像“Hello, world!”和单独的100一样也是表达式。100 + 100的值自然是 200。数字有一个名为+的方法,这是它们如何将自己添加到其他数字的方法。这正是我们在这里所做的事情。
加法、连接和异常
+符号不仅能加数字。让我们添加另外两个表达式:
irb(main):005:0> "Hello, " + "world!"
=> "Hello, world!"
通过将字符串“Hello,”添加到字符串“world!”中,我们创建了新的更长的字符串“Hello, world!”。字符串并不真正执行加法操作,它们使用+符号执行一个称为连接的操作,这仅仅是把一个东西附加到另一个东西的末尾。在 Ruby 中,+符号意味着执行对这个类对象最有意义的类似加法操作。这允许你只使用+符号并假设整数将以合理的“数字”方式相加,字符串将以合理的“字符串”方式相加,依此类推。
当我们尝试将两种不同类型的对象相加时会发生什么?让我们在 irb 中找出答案。
irb(main):006:0> "Hello, world!" + 100
TypeError: failed to convert Fixnum into String
from (irb):6:in '+'
from (irb):6
这个表达式没有像其他表达式那样顺利。TypeError是 Ruby(以及许多其他语言)称为异常的例子,这是编程语言发出的错误通知。我们的TypeError意味着 Ruby 对我们要求将字符串添加到数字中并不高兴.^([4]) 字符串知道如何将自己相加,数字也是如此——但它们不能跨越类型。在加法中,我们希望两个操作数都是同一类型。
类型转换
这个问题的解决方案是一个称为类型转换的操作,即将某物从一种类型转换为另一种类型。让我们在 irb 中看看类型转换的例子:
irb(main):007:0> "Hello, world!" + 100.to_s
=> "Hello, world!100"
在尝试将100添加到“Hello, world!”之前,我们调用to_s方法。这个方法代表将对象转换为字符串——正如你可能猜到的,它将调用它的对象转换为字符串。在我们需要将这两个操作数相加的时候,它们都是字符串,Ruby 忠实地将它们连接起来.^([5]) 让我们验证100.to_s是否是一个字符串:
irb(main):008:0> 100.to_s
=> "100"
所以是的。但当我们想要将某个东西转换为整数时会发生什么?是否有to_i方法可以调用字符串“100”?让我们找出答案。
注意
在强类型语言中,如 Ruby,类型转换很常见。在弱类型语言中,虽然也可能出现,但不太常见。两种方法都有其支持者。
irb(main):009:0> "100".to_i
=> 100
我们确实可以。所以我们现在知道如何通过to_s或to_i方法将字符串和整数相互转换。如果我们能查看一个给定对象可以调用的所有方法列表,那会很好。我们也可以通过一个恰如其名的methods方法来做这件事。让我们在整数 100 上调用它:
irb(main):010:0> 100.methods
=> ["<=", "to_f", "abs", "-", "upto", "succ", "|", "/", "type", "times", "%",
"-@", "&", "~", "<", "**", "zero?", "^", "<=>", "to_s", "step", "[]", ">",
"==", "modulo", "next", "id2name", "size", "<<", "*", "downto", ">>", ">=",
"divmod", "+", "floor", "to_int", "to_i", "chr", "truncate", "round", "ceil",
"integer?", "prec_f", "prec_i", "prec", "coerce", "nonzero?", "+@", "remainder",
"eql?", "===", "clone", "between?", "is_a?", "equal?", "singleton_methods",
"freeze", "instance_of?", "send", "methods", "tainted?", "id",
"instance_variables", "extend", "dup", "protected_methods", "=~", "frozen?",
"kind_of?", "respond_to?", "class", "nil?", "instance_eval", "public_methods",
"__send__", "untaint", "__id__", "inspect", "display", "taint", "method",
"private_methods", "hash", "to_a"]
你可以看到+和to_s都在方法名列表中.^([6])
数组
注意methods输出的括号是如何用方括号[]括起来的。这些括号表示括号内的项是数组的成员,数组是对象的列表。数组只是 Ruby 中的另一个类,就像 String 或 Integer 一样,而且(与某些其他语言不同)没有要求给定数组中的所有成员必须是同一类的实例。
将单个项目转换为数组的一种简单方法是将它用括号括起来,如下所示:
irb(main):011:0> [100].class
=> Array
数组也知道如何添加自己,如下所示:
irb(main):012:0> [100] + ["Hello, world!"]
=> [100, "Hello, world!"]
结果只是一个包含所有添加的数组元素的另一个数组。
布尔值
除了 String、Integer 和 Array 之外,Ruby 还有一个名为 Boolean 的类。字符串是字符序列,整数是任何可以被 1 整除的数,数组是成员的列表。布尔值只能是真或假。布尔值有很多用途,但它们最常用于确定是否执行一个动作或另一个动作的评估操作。这类操作被称为流程控制。
注意
布尔值是以数学家乔治·布尔的名字命名的,他做了大量早期工作来形式化它们。
流程控制
最常用的流程控制操作之一是if。它评估其后的表达式为真或假。让我们用if演示一些流程控制:
irb(main):013:0> 100 if true
=> 100
我们刚刚询问表达式100 if true是否为真。由于表达式true评估为真值,所以我们确实得到了值100。当if评估的表达式不为真时会发生什么?
irb(main):014:0> 100 if false
=> nil
这是一种新的情况。表达式false不是真的,所以我们没有得到表达式100。事实上,我们什么都没有得到——irb 告诉我们它没有值可以报告。Ruby 有一个特定的值表示值的缺失(或无意义的值),即nil。
值可能因多种原因而缺失。它可能是一个无法表达的概念,或者它可能指的是缺失的数据,这正是我们例子中的情况。当我们评估的表达式为假时,我们从未告诉 irb 要报告什么,因此值是缺失的。任何可能需要表示为n/a的值都是nil值的良好候选。当你与数据库交互时,这种情况经常发生。并非所有语言都有nil;一些语言有,但假设它必须是错误。Ruby 完全适应在适当的地方使用nil值。
nil值与所有其他值都不同。然而,当我们强制 Ruby 将nil评估为布尔值时,它评估为false,如下所示:
irb(main):015:0> "It's true!" if nil
=> nil
唯一评估为假布尔值的值是nil和false。在许多其他语言中,0或“”(一个零字符的字符串)也会评估为假,但在 Ruby 中并非如此。当强制转换为布尔值时,除了nil或false之外的所有内容都评估为真。
注意
我们必须显式地将字符串和整数转换为彼此,使用*to_s*和*to_i*方法,但请注意,对于布尔值我们不需要这样做。当你使用*if*时,布尔转换是隐式的。如果你要显式地将布尔值转换为其他类型,你可能会期望有一个类似于*to_s*和*to_i*的方法,称为*to_b*。但在 Ruby 中目前还没有这样的方法,但我们在第三章中会自己编写一个。
假设我们希望在评估表达式为真时得到某个特定值(就像我们之前用if做的那样),同时当评估表达式为假时也希望得到一些非nil值。我们该如何做呢?以下是一个在 irb 中的示例:
irb(main):016:0> if true
irb(main):017:1> 100
irb(main):018:1> else
irb(main):019:1* 50
irb(main):020:1> end
=> 100
这是我们第一次在 irb 中的多行表达式。它应该相当简单,返回100,因为true评估为真。让我们再次尝试,但有一些不同之处:
irb(main):021:0> if false
irb(main):022:1> 100
irb(main):023:1> else
irb(main):024:1* 50
irb(main):025:1> end
=> 50
这次,由于false评估为非真,多行表达式的值就是else中的值,即50。这种格式比之前只使用if的测试要复杂一些。我们还需要end关键字来告诉 irb 我们何时完成了从if开始的表达式。如果我们想经常进行这样的多行表达式测试,反复输入相同基本想法的微小变化可能会变得繁琐。这就是方法发挥作用的地方。
注意
注意,irb 在其提示符中给你一些有用的信息。提示符通常以*符号结束,通常前面有一个数字。这个数字表示你处于多少层深度,即你需要多少个end语句才能回到顶层。你也会注意到,有时提示符不会以*符号结束,而是以星号()结束。这意味着 irb 只有一个不完整的语句,正在等待该语句被完成。非常有用*。
方法
我们之前提到了方法,但现在我们将更详细地讨论它们。一个方法只是附加到对象上的一小段代码;它接受一个或多个输入值并返回一个结果。^([7]) 我们称方法的输入为参数或参数,称结果值为返回值。我们在 Ruby 中使用def关键字定义方法:
irb(main):026:0> def first_if_true(first, second, to_be_tested)
irb(main):027:1> if to_be_tested
irb(main):028:2> first
irb(main):029:2> else
irb(main):030:2* second
irb(main):031:2> end
irb(main):032:1> end
=> nil
我们刚刚定义了一个名为first_if_true的方法,它接受三个参数(分别称为first, second和to_be_tested)并根据to_be_tested是否评估为真返回first或second的值。我们现在已经将之前的多行测试定义为可以重复使用不同值的抽象概念。让我们试试看。
注意
first_if_true 的名字告诉你它将做什么。这是一个好习惯。方法名应该告诉你它们做什么。清晰直观的方法名是良好文档的重要部分。同样的建议也适用于稍后描述的变量。根据这个标准,result(如稍后所见)不是一个很好的名字。对于仅仅介绍将值赋给变量的概念的一个简单例子来说是可以的,但对于真正的生产代码来说,它太模糊了。
记住 first_if_true 测试第三个值,然后返回第一个值或第二个值。
irb(main):033:0> first_if_true(1, 2, true)
=> 1
irb(main):034:0> first_if_true(1, 2, false)
=> 2
irb(main):035:0> first_if_true(1, 2, nil)
=> 2
irb(main):036:0> first_if_true(nil, "Hello, world!", true)
=> nil
irb(main):037:0> first_if_true(nil, "Hello, world!", false)
=> "Hello, world!"
随时可以在 irb 中尝试使用不同的参数来测试 first_if_true 方法,无论是现在还是以后。这应该能给你一个很好的想法,了解 Ruby 如何处理表达式。
注意
当方法被使用时,它们会返回值,但定义一个方法的简单行为会返回 nil,正如你所看到的。
变量
如果你想要将一个方法的输出作为另一个方法的输入,会发生什么?这样做最方便的方法之一是使用 变量。类似于代数或物理学,我们只是决定通过名字来引用某个值,比如用 m 表示某个特定的质量,用 v 表示某个特定的速度。我们用一个单独的 = 符号将值赋给变量,如下所示:
irb(main):038:0> result = first_if_true(nil, "Hello, world!", false)
=> "Hello, world!"
irb(main):039:0> result
=> "Hello, world!"
我们将 first_if_true(nil, “Hello, world!”, false)(恰好是 "Hello, world!")的值赋给了一个名为 result 的变量。现在,我们已经在 result 这个名字下存储了 "Hello, world!" 这个值,它仍然按照你预期的那样评估,正如你在第 39 行所看到的。现在我们可以像使用任何其他值一样使用 result:
irb(main):040:0> first_if_true(result, 1, true)
=> "Hello, world!"
irb(main):041:0> first_if_true(result, 1, result)
=> "Hello, world!"
注意我们如何可以将 result 通过 first_if_true 传递,并评估它(作为 to_be_tested)的布尔值。我们也可以将它作为更大表达式的一部分:
irb(main):042:0> first_if_true(result, 1, (not result))
=> 1
在第 42 行的例子中,我们在将 result 传递给 first_if_true 之前,用关键字 not 反转了 result 的布尔值。我们在第 42 行没有对 result 进行任何修改。我们只是创建了一个新的表达式 (not result),它恰好评估为 result 的布尔相反值。result 本身保持不变。
注意
我添加了一些空格,只是为了更容易阅读哪些括号包含了方法的参数,哪些括号包含了 (not result) 表达式。Ruby 和 irb 对空白符并不很关心。
常量
有时候我们想要通过名字来引用一个值,但不需要改变它。实际上,有时候我们打算不改变它。物理学中的好例子是光速或地球重力加速度——它们不会改变。在 Ruby 中,我们可以将这些值定义为 常量,这些常量必须以大写字母开头。(按照传统,它们通常是全部大写。)让我们定义一个常量然后使用它:
irb(main):043:0> HUNDRED = 100
=> 100
irb(main):044:0> first_if_true( HUNDRED.to_s + ' is true', false, HUNDRED )
=> "100 is true"
我们看到我们可以像给变量赋值一样给常量赋值。然后我们可以按名字使用那个常量,作为一个表达式或在一个更大的表达式中,根据需要。
^([4]) 在我们的例子中,特别是一个 Fixnum。
^([5]) 从技术上来说,我们并没有进行类型转换,而是创建了一个全新的对象,它恰好是数字 100 的字符串等价物。
^([6]) 顺便说一下,你可以将方法链接起来,例如100.methods.sort。如果你在 irb 中尝试这样做,你会得到与100.methods相同的函数列表,但按字母顺序排列。
^([7]) Ruby 是面向对象的,因此它使用方法这个术语。那些不太注重面向对象的语言可能会把方法称为函数。方法就是一个附加到对象上的函数。
使用 Ruby 解释器和环境
如果你来自 Unix 背景,你可能已经熟悉命令行选项和环境变量的概念。如果你不熟悉这些术语,它们只是计算机跟踪外部数据(通常是配置选项)的方式。Ruby 使用命令行选项和环境变量来跟踪诸如在安全方面应该多么谨慎或宽松,以及关于警告应该多么详细等问题。我们已经在从源代码下载安装 Ruby 的说明中看到了一个例子,当时我们执行了以下命令:
ruby --version
如你所料,这仅仅是要求 Ruby 报告其版本。你可以通过执行以下命令来找出 Ruby 理解的各种命令行选项:
ruby -h
环境变量可以存储这些命令行选项作为默认值;它们还可以存储 Ruby 可能仍认为执行某些任务所必需的其他信息。Unix-like 系统的用户将他们的文件存储在一个称为HOME目录中,这样可以避免其他用户的数据。Windows 中的“我的文档”文件夹与此类似。另一个重要的环境变量是ARGV,它是一个数组,用于跟踪传递给 Ruby 的所有参数。当你执行外部 Ruby 程序时(你通常会使用以下语法),程序的名字将会在ARGV中找到。
ruby some_external_program.rb
让我们继续探讨一些具体的示例程序。我们将更详细地处理本章中仅略提的一些主题,并针对每个示例进行适当的深入探讨。
第二章. 娱乐和简单实用工具

从上一章,你应该现在对 irb 和 Ruby 如何处理各种表达式相对熟悉了。现在我们将尝试一些存储在单独文件中并在 irb 之外执行 Ruby 程序。你可以在www.nostarch.com/ruby.htm下载所有这些程序。
我们将使用ruby命令来运行我们的程序,所以当我们想要运行名为check_payday.rb的脚本时,我们将在 Unix-like 系统的 shell 中或 Windows 的命令提示符中键入ruby check_payday.rb。我们通常还会使用-w选项,这意味着开启警告,使我们的示例变为ruby -w check_payday.rb。这仅仅是一种更安全的操作方式,而且在学习新语言时特别有用。我们偶尔也会看到 Ruby 文档(RDoc),它允许我们将相对复杂的注释直接放入源代码中。我们将在99bottles.rb示例中讨论这一点,我们第一次使用它。
#1 是否是发工资日?(check_payday.rb)
这个脚本是一个简单的实用工具,我用它来提醒自己何时发工资。它非常简单直接,而且是故意这样做的。
代码
❶ #!/usr/bin/env ruby
❷ # check_payday.rb
❸ DAYS_IN_PAY_PERIOD = 14 *CONSTANTS*
SECONDS_IN_A_DAY = 60 * 60 * 24
❹ matching_date = Time.local(0, 0, 0, 22, 9, 2006, 5, 265, true, "EDT") *Variables*
❺ current_date = Time.new()
difference_in_seconds = (current_date - matching_date)
❻ difference_in_days = (difference_in_seconds / SECONDS_IN_A_DAY).to_i
❼ days_to_wait = (
DAYS_IN_PAY_PERIOD - difference_in_days
) % DAYS_IN_A_PAY_PERIOD
if (days_to_wait.zero?)
❽ puts 'Payday today.'
else
print 'Payday in ' + days_to_wait.to_s + ' day'
puts days_to_wait == 1 ? '.' : 's.'
end
工作原理
❶行是给计算机的一个提示,表明这个程序是用 Ruby 编写的。❷行的注释是为了人类读者而写的,它说明了程序的名字。在 Ruby 中,注释从#字符开始,直到行尾。
定义常量
我们在第❸行定义了两个常量。虽然常量只需要以大写字母开头,但我喜欢使用全部大写字母来使它们更加突出。(这在许多语言中是一个常见的约定,也是一个好习惯。)
常量DAYS_IN_PAY_PERIOD和SECONDS_IN_A_DAY的名称应该能让你很好地理解它们的意义——具体来说,就是工资周期中的天数和一天中的秒数。我每两周发一次工资,也就是 14 天。
SECONDS_IN_A_DAY的定义使用了乘法(60 * 60 * 24),这是你从 irb 中的实验中知道的 Ruby 语法。将这些特定数字表示为乘法的结果而不是一个大的最终结果,也更容易阅读,因为阅读这段代码的人会看到并理解一分钟中的 60 秒、一小时中的 60 分钟和一天中的 24 小时之间的关系。
为什么要用比它们所持有的值更多的字符来定义常量呢?虽然在这个程序中这并没有太大的区别,但对于更大的程序来说,这是一个好习惯。
注意
常量是一个非常不错的想法。它们允许你避免编程中的一个罪恶,即魔法数字,这是两种编程罪恶之一:重复使用的字面值,或者使用不明显的字面值,即使它只使用一次。用有意义的名称定义这样的值一次,可以使你的代码在程序员自己和其他程序员看来都更容易阅读,在你已经忘记了程序的所有内容之后。再次强调,常量是一个非常不错的想法*。
定义变量
定义了我们的常量后,我们使用 Ruby 的内置 Time.local 方法定义了一个变量,该变量在❹处被命名为 matching_date。此方法接受 10 个参数,顺序为:秒、分钟、小时、月份中的天数、月份、年份、星期中的天数、年内天数(1 到 366)、日期是否在夏令时内,以及时区的三位字母代码。这里使用的值是 2006 年 9 月 22 日,那天恰好是我的发薪日。年内天数最多为 366 而不是 365,因为闰年有 366 天。
在❺处,我们使用 Ruby 的内置 Time.new 方法获取 current_date,然后从其中减去 matching_date 以得到差值,单位为秒。因为我们更感兴趣的是天数差而不是秒数差,所以我们把 difference_in_seconds 除以 SECONDS_IN_A_DAY 的数量来得到天数差,然后我们通过将结果转换为 Integer 来向下取整,这样我们就得到了 difference_in_days 变量中的一个有用的值。
difference_in_days 变量告诉我们自上次发薪以来有多少天。然而,因为我们真的想要我们的现金,所以我们更感兴趣的是我们还需要等待多久才能到下一次发薪。为了找出答案,在❻处,我们从上次发薪以来的天数(difference_in_days 变量)中减去 DAYS_IN_A_PAY_PERIOD 的数量,以得到一个新的变量,在❼处被命名为 days_to_wait。
如果 days_to_wait 的值为零,今天必须是发薪日,所以在❽处我们使用 Ruby 的内置方法 puts 输出该信息。puts 方法代表 输出字符串,它会打印其字符串参数(在我们的脚本中是 ‘Payday today.’),然后自动换行,也称为 换行符。如果 days_to_wait 不为零,我们再次使用 puts 来告知我们还需要等待多少天才能发薪,并且为了方便,如果天数是复数,我们在 day 这个词后面加上字母 s。
注意
我们使用不带括号的 print 和 puts。这是完全合法的,除非表达式或方法的特定参数存在歧义。
那就是整个程序。完成这个程序的一些任务有更优雅的方法,但它引入了一些新的概念,例如常量、puts方法和日期。您可以亲自运行它,并将它输出的结果与您自己的实际发薪日表进行比较,相应地调整matching_day变量。
注意
熟悉 crontab 的读者可能会发现,我在我的机器上使用以下 crontab 条目来运行这个脚本: ruby ~/scripts/check_payday.rb | mutt -s “payday” kbaird**。
结果
您的结果应该是一条类似“10 天后发薪日”的消息,具体取决于您运行程序的日子。
#2 随机签名生成器(random_sig.rb 和 random_sig-windows.rb)
下一个脚本为电子邮件签名生成动态内容,将标准信息,如姓名和电子邮件地址,添加到从已知文件中随机抽取的引语中。Unix 和 Windows 版本需要略有不同,因此它们被分离成两个不同的文件。我将首先讨论 Unix 版本,但会包含两个文件中的源代码。在这个例子中,我们还将看到 Ruby 如何处理复杂的赋值。这需要覆盖很多信息。
代码
#!/usr/bin/env ruby
# random_sig.rb
❶ filename = ARGV[0] || (ENV['HOME'] + '/scripts/sig_quotes.txt') *Environment Variables; The File Object*
❷ quotation_file = File.new(filename, 'r')
file_lines = quotation_file.readlines()
❸ quotation_file.close()
❹ quotations = file_lines.to_s.split("\n\n") *The **`split`** Method*
❺ random_index = rand(quotations.size)
❻ quotation = quotations[random_index]
sig_file_name = ENV['HOME'] + '/.signature'
❼ signature_file = File.new(sig_file_name, 'w')
❽ signature_file.puts 'Kevin Baird | kcbaird@world.oberlin.edu | http://
kevinbaird.net/'
signature_file.puts quotation
signature_file.close()
工作原理
在❶处,我们给一个名为filename的变量赋值,但放入其中的值比一个简单的数字或字符串要复杂得多。ARGV是一个环境变量的例子。由于历史原因,ARGV代表Argument Vector,它是任何程序在运行时命令行参数的数组。
但这并不是整行。正如等号是一个将值放入某物的运算符一样,双竖线(||)是一个表示“或”的运算符。让我们使用 irb 来看看它是如何工作的。
irb(main):001:0> 0 || false ***`||`** operator*
=> 0
irb(main):002:0> false || 0
=> 0
irb(main):003:0> nil || true
=> true
irb(main):004:0> nil || false
=> false
irb(main):005:0> false || nil
=> nil
使用||运算符的表达式评估其左侧的内容。如果左侧为真,整个表达式的值就是该值,无论它碰巧是哪个可能的真值。如果左侧为假,整个表达式的值就是||右侧的值,无论该值是什么——true、false、nil,无论什么。缺少的参数是nil,并且当通过||测试时,nil评估为false。ARGV的元素从零开始计数,就像 Ruby 中的所有数组(以及许多其他语言)一样。我们的filename变量要么是程序的第一个参数,要么如果没有参数,它就设置为括号内的所有内容:(ENV[‘HOME’] + ‘/scripts/sig_quotes.txt’)。
注意
Windows 用户需要使用 (ENV[‘HOMEDRIVE’] + ENV[‘HOMEPATH’]) 代替 ENV[‘HOME’]*。我们将在脚本的 Windows 版本中详细讨论这一点。
ENV 是一个环境变量,正如其缩写所暗示的,括号表示表达式边界,就像在数学表达式中一样,例如 (5 + 2) * 2 = 14。ENV['HOME'] 简单来说就是让你到达属于特定用户的目录。对于我的用户名 kbaird,这可能是 /home/kbaird,或者在 Mac OS X 下的 /Users/kbaird。家目录在 Windows 中类似于我的文档文件夹。
ENV['HOME'] 是一个字符串,在我们的表达式中,我们将其添加到字符串 ‘/scripts/sig_quotes.txt’。这全部意味着我们的文件名有一个默认值 sig_quotes.txt,位于 scripts 目录下,位于用户的家目录中。现在我们知道了读取引言的文件名,所以让我们使用它。
Ruby 使用 File.new() 创建新的外部文件对象,它接受两个参数:文件的名称以及你想要使用该文件的方式。在这种情况下,我们想要从文件中读取,所以在 ❷ 我们给它第二个参数 ‘r’,它自然代表 read。我们称这个文件为 quotations_file,并将它的行读取到一个名为 file_lines 的变量中。由于我们现在已经完成了文件操作,我们可以关闭它,我们在 ❸ 处这样做。
新变量 file_lines 是一个数组,其中每个元素都是引言文件的单独一行。当我们想要更长的引言时,我们已经在 ❹ 处处理了这个问题,通过使用我们老朋友 to_s 方法将这些行组合成一个字符串,然后使用一个名为 split 的方法将其转换回数组,该方法接受一个断点参数来将字符串分割成块。让我们看看它是如何工作的。
irb(main):001:0> 'break at each space'.split(' ')
=> ["break", "at", "each", "space"]
irb(main):002:0> 'abacab'.split('a')
=> ["", "b", "c", "b"]
在我们的程序中,我们是在双行断处进行分割,这在 Ruby 中表示为 \n\n,正如在许多其他语言中一样。我们现在有一个名为 quotations 的变量,它是一个数组,其中的每个成员都是来自外部文件的引言。
我们想要选择一个随机的引言,数组的元素可以通过索引方便地存储,所以从数组中选择一个随机元素的一个非常合适的方法是在数组的索引范围内生成一个随机数,然后从数组中读取该索引处的元素。这正是我们在 ❺ 处使用 rand 方法所做的事情,我们将 quotations 数组的 size 传递给它。我们将选定的特定引言放入一个名为 ❻ 的变量中,命名为 quotation。
现在我们有了引言,我们可以用它做什么呢?我们想要将其写入我们的签名文件。我们通常使用 puts 来打印东西,就像我们在第 14 页的 #1 Is It Payday? (check_payday.rb)") 中使用的 check_payday.rb 一样。让我们在一个新的 irb 会话中试试。
irb(main):001:0> puts 'Hello, World!'
Hello, World!
=> nil
你会注意到puts会输出你给出的任何参数,但它返回的值是nil。记住这个区别很重要。如果你在文件上使用puts,它将打印其参数到该文件而不是打印到屏幕。我们已经知道我们可以使用第二个参数‘r’从外部文件中读取。同样,我们可以使用第二个参数‘w’将数据写入外部文件,这是我们在第❽处打开signature_file的方式。让我们看看puts在 irb 中的行为。
irb(main):002:0> t = File.new(ENV['HOME'] + '/test_file.txt', 'w')
=> #<File:0xaca10>
irb(main):003:0> t.puts 'Write some content' **some_file*.puts*
=> nil
irb(main):004:0> t.close
=> nil
puts方法继续返回nil,但看看你主目录内新创建的名为test_file的文件。它现在应该包含文本Write some content,这证明puts也可以轻松地将内容打印到文件中。注意我们使用的文件名意味着用户主目录中的.file 签名文件,这是电子邮件签名文件的传统位置。剩下要做的就是写入标准标题在第❽,然后添加随机选择的引语,最后关闭签名文件。
如果你使用类 Unix 操作系统,你可以将对该程序的调用放入 crontab 中,^([8)) 就像我在我 Debian 机器上做的那样。Windows 用户可以修改脚本以写入他们选择的任何名称的签名文件,然后更改他们电子邮件程序的设置以使用该签名文件。
运行脚本
使用ruby -w random_sig.rb(假设默认的sig_quotes.txt文件)运行此脚本,或者使用ruby -w random_sig.rb some_file,将some_file替换为你喜欢的sig_quotes.txt版本名称。
结果
这里是我的结果。$ 表示我的 GNU/Linux 系统上的 bash 提示符。我额外添加了cat ~/.signature(它只显示~/.signature的内容)来显示结果,因为脚本将写入该文件而不是打印到屏幕上。
$ ruby -w random_sig.rb extras/sig_quotes.txt ; cat ~/.signature
Kevin Baird | kcbaird@world.oberlin.edu | http://kevinbaird.net/
Those who do not understand Unix are condemned to reinvent it, poorly.
$ ruby -w random_sig.rb extras/sig_quotes.txt ; cat ~/.signature
Kevin Baird | kcbaird@world.oberlin.edu | http://kevinbaird.net/
"You cannot fight against the future. Time is on our side."
- William Ewart Gladstone
修改脚本
看看下面的 Windows 源代码,在继续我的解释之前,试着找出变化。
#!/usr/bin/env ruby
# random_sig-windows.rb
❶ home = "#{ENV['HOMEDRIVE']}" + "#{ENV['HOMEPATH']}"
❷ filename = ARGV[0] || (home + '\\scripts\\sig_quotes.txt')
quotations_file = File.new(filename, 'r')
file_lines = quotations_file.readlines()
quotations_file.close()
quotations = file_lines.to_s.split("\n\n")
random_index = rand(quotations.size)
quotation = quotations[random_index]
❸ sig_file_name = home + '\.signature'
signature_file = File.new(sig_file_name, 'w')
signature_file.puts 'Kevin Baird | kcbaird@world.oberlin.edu | http://
kevinbaird.net/'
signature_file.puts quotation
signature_file.close()
唯一的重大差异与文件系统有关,这是操作系统和程序访问你的机器硬盘、CD-ROM 驱动器等的方式。Windows 使用单独的驱动器字母,它由ENV[‘HOMEDRIVE’]表示,以及该驱动器字母内的路径,它由ENV[‘HOMEPATH’]表示。由于 Windows 对家的定义更加复杂,我们在脚本中将它放入了一个变量中在第❶。唯一的其他差异是在第❷和❸处使用反斜杠而不是正斜杠。
^([8)) crontab 只是 Unix 机器安排操作的一种方式。如果你使用类 Unix 操作系统,只需在 shell 中执行man crontab即可。如果你使用 Windows,可以使用批处理文件配合 Windows 计划任务。
#3 啤酒瓶歌(99bottles.rb)
这个脚本通过(好吧,打印)“99 瓶啤酒”这首歌来演示基本的面向对象。示例的内容可能有点牵强,但程序本身揭示了 Ruby 中许多命名约定的信息。我们将定义一个 Wall,上面有瓶子,其数量会反复减一。
下面是代码。类是 Ruby 中的基本构建块,所以对于任何对这种语言感兴趣的人来说,深入理解它们是很有价值的。我们已经看到了一些内置的类(String、Integer 和 Array),所以在这个阶段它们对你来说不是一个全新的概念。新的地方在于能够定义完全新颖的类,就像我们下面做的那样。
代码
#!/usr/bin/env ruby
# 99 bottles problem in Ruby
❶ class Wall *Classes*
❷ def initialize(num_of_bottles) *Instance Variables*
@bottles = num_of_bottles
❸ end
=begin rdoc
Predicate, ends in a question mark, returns <b>Boolean</b>.
=end
❹ def empty?() *Predicate Methods*
@bottles.zero?
end
❺ def sing_one_verse!() *Destructive Methods*
puts sing(' on the wall, ') + sing("\n") + take_one_down! + sing(" on the
wall.\n\n")
end
❻ private *Private Methods*
def sing(extra='')
❼ "#{(@bottles > 0) ? @bottles : 'no more'} #{(@bottles == 1) ? 'bottle' :
'bottles'} of beer" + extra
end
=begin rdoc
Destructive method named with a bang because it decrements @bottles.
Returns a <b>String</b>.
=end
❽ def take_one_down!()
@bottles -= 1
'take one down, pass it around, '
end
end
它是如何工作的
我们使用关键字 class 后跟我们所选择的任何名称来定义类,我们在 ❶ 处为类 Wall 做了这样的事情。类必须以大写字母开头,并且传统上使用混合大小写,例如 MultiWordClassName。我们的类叫做 Wall,这会在读者的脑海中唤起一个现实世界的对象。这是歌曲中瓶子放置的墙壁。
同样传统的是,如果类名由多个单词组成,则在文件中使用所有小写字母和单词之间的下划线来定义类(即 multi_word_class_name.rb)。这只是个约定,但它是一个广泛遵循的约定,如果你决定使用 Rails,遵循这个约定将使你的生活变得更加容易。
如果我们的墙壁只是坐那里什么也不做,那么创建它的意义就很小了。我们希望我们的墙壁能够执行某种动作。这些动作是方法,就像我们之前遇到的那样。我们已经使用 def 关键字定义了函数。现在我们将在类内部这样做——这会将我们正在定义的函数附加到该类上,使其成为该类的 方法。
每个类都应该有一个名为 initialize 的方法,这是该类在创建自身时使用的方法。从外部来看,我们称这个方法为 new,但类本身使用的是 initialize 这个名字。(我们很快就会讨论为什么存在这种区别。)我们的墙壁 initialize 方法,在 ❷ 处定义,接受一个名为 num_of_bottles 的参数。然后它将一个名为 @bottles 的变量的值设置为 num_of_bottles 的值。为什么 @bottles 前面有一个 @ 符号?@ 符号是 Ruby 表示某个东西是一个所谓的 实例变量 的方式。
实例变量只是某些事物的特征。如果我们有一个名为Person的类,每个人都可以有诸如姓名、年龄、性别等特征。这些特征都是实例变量的好例子,因为它们可以(并且确实)因人而异。就像Person有一个年龄一样,Wall有一个特定的瓶子数量。我们的墙恰好有 99 个瓶子,因为我们告诉它要有那么多。让我们在 irb 中尝试不同的瓶子数量。你可以使用带有-r命令行标志的外部内容,该标志代表require。
$ irb -r 99bottles.rb
irb(main):001:0> other_wall = Wall.new(10)
=> #<Wall:0xb2708 @bottles=10>
从返回值中我们可以看出,在我们的新变量other_wall的情况下,@bottles被设置为 10。wall和other_wall都是Wall类的一个例子(或实例)。它们在关键方面有所不同,例如它们能容纳的瓶子数量。
当我们创建一个新的墙时,我们只想设置它的瓶子数量,所以在❸处,我们在设置@bottles的值后声明方法的结束。在我们创建我们的墙之后,我们将询问墙是否还有剩余的瓶子。我们将通过一个名为empty?的方法来实现这一点,我们在❹处定义它。注意问号,它是方法名称的一个完全合法的部分。Ruby 从其祖先 Lisp 那里继承了一种传统,即当方法返回true或false时,用问号命名方法。仅返回布尔值的这些方法被称为predicates。很明显,墙要么是空的,要么不是空的,所以empty?方法是一个 predicates,因为它将返回true或false。
我们在empty?方法的定义之前,也包含了一些 RDoc 注释❹。表示 RDoc 注释的方式是将文本=begin rdoc左对齐,前面没有空格。Ruby 会将=begin rdoc之后和=end之前的所有内容,也左对齐且前面没有前置空格,视为供人类阅读的注释,而不是要执行的内容。RDoc 允许使用类似 HTML 的标签,正如我们脚本中加粗的Boolean所示。RDoc 还允许使用各种其他标记选项,我们将在稍后更详细地讨论。
实例变量@bottles是一个数字,在 Ruby 中表现为 Integer 的实例。整数有一个内置的方法叫做zero?,它简单地告诉我们这个整数是否为零。这是一个已经为我们准备好的、遵循问号命名约定的 predicates 的例子。我们为Wall类定义的empty?也使用括号来表示它不接受任何参数。在没有任何参数的情况下,通常引用方法时使用括号是一个好主意。这样做的主要原因是为了清楚地表明你正在处理一个方法,而不是一个变量。由于你可以定义方法和变量,并且两者都由小写字母组成,括号有助于 Ruby 区分两者。
一首歌是为了被唱的,所以我们要告诉 Wall 如何做到这一点。我们将在 ❺ 处定义一个名为 sing_one_verse! 的方法。就像 empty? 使用问号一样,sing_one_verse! 以感叹号结尾(也称为 bang),这表示该方法是有破坏性的。有破坏性 的方法会改变它们对象的状态,这意味着它们在调用方法后会对对象执行一些持续的动作。
sing_one_verse! 负责输出的诗句有一些内部重复,所以只有将其分解并抽象出来才有意义。我们通过 sing 方法来做这件事,该方法接受一个可选的字符串参数,称为 extra。这个可选参数代表对关于剩余瓶子数量的某些样板文本的任何添加。在 sing 方法内部的 ❼ 行中有一个一行表达式,其中包含一些我们之前没有看到的东西。
有时候我们希望在字符串中显示表达式的值。这个过程被称为 插值,Ruby 会这样做,就像我们在这里用 irb 展示的那样:
irb(main):001:0> my_var = 'I am the value of my_var' *Interpolation*
=> "I am the value of my_var"
irb(main):002:0> "my_var = my_var"
=> "my_var = my_var"
irb(main):003:0> "my_var = #{my_var}"
=> "my_var = I am the value of my_var"
irb(main):004:0> 'my_var = #{my_var}'
=> "my_var = \#{my_var}"
当我们使用双引号并将表达式包裹在 #{} 中时,表达式在插入到字符串之前会被评估。当我们使用单引号或省略 #{} 包装器时,所有文本都会以字面意义出现,即使这些文本碰巧是有效的表达式,比如变量的名称。双引号和 #{} 包装器的组合是一种告诉 Ruby 你想要进行插值的方式。
注意
如果你想在使用插值的字符串中包含双引号,你可以使用 %Q,如下所示: %Q[I am an interpolating String. Call me “#{ ‘Ishmael’ }”.]*. 注意,分隔符不必是括号,理论上可以是任何字符。常见的选项有 [,{‘ 和 !。
sing 方法还会根据剩余瓶子的数量进行一些测试。这决定了它返回的具体输出。对此至关重要的是,我们可以插值任何表达式,而不仅仅是变量。插值中的第一个表达式在 ❼ 处是一个测试,检查 @bottles 的值是否大于零。如果是,第一个表达式计算结果为 @bottles,否则计算结果为字符串 ‘no more’。我们通过所谓的 三元运算符 来做这件事。让我们在 irb 中也稍微看看三元运算符。
irb(main):001:0> true ? 'I am true' : 'I am false' *Ternary Operator*
=> "I am true"
irb(main):002:0> false ? 'I am true' : 'I am false'
=> "I am false"
三元运算符检查问号左边的表达式;如果检查的表达式为真,则评估为问号后面的表达式,否则评估为冒号后面的表达式。你可以将其视为实现流程控制的一种方式,有时比if测试更方便。在下一行代码中,使用三元运算符的表达式评估为单词bottle或单词bottles,这取决于墙上当前有多少瓶啤酒。然后我们附加信息说明这些是啤酒瓶,而不是其他液体,并添加extra参数。由于参数默认为空字符串,将空字符串附加到某个东西上没有明显效果,所以当没有参数时我们很安全。
注意
实际上,这只是一个三元运算符。它恰好是最常见的一个,因此经常得到特殊的命名处理。这是 Ruby 中唯一的内置三元运算符。
在唱完一段歌词后,我们用take_one_down!这个方法来喝下一瓶啤酒,这个方法在第❽处定义,同样以感叹号命名。我们将取走一瓶啤酒并报告这一事实的动作组合在一起,这在概念上似乎是有道理的。由于 Ruby 方法返回最后一个表达式评估的结果,这个方法返回字符串‘take one down, pass it around, ’,这个字符串被整合到整个歌词中的sing_one_verse!方法内部。
我们用end关键字结束所有这些方法定义,并再次使用end来结束类的定义。所以我们已经完成了——除了在第❻处定义的单词private。为了了解这是如何工作的,让我们再次打开 irb 并实例化一个新的 Wall 对象。
$ irb -r 99bottles.rb
irb(main):001:0> wall = Wall.new(99)
=> #<Wall:0xb7d2e628 @bottles=99>
irb(main):002:0> wall.sing
NoMethodError: private method 'sing' called for #<Wall:0xb7d2e628 @bottles=99>
from (irb):2
from :0
irb(main):003:0> wall.take_one_down!
NoMethodError: private method 'take_one_down!' called for #<Wall:0xb7d2e628 @bottles=99>
from (irb):3
from :0
irb(main):004:0> wall.empty?
=> false
只有在单词private出现之后定义的方法才能被该类本身访问。其他可以从外部访问的方法被称为public方法。为什么要有这种区分?这是因为它允许我们为对象定义一个不变的接口。我们可以在内部随意修改并改变类实际完成其职责的方式,而外部的人却没有任何意识到有任何变化。
以这种方式使用类在与其他程序员一起进行大型项目开发时特别有用。你可以定义你的类,包括其他团队成员所知的公共方法,并编写那些方法的小型占位符版本,这些方法返回一些临时硬编码的合法值。这允许你的同事在你编写这些方法的实际版本之前,就开始他们的类的工作,这些类可能依赖于你的类方法的输出。这非常方便。
顺便说一下,我们之前看到的 new 与 initialize 的区别是公共与私有的区别。initialize 方法默认是私有的,任何对象的(公共的)new 方法会自动调用该对象(私有的)initialize 方法。这就是为什么我们在编写全新的类时创建一个 initialize 方法。
运行脚本
让我们在 irb 中尝试这个 irb -r 99bottles.rb。请注意,这将输出这首歌的所有 99 首诗,所以当你看到它发生时不要感到惊讶。
结果
$ irb -r 99bottles.rb
irb(main):001:0> wall = Wall.new(99)
=> #<Wall:0xb3040 @bottles=99>
irb(main):002:0> wall.sing_one_verse! until wall.empty?
这里是输出的一部分简短说明:
99 bottles of beer on the wall, 99 bottles of beer
take one down, pass it around, 98 bottles of beer on the wall.
98 bottles of beer on the wall, 98 bottles of beer
take one down, pass it around, 97 bottles of beer on the wall.
97 bottles of beer on the wall, 97 bottles of beer
take one down, pass it around, 96 bottles of beer on the wall.
...
2 bottles of beer on the wall, 2 bottles of beer
take one down, pass it around, 1 bottle of beer on the wall.
1 bottle of beer on the wall, 1 bottle of beer
take one down, pass it around, no more bottles of beer on the wall.
=> nil
#4 声音文件播放器(shuffle_play.rb)
在这个脚本中,我们将创建一个以随机顺序播放音乐文件的程序。我们在前面的例子中探讨了类,我们将在这里了解更多关于它们的内容。当我们想要一个与现有类非常相似的类时会发生什么?我们有几种选择。
注意
这个版本相当侧重于 Unix。您可以在www.nostarch.com/ruby.htm下载一个使用 Winamp 播放器的 Windows 版本。
在 Ruby 中,我们知道一切都是对象,这仅仅是一种说法,即它是类的一个成员(或实例)。我们了解诸如 Arrays、Strings、Integers 等定义良好的类。所有这些都是我们所说的 开放类,这意味着我们可以向现有类中添加代码。例如,我们可以更改我们程序中的所有 Arrays,使它们拥有一个新方法,这个更改将影响任何和所有的 Arrays。我们不必创建一个新的特殊类型的 Array 来添加这个假设的新方法。
注意
经验丰富的面向对象开发者会认出,创建一个与现有类相似但属于新类型的新类,这相当于使用继承。我们将在后面的章节中讨论 Ruby 中的继承。
我们需要处理一个文件列表。内置的类 Array 非常适合作为项目列表,因此我们将我们的播放器基于一个文件数组。这样做的同时,我们也会给所有数组添加一些行为,这将使得实现我们想要的随机播放更容易。
代码
#!/usr/bin/env ruby
# shuffle_play
class Array *Open Classes*
=begin rdoc
Non-destructive; returns a copy of self, re-ordered randomly.
=end
❶ def shuffle()
sort_by { rand } *Blocks; The **`sort_by`** Method*
end
=begin rdoc
Destructive; re-orders self randomly.
=end
❷ def shuffle!()
replace(shuffle) *The **`replace`** Method*
end
=begin rdoc
While we're here, we might as well offer a method
for pulling out a random member of the <b>Array</b>.
=end
❸ def random_element()
shuffle[0]
end
end # Array
###
class ShufflePlayer
❹ def initialize(files)
@files = files
end
=begin rdoc
Plays a shuffled version of self with the play_file method.
=end
❺ def play()
@files.shuffle.each do |file| *The **`each`** Method*
play_file(file)
end
end
❻ private
=begin rdoc
Uses ogg123, assumes presence and appropriateness.
=end
def play_file(file)
❼ system("ogg123 #{file}") *The **`system`** Method*
end
end # ShufflePlayer
###
❽ sp = ShufflePlayer.new(ARGV)
sp.play()
工作原理
在这个例子中,我们使用了两种不同的类定义:一种是我们向 Array 类添加行为,另一种是我们创建了一个全新的类,称为ShufflePlayer。我们在❶处向 Array 类添加的一个关键方法是shuffle。数组已经有两个非常方便的方法:sort_by和rand。你会注意到sort_by后面跟着一个开括号字符({),然后是rand方法,然后是闭括号(})。这个开括号和闭括号之间的内容是一个块,这是 Ruby 转换或迭代数据集合(如数组等)的核心。sort_by方法是一个排序操作,它接受一个块参数,该参数决定了排序的方式。通过在我们的块中调用rand方法,我们要求我们的数组随机排序其元素,这就是数组在 Ruby 读取这个方法定义后如何实现shuffle方法的原因。
注意
佩尔勒斯(或 JAPHs)可能会感兴趣的是,sort_by* 在底层使用的是 Schwartzian 变换。此外,random 生成的数字在技术上是伪随机数,而不是真正的随机数。对于这个脚本的用途来说,这种区别并不关键。
现在所有的数组都可以在我们的代码中自行打乱了。这就是我们对数组所需的一切,但既然这本书旨在向人们介绍 Ruby,就像它旨在完成像播放打乱音频文件这样的任务一样,我们将继续讨论。我们的数组也将能够使用在❷处定义的shuffle!方法自行打乱,这个方法与不带感叹号的shuffle方法类似,但有所不同。你最近了解到,以感叹号结尾的方法是破坏性的,意味着它们会改变调用对象的状态。我们通过使用replace方法来实现这种状态变化,该方法将调用对象转换为它接收的任何参数。shuffle方法返回调用数组的打乱版本。由于我们将那个打乱数组传递给replace方法,这是一种创建破坏性方法shuffle!的非常简单的方式,这正是我们所做的。
添加random_element方法也非常简单,我们在❸处这样做。由于数组的打乱版本是随机顺序(根据定义),从这个打乱数组中返回任何成员都会产生一个随机元素。在这里,我们返回第一个元素,但我们可以返回最后一个元素,或者任何其他元素。返回第一个元素是一个不错的选择,因为任何包含成员的数组肯定会有一个第一个成员。
通过几个简短的方法,我们极大地增强了所有数组的功能。我们将在新的 ShufflePlayer 类中使用这些功能。由于 ShufflePlayer 是一个全新的类,我们需要定义它的 initialize 方法(❹),它接受一个名为 files 的参数并将其赋值给一个名为 @files 的实例变量。如果你查看 ❽,在程序末尾附近,你会看到我们用 ARGV 作为文件参数实例化了一个新的 ShufflePlayer。
一旦创建了 ShufflePlayer,我们希望它以随机顺序播放文件。我们通过定义在 ❺ 的 play 方法来实现这一点。在 ShufflePlayer 中,@files 是一个包含文件名的数组。我们扩展了数组类,为所有数组添加了 shuffle 方法。因此,@files 可以对自己调用 shuffle 方法。由于它是一个公共方法,其他对象也可以对数组调用 shuffle。这就是 ShufflePlayer 在我们的例子中所做的。由于 shuffle 方法的返回值也是一个数组,它也可以调用数组的所有方法,包括再次调用 shuffle。然而,我们不会重新洗牌,而是调用一个名为 each 的方法,它接受一个描述如何对数组中的每个元素进行操作的块。
我们用大括号来划分块,对吧?有时候。我们可以像这样实现我们的 play 方法:
def play()
@files.shuffle.each { |file| play_file(file) }
end
然而,我选择这样做是为了展示你可以在代码中使用块的不同方式。块可以以 { 开始并以 } 结束,或者以 do 开始并以 end 结束。不同的 Ruby 程序员对如何最好地表示块有不同的看法,但似乎约定是,大括号分隔符更适合单行块,而 do 和 end 分隔符更适合多行块。这就是我在这本书中使用的约定,也是我在个人代码中使用的约定。然而,Ruby 本身并不真正关心这一点。
注意
划分块的不同方式有不同的优先级,对于那些好奇的人来说。这意味着 Ruby 会先评估用 { 和 } 划分的块,然后再评估用 do 和 end 划分的块。这与它们常用的方式非常吻合。
注意,当我们调用 each 时,在两个管道字符中有单词 file。喜欢美式足球的 Ruby 程序员有时称这为 goalpost。goalpost 只是告诉 each 方法中的代码每个元素在块中应该被称为什么。从概念上讲,它类似于方法的一个参数,在本书的后面,我们将进一步模糊这个区别。在这种情况下,我们要求 ShufflePlayer 遍历 @files 的每个元素,将该元素称为 file,并调用一个名为 play_file 的方法,该方法接受 file 作为参数。
由于我们从不需要在外部调用 play_file,我们可以将其设为一个私有方法,如❻所示。play_file 所做的只是接受一个名为 file 的参数,并使用 system 方法在❻处调用,以便使用 ogg123 程序播放该文件参数。正如你可能猜到的,system 超出了 Ruby 的范围,并请求操作系统执行某些操作——比如播放音频文件。
注意
我有很多 Ogg Vorbis 音频格式的文件,所以我使用 ogg123 程序来播放它们。你当然可以用 mpg321 或任何其他命令行音频播放器替换 ogg123。
play_file 方法当然做了一些假设。它假设它被要求播放的每个文件都可以用 ogg123 播放。它假设像 ogg123 some_file_name 这样的命令可以被操作系统理解。最明显的是,它假设在运行此程序的计算机上有一个名为 ogg123 的程序。我编写这个程序是为了在我的工作电脑上播放音频文件,在那里我可以安全地做出这些假设。这使得程序变得更短,因为它不必担心检查 ogg123 的存在等问题。
运行脚本
你可以通过 ruby -w shuffle_play.rb some_ogg_files 或 ./shuffle_play.rb some_ogg_files 来运行这个脚本。
结果
现在我已经解释了我们的脚本,让我们来试一试。这些示例是在 Linux 的 bash shell 中,并使用 shuffle_play.rb 的长版本。你将看到的特定输出将严重取决于你选择的要播放的文件(如运行脚本中所示,用 some_ogg_files 表示)。由于随机排序是伪随机的,连续运行的结果也可能不同,即使在同一组文件上。
$ ./shuffle_play.rb ~/Documents/Audio/Music/Rock/King_Crimson/Discipline/*.ogg
Audio Device: OSS audio driver output
Playing: /home/kevinb/Documents/Audio/Music/Rock/King_Crimson/Discipline/03-Matte_Kudasai.ogg
Ogg Vorbis stream: 2 channel, 44100 Hz
Title: Matte Kudasai
Artist: King Crimson
Album: Discipline
Date: 1981
Track number: 03
Tracktotal: 07
Genre: Prog Rock
Comment: Belew, Fripp, Levin, Bruford
Comment: Belew, Fripp, Levin, Bruford
Copyright 1981 EG Records, Ltd.
Musicbrainz_albumid:
Musicbrainz_albumartistid:
Musicbrainz_artistid:
Musicbrainz_trackid:
Time: 00:05.74 [03:43.52] of 03:49.25 (164.5 kbps) Output Buffer 96.9%
或者在其他目录下:
$ ./shuffle_play.rb ~/Documents/Audio/Music/Jazz/The_Respect_Sextet/
The_Full_Respect/*.ogg
Audio Device: OSS audio driver output
Playing: /home/kevinb/Documents/Audio/Music/Jazz/The_Respect_Sextet/
The_Full_Respect/08-Carrion_Luggage.ogg
Ogg Vorbis stream: 2 channel, 44100 Hz
Title: Carrion Luggage
Artist: The Respect Sextet
Album: The Full Respect
Date: 2003
Track number: 08
Tracktotal: 18
Genre: Jazz
Composer: Red Wierenga
Copyright 2003 Roister Records
License: http://respectsextet.com/
Time: 00:20.64 [05:15.00] of 05:35.64 (151.4 kbps) Output Buffer 96.9%
在这些示例中,bash shell 在到达 Ruby 之前会先展开 *.ogg 文件名。所有这些文件都是我们 ShufflePlayer 的参数,然后它会以随机顺序播放它们,这意味着一旦处理完一个文件,它会继续播放其他所有文件,而不会重复任何文件。我们将在本书后面的章节中探讨为广播电台使用设计的两个程序中另一种音频文件的随机播放方法。
修改脚本
偶然的是,如果你对更短的程序感兴趣,整个程序可以被这两行代码替换:
#!/usr/bin/env ruby
ARGV.sort_by { rand }.each { |f| system("ogg123 #{f}") }
如果你总是将程序作为 Ruby 解释器的参数调用,那么你可以只保留第二行。
ruby short_shuffle.rb *`some_file.ogg`*
然而,我认为以牺牲清晰度为代价的极端简洁性并不是一个值得追求的目标,而且在这本书中,我不会朝着这个方向编写代码。简洁性对于一本旨在教授人们编程知识的书来说尤其不合适,除非是为了展示相同功能的替代格式,就像这里所做的那样。
章节总结
本章有什么新内容?
-
日期
-
常量与魔法数字
-
使用
||操作符的表达式 -
ENV[‘HOME’] -
使用
File.new进行外部文件访问,包括读取和写入 -
将字符串拆分为数组
-
使用
puts打印 -
生成(伪)随机数
-
在命令行中运行 Ruby 程序
-
定义和实例化新的类
-
实例变量:
@i_am_an_instance_variable -
Ruby 方法命名约定:predicate?,destructive!
-
RDoc 简介
-
字符串内的表达式插值:
“#{interpolate_me}” -
三元运算符:(
expression ? if_true : if_false) -
访问控制
-
开放类
-
使用
ARGV -
使用带有代码块的
each方法 -
system方法
这是一大堆非平凡的信息。如果你是相对编程新手,已经走到这一步,并且对到目前为止的内容感到相当舒适,你已经取得了显著和值得赞扬的成就。恭喜你。如果你是老手,希望这一章能给你一个很好的想法,了解 Ruby 是如何处理你在其他语言中已经做过的某些事情的。
第三章:程序员工具

本章主要介绍一些揭示 Ruby 更多特性的工具,使程序员的工作既容易又有趣。我们将回顾本书早期简要提到的几个主题,这次将给予它们更多的关注。
#5 什么是真理?(boolean_golf.rb)
在第一章中,我们讨论了各种语言如何将数据从一种类型转换为另一种类型。你可能还记得这个过程被称为类型转换,Ruby 通常要求程序员显式地进行这种转换,而一些其他语言提供了简化的方法来实现隐式类型转换。
在 Ruby 中,这一政策的唯一一个主要例外是布尔类型,它要么为真,要么为假。然而,我们之前提到,你也可以使用to_b方法,这使得 Ruby 中的数据转换完全一致,因为它总是显式的。下面程序中的to_b方法的概念将会有所变化,我们将其称为boolean_golf.rb。这个名字受到了 Perl 社区中一种实践的启发,即程序员尝试用尽可能少的按键完成给定的任务——就像打高尔夫一样评分。这个脚本尽可能地简洁地完成其任务,而不至于过于简略。
代码
#!/usr/bin/env ruby
# boolean_golf.rb
=begin rdoc
This is intended merely to add handy true? and false? methods to every
object. The most succinct way seemed to be declaring these particular
methods in this order. Note that to_b (to Boolean) is an alias to the
true?() method.
=end
class Object *Superclasses*
❶ def false?()
not self
end
❷ def true?()
not false?
end
❸ alias :to_b :true? *Metaprogramming; Symbols*
end
工作原理
这个程序利用了 Ruby 对开放类的支持,并为 Object 类添加了新的行为。Object 是那些熟悉面向对象的老手所称呼的超类。超类是其他类的祖先。在 Ruby 中,Object 恰好是终极超类,因为它是一切其他 Ruby 类祖先。这种地位意味着你添加到 Object 中的方法将在之后的任何时间对任何类型的变量都可用。这非常强大,正如你所期望的。
我们添加的方法是之前已经讨论过的显式转换为布尔类型的方法。当我们引入这个概念时,我们假设的方法叫做to_b。上面的程序中包含了这个方法,但通过迂回的方式访问它。程序中定义的第一个方法(在❶处)是false?。记住,返回布尔值的方法被称为谓词,Ruby 遵循 Lisp 的传统,将谓词的名称以问号结尾。false?方法在 Ruby 内部使用隐式布尔转换——它只是使用not运算符强制调用对象进行隐式布尔测试,这也会反转布尔值。因此,false?是to_b的反义词。
让我们在 irb 中展示这一点:
$ irb -r boolean_golf.rb
irb(main):001:0> true.to_b
=> true
irb(main):002:0> false.to_b
=> false
irb(main):003:0> nil.to_b
=> false
irb(main):004:0> true.false?
=> false
irb(main):005:0> false.false?
=> true
irb(main):006:0> nil.false?
=> true
你可以看到to_b方法报告其调用对象是否被 Ruby 认为是true。false?方法做相反的操作——当 Ruby 认为调用对象是true时返回false,当 Ruby 认为调用对象是false时返回true。你也可以尝试在其他对象上调用这些方法,以及在这些值和任何其他值上调用true?方法(❷)。你会发现true?返回与to_b方法相同的值。这个程序以与false?类似的方式定义了true?,除了不反转self,它反转了false?的输出。
true?和false?方法看起来很熟悉,因为它们是以通常的方式定义的。在❸处,我们以不同的方式定义了to_b。Ruby 给我们提供了进行所谓的元编程的选项,这允许我们在定义对象的过程中操纵它们。在这种情况下,我们将to_b定义为刚刚创建的true?方法的别名。代码相当易于阅读,不是吗?你可能好奇为什么我们在方法名前加冒号。在这个用法中,alias, :true?和:to_b是 Symbol 类的实例,它们前面有冒号。我们将在后面的章节中讨论 Symbol。目前,只需记住我们使用关键字alias,新名称的 Symbol 版本(以冒号开头),以及旧名称的 Symbol 版本(以冒号开头),按此顺序定义别名。我们将在现有的 irb 会话中展示这一点。
注意
元编程是编写创建或操作其他程序的通用术语。在我们的情况下,我们编写了一个操作自己的程序,这在概念上可能有点奇怪。然而,它非常强大,并且在 Rails 中被广泛使用。技术上,编译器或解释器是元编程的一个例子,因为它允许你用高级语言(如 Ruby)编写简短的程序,在底层(通常是 C 语言)创建程序,然后执行。本书中另一种不同类型的元编程的例子是一个名为methinks_meta.rb*的脚本,我们将在第 168 页的第三十五章 将字符串变为狐狸(methinks.rb)")中看到。
操纵脚本
在这个 irb 会话中,我们只是用繁琐的名称make_me_into_an_integer为to_i创建了一个不太有用的别名。然而,它很好地展示了如何定义别名。我们完成了几个任务。我们向 Ruby 中的每一个对象添加了新方法。这些方法允许我们对布尔类型转换进行完全的严谨处理——换句话说,我们现在有了将值显式转换为布尔值的方法。在这样做的同时,我们刷新了我们对方法命名约定的知识,也了解了一些关于别名和元编程的知识。
irb(main):007:0> class String
irb(main):008:1> alias :make_me_into_an_integer :to_i
irb(main):009:1> end
=> nil
irb(main):010:0> '5'.make_me_into_an_integer
=> 5
运行脚本
最简单的方法是使用 irb。
$ irb -r boolean_golf.rb
irb(main):001:0> true.true?
=> true
irb(main):002:0> true.false?
=> false
irb(main):003:0> nil.false?
=> true
结果
这个库文件只返回 true 或 false,如上所示。
#6 创建列表(array_join.rb)
在之前的脚本中,我们添加了新方法,允许在 Ruby 中的每个对象上进行显式的布尔类型转换。在这个例子中,我们创建了一个新方法,它是现有方法的一个微小变化。在这种情况下,我们正在改变数组表示自身为字符串的方式。
当我们用自然语言谈论列表时,我们经常用单词 and 将最后一个项与它前面的项分开。这不是 Ruby 默认处理数组的方式。让我们在 irb 中验证这一点:
irb(main):001:0> a = [0, 1, 2]
=> [0, 1, 2]
irb(main):002:0> a.join(' ') *The **`join`** Method*
=> "0 1 2"
irb(main):003:0> a.join(', ')
=> "0, 1, 2"
irb(main):004:0> a.join('')
=> "012"
我们正在创建 join 方法的变体,这个方法对所有数组都可用,我们在 irb 会话中看到了它的行为。它将数组的项连接起来形成一个字符串,每个项之间用 join 方法的参数分隔,但不在第一个项之前或最后一个项之后。这就是 join 的行为。我们如何创建自己的 join 方法,在最后一个项之前添加字符串 and?下面是如何做的。
代码
#!/usr/bin/env ruby
# array_join.rb
class Array
❶ def my_join(separator1=', ', separator2=' and ')
modified_join(separator1, separator2)
end
❷ protected *Protected Methods*
❸ def modified_join!(separator1, separator2)
last_one = self.pop() *The **`pop`** Method*
join(separator1) + separator2 + last_one.to_s
end
❹ def modified_join(separator1, separator2)
self.dup.modified_join!(separator1, separator2) *The **`dup`** Method*
end
end
它是如何工作的
在我们对 Array 的公开类修改中,我们在❶处定义了一个名为 my_join 的新方法,它接受两个分隔符参数。它调用另一个名为 modified_join 的方法,无论我们的两个分隔符参数是什么。
modified_join 方法还没有被定义,并且不需要在 my_join 方法之外被调用。你可能认为它可以是 private 方法,因此你可能会期望在方法定义之前看到单词 private。相反,在❷处,你看到的是单词 protected。为什么它不能只是 private 呢?我们很快就会找到答案。
modified_join 方法在❹处简单地定义为在调用对象的副本上调用新的破坏性方法 modified_join!。我们通过使用 dup 方法简单地获取调用对象的副本。我们在❸处定义了破坏性方法 modified_join!。它接受两个分隔符参数,就像我们在这个程序中的所有新方法一样。它定义了一个新的局部变量 last_one,它是调用自身 pop 方法的对象的值。Pop 是许多语言中从数组中移除最后一个项目的标准术语。以下是一个 pop 操作的示例,继续我们现有的 irb 会话:
irb(main):005:0> a
=> [0, 1, 2]
irb(main):006:0> a.pop
=> 2
irb(main):007:0> a
=> [0, 1]
irb(main):008:0> a.pop
=> 1
irb(main):009:0> a
=> [0]
irb(main):010:0> a.pop
=> 0
irb(main):011:0> a
=> []
你可以看到,当数组 a 调用自身的 pop 方法时,它会被修改。你可能会问,为什么这个方法不叫 pop!,因为它具有破坏性?这是一个好问题。答案是惯例——pop 是从先于 Ruby 的语言中继承来的这个操作的既定术语。如果你觉得这个惯例让你感到困扰,只需记住 Ruby 有祖先,就像真正的人类语言一样。想想英语的拼写规则。在事后看来,它们几乎没有意义,但当你意识到英语是一千年前的诺曼士兵试图勾搭撒克逊酒吧女招待的产物时,它们就变得完全合理了。
Ruby 依赖于其祖先,类似于一种口语语言,考虑到破坏性方法使用感叹号命名的惯例与其他语言的先例之间的选择,Matz 决定让 Ruby 与其他语言友好相处。
现在我们将最后一个项目存储在一个名为 last_one 的单独变量中,由于 pop 是破坏性的,该项目在 pop 发生后已经从调用 Array 中移除。我们对原始版本 join 在最后一个项目之前所有项目的处理方式感到满意,因此我们可以直接对这些项目调用普通的 join。我们添加第二个分隔符,然后添加我们弹出并移除的最后一个项目,确保它是一个 String(因此愿意被连接),通过在它上面调用 to_s 方法来实现。
那么使用 protected 而不是 private 的所有这些讨论都是关于什么的呢?我们使用 protected 的原因是在(非破坏性的)modified_join 方法内部,我们的 Array 对象不会对自己调用(破坏性的)modified_join! 方法。相反,它会对自己的副本调用 modified_join!。它不再是同一个对象,并且副本不会允许另一个实例调用其 private 方法。那么我们该怎么办呢?是否应该有一种方式让 Array 能够调用一个对 Integer、String 或 Symbol 不可用的方法?确实存在这样的方式,这正是 protected 访问控制关键字的作用。下面是一些 irb 操作示例,展示了程序是如何工作的。
运行脚本
$ irb -r array_join.rb
irb(main):001:0> a = [0, 1, 2]
=> [0, 1, 2]
irb(main):002:0> a.join(', ')
=> "0, 1, 2"
irb(main):003:0> a.my_join(', ')
=> "0, 1 and 2"
irb(main):004:0>
操纵脚本
一旦你尝试过并且对此感到舒适,将 protected 改为 private 并再次尝试运行。它应该会失败,如下所示。
$ irb -r array_join.rb
irb(main):001:0> a = [0, 1, 2]
=> [0, 1, 2]
irb(main):002:0> a.join(', ')
=> "0, 1, 2"
irb(main):003:0> a.my_join(', ')
NoMethodError: private method 'modified_join!' called for [0, 1, 2]:Array
from ./array_join.rb:14:in 'modified_join'
from ./array_join.rb:7:in 'my_join'
from (irb):3
from :0
irb(main):004:0>
这个 private 方法错误是我们想要在这个程序中将非 public 方法设置为 protected 而不是 private 的原因。这应该给你一个基本的 Ruby 访问控制理解:public, private 和 protected。
#7 命令行界面(uses_cli.rb 和 simple_cli.rb)
程序 uses_cli.rb 理解命令行选项,这些是配置选项,您可以使用它们根据所选的具体值使脚本以不同的方式运行。它使用了一些已经变得相当标准的特定选项,例如 -h 或 --help。形式为单个连字符和一个字母的选项是 短选项,而形式为双连字符和完整单词的选项(不出所料)被称为 长选项。让我们看看 uses_cli.rb 的代码。
注意
我认为自己编写命令行解析器的教学价值足够高,使其变得值得,尤其是在像这样的书中。然而,我应该指出,Ruby 中有两个优秀的内置 CLI 解析器:GetOptLong(由 Motoyuki Kasahara 开发,www.sra.co.jp/people/m-kasahr/ruby/getoptlong)和 OptionParser(由 Nobu Nakada 开发,optionparser.rubyforge.org)。我仅提供这些网址作为信息参考;它们是 Ruby 标准库的一部分,因此您不需要下载它们。
代码
#!/usr/bin/env ruby
# use_cli.rb
=begin rdoc
Please refer to the SimpleCLI Class for documentation.
=end
❶ require 'simple_cli' *Require*
❷ cli = SimpleCLI.new()
cli.parse_opts(ARGV)
这里没有太多内容,脚本几乎没有提供任何信息,除了在❷处,它建议我们需要查看 SimpleCLI 类内部的文档。为什么会有重定向?对于这样一个直接的例子,这是一个合理的问题。计算机编程的圣杯是可重用代码的概念。有许多方法可以实现这一目标,但最持久成功的方法之一是拥有合理抽象的外部函数库,这就是我们例子中 simple_cli.rb 文件所扮演的角色。其他一些特定的文件可以使用这个库文件,就像我们在 uses_cli.rb 中的❶处使用 require 关键字一样,它接受一个 String 参数,该参数是外部文件的名字,不带 .rb 扩展名。这使得外部文件中的代码对需要它的文件可用——这类似于使用 -r 标志运行 irb。因此,在❷处,我们可以轻松实例化一个名为 cli 的 SimpleCLI 实例,并将 uses_cli.rb 中使用的所有命令行选项传递给它。
如果我们要了解 SimpleCLI 的工作原理,我们就必须查看其代码。注意,SimpleCLI 中的一些方法是 占位符,这意味着它们不做任何值得在生产代码中实现的事情,但它们展示了选项被适当地解析。如果您发现这个例子对您自己的代码作为脚手架或指南有用,您只需根据您的需求替换两种类型的选项及其具体实现即可。这些只是示例。在这里,我们实例化 SimpleCLI,然后使用 uses_cli.rb 中使用的每个命令行选项调用其 parse_opts 方法。让我们通过查看 simple_cli.rb 来看看这个方法做了什么。
注意
help 和 version 命令行选项已经相当标准化了,并且它们的包含通常受到欢迎*。
#!/usr/bin/env ruby
# simple_cli.rb
=begin rdoc
Parses command line options.
=end
class SimpleCLI
❶ # CONSTANTS
OPTIONS = { *Hashes*
:version => ['-v', '--version'],
:help => ['-h', '--help'],
:reset => ['-r', '--reset'],
}
❷ USAGE =<<END_OF_USAGE *Here Docs*
This program understands the following options:
-v, --version : displays the current version of the program
-h, --help : displays a message with usage instructions
-r, --reset : resets the program
With no command-line options, the program performs its default behavior.
END_OF_USAGE
VERSION = "Some Project version 0.01 (Pre-Alpha)\n"
# METHODS
❸ def parse_opts(args)
return option_by_args(args[0]) if understand_args?(args)
# options are not understandable, therefore display_usage
display(USAGE)
end
❹ private
❺ def display(content)
puts content
end
def do_default()
puts 'I am performing my default behavior'
end
❻ def option_by_args(arg)
return display(VERSION) if OPTIONS[:version].include?(arg)
return display(USAGE) if OPTIONS[:help].include?(arg)
return reset() if OPTIONS[:reset].include?(arg)
do_default()
end
def reset()
puts 'I am resetting myself.'
end
❼ def understand_args?(args)
# works in Ruby1.8
OPTIONS.keys.any? { |key| OPTIONS[key].include?(args[0]) } *The **`any?`** Method*
❽ =begin works in Ruby1.6
return true unless args
return true unless args[0]
return true if args[0].size.zero?
OPTIONS.keys.each do |key|
return true if OPTIONS[key].include?(args[0]) *The **`include?`** Method*
end
return false
=end
end
end
它是如何工作的
这个文件,simple_cli.rb,是SimpleCLI类的基本定义,当然,在类定义之前有 RDoc,以及一些有用的常量立即在❶处。我们之前见过常量,但我们是在类定义内部声明这些常量的。这实际上是 Ruby 中使用常量的首选方式。你经常想要在对象内部封装方法,常量也是如此。你用于某些物理计算的代码关心光速,而你的工资通知程序关心支付周期中的天数。在我们的情况下,命令行解析器关心它可以理解哪些OPTIONS以及它应该报告的USAGE消息。
OPTIONS常量是一种新的数据结构,称为哈希表。哈希表是查找表,在某种程度上与函数相似。你将某个东西传递给哈希表,然后从它那里得到一个东西。除非你改变传递给哈希表的内容,或者改变哈希表的内部结构,否则这个东西永远不会改变。正如你所看到的,你用大括号声明哈希表。=>左侧的项目是哈希表的键,而=>右侧的项目是哈希表的值。如果你传递一个键,哈希表将返回匹配的值。
注意
请注意,你收到的可能是一个复合数据类型。例如,在我们的OPTIONS哈希中,你收到的值是数组。关键是,对于给定的输入值,你总是会收到相同的数组。
让我们在 irb 中演示一下。在类中引用常量的语法是Class::CONSTANT,所以我们就这么做。记住,数组[“-v”, “--version”]是SimpleCLI::OPTIONS类与键:version关联的值。这意味着如果你传入符号:version,你会收到数组[“-v”, “--version”]。
$ irb -r simple_cli.rb
irb(main):001:0> SimpleCLI::OPTIONS[:version]
=> ["-v", "--version"]
irb(main):002:0> SimpleCLI::OPTIONS[:help]
=> ["-h", "--help"]
irb(main):003:0> SimpleCLI::OPTIONS[:reset]
=> ["-r", "--reset"]
它是有效的。如果你在 irb 中比较我们的结果与代码中哈希表的声明,你不应该对我们得到的结果感到惊讶。哈希表是至关重要的数据结构。我特别喜欢将它们定义为类中的常量,所以你会在本书的整个过程中看到这种做法被反复使用。
注意
我经常在类中定义常量,这些常量是哈希表,有几个原因。它们在类中,因为它们需要在该类内部可访问,但不能在外部访问。它们通常是哈希表的原因是,我经常发现(无论什么原因)简单的查找表是有用的数据结构。在你阅读了一些函数式编程信息之后,你可能会发现将 lambda 和 Proc 定义为类常量也很有趣。我发现我经常这么做。
在第❷处声明的USAGE常量看起来有点奇怪,等号后面跟着两个左箭头。然而,这是一个非常有用的多行文本工具,称为here doc。使用here doc声明,程序员可以说一个表达式应该跨越多行,直到达到一个特定的标记——在这种情况下是END_OF_USAGE。这对于大量需要使用多个print或puts语句构建的逐字文本来说非常方便。
接下来是一个更直接的常量,称为VERSION,它是一个常规的字符串。它的定义使用双引号字符,因为我们想在末尾有一个换行符(由\n表示)。接下来的两个语句会打印相同的内容;\n只是将换行符包含在字符串中的方式。
puts 'Some Project version 0.01 (Pre-Alpha)'
print "Some Project version 0.01 (Pre-Alpha)\n"
我们有了常量,那么让我们继续到我们的方法。主要的方法(实际上,唯一公开的方法)是parse_opts,它在❸处定义。它解析选项,并且在这个点上它的实现应该是相当易读的。如果它理解args,它将返回调用option_by_args方法的结果,否则将把它的USAGE消息传递给display方法。我喜欢那些告诉你它们应该做什么的方法名。如果你关心细节,你可以查看内部以了解更多信息,但名称应该提供你所需的基本信息。
除了parse_opts方法之外,我们所有的方法都是private(❹),因为它们只需要由SimpleCLI实例在其自身上调用。从❺开始的display, do_default和reset方法应该对你来说相当直观。这些是你会在实际生产代码中更改以执行更有用操作的方法。类的主要逻辑发生在剩余的方法option_by_arg(❻)和understand_args?(❼)中。我们知道understand_args?是一个谓词,因为它名字的结尾有一个问号,所以它将返回true或false。
option_by_args方法检查OPTIONS常量的每个键,如果找到匹配项,则返回适当的操作。这意味着它找到匹配项后不会继续检查键,因此键的顺序很重要。它使用一个名为include?的数组谓词方法来检查匹配项,该方法如果数组中找到参数则返回true,如果没有则返回false。这使得拥有像-v和--version这样的命令行别名变得非常容易,因为它们都会导致include?返回true。如果option_by_args没有找到匹配项,它将执行其默认行为。
所有这一切的关键在于SimpleCLI的实例是否理解它的参数。在 Ruby1.8 中,这本书假设你正在使用,你可以使用另一个名为any?的谓词方法来轻松确定这个问题。它接受一个块,如果调用对象(通常是数组)的任何元素满足该块中的内容为true,则返回true。让我们在 irb 中演示一下:
$ irb
irb(main):001:0> a = [0, 1, 2]
=> [0, 1, 2]
irb(main):002:0> a.any? { |i| i > 1 }
=> true
irb(main):003:0> a.any? { |i| i > 2 }
=> false
在我们的情况下,我们检查从OPTIONS哈希返回的数组值是否包含understand_args?方法的第一个参数,对于OPTIONS哈希的任何键。正如你所看到的,哈希有一个名为keys的方法,它返回所有键作为一个单一的数组。如果我们的any?测试返回true,这意味着SimpleCLI知道如何对其收到的参数做出反应。这个设置的好处是,为了使SimpleCLI理解更多的选项,我们只需向OPTIONS哈希中添加更多数据。understand_args?方法不需要改变,只需改变它的输入。程序员称之为数据驱动编程,并且通常对这种做法评价很高。
那就是我们的命令行解析示例。让我们使用显示的选项运行它。就像在 irb 中一样,我会显示输出。
运行脚本
$ ./uses_cli.rb -r
I am resetting myself.
$ ./uses_cli.rb -v
Some Project version 0.01 (Pre-Alpha)
$ ./uses_cli.rb -h
This program understands the following options:
-v, --version : displays the current version of the program
-h, --help : displays a message with usage instructions
-r, --reset : resets the program
With no command-line options, the program performs its default behavior.
$ ./uses_cli.rb
I am performing my default behavior
$ ./uses_cli.rb --reset
I am resetting myself.
$ ./uses_cli.rb --version
Some Project version 0.01 (Pre-Alpha)
$ ./uses_cli.rb --help
This program understands the following options:
-v, --version : displays the current version of the program
-h, --help : displays a message with usage instructions
-r, --reset : resets the program
With no command-line options, the program performs its default behavior.
修改脚本
我提到了 Ruby1.8,它提供了any?方法。我在编写这本书的时候使用的一台机器只有 Ruby1.6。我在修改后的 RDoc 部分中包含了一些替代代码,以展示any?对我们来说是多么方便。正如你所看到的,RDoc 可以用于其他事情,而不仅仅是最终注释。
#8 回文(palindrome.rb 和 palindrome2.rb)
我用几个关于回文的简短例子来结束这一章,回文是指当文字反转时与正常阅读时相同的文本片段。通常,我们允许作弊,忽略空格、大小写差异和标点符号,所以 A man, a plan, a canal, Panama 在这些条件下可以算作一个回文。在撰写这本书的过程中,我读到了另一本关于回文的编程书。“太好了!”我想。“我将为所有字符串添加一个palindrome?谓词方法。这将是一个很好的简单内容,可以放在我讨论文本的章节中。”所以我开始思考如何将字符串分解成单个字符,编写一个方法来比较字符串两端等距离的字符,以及在其他语言中需要做的所有其他事情。然后我意识到在 Ruby 中实现这个方法是多么容易。
代码
class String
def palindrome?()
(self == self.reverse) *The **`reverse`** Method*
end
end
它是如何工作的
就这样。这样一个简单的解决方案一直就在我面前。字符串可以reverse自己,回文字符串的定义就是它反转后与自身相同。这就是我意识到这个例子应该放在这一章的原因,因为这个任务的相对简单性和它对程序员能够自己编写库的启示。
虽然做起来很简单,但这种回文的版本并不完全令人满意。一方面,它不适用于我们的示例句子。我们需要一个更复杂的 palindrome? 断言版本。这里就是它。我将 操纵脚本 子节提前放在本节中,因为我用它来演示 运行脚本 和 结果 子节中的某些想法,希望这会变得清晰。
操纵脚本
palindrome2.rb 文件稍微复杂一些,但正如你所见,与一些其他语言相比,它在 Ruby 中仍然相当简单。
#!/usr/bin/env ruby
# palindrome2.rb
=begin rdoc
Gives every <b>String</b> the ability to identify whether it is a
a palindrome. This version ignores all non-alphabetic characters,
making it suitable for longer text items.
=end
class String
❶ DUAL_CASE_ALPHABET = ('a'..'z').to_a + ('A'..'Z').to_a
=begin rdoc
Contrast this with some other languages, involving iterating through each
string index and comparing with the same index from the opposite end.
Takes 1 optional Boolean, which indicates whether case matters.
Assumed to be true.
=end
❷ def palindrome?(case_matters=true)
letters_only(case_matters) == letters_only(case_matters).reverse
end
private
=begin rdoc
Takes 1 optional Boolean, which indicates whether case matters.
Assumed to be false.
=end
❸ def letters_only(case_matters=false)
just_letters = split('').find_all do |char| *The **`find_all`** Method*
DUAL_CASE_ALPHABET.include?(char)
end.join('')
return just_letters if (case_matters)
return just_letters.downcase
end
end
这个文件有 shebang,告诉我们它应该在 Ruby 中运行,即使它是一个库文件,而不是一个将被直接执行的文件。为什么是这样?主要原因是我们不希望 bash 尝试解释 RDoc。有了 shebang,如果它意外地在命令行中执行,它将自动由 Ruby 运行。如果你特别偏执,你还可以将第一行添加到 palindrome.rb 中。
注意
Shebang 是 Unix 极客对 #! 的标准发音,通常在脚本的开始处可以看到。
在这个程序中,我们希望能够测试回文,这样我们就可以忽略所有非字母字符,并且如果我们选择的话,还有能力忽略大小写。这很容易做到。我们新的 String 有一个名为 letters_only 的私有方法,它做你期望它做的事情:它编译出一个新的 String,只包含那些通过 DUAL_CASE_ALPHABET.include? 的字符,其中 DUAL_CASE_ALPHABET (❶) 是一个包含所有字母(大小写)的数组。如果它接收一个 case_matters 参数,其值为 true,则返回这些字母的原样,否则返回这些字母的全小写版本,我们通过 downcase 方法实现这一点。split 方法将一个 String 分割成块(在这种情况下,每个字符),而 join 方法则用分隔符将它们重新组合起来,在这个例子中,分隔符是空字符串。
在❸处的 letters_only 方法足够方便,以至于在我们的 palindrome? 断言(❷)中,我们只需要比较其输出与输出的反转,我们就有了更灵活的回文检测器。让我们看看它是如何工作的。
运行脚本
我编写了一个名为 test_palidrome.rb 的测试程序,它存放在一个名为 tests/ 的单独目录中。以下是该文件内容,以及我运行它的 bash 会话。
#!/usr/bin/env ruby
# test_palindrome.rb
puts "Band\tPal?\tpal?"
bands = %w[abba Abba asia Asia]
bands.each do |band|
puts "#{band}\t#{band.palindrome?}\t#{band.palindrome?(false)}"
end
结果
$ ruby -r palindrome2.rb tests/test_palindrome.rb
Band Pal? pal?
abba true true
Abba false true
asia false false
Asia false false
我开始思考那些既以字母 A 开头又以字母 A 结尾的音乐团体。我没有走得很远——但足以演示程序。请注意,我们在命令行中 require palindrome2.rb,而不是在 test_palindrome.rb 内部使用显式的 require 关键字。当然,我们也可以在 irb 中进行测试。
$ irb -r palindrome2.rb
irb(main):001:0> 'Ika Yaki'.palindrome?
=> false
irb(main):002:0> 'Ika Yaki'.palindrome?(false)
=> true
irb(main):003:0> 'ika yaki'.palindrome?
=> true
我们看到,根据我们告诉 palindrome? 断言使用的参数,日本烤鱿鱼(Ika Yaki)要么被正确地识别为回文,要么不是。这些与字符串相关的操作应该能让我们为下一章更详细地处理文本做好准备。在此之前,我们应该回顾一下本章的新内容。
注意
如果你尝试 ruby -r palindrome.rb tests/test_palindrome.rb,测试脚本将会失败。你能找出原因吗?原因与参数有关。
章节摘要
本章有什么新内容?
-
创建新的断言以进行显式的布尔转换
-
方法别名
-
超类
-
元编程
-
符号类
-
数组和
join方法 -
访问控制中的
protected级别 -
dup和pop方法 -
创建命令行界面标志
-
可重用代码的库文件
-
类常量
-
哈希类
-
哈希键和值
-
here doc声明 -
字符串中的换行符
-
使用
Array.include?来测试成员资格 -
any?断言 -
Hash.keys方法 -
Ruby1.8 与 Ruby1.6 以及
any?断言的比较 -
回文和反转字符串
-
从字符串中提取字母
-
改变字符串的大小写
这甚至比上一章还要复杂,上一章几乎是在手把手地教你。再次恭喜。让我们继续进入下一章对字符串的更复杂处理。
第四章:文本操作

文本是存储配置数据、网页内容、电子邮件以及 XML(可扩展标记语言)和 YAML(YAML 不是标记语言)等数据的常用格式,我们将在稍后更详细地探讨这些内容。对于一种编程语言来说,能够轻松高效地处理文本是很重要的。幸运的是,Ruby 满足了这一要求。本章包含几个脚本,展示了 Ruby 在处理一些常见文本问题上的方法。
#9 行尾转换 (dos2unix.rb)
如果你从未处理过操作系统之间的行尾(EOL)差异,那么你应该感到幸运。微软、苹果以及各种类 Unix 操作系统(如 BSD 和 GNU/Linux 系统)都对文本文件应该如何显示行尾有不同的看法。苹果从 Mac OS X 过渡到类 Unix 操作系统进一步复杂化了这个问题,它与 FreeBSD 非常相似。类 Unix 系统使用换行符(也称为新行)标记行尾;在阴极射线管(CRT)之前的接口中,这个字符表示纸张应该向上移动一行,以便有更多的空白纸张可以打印。另一方面,较老的 Macintosh 系统(在 Mac OS X 之前)使用回车符来标记行尾,这表示打印机应该回到左边开始重新打印(这假设你使用的是从左到右书写的语言,如英语)。Windows(和 DOS)系统,另一方面,使用回车符后跟换行符来标记行尾。
注意
一些互联网协议也使用 Windows 行尾约定,尽管它们通常托管在类 Unix 机器上。想想看。
为什么会有这种差异?有人可能会认为 Windows 的方法最有道理——如果我们模拟类似打字机的物理动作,那么需要一个回车符和一个换行符。然而,类 Unix 和 Macintosh 的方法的好处是只使用一个字符。考虑到文本文档中新行出现的频率,这是一个重要的节省,而且在计算机的早期,RAM 和存储都比现在要有限和昂贵得多.^([9])
现在,大多数文本编辑器和类似程序都能轻松处理这些差异,因此行尾兼容性问题通常不会造成太大的麻烦。但为什么非得忍受这种麻烦呢?我们可以编写一个 Ruby 程序,将 DOS 或旧式 Mac 行尾转换为 Unix 行尾。
代码
#!/usr/bin/env ruby
# dos2unix.rb
# converts line feeds from DOS (or old-style Mac) to Unix format
❶ ARGV.each do |filename|
contents_file = File.open(filename, 'r')
❷ contents = contents_file.read()
contents_file.close()
❸ contents.gsub!(/\r\n?/, "\n") *Regular Expressions*
replace_file = File.new(filename, 'w+')
❹ replace_file.puts(contents)
replace_file.close()
end
工作原理
从这个程序的名字和目的来看,我的 Unix 倾向是显而易见的。让我们看看它做了什么。在❶处,我们开始遍历脚本的参数,依次调用每个filename。我们打开和关闭这个参数(目前称为filename),就像之前做的那样,将其内容读入名为contents的变量中。我们在❸处用gsub!做了一些魔法,然后将contents写入一个名为replace_file的新文件中。❸处的魔法是什么?让我们再看一遍。
contents.gsub!(/\r\n?/, "\n")
我们在我们的contents字符串上调用了一个名为gsub!的方法。我们知道gsub!(代表全局替换)是一个破坏性方法,因为它以感叹号结尾,看起来它接受两个参数。第一个参数被正则斜杠包围,第二个参数是一个换行字符串。第一个参数是一个正则表达式,这是一种特殊类型的变量,可以在不知道文本所有内容的情况下描述文本的内容。正则表达式(简称regexes)允许你测试条件,例如这个文本是否完全由数字组成?,这在使用字符串的to_i方法之前可能很有用。正则表达式还允许测试,例如文本中是否有恰好七个单词?或这个文本中的所有单词是否都以大写字母开头?,以及许多其他情况。
正则表达式通过定义字符、分组以及这些字符出现的次数的描述符来完成任务。正如你在代码中所看到的,正则表达式是用斜杠界定的。使用斜杠的做法并不仅限于 Ruby;在其他语言中也很常见。正则表达式中的问号并不代表文本中出现的字面问号;相反,它表示它之前的内容是可选的,出现零次或多次。让我们在 irb 中尝试一些正则表达式。我们将使用一个名为=~的新操作符,它类似于==。不过,它不是测试精确相等,而是测试正则表达式是否与我们所调用的字符串的任何部分匹配。如果正则表达式所代表的问题(即这个文本是否完全由数字组成?)对于该字符串为真,它返回匹配发生的第一个点;如果没有匹配,它返回nil。
irb(main):001:0> letters = 'abcde'
=> "abcde"
irb(main):002:0> letters =~ /a/
=> 0
irb(main):003:0> letters =~ /b/
=> 1
irb(main):004:0> letters =~ /e/
=> 4
irb(main):005:0> letters =~ /x/
=> nil
我们有字符串letters,它只是字母表的前五个字母。然后我们测试字母a是否出现在letters中的任何位置。它确实出现了,就在开头,所以我们的测试返回零。为什么?因为这是字符串中第一次匹配发生的索引——记住我们是从零开始计数的,而不是从一。由于下一个字母是b,当我们测试b在letters中的存在时,我们应该得到一个比测试a时高一个的结果。我们确实得到了。跳到字母e,我们在最后一个索引处有一个匹配,这是第五个字母,索引为四,同样是因为我们是从零开始计数的。当我们测试一个不在letters中出现的字母时,我们得到返回值nil。
这是一种简单的匹配。现在让我们使用那个问号。
irb(main):006:0> letters =~ /aa?/
=> 0
irb(main):007:0> letters =~ /ax?/
=> 0
起初,第六行看起来与第二行相似。第七行更有趣,因为可选的第二个字母是一个在letters中根本不出现的新的字母。在这两种情况下,第二个字母前面都有一个问号,这使得它是可选的。在第六行,我们是在问我们的字符串(由前五个字母组成)是否有一个a后面跟着零个或多个a。它确实有,从索引零开始,所以这就是我们的返回值。然后我们问我们的字符串是否有一个a后面跟着零个或多个x。它确实有,从索引零开始。让我们继续。
irb(main):008:0> letters =~ /ab?/
=> 0
irb(main):009:0> letters =~ /bc?/
=> 1
irb(main):010:0> letters =~ /b?/
=> 0
第八行询问letters是否有一个a后面跟着任何可选的b,它在索引零处确实有。第九行询问letters是否有一个b后面跟着任何可选的c,它在索引一处确实有。第十行询问letters是否有一个可选的b,它在索引零处确实有。教训很明确——匹配可选字符非常热情,一个字符的完全缺失匹配零个或多个任何字符的出现。在使用问号时,特别是作为一个由破坏性方法使用的正则表达式参数时,要非常小心。这里是一个匹配零个字符出现的另一个示例:
irb(main):011:0> letters =~ //
=> 0
在letters的开头没有内容。匹配空内容在概念上很奇怪,但当你想要将字符串分割成每个字符的数组时,它非常有用。你可能记得我们在palindrome2.rb脚本中使用了split方法匹配空字符串(第三章),逐个处理字符串中的每个字母。
现在我们已经完成了匹配。我之前说过gsub代表全局替换,所以让我们再次进行一些替换,这次在 irb 中。
irb(main):012:0> letters.gsub(/a/, 'x') *The **`gsub`** Method*
=> "xbcde"
irb(main):013:0> letters.gsub(/ab?/, 'x')
=> "xcde"
irb(main):014:0> letters.gsub(/ac?/, 'x')
=> "xbcde"
你可以看到gsub找到与第一个参数匹配的字符串部分,并将第一个参数替换为第二个参数后返回结果。现在让我们回顾一下破坏性和非破坏性方法之间的区别,因为它们与这些替换相关。
irb(main):015:0> letters
=> "abcde"
irb(main):016:0> letters.gsub!(/ac?/, 'x')
=> "xbcde"
irb(main):017:0> letters
=> "xbcde"
非破坏性版本会保留原始的letters不变,正如你所期望的,而破坏性版本会对letters进行永久性更改。gsub!方法如果不能执行,也会返回nil,如下所示在 irb 中:
irb(main):001:0> foo = 'abcd'
=> "abcd"
irb(main):002:0> foo.gsub(/a/, 'b')
=> "bbcd"
irb(main):003:0> foo.gsub!(/a/, 'b')
=> "bbcd"
irb(main):004:0> foo.gsub(/a/, 'b')
=> "bbcd"
irb(main):005:0> foo.gsub!(/a/, 'b')
=> nil
这个插曲只是触及了正则表达式的表面——它们非常有用。我肯定会解释这本书中脚本中使用的特定正则表达式,但还有更多关于它们的知识可以学习。如果你想进一步探索正则表达式,一个极好的资源是 Jeffrey Friedl 的精通正则表达式(O’Reilly,2006)及其配套网站regex.info。这是关于正则表达式的权威文本。它有轻微的 Perl 倾向,尽管它的 Ruby 尊重似乎随着每一版的新增而增加。由于许多语言(包括 Ruby)中的正则表达式实现都受到 Perl 的启发,因此 Perl 特定的内容很容易转移到 Ruby 上,主要是因为这两种语言在处理正则表达式方面从一开始就非常相似。
所有这些与我们的脚本dos2unix.rb有什么关系?\r字符串代表回车字符——在较老的 Macintosh 系统中用来表示换行。\n字符串是换行字符,在类 Unix 系统中以及 Windows 系统中的回车之后用来表示换行。这个替换操作会找到所有回车字符的出现,以及任何可选的新行,并将它们替换为单个换行。
运行脚本
以ruby -w dos2unix.rb file_to_modify执行此命令。
结果
当我用我选择的文本编辑器(vim)查看我的样本文件extras/DOS_file.txt时,它看起来像这样:
I am a DOS file.^MI am a DOS file.
^M是我系统上 vim 显示\r字符的方式。在用ruby -w dos2unix.rb extras/DOS_file.txt运行脚本后,结果是
I am a DOS file.
I am a DOS file.
脚本黑客
如果你想转换到其他行结束格式怎么办?要将文件转换为 Windows EOL 格式,你可以在dos2unix.rb中的第❸行替换为以下行,这实际上意味着将所有回车或新行的出现替换为回车后跟新行。
contents.gsub!(/(\r|\n)/, "\r\n")
对于想要回到其预-OS X 行结束符的怀旧 Mac,你可以通过在第❸行替换以下行来将其转换为旧的 Apple 格式;这将替换所有可选的回车后跟强制换行,只留下回车。
contents.gsub!(/\r?\n/, "\r")
正则表达式中的括号与 Ruby 中的括号类似——它们表示一个应该被视为单个实体的分组。正则表达式中的管道字符(也称为垂直线)表示在其两侧之间的选择。
注意
由正则表达式中的括号分组在一起的表达式子集也会被捕获到特定的变量中,这取决于编程语言对正则表达式的实现。如果您对这个主题感兴趣,可以在 Friedl 的书中了解更多信息。
您还可以使用一行命令完成 DOS 到 Unix EOL 的转换:
ruby -pi -e 'gsub(/\r\n?/, "\n")' some_file
有时,一个快速而简单的解决方案就足够了。如果您对这个一行的实现感兴趣,可以查阅 Ruby 手册页(man ruby)了解更多关于 -p 标志(提供处理文件行的快捷方式)、-i 标志(指定文件就地编辑)和 -e 标志(指定应执行命令)的信息。
^([9]) 这也是为什么许多 Unix 命令如此简短的原因:rm 用于 删除,cp 用于 复制,等等。
#10 显示行号(line_num.rb)
在处理文本文件时,另一个有用的技巧是能够自动为它们添加行号。以下是一个执行此操作的脚本。
代码
#!/usr/bin/env ruby
# line_num.rb
❶ def get_lines(filename)
return File.open(filename, 'r').readlines
end
❷ def get_format(lines)
return "%0#{lines.size.to_s.size}d" ***`sprintf`** Formats*
end
❸ def get_output(lines)
format = get_format(lines)
❹ output = '' *The **`each_with_index`** and **`sprintf`** Methods*
❺ lines.each_with_index do |line,i|
❻ output += "#{sprintf(format, i+1)}: #{line}"
end
return output
end
print get_output(get_lines(ARGV[0]))
工作原理
到目前为止,get_lines 方法(❶)应该看起来很熟悉,因为我们已经在本书的早期部分介绍了一些非常类似的方法。另一方面,get_format 方法(❷)的行为略有不同。它返回一个格式为 “%0xd” 的单个字符串,其中 x 是 lines 数组的成员数量的字符串表示所占的字符数。让我们在 irb 中探索一下这些方法:
irb(main):001:0> def get_format(lines)
irb(main):002:1> return "%0#{lines.size.to_s.size}d"
irb(main):003:1> end
=> nil
irb(main):004:0> has10items = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
=> [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
irb(main):005:0> get_format(has10items)
=> "%02d"
irb(main):006:0> has100items = has10items * 10 *Multiplying Arrays*
=> [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4,
5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0,
1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6,
7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
irb(main):007:0> get_format(has100items)
=> "%03d"
您可以看到格式中的数字部分发生了变化;它始终等于数组大小的数字。顺便说一下,您还可以看到数组类是如何实现乘法的。一种方法是将数组中的每个成员乘以数组外的操作数,但这只有在数组的每个成员都知道如何与某物相乘时才有效。相反,数组会根据操作数的值重复自身。如果您乘以一个数组,您应该得到一个等效的数组。
irb(main):008:0> has10items * 1
=> [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
irb(main):009:0> (has10items * 1) == has10items
=> true
我们看到确实是这样的。
get_output 方法(❸)首先建立必要的 format 并将一个名为 output 的变量设置为空字符串。您可以猜测我们将在其上连接其他字符串。
我们用一个新的数组方法 each_with_index 在❺处这样做。这个方法与我们已经看到的 each 方法非常相似,只不过它还给了我们适当的索引号。我们将 lines 的给定元素命名为 line,并将索引号称为字母 i。然后我们使用一个新的名为 sprintf 的方法,该方法将数据格式化为字符串(❻)。它接受两个参数:第一个是要使用的格式,第二个是要格式化的数据。我们想使用 get_format 方法的输出来格式化索引号 i.^([10]) 这个操作的目的是为了计算我们将要显示的最大行号所需的数字位数(宽度),并按该宽度格式化每个行号。这种格式化确保了更美观的输出。
我们输出的每一行都由 sprintf 的输出、一个冒号、一个空格和原始行组成。所有这些都是在命令行的第一个参数上发生的。
运行脚本
你可以用 ruby -w line_num.rb some_file 运行,将 some_file 替换为你想要添加行号的文件。
结果
$ ruby -w line_num.rb line_num.rb
01: #!/usr/bin/env ruby
02: # line_num.rb
03:
04: def get_lines(filename)
05: return File.open(filename, 'r').readlines
06: end
07:
08: def get_format(lines)
09: return "%0#{lines.size.to_s.size}d"
10: end
11:
12: def get_output(lines)
13: format = get_format(lines)
14: output = ''
15: lines.each_with_index do |line,i|
16: output += "#{sprintf(format, i+1)}: #{line}"
17: end
18: return output
19: end
20:
21: print get_output(get_lines(ARGV[0]))
如果你的文本文件有一行 100 或更多,输出中冒号之前的部分将自动添加所需的所有字符,以适应其新的要求。这就是全部。
^([10]) 实际上,我们格式化 ``i` + 1 的值;我们希望将第一行编号为 1,但索引值是 0,因为计算机从 0 开始计数。
#11 文本换行(softwrap.rb)
有时候你可能有一个文本文件,你想对其执行空白压缩,例如将所有重复的空格转换为单个空格。下面的脚本假设所有双行断应该保留,而所有单行断应该转换为空格。每组重复的空格也应该转换为单个空格。让我们直接进入正题。
代码
#!/usr/bin/env ruby
# softwrap.rb
=begin rdoc
"Softwrap" a filename argument, preserving "\n\n"
between paragraphs but compressing "\n" and other
whitespace within each paragraph into a single space.
=end
❶ def softwrap(filename)
❷ File.open(filename, 'r').readlines.inject('') do |output,line| *The **`inject`** Method*
❸ output += softwrap_line(line)
❽ end.gsub(/\t+/, ' ').gsub(/ +/, ' ')
end # softwrap
=begin rdoc
Return "\n\n" if the <b>String</b> argument has no length after being
chomped (signifying that it was a blank line separating paragraphs),
otherwise return the chomped line with a trailing space for padding.
=end
❹ def softwrap_line(line)
❺ return "\n\n" if line == "\n"
❻ return line.chomp + ' '
end # softwrap_line
❼ puts softwrap(ARGV[0])
我们定义了一种名为 softwrap 的方法(❶),它接受一个 filename 参数,然后在脚本的第一个命令行参数上调用 softwrap。脚本随后在文件打开时调用 readlines 方法,就像我们已经多次做的那样。通常,就像在之前的脚本中一样,我们会将结果分配给一个包含行的数组。这次,我们调用一个新的名为 inject 的方法,你可以看到它接受一个参数(在我们的例子中是空字符串)和一个块;在这个过程中我们定义了两个变量(❷)。
在我们的例子中,我们称这两个变量为 output 和 line。line 这个名字足够熟悉。output 这个名字很合适,因为 inject 方法假定第一个块级变量应该以 inject 参数的值开始,这个参数在块之前,在这种情况下是空字符串。inject 方法非常出色,因为 output 变量的修改会从每次迭代持续到下一次。在 ❸,我们每次通过 inject 内部的迭代将 softwrap_line(line) 附加到 output 上,并且每次都会记住这些附加操作。inject 方法对于任何类型的附加或连续操作都非常有用。让我们看看它在 irb 中的操作方式。
irb(main):001:0> nums = [1, 2, 3, 4]
=> [1, 2, 3, 4]
irb(main):002:0> nums.inject(0) { |sum,number| sum += number }
=> 10
irb(main):003:0> nums.inject(0) { |product,number| product *= number }
=> 0
irb(main):004:0> nums.inject(1) { |product,number| product *= number }
=> 24
在第一行,我们定义了一个变量,它包含了从一到四的数字。inject 似乎非常适合执行添加数字列表的操作;我们在第二行这样做。不过,inject 方法可以处理任何操作,所以让我们在第三行尝试乘法。当我们这样做时,我们得到的结果是零。原因是我们的 product 初始值是零,所以之后的任何乘法都不会有任何结果。在第四行,我们将初始值设置为 一,这对于乘法来说更合适,我们得到了一个有意义的 结果。
inject 方法是你第一次真正接触到的 函数式编程,这是一种编程风格,其中操作被视为数学函数,副作用被最小化。在后面的章节中,我们将看到更多关于 inject 和类似方法的介绍。目前,我们只需要关注这样一个事实:它收集每一行,将 line 通过 softwrap_line 函数传递,然后将结果附加到 output 上。
注意
请记住,副作用是指对除了返回值之外的东西(任何东西)所做的持久性更改。在 Ruby 中,具有副作用的方法通常以感叹号结尾,正如我们之前所看到的。没有副作用的方法返回你请求的某个值,但将方法被调用的对象留在调用方法之前的状态。
softwrap_line 做什么?这个名字暗示它对每一行执行软换行操作(无论我们即将如何定义它)。
方法定义从 ❹ 开始,它接收一个 line 参数。在 ❺,如果我们的新 line 变量仅是一个回车符,我们就立即返回,因为这表明了一个我们想要保留的实际断行。在其他所有情况下,我们返回被截断的 line 加上一个空格字符(❻),这就是这个脚本实现实际换行的方法。我们对每一行执行 softwrap_line 操作,如前所述,将其附加到 inject 的 output 变量上,在 ❸ 处。我们的 inject 块是 do/end 类型的,而不是使用花括号的类型。
在❽处我们看到一个新的现象——在关键字end上调用了一个方法。^([11]) 没有理由我们不应该看到这一点。在 Ruby 中,一切都是对象,我们inject方法的结果就是其output变量中累积的内容。在我们的脚本中,它是一个字符串,所以我们的inject块可以响应任何字符串方法,例如gsub。
❽行上的第一个gsub搜索任何制表符字符的分组(在正则表达式中表示为“\t”),并将这些制表符替换为一个空格。正则表达式中的加号与之前见过的问号类似,但它的意思不是“前面的东西零个或多个”,而是“前面的东西一个或多个”。这个正则表达式将一个制表符替换为一个空格,三个制表符替换为一个空格,依此类推。让我们在 irb 中尝试类似的方法。在 irb 的例子中,我将使用字母而不是制表符,因为在打印的书中更容易阅读。问号只是用来复习,并展示它与正则表达式中的加号的区别。
irb(main):001:0> s = 'abcde' *The **`+`** sign in Regular Expressions*
=> "abcde"
irb(main):002:0> s.gsub(/ab+/, 'ba')
=> "bacde"
irb(main):003:0> s.gsub(/abb+/, 'ba')
=> "abcde"
irb(main):004:0> s.gsub(/abb?/, 'ba')
=> "bacde"
因此,我们将制表符(如果有)替换为空格。第一个gsub的输出也是一个字符串,所以它可以响应任何字符串方法,例如另一个gsub。这次我们想要将一个或多个空格替换为单个空格——基本上就是压缩空白。脚本的第❽行显示,我们是在脚本的第一个filename参数上执行所有这些操作。
运行脚本
这个脚本通过ruby -w softwrap.rb some_file运行,其中some_file是要压缩空白的文件。请注意,这个脚本不会修改原始文件,而是输出更改后的版本,就像 Ruby 中的非破坏性方法一样。
结果
下面是调用此脚本自身的输出结果:
$ ruby -w softwrap.rb softwrap.rb
#!/usr/bin/env ruby # softwrap.rb
=begin rdoc "Softwrap" a filename argument, preserving "\n\n" between
paragraphs but compressing "\n" and other whitespace within each paragraph
into a single space. =end def softwrap(filename) File.open(filename,
'r').readlines.inject('') do |output,line| output += softwrap_line(line)
end.gsub(/\t+/, ' ').gsub(/ +/, ' ') end # softwrap
=begin rdoc Return "\n\n" if the <b>String</b> argument has no length after
being chomped (signifying that it was a blank line separating paragraphs),
otherwise return the chomped line with a trailing space for padding. =end def
softwrap_line(line) return "\n\n" if line == "\n" return line.chomp + ' ' end
# softwrap_line
puts softwrap(ARGV[0])
操纵脚本
❽行上的连续gsub调用可以用更复杂的正则表达式来表示:gsub(/(\t| )+/, ‘ ’)。
^([11]) 更确切地说,方法是在end结束的代码结果上被调用的。
#12 文件中单词计数(word_count.rb)
知道一个文件中的单词数量通常很有用。单词计数是文字处理程序的标准功能,但如果你不使用文字处理器,获取单词计数可能并不容易。我最初编写这个脚本时,我正在使用一个基于 XML 的文档生成系统 DocBook (www.docbook.org) 进行项目工作,并希望有一个单词计数,大致相当于从文字处理器中获得的单词计数。Unix 命令 wc 可以计算单词数,但报告的数字不一定与文字处理器报告的数字相符;主要原因可能涉及诸如是否应该将少于一定数量的字母的单词视为文字处理器计数器中的“单词”等问题。我知道文字处理器单词计数与 wc 输出的近似比率(我称之为 fudge factor),我当然可以进行数学计算,但我想有一个能自动完成所有这些的脚本。让我们看看。
代码
#!/usr/bin/env ruby
# word_count.rb
class String
❶ def num_matches(thing_to_match)
return self.split(thing_to_match).size - 1
end # num_matches
end # String
❷ BAR_LENGTH = 20
# to match these calculations with the output of some word processors
❸ FUDGE_FACTOR = 0.82
❹ def word_count(files)
output = ''
total_word_count = 0
❺ files.each do |filename|
file_word_count = word_count_for_file(filename)
output += "#{filename} has #{file_word_count} words.\n"
total_word_count += file_word_count
end # each file
❻ return output +
'-' * BAR_LENGTH + "\n" + *Multiplying Strings*
"Total word count = #{total_word_count}" +
" (#{(total_word_count * FUDGE_FACTOR)})"
end # word_count
❼ def word_count_for_file(filename)
f = File.new(filename, 'r')
contents = f.read()
f.close()
spaces = contents.num_matches(' ')
breaks = contents.num_matches("\n")
false_doubles = contents.num_matches(" \n")
double_spaces = contents.num_matches(' ')
hyphens = contents.num_matches('-')
false_doubles += double_spaces + hyphens
words = spaces + breaks - false_doubles + 1
return words
end # word_count_for_file
puts word_count(ARGV)
工作原理
我们首先向 String 类添加一个名为 num_matches 的新方法(❶)。它简单地返回参数在调用字符串中出现的次数。我还定义了顶级常量 BAR_LENGTH(❷),它仅用于视觉格式化,以及 FUDGE_FACTOR(❸),这是我之前提到的两个不同单词计数程序之间的比率。
我们定义了一个名为 word_count 的方法(❹),它接受 files 参数。你会在脚本的最后一行注意到这个程序接受任意数量的文件名作为其参数,这与我们之前的脚本不同,之前的脚本一次只会处理一个文件。word_count 方法定义了局部变量 output 和 total_word_count,分别将它们设置为 String 和 Integer 的有用默认值。然后我们遍历文件(❺),将适当的值赋给 file_word_count 和 output,并将每个 file_word_count 累加到 total_word_count 中。现在 output 变量包含了每个文件计数的描述。我们 return 这个结果,然后是一行由 BAR_LENGTH 常量乘以连字符字符(❻)。字符串的乘法与数组的乘法非常相似,我们之前已经见过。我们向整体表达式返回值中添加了一个由 20 个连字符字符组成的字符串。返回的表达式以括号中的 total 乘以 FUDGE_FACTOR 常量结束。
在完成这个脚本之前,我们需要了解它是如何计算每个文件的单词计数的。让我们检查 word_count_for_file 函数(❼)。它首先从正在处理的文件中获取 contents。然后它使用对 contents 变量的 num_matches 方法的快速而简单的调用,以获取空格、换行符等的计数。然后它使用这些粗略的数字计算 contents 字符串中的单词数。
在字符串中计数单词有更准确的方法,其中许多方法使用了 Jeffrey Friedl 的 Mastering Regular Expressions 中描述的技术。然而,此脚本旨在提供快速、近似的结果,因为它使用了伪造因子。此脚本表明,只需向现有类添加一个新方法,就可以非常方便地完成短期任务。我们将在后面的脚本中看到更多这样的例子。
运行脚本
您可以使用 ruby -w word_count.rb some_file 运行此脚本,其中 some_file 是您想要计算单词计数的文件。
结果
这是调用此文件的结果:
$ ruby -w word_count.rb word_count.rb
word_count.rb has 132 words.
--------------------
Total word count = 132 (108.24)
注意脚本如何报告字面和伪造的单词计数。
#13 单词直方图(most_common_words.rb)
现在让我们看看大多数文字处理器都不做的功能:在文档中查找最常用的单词。像之前的脚本一样,它向现有的内置类添加了一个额外的“辅助”方法,以简化我们的新主方法的工作。让我们看看。
代码
#!/usr/bin/env ruby
#most_common_words.rb
class Array
❶ def count_of(item)
❷ grep(item).size *The **`grep`** Method*
❸ #inject(0) { |count,each_item| item == each_item ? count+1 : count }
end
end
❹ def most_common_words(input, limit=25)
freq = Hash.new()
sample = input.downcase.split(/\W/)
sample.uniq.each do |word|
❺ freq[word] = sample.count_of(word) unless word == ''
end
❻ words = freq.keys.sort_by do |word|
freq[word]
end.reverse.map do |word| *The **`map`** Method*
❼ "#{word} #{freq[word]}"
end
❽ return words[0, limit]
end
❾ puts most_common_words(readlines.to_s).join("\n")
工作原理
数组的新方法被称为 count_of(❶);它接受一个名为 item 的参数,并返回该 item 在所讨论的数组中出现的次数。此方法的默认实现(❷)使用一个名为 grep 的数组方法,该方法接受一个参数并返回所有匹配该元素的元素。由于我们想要匹配条件的项目数量(而不是这些项目本身),我们在 grep 的返回值上调用 size 方法。
❸ 行显示了使用 inject 方法完成相同任务的方法,我们之前已经介绍过。
在 ❹ 我们定义了 most_common_words 方法;它接受一个必需的 input 参数和一个可选的 limit 参数,默认为 25。我们定义了一个名为 freq 的新哈希变量,它将存储每个单词的频率。我们定义了一个名为 sample 的数组,它由不区分大小写的输入组成,在每个空白部分(正则表达式中的 \W 表示 任何空白)处断裂。我们遍历样本中的每个唯一的 word,将其频率添加到 freq 哈希中。我选择跳过空字符串,不计入单词(❺)。
一旦我们构建了 freq 哈希,我们想要使用我们的 limit 参数。我们遍历 freq 的键(即实际的单词本身)并按它们出现的频率排序(❻)。我们想要看到最常见的单词,而不是最不常见的单词,所以我们将排序后的列表 reverse,并对它执行 map 操作。
map 操作在函数式编程的世界中非常常见。它通常用作循环的替代方案,所以在 Ruby 中,我们经常会发现,根据我们的需求,我们可能想要使用 each 方法或 map 方法来完成给定的任务。一般来说,如果你想对一系列项目进行破坏性更改,请使用 each;如果你想创建一个新列表,其中包含转换后的项目,请使用 map。让我们在 irb 中尝试 map。我一直在向你展示很多带有数字的 irb 示例,所以现在我将向你展示一种快速创建数字数组的方法。Ruby 有一个名为 Range 的类,它表示从给定起点到给定终点的项目。我们将使用该类来构建一个数组。
irb(main):001:0> digit_range = 0..9 *Ranges*
=> 0..9
irb(main):002:0> digit_range.class
=> Range
irb(main):003:0> digits = digit_range.to_a
=> [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
irb(main):004:0> digits.map { |num| num + 1 }
=> [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
irb(main):005:0> digits.map { |num| num + 10 }
=> [10, 11, 12, 13, 14, 15, 16, 17, 18, 19]
irb(main):006:0> digits.map { |num| num * 2 }
=> [0, 2, 4, 6, 8, 10, 12, 14, 16, 18]
irb(main):007:0> digits.map { |num| num ** 2 }
=> [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]
irb(main):008:0> digits
=> [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
irb(main):009:0> digits.map! { |num| num ** 2 }
=> [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]
irb(main):010:0> digits
=> [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]
如您所见,map 对于任何可以用简单描述表达的项目列表的转换都非常方便,例如在第六行上的 将这些所有东西都加倍,或者在第七行上的 将这些所有东西都平方。请记住,map 是非破坏性的(如第八行所示),除非你用感叹号调用它(如第九行和第十行所示)。我们将按频率出现顺序对样本文本中的单词进行排序,并将操作映射到单词上。要映射的操作(❼)是输出一个由 word 本身、一个空格字符和该 word 的频率组成的字符串。
所有这些都在与❺同一行的 words 变量的赋值中发生,因此 words 数组中的每个成员都是一个字符串,它是 ❷ 操作的结果。在❽处,我们返回 words 数组的子集,从开头开始,并限制其长度等于 limit 参数。由于 most_common_words 方法的输出是一个数组,而我们想将其作为字符串打印出来,所以在❾处我们使用换行符进行 join,使每个数组项成为单独的一行。
运行脚本
我们使用 ruby most_common_words.rb filename_to_analyze 来调用此脚本,对 filename 参数调用 readlines.to_s,这提供了要分析输入。让我们尝试用它自己来试试。
结果
$ ruby most_common_words.rb most_common_words.rb
word 9
end 6
freq 5
do 3
sample 3
most_common_words 3
count 3
item 3
0 2
count_of 2
words 2
input 2
def 2
limit 2
each_item 2
split 1
unless 1
1 1
downcase 1
map 1
rb 1
array 1
ruby 1
usr 1
each 1
修改脚本
顺便说一下,你也可以使用此行来实现 count_of:
dup.delete_if { |i| i != item }.size || 0
#14 在字符串中旋转字符(rotate.rb)
我们将以一个简单的程序结束,该程序将旋转字符串中字符的顺序。我们将通过一个接受一个字符(意味着长度为 1 的字符串)参数的方法来完成此操作。要旋转的字符串将尝试旋转,直到字符参数出现在索引 0 处。如果字符根本找不到,它将返回 nil。
代码
#!/usr/bin/env ruby
# rotate.rb
class String
❶ def rotate(char)
❷ return nil unless self.match(char)
❸ return self if (self[0] == char[0])
❹ chars = self.split(//)
return ([chars.pop] + chars).join('').rotate(char) *Recursion*
end
❻ def rotate!(char)
replace(rotate(char))
end
end
它是如何工作的
本程序介绍了一个名为 递归 的概念,它(就像 map 一样)在函数式编程中经常被使用,通常作为循环的替代方案。一个 递归操作 是部分定义为自己本身的操作。让我们在我们的 rotate.rb 脚本中探索这个概念。
我们添加到字符串对象中的主要rotate方法的定义在❶处。我之前说过,如果字符参数(称为char)在主字符串(这里称为self)中找不到,rotate方法将返回nil(❷)。如果char是字符串中的初始字符,我们不需要进行任何旋转,所以它将在这些条件下返回主字符串(❸)。大括号内的数字0不是一个匿名数组——它是self的方法,用于返回字符串的第一个字符。我们在self字符串和单字符字符串char上调用该方法。当这两个字符串相等时,我们知道self字符串以请求的旋转字符开头。
注意
我们在大括号内使用零索引来返回第❸行的字符串中的第一个字符,因为 Ruby(像许多语言一样)从零开始计数索引,而不是从一。
我们知道,如果我们没有返回就到这里,我们就有一个符合条件的字符串,可以进行旋转(因为它包含char),并且需要旋转以匹配(因为它不以char开头)。我们通过定义一个新的变量chars(❹)来执行旋转,它是一个包含字符串中每个字符的数组。我们在❺处使用pop方法来从chars中移除最后一个字符,记住pop是破坏性的(尽管没有感叹号,但出于历史原因)。现在chars数组包含除了刚刚pop出来的字符之外的所有字符。如果我们把这些数组加在一起,把包含pop出来的字符的数组放在前面,我们就创建了一个新的数组,其中最后一个成员已经被从末尾移动到前面,其他成员都向后移动。
我们将pop出来的字符用括号括起来,这样我们就可以更容易地添加两个数组(分别是从pop出来的字符和剩余的字符)。由于rotate方法最终会返回一个字符串,我们使用空字符串作为分隔符来join我们的数组元素。这会产生一个旋转过一次的字符串。我们完成了吗?其实并没有。
递归
旋转工作得很好,但可能还不够。如果我们需要在找到匹配之前旋转多个字符怎么办?有一个简单的方法来做这件事;它被称为rotate方法——你知道的,我们还在定义过程中的方法。我们只需在我们的新创建的字符串上调用rotate。
我们已经知道我们新创建的字符串会在❷处通过测试。我们主要感兴趣的是它是否需要进一步的旋转。这就是❸处的测试。如果只需要一次旋转,这个rotate方法的第二次调用将返回新创建的字符串,并且由于在❺行的return调用中进行了第二次rotate调用,主要的rotate调用也将返回这个值。
如果一次旋转不足以找到匹配项,我们对rotate方法的第二次调用将执行我们刚才讨论的相同字符移动(从❹开始),最终又调用一次rotate,这次是在旋转了两个字符的字符串上,依此类推。
每次调用rotate时,要操作的字符串都会更接近我们期望的结果。这在递归中非常常见,我们将在后面的章节中更深入地讨论。正如您在❻中看到的,我们还定义了一个破坏性版本,称为rotate!。
运行脚本
让我们看看使用irb -r rotate.rb的 irb 命令的输出。
结果
$ irb -r rotate.rb
irb(main):001:0> 'I am a String.'.rotate('a')
=> "a String.I am "
irb(main):002:0> 'I am a String.'.rotate('S')
=> "String.I am a "
在每种情况下,被rotate方法调用的字符串中的字符都会移动,直到所需的字符成为字符串的第一个字符。这就是本章脚本的结束。
本章回顾
本章有哪些新内容?
-
操作系统之间的换行符差异
-
正则表达式,包括
?计数器 -
sprintf方法 -
数组的乘法
-
inject方法 -
带有
+计数器的正则表达式 -
块的结果作为对象
-
在方法输出上连续调用方法(“方法链”)
-
在快速脚本中使用 Open Classes 的新方法
-
字符串的乘法
-
grep方法 -
map方法 -
Range 类
-
递归
这包括很多内容,包括一些重要的新功能概念,如递归和一些非常实用的功能方法。随着我们继续前进,您将需要这些概念。让我们继续到第五章(Chapter 5)中更复杂的数字处理。
第五章. 数字工具

数字对于所有计算机和编程语言都是基本的,Ruby 也不例外。在本章的脚本中,我们将处理主要是数字但其他方面相当多样化的有用数据。我们将探索一些纯数学,接着是我在第四章中介绍的递归。我们还将进行一些类型转换,其中数字将以对人类用户方便的不同方式表示。我们还将进行一些单位转换,特别是货币单位.^([12]) 在做所有这些的同时,我们还将进一步深入研究元编程、哈希、使用外部库以及在外部文件中存储数据的两种不同格式:XML(可扩展标记语言)和 YAML(YAML 不是标记语言)。这需要覆盖很多内容,所以让我们开始吧。
#15 计算幂次(power_of.rb)
这是本章脚本中最纯粹数学的一个,它处理幂次运算。在我们深入脚本本身之前,让我们使用 irb 来探索 Ruby 如何处理幂次运算:
irb(main):001:0> 2 ** 2 *Exponentiation*
=> 4
irb(main):002:0> 2 ** 3
=> 8
如您所见,在 Ruby 中表达“的幂”的方式是使用双星号。由于被提升到某个幂次的数和幂本身都是表达式,它们也可以更复杂,如下所示:
irb(main):003:0> 2 ** (1 + 2)
=> 8
irb(main):004:0> 8 ** (1.0/3.0)
=> 2.0
您可以使用 ** 操作符轻松地将一个数(称为基数)提升到给定的指数。如您在上面的代码第四行所见,当您想要反转传统的幂次运算时,您可以使用倒数幂。
注意
我们在指数中使用浮点数,因为我们不希望我们的表达式被四舍五入到零。
如果您有基数和指数,您可以找到缺失的结果。如果您有幂次运算的结果和指数,您可以通过使用指数的倒数来撤销您的操作以找到基数。如果您知道基数和结果,但想找到指数呢?这正是这个脚本的目的。让我们看看。
代码
#!/usr/bin/env ruby
# power_of.rb
class Integer
=begin rdoc
Add a simple <b>Integer</b>-only method that reports the
exponent to which the base must be raised to get self.
=end
❶ def power_of(base) *Recursion*
# return nil for inapplicable situations
return nil unless base.is_a?(Integer) *The **`is_a?`** Method*
❷ return nil if (base.zero? and not [0,1].include?(self))
# deal with odd but reasonable
# numeric situations
❸ return 1 if base == self
❹ return 0 if self == 1
❺ return false if base == 1
❻ return false if base.abs > self.abs *The **`abs`** Method*
❼ exponent = (self/base).power_of(base)
❽ return exponent ? exponent + 1 : exponent
end
end
工作原理
我们希望这个操作是一个可以被任何整数调用的方法,所以我们利用 Ruby 的开放类,简单地添加了一个新方法。我们有了标准模板和 RDoc,直到方法定义的 ❶,它显示该方法接受一个名为 base 的参数。直到包括 ❷ 的行导致我们的 power_of 方法在不适合作业的情况下提前退出。当我们被要求在一个甚至不是整数的 base 上找到幂时,我们返回 nil 值,因为这个问题是没有意义的。当 base 为零且结果既不是零也不是一时,我们也返回 nil,因为零的任何幂次都将始终是零或一,使这个问题同样没有意义。
当然会有其他情况下我们的响应是有意义的。当底数和指数的运算结果(self)相同值时,我们在❸处返回1,因为任何数的幂为一是它本身。如果self是1,我们在❹处返回0,因为任何数的零次幂等于一。这对许多人来说很困惑。为什么一个自身乘零次的数可以是任何东西呢?
答案在于所谓的乘法恒等性,这是数学家描述任何数乘以一等于一乘以那个数以及那个数本身的实际情况。在任何标准的乘法中,你都可以假设你的乘法中可以有任意数量的“乘以一”的加法,而且这不会产生影响。我们也可以在 irb 中看到这一点:
irb(main):005:0> (42 * 1) == (1 * 42)
=> true
irb(main):006:0> (1 * 42) == (42 * 1)
=> true
irb(main):007:0> (42 * 1) == 42
=> true
irb(main):008:0> (1 * 42) == 42
=> true
由于你可以假设任何东西乘以自身两次或一次的“乘以一”,你可以类似地假设乘以自身零次的情况,这就是所有提升到零次幂的含义。因此,提升到零次幂将得到一个值为1的结果。
在❺处,如果底数是1,我们返回false。这是因为1永远不能被提升到产生除了1之外值的幂。我们如何知道我们的结果不是1呢?因为如果self是1,我们已经在❹处返回了一个零。在❻处,如果底数的绝对值(通过调用base.abs获得)大于self的绝对值,我们也返回false。我们这样做是因为你不能将一个base提升到整数幂并得到一个绝对值小于原始base的结果。
从❶到❻的所有内容都处理了奇数情况——要么是无意义的情境,要么是让我们知道我们已经完成的情况,通常称为退出条件。接下来会发生什么?如果一个给定的数是给定base的幂,这意味着这个数除以base也是base的幂,但指数会低一个。让我们在 irb 中演示。
irb(main):009:0> 3 ** 3
=> 27
irb(main):010:0> 3 ** 2
=> 9
irb(main):011:0> 27 == 9 * 3
=> true
3的3次幂是27,3的2次幂是9,而27等于9乘以3。如果我们试图找到一个指数,并且我们的基本情况都不适用,我们可以简单地除以self和base,尝试得到新的除法值相对于相同base的幂,如果结果是整数,记得将我们的新结果加一。
这正是我们在❷和❽处所做的事情。我们定义了一个新的变量,称为exponent,它是self除以base调用power_of方法的结果。exponent变量将是nil、false或一个整数。我们如何知道这一点?因为我们返回了nil直到❷,❺或❻处的false,或者一个整数。
所有整数都有真实的布尔值,因此我们可以使用我们的标准三元运算符进行测试,就像我们在❽中做的那样。如果exponent评估为true,则它是一个整数(因为nil和false在布尔三元运算中都会评估为false)。因此,我们return它,并记住加一,因为我们已经除以了base一次。如果exponent评估为false,我们只想简单地return那个值:要么是false,要么是nil。
在第 ❽ 调用power_of方法时,对self除以base发生了什么?它通过了从❶到❻的所有相同测试,如果没有一个适用,它将再次将新的self值除以base,并记住将另一个加到最终结果中。所有这些都在power_of方法的每个迭代中发生——最顶层的第一个版本不需要知道或关心有多少其他power_of迭代最终被调用。这正是递归的全部意义。
运行脚本
你可以在 irb 中尝试这个脚本,通过在命令行中使用irb -r power_of.rb或进入 irb 后输入require 'power_of.rb'。记住,这个脚本只能处理整数,所以2.power_of(4)将返回false,而不是0.5。
结果
这里是一个带有一些输出的示例 irb 会话。
$ irb -r power_of.rb
irb(main):001:0> 1.power_of(1)
=> 1
irb(main):002:0> 1.power_of(2)
=> 0
irb(main):003:0> 4.power_of(2)
=> 2
irb(main):004:0> 2.power_of(4)
=> false
^([12]) 我们将在第七章(第七章. 使用、优化和测试函数技术)中创建一个温度转换器,因为它依赖于我们尚未涉及的概念。
#16 向数字添加逗号(commify.rb)
一种标准的数字格式化方式是将它们以逗号(或某些其他分隔符)分隔成每组千位。我们的下一个脚本通过向所有数字添加一个名为commify的方法来实现这一点。你可能会认为我们可以通过打开 Integer 类并添加一个新方法来实现这一点,就像我们在power_of.rb中做的那样。这当然是一个合理的做法,但我们可能还想在浮点数上使用commify。解决方案是什么?
继承
答案涉及到一个面向对象的概念,称为继承。我们之前在第三章(第三章. 程序员工具)中讨论过这个概念,当时我们向 Object 类添加了方法。继承允许所有其他类使用 Object 类的方法,因为这些其他类继承自 Object。继承也是我们commify脚本中的一个因素。让我们在 irb 中检查一些数字类的继承层次结构。
irb(main):001:0> Integer.ancestors *The **`ancestors`** Method*
=> [Integer, Precision, Numeric, Comparable, Object, Kernel]
irb(main):002:0> Float.ancestors
=> [Float, Precision, Numeric, Comparable, Object, Kernel]
irb(main):003:0>
我们使用了一个名为ancestors的方法,它不仅可以在类的实例上调用,还可以在类本身上调用。它返回一个数组,包含调用该方法的类的所有祖先(在这里,我简单地说祖先是指从它们那里继承的类)。你可能发现将继承比作生物学隐喻很有用,其中每个类都是一个物种,祖先类是该物种的祖先物种。我们可以看到 Integer 类和 Float 类都直接继承自称为Precision的东西。
Precision 必须是一个类——某种类型的数字,对吗?并不完全是这样。让我们在 irb 中继续。
irb(main):003:0> Integer.class
=> Class
irb(main):004:0> Float.class
=> Class
irb(main):005:0> Precision.class
=> Module
我们可以看到 Integer 是一个类,可以被实例化。Float 也是如此。这并不奇怪。5 是一个 Integer,3.14 是一个 Float。但 Precision 是一种称为 Module 的东西,根本不是类。模块是用来做什么的?
模块
让我们继续使用生物学隐喻。人类和蝙蝠都是哺乳动物,所以如果我们调用Human.ancestors和Bat.ancestors,我们会发现有很大的重叠——人类和蝙蝠有共同的祖先,特别是早期的哺乳动物。如果我们调用Bird.ancestors,与另外两个的重叠就会少一些,因为鸟类不是哺乳动物。然而,蝙蝠和大多数鸟类都能飞,这在面向对象术语中可以被视为一种方法。我们可以分别定义Bat.fly和Bird.fly,但还有一种可供我们选择的方法。
因此,我们可以定义飞行的能力(以及相关的特性和行为),并将其添加到现有的类中。这个过程被称为混合进,这也是 Ruby 处理将相同的方法分配给具有不同祖先类的不同类的问题(如我们的蝙蝠和鸟类示例)的方式。
我们通过将飞行的能力定义为模块,可能称为 Flyable,来实现这一点。模块类似于类,但它们不会被实例化。我们将在第十章中稍后编写自己的模块。现在,请记住,Precision 模块为 Integer 和 Float 添加了行为,就像我们的假设 Flyable 一样。Flyable 赋予混合进来的生物以飞行的能力,而 Precision 赋予混合进来的数字以精确计算的能力。
模块是开放的,就像类一样,因此我们可以向 Precision 模块添加新的行为,就像我们之前向 Object 类添加行为一样。让我们看看commify.rb脚本。
代码
module Precision *Modules*
❶ # What character should be displayed at each breakpoint?
COMMIFY_DELIMITER = ','
# What should the decimal point character be?
COMMIFY_DECIMAL = '.'
# What power of 10 defines each breakpoint?
COMMIFY_BREAKPOINT = 3
# Should an explicit '0' be shown in the 100ths place,
# such as for currency?
COMMIFY_PAD_100THS = true
=begin rdoc
This method returns a <b>String</b> representing the numeric value of
self, with delimiters at every digit breakpoint. 4 Optional arguments:
1\. delimiter (<b>String</b>): defaults to a comma
2\. breakpoint (<b>Integer</b>): defaults to 3, showing every multiple of 1000
3\. decimal_pt (<b>String</b>): defaults to '.'
4\. show_hundredths (<b>Boolean</b>): whether an explicit '0' should be shown
in the hundredths place, defaulting to <b>true</b>.
=end
❷ def commify(args = {}) *Optional Arguments*
❸ args[:delimiter] ||= COMMIFY_DELIMITER
args[:breakpoint] ||= COMMIFY_BREAKPOINT
args[:decimal_pt] ||= COMMIFY_DECIMAL
args[:show_hundredths] ||= COMMIFY_PAD_100THS
❹ int_as_string, float_as_string = to_s.split('.')
int_out = format_int(
int_as_string,
args[:breakpoint],
args[:delimiter]
)
float_out = format_float(
float_as_string,
args[:decimal_pt],
args[:show_hundredths]
)
❺ return int_out + float_out
end
private
=begin rdoc
Return a <b>String</b> representing the properly-formatted
<b>Integer</b> portion of self.
=end
❻ def format_int(int_as_string, breakpoint, delimiter)
reversed_groups = int_as_string.reverse.split(/(\d{#{breakpoint}})/)
reversed_digits = reversed_groups.grep(/\d+/)
digit_groups = reversed_digits.reverse.map { |unit| unit.reverse }
return digit_groups.join(delimiter)
end
=begin rdoc
Return a <b>String</b> representing the properly-formatted
floating-point portion of self.
=end
❼ def format_float(float_as_string, decimal_pt, show_hundredths)
return ''unless float_as_string
output = decimal_pt + float_as_string
❽ return output unless show_hundredths
❾ output += '0' if (float_as_string.size == 1)
return output
end
end
它是如何工作的
从❶开始,我们定义了一些有用的常量,就像我们为类做的那样。每个定义都由一些注释说明常量的用途。我提到 commify 方法将在每千位分组处插入逗号。这在美国是惯例,但许多其他国家使用句点代替逗号,并使用逗号来分隔整数部分和小数部分(美国使用句点)。这些常量预设为对我有用的美国表示法,因为我住在这里,但你可以轻松地自定义它们以匹配你家乡国家的适当表示法。
在❷处,在以单个哈希表形式解释输入参数的 RDoc 之后,我们得到了 commify 方法的定义,这是我们的唯一公共方法。它接受一个名为 args 的哈希表参数来覆盖默认配置常量,如❸所示。请注意,||= 操作符意味着如果 args 请求覆盖(意味着它本身具有适当符号的值,例如 :delimiter 用于分隔符),我们将使用 args 中的内容。否则,我们将回退到模块的适当常量。在❹处,我们拆分了 self 的整数和浮点部分,尽管要注意它们都是字符串类的实例,尽管它们代表数字。Ruby 允许我们同时将值赋给两个不同的变量名,就像我们在这里做的那样。
注意
符号是出色的哈希键,这是你将在我的脚本和整个 Ruby 社区中看到的一个约定。如果你不尊重这个约定,在 Rails 中做任何事情都会很困难。符号非常适合这项工作,因为它们可以用作事物的名称或标签,并且它们占用极小的内存^([13])
然后我们定义了一个名为 int_out 的变量,并给它赋值为名为 format_int 的方法。我们对 float_out 也做了同样的事情,最后在❺处返回这两个字符串的连接。你可以看到实际的工作发生在格式化方法(format_int 和 format_float)中,这两个方法都是私有的。
格式化整数方法
在❻处的 format_int 方法是两个方法中概念上更复杂的一个。让我们再次打开 irb 并逐步执行这个方法的操作。首先,让我们定义一些代表方法输入的变量。
irb(main):001:0> int_as_string = '186282'
=> "186282"
irb(main):002:0> breakpoint = 3
=> 3
irb(main):003:0> delimiter = ','
=> ","
接下来,让我们使用表示适当长度的数字组的正则表达式在适当的断点处拆分我们的字符串。正则表达式中的 {x} 表示“左边的任何内容重复 X 次”,所以 a{3} 表示“字母 a 的三个实例”。我们还使用字符串插值,以便可以使用我们的 breakpoint 参数来指定要拆分的数字数量。我们希望从右向左进行,因此我们将在将字符串拆分成数组之前使用 reverse 方法。
irb(main):004:0> reversed_groups = int_as_string.reverse.split(/(\d{#{breakpoint}})/)
=> ["", "282", "", "681"]
然后,我们想要提取那些真正的数字组 Array 成员,这可以通过另一个正则表达式 /\d+/(意味着由一个或多个数字组成,没有其他内容)和 grep 方法轻松完成,grep 方法找到与 grep 传入的 regex 参数匹配的 Array 的所有成员。
irb(main):005:0> reversed_digits = reversed_groups.grep(/\d+/)
=> ["282", "681"]
到目前为止,我们的内容还有什么问题?不仅数字组顺序错误,而且每个组内的数字也是颠倒的。这是因为我们在 split 之前反转了整个 String。现在我们想要把一切都按正确的顺序排列。我们只需 reverse 我们的 Array,对吧?
irb(main):006:0> reversed_digits.reverse
=> ["681", "282"]
这不会起作用。它将组按正确的顺序排列,但每个组内的数字仍然是颠倒的。我们可以使用 map 方法来反转 Array 的每个成员。
irb(main):007:0> reversed_digits.map { |unit| unit.reverse }
=> ["282", "186"]
哎呀。现在每组三个数字内的数字顺序是正确的,但组顺序是错误的。我们可以在两步操作中定义另一个变量,比如 reversed_digits,但为什么不用 Ruby 的链式方法能力呢?
irb(main):008:0> digit_groups = reversed_digits.reverse.map { |unit| unit.reverse }
=> ["186", "282"]
现在我们的数字组已经按正确的顺序排列,并且内部顺序也正确。
注意,在 irb 示例中对 reverse 方法的两次不同调用是完全不同的。一次是对 reversed_digits 的 Array 方法 reverse 的调用,另一次是对我们称为 unit 的每个数字组的 String 方法 reverse 的调用。
我们仍然有一个 Array,我们想要一个 String。这需要使用分隔符进行 join。
irb(main):009:0> digit_groups.join(delimiter)
=> "186,282"
我们现在的 format_int 方法返回一个 String,它是我们的 int_as_string 参数的修改版本。我们在正确的位置(breakpoint)将 int_as_string 分割,在数字组之间插入 delimiter,并确保一切保持正确的顺序。这就是整数部分的全部内容。
格式化浮点数的方法
我们还希望能够格式化数字的小数部分,这可以通过在 ❼ 处的 format_float 方法来完成。如果没有小数部分,它立即返回一个空 String。否则,它创建一个新的变量 output,由 decimal_pt 参数与 float_as_string 参数连接而成——记住它们都是 String,所以加号表示连接。如果配置选项使得百分位不是强制性的(你可以从 show_hundredths 参数中看出),我们可以在 ❽ 处简单地 return output 变量。如果我们需要显示百分位,并且小数部分只有一个字符宽,我们需要在输出的末尾连接 String ‘0’ 在 ❾ 处。否则,我们只需简单地 return output 变量。
类型测试
你会记得在 power.rb 中,我们有一个基于 base 参数是否为整数的早期退出条件。你也会注意到在这个脚本中,我们没有测试任何数字来确定它们是否为实数。为什么是这样?原因是我们的新方法将被包含在 Precision 模块中,该模块只混合到表示某种数字的类中,如 Integer 和 Float。因此,检查数字类型是不必要的。
运行脚本
让我们用一个测试脚本来试试。以下是 tests/test_commify.rb 的内容,我们将在与 commify.rb 相同的目录下运行它,使用命令 ruby -w tests/test_commify.rb 186282.437 在 shell 中运行。
#!/usr/bin/env ruby
# test_commify.rb
require 'commify'
puts ARGV[0].to_f.commify()
alt_args = {
:breakpoint => 2,
:decimal_pt => 'dp',
:show_hundredths => false
}
puts ARGV[0].to_f.commify(alt_args)
我们在脚本名称之后的第一个参数上调用 commify 方法,在我们的例子中是浮点数 186282.437。首先,我们使用默认参数(关于分隔符字符、断点大小等)调用它。然后我们使用一些修改后的配置参数调用它,只是为了看看它们是如何工作的。
结果
这是我的输出结果:
186,282.437
18,62,82dp437
你的应该看起来一样。这就是这个脚本的全部内容。
^([13]) 我的技术审稿人 Pat Eyler 聪明地要求我强调,符号占用这么少的空间的原因是每个符号只占用一次空间,所有后续实例只是再次引用相同的内存空间,而不是像字符串或其他类型的对象那样重复它。
#17 罗马数字 (roman_numeral.rb)
在之前的脚本中,你学习了如何将数字表示为字符串,以便在适当的位置添加逗号(或某些其他所需的分隔字符),以便更容易阅读。将数字表示为字符串的最传统方式之一是作为罗马数字。这个脚本为所有整数添加了一个名为 to_roman 的新方法。让我们在 irb 中看看它的实际效果。
$ irb -r roman_numeral.rb
irb(main):001:0> 42.to_roman
=> "XLII"
irb(main):002:0> 1.to_roman
=> "I"
irb(main):003:0> 5.to_roman
=> "V"
irb(main):004:0> digits = (0..9).to_a
=> [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
irb(main):005:0> digits.map { |d| d.to_roman }
=> ["", "I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX"]
如果你记得罗马数字,你会看到 to_roman 符合你的预期。它对零返回空字符串,并使用 减法 方法报告四为 IV,使用比左侧字母值低的字母来表示减法。让我们看看源代码,看看它是如何工作的。
代码
class Integer
# Base conversion Hash
❶ ARABIC_TO_ROMAN = {
1000 => 'M',
500 => 'D',
100 => 'C',
50 => 'L',
10 => 'X',
5 => 'V',
1 => 'I',
0 => '',
}
# Represent 4 as 'IV', rather than 'IIII'?
SUBTRACTIVE_TO_ROMAN = {
900 => 'CM',
400 => 'CD',
90 => 'XC',
40 => 'XL',
9 => 'IX',
4 => 'IV',
}
# Use SUBTRACTIVE_TO_ROMAN Hash?
SUBTRACTIVE = true
❷ def to_roman()
@@roman_of ||= create_roman_of() *Class Variables*
❸ return ''unless (self > 0)
❹ return to_s if self > maximum_representable()
❺ base = @@roman_of.keys.sort.reverse.detect { |k| k <= self } *The **`detect`** Method*
❻ return '' unless (base and base > 0)
❼ return (@@roman_of[base] * round_to_base(base)) + (self % base).to_roman()
end
private
=begin rdoc
Use constants to create a <b>Hash</b> of appropriate roman numeral values.
=end
❽ def create_roman_of()
return ARABIC_TO_ROMAN unless SUBTRACTIVE
ARABIC_TO_ROMAN.merge(SUBTRACTIVE_TO_ROMAN) *The **`merge`** Method*
end
=begin rdoc
What is the largest number that this method can reasonably represent?
=end
def maximum_representable()
❾ (@@roman_of.keys.max * 5) - 1
end
❿ def round_to_base(base)
(self - (self % base)) / base
end
end
它是如何工作的
由于我们只需要让整数有报告其罗马数字表示的能力,我们将打开整数类并给它这个新方法。在定义了一些常量❶之后,让我们跳到❷,在那里我们定义了公共方法to_roman,我们已经在 irb 中看到过它的使用。在其中,我们定义了一个叫做@@roman_of的东西,并使用||=运算符将其值设置为名为create_roman_of的方法的输出值,除非@@roman_of已经评估为true。为什么它前面有两个@符号?我们已经看到了带有单个@符号的实例变量和必须以大写字母开头(并且传统上全部大写)的常量,但这是新的一种叫做类变量的东西。
类变量
类变量在类的每个实例之间共享,但能够改变值。让我们在 irb 中验证任何给定类变量的几个不同实例具有相同的值。
irb(main):001:0> class String
irb(main):002:1> @@class_var = "I'm a Class Variable."
irb(main):003:1> def cv
irb(main):004:2> @@class_var
irb(main):005:2> end
irb(main):006:1> end
=> nil
irb(main):007:0> ''.cv
=> "I'm a Class Variable."
irb(main):008:0> 'Some other String'.cv
=> "I'm a Class Variable."
irb(main):009:0> 'Yet another String.'.cv
=> "I'm a Class Variable."
我们为所有字符串定义了一个新的类变量@@class_var,并为所有字符串提供了一个名为cv的新方法,该方法返回@@class_var。我们发现它的值对所有字符串都相同,包括在我们定义@@class_var时还不存在的字符串。
我们有一个名为@@roman_of的类变量。它是什么?为了回答这个问题,我们需要查看私有的create_roman_of方法❽。它返回一个名为ARABIC_TO_ROMAN的常量,除非另一个名为SUBTRACTIVE的常量是true。我们可以从我们的常量定义部分❶中看到,我们已经将SUBTRACTIVE设置为true,所以create_roman_of不会返回ARABIC_TO_ROMAN,使用我们当前的配置设置。相反,它将返回调用merge方法的结果,其中SUBTRACTIVE_TO_ROMAN是其单个参数。
Hash.merge
在这一点上,我们需要了解ARABIC_TO_ROMAN是什么,这样我们才知道当merge被调用时会发生什么。我们可以从❶中看到,ARABIC_TO_ROMAN和SUBTRACTIVE_TO_ROMAN都是 Hash。它们的键是阿拉伯数字,每个键的值是键的罗马数字表示。这个脚本只能表示到 4,999 的罗马数字,所以我们可以简单地定义一个包含从一到 4,999 每个值的ALL_ARABICS_TO_ROMAN的单一 Hash,然后完成它。
那会起作用,但会很糟糕。我们实际上做的是定义了一些基础情况,我们将从中推断出 0 到 4,999 之间的所有情况。我们还把减法表示的情况(如 IV 表示四)分离到一个单独的 Hash 中,这样我们就可以轻松地打开或关闭这个功能,就像我们使用SUBTRACTIVE常量和使用merge方法的create_roman_of方法一样。这个merge方法允许一个 Hash 将其信息合并到另一个 Hash 中。让我们在 irb 中探索一下。
irb(main):001:0> hash1 = { 'key1' => 'value1', 'key2' => 'value2' }
=> {"key1"=>"value1", "key2"=>"value2"}
irb(main):002:0> hash2 = { 'key3' => 'value3', 'key4' => 'value4' }
=> {"key3"=>"value3", "key4"=>"value4"}
irb(main):003:0> hash1
=> {"key1"=>"value1", "key2"=>"value2"}
irb(main):004:0> hash2
=> {"key3"=>"value3", "key4"=>"value4"}
irb(main):005:0> hash1.merge(hash2)
=> {"key1"=>"value1", "key2"=>"value2", "key3"=>"value3", "key4"=>"value4"}
irb(main):006:0> hash3 = { 'key1' => nil }
=> {"key1"=>nil}
irb(main):007:0> hash1.merge(hash2).merge(hash3)
=> {"key1"=>nil, "key2"=>"value2", "key3"=>"value3", "key4"=>"value4"}
你可以看到merge不仅结合了键值对,而且传入的信息(即merge方法的哈希参数)覆盖了调用哈希表中的现有对。这就是为什么hash3中的“key1”=>nil对覆盖了hash1中的”key1“=>“value1”对。你还会注意到merge方法的返回值本身也是一个哈希表,因此我们可以对其调用任何哈希方法,包括再次调用merge。
因此,当我们第一次调用to_roman方法(❷)时,我们创建了一个名为@@roman_of的类变量,它包含将数字转换为罗马数字的基本情况。它要么使用减法方法,要么不使用,这取决于我们的配置选项。默认情况下,它包括减法表示。在所有这些之后,我们在❸处返回一个空字符串,除非整数(self)大于零。
你可能记得我说过这个脚本可以处理整数到 4,999 的罗马数字。这就是第❹行和maximum_representable方法(定义在第❾行)的作用所在。罗马数字可以表示的最大值(不引入不在标准罗马字母严格部分之上的竖线)是 4,999,所以我决定在那里停止。如果问题中的整数(self)大于可以显示的最大值,我们只需返回to_s方法的结果(❹)。让我们在 irb 中看看这个动作。
irb(main):001:0> digits = (0..9).to_a
=> [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
irb(main):002:0> digits.map { |d| (4995+d).to_roman }
=> ["MMMMCMXCV", "MMMMCMXCVI", "MMMMCMXCVII", "MMMMCMXCVIII", "MMMMCMXCIX",
"5000", "5001", "5002", "5003", "5004"]
一旦达到上限,我们仍然返回一个表示数值的字符串(罗马数字就是这样),我们只是在字符串中使用熟悉的阿拉伯数字符号。
更多递归
如果第❷行到❹行的代码让你想起了power_of.rb中的退出条件,你一直在注意。这正是我们接下来要做的。在❺处,我们创建了一个名为base的变量,它是从@@roman_of类变量开始的一系列方法调用的值。这些方法调用的目的是找到小于或等于self整数的@@roman_of哈希表中的最大键。
我们使用keys方法来获取哈希表的键,该方法返回一个包含哈希表键的数组。然后我们以reverse顺序对数组进行sort,这意味着我们从最高到最低开始。然后我们调用一个新的数组方法detect,并使用小于或等于self的条件。我认为detect的一个很好的别名是find first。让我们在 irb 中看看它。
irb(main):001:0> digits = (0..9).to_a
=> [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
irb(main):002:0> digits.detect { |d| d % 3 }
=> 0
irb(main):003:0> digits.reverse.detect { |d| d % 3 }
=> 9
irb(main):004:0> digits.detect { |d| d > 4 }
=> 5
irb(main):005:0> digits.reverse.detect { |d| d % 2 == 1 }
=> 9
detect方法遍历数组的每个成员,并返回第一个符合块中条件的数组元素。这使我们能够找到以@@roman_of哈希表表示的最高值,我们将它放入base变量中,位置❺。在❻处,我们return一个空字符串,除非我们找到了一个大于零的base;如果没有大于零的base,我们无法返回任何有用的信息。
基数的倍数
现在我们有一个大于零的整数base。如果我们调用像1066.to_roman这样的方法,我们没有任何问题,因为我们的base值(1,000)是整数中的整个千位部分。但如果我们想调用像2112.to_roman这样的方法呢?我们需要能够跟踪base的多少倍可以进入我们的整数。这就是我们在❶处所做的工作。
我们使用round_to_base方法(在❺处定义)来确定我们需要处理多少个base的倍数。我们对round_to_base的调用告诉我们需要处理多少个base的倍数。调用@@roman_of[base]也会找到表示基数的单个字母。
将字符串乘以整数
将字符串乘以一个整数会导致该字符串根据整数重复连接自身。让我们在 irb 中看看这个例子:
irb(main):006:0> 'M' * 2
=> "MM"
这直接来自我们的2112.to_roman示例。“MM”的输出负责表示 2112 中的 2,000 部分,并且在❶行我们也对to_roman进行了递归调用;然而,这次我们调用的是一个更小的数字,具体来说是 112。因为我们在移除base的倍数时,调用to_roman方法的数字会不断减小,我们最终会达到一个点,在❸处退出,标记所有递归调用to_roman的结束。那时我们将得到最终输出。
运行脚本
这在 irb 中很容易演示。
$ irb -r roman_numeral.rb
irb(main):001:0> (0..9).to_a.map { |n| n.to_roman }
结果
这里是输出:
=> ["", "I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX"]
修改脚本
对于这个脚本,你还可以选择其他选项。我们不必将SUBTRACTIVE作为类常量,我们也可以让to_roman方法接受一个参数。如果你这样做,你需要跟踪两个不同的[SOMETHING]_TO_ROMAN哈希表,一个使用减法显示方法,另一个不使用。我决定假设将使用减法方法,因为这确实似乎在罗马数字中非常常见。然而,我想提到如何自定义这个脚本使其稍微复杂一些——但也更灵活。
当我们创建to_lang方法时,我们将重新审视将整数表示为不同类型的字符串的想法。现在,让我们继续到我们的第一个货币转换器。
#18 货币转换,基础(currency_converter1.rb)
我之前提到,commify 方法需要根据每个国家如何处理数字的表示方式而有所不同。这个问题最常出现的地方当然是货币。实际的转换过程包括相对直接的数学运算,但我们将使用这个脚本作为介绍我们下一脚本中引入的两个重要概念的载体——特别是使用 XML(可扩展标记语言,www.w3c.org/xml)或 YAML(YAML Ain’t Markup Language,www.yaml.org)来表示数据。我们将在下一脚本中进一步探索 XML 和 YAML,但现在,让我们尝试使用 irb -r currency_converter.rb 在 irb 中运行我们的当前脚本。
irb(main):001:0> cc = CurrencyConverter.new()
=> #<CurrencyConverter:0xb7c979f4 @name_of={"USD"=>"US Dollar"},
@base_currency="USD">
❶ irb(main):002:0> puts cc.output_rates(1)
1 US Dollar (USD) =
46.540136 Indian Rupees(INR)
0.781738 Euros(EUR)
10.890852 Mexican Pesos(MXN)
7.977233 Chinese Yuans(CNY)
1.127004 Canadian Dollars(CAD)
=> nil
❷ irb(main):003:0> puts cc.output_rates(42)
42 US Dollars (USD) =
1954.685712 Indian Rupees(INR)
32.832996 Euros(EUR)
457.415784 Mexican Pesos(MXN)
335.043786 Chinese Yuans(CNY)
47.334168 Canadian Dollars(CAD)
=> nil
在我们的 irb 会话的第一个响应中,我们可以看到我们的 cc 实例似乎对美元情有独钟——但如果你在其他国家,请不要担心,你将在脚本的改进版本中学习如何使用不同的货币。在❶中,你可以看到我们的 cc 实例的 output_rates 方法接受一个参数,并似乎以几种其他货币输出相当于那么多美元的金额。在❷中,你可以看到随着美元数量的不同,值按预期发生变化。让我们通过检查源代码来看看这是如何工作的。
代码
#!/usr/bin/env ruby
# currency_converter1.rb
# Using fixed exchange rates
class CurrencyConverter
❶ BASE_ABBR_AND_NAME = { 'USD' => 'US Dollar' }
FULLNAME_OF = {
'EUR' => 'Euro',
'CAD' => 'Canadian Dollar',
'CNY' => 'Chinese Yuan',
'INR' => 'Indian Rupee',
'MXN' => 'Mexican Peso',
}
EXCHANGE_RATES = {
'EUR' => 0.781738,
'INR' => 46.540136,
'CNY' => 7.977233,
'MXN' => 10.890852,
'CAD' => 1.127004,
}
❷ def initialize() *Initializing Class Variables*
@base_currency = BASE_ABBR_AND_NAME.keys[0]
@name = BASE_ABBR_AND_NAME[@base_currency]
end
❸ def output_rates(mult=1)
get_value(mult, get_rates) + "\n"
end
private
❹ def get_rates()
return EXCHANGE_RATES
end
❺ def get_value(mult, rates)
❻ return pluralize(mult, @name) +
" (#{@base_currency}) = \n" +
❼ rates.keys.map do |abbr|
❽ "\t" +
pluralize(mult * rates[abbr], FULLNAME_OF[abbr]) +
"(#{abbr})"
❾ end.join("\n")
end
=begin rdoc
This assumes that all plurals will be formed by adding an 's'.
It could be made more flexible with a Hash of plural suffixes
(which could be the empty string) or explicit plural forms that
are simple replacements for the singular.
For convenience, this outputs a string with the number of items,
a space, and then the pluralized form of the currency unit.
That suited the needs of this particular script.
=end
❿ def pluralize(num, term)
(num == 1) ? "#{num} #{term}" : "#{num} #{term}s"
end
end
它是如何工作的
在❶处,我们定义了类的“家庭”货币,紧接着,我们通过 FULLNAME_OF 和 EXCHANGE_RATES 哈希表定义了一些其他货币的便捷代码。EXCHANGE_RATES 哈希表包含我们的预设汇率值。这些值在我创建此对象时是有效的,但我相信在你阅读时至少会有所不同。
在❷处的 initialize 方法为我们提供了一些与家庭货币相关的便捷实例变量,而我们唯一的公共方法 output_rates(❸)只是私有 get_value 方法(❺)的一个包装器,并在其中添加了一个换行符.^([14]) get_value 方法还使用了一个名为 get_rates 的另一个私有方法,其定义(❹)在此点应该对你来说相当清晰。
get_value 方法还使用了一个名为 pluralize 的另一个私有方法(❿),该方法在适当的情况下返回一个字符串,其中货币的术语是复数形式。我非常简单地实现了这个方法,因为英语只需要在术语的末尾加上一个 s 来表示复数。通过一些修改,这个方法可以处理其他语言或具有更复杂复数需求术语,最有可能是一个以货币术语为键、复数结尾为值的哈希表。目前,我们只需要在大于一个的货币金额末尾添加一个 s。
get_value 方法返回(❻)一个带有等号的复数形式的基准货币,后面跟着该类所知的每种货币的信息。从 ❼ 开始,它将一个操作映射到每种货币类型(由 rates Hash 的键表示)。映射的操作(❽)是输出一个制表符,然后根据其相对价值、全名和缩写,输出该货币的正确复数形式。然后,每种货币的字符串输出在 ❾ 处通过换行符连接起来,从而完成从 ❻ 处开始的 return 语句。
运行脚本
这也可以在 irb 中通过 irb -r currency_converter1.rb 来轻松演示。
结果
$ irb -r currency_converter1.rb
irb(main):001:0> cc = CurrencyConverter.new()
=> #<CurrencyConverter:0xb7c94b4c @base_currency="USD", @name="US Dollar">
irb(main):002:0> cc.output_rates
=> "1 US Dollar (USD) = \n\t46.540136 Indian Rupees(INR)\n\t0.781738
Euros(EUR)\n\t10.890852 Mexican Pesos(MXN)\n\t7.977233 Chinese Yuans(CNY)\n\
t1.127004 Canadian Dollars(CAD)\n"
irb(main):003:0> puts cc.output_rates
1 US Dollar (USD) =
46.540136 Indian Rupees(INR)
0.781738 Euros(EUR)
10.890852 Mexican Pesos(MXN)
7.977233 Chinese Yuans(CNY)
1.127004 Canadian Dollars(CAD)
=> nil
注意到更美观的输出是通过使用 puts 实现的,而且 output_rates 返回的值是 nil,这主要是因为它旨在打印结果。
修改脚本
当汇率保持恒定并可以存储在恒定的 Hash 中时,比如在这个脚本中,这一切都很正常。然而,货币转换器的主要动力来自于汇率不断变化的事实。我们需要一个转换器,当新信息可用时能够更新自己,同时在信息不可用的情况下(无论什么原因)仍然可以工作。这就是我们的下一个脚本。
^([14]) 我们在 private 关键字之前定义了 initialize,但 initialize 总是私有的方法,所以 output_rates 是 CurrencyConverter 的唯一公共方法。
#19 货币转换,高级(currency_converter2.rb)
这个脚本基于我们从之前的脚本中学到的知识,并使用类似的方法进行实际的转换过程。我们增加的功能是能够以 YAML 和 XML 格式存储和检索外部数据。YAML 的可读性如此之高,以至于我只需简单告诉你这个脚本你需要知道的内容,我相信你一定会被激发去了解更多关于它是如何工作的信息。XML 要复杂一些,如果你不熟悉它,本书的范围不包括教你 XML,但你不需要成为专家就能理解。我会描述这个脚本操作中相关的 XML 部分,就像我会做 YAML 一样。如果你觉得这一章中关于 XML 的内容有点快,请参考优秀的在线 XML 教程 www.w3schools.com/xml。
这个脚本与之前的脚本在几个方面有所不同。让我们看看它是如何做到的。
代码
#!/usr/bin/env ruby
# currency_converter2.rb
### RSS feeds for rates at
# http://www.currencysource.com/rss_currencyexchangerates.html
=begin rdoc
open-uri allows Kernel.open to read data using a URI, not just from
a local file.
=end
❶ require 'open-uri'
=begin rdoc
YAML[http://www.yaml.org] stands for "YAML Ain't Markup Language"
and is a simple human-readable data markup format.
=end
require 'yaml' *YAML*
=begin rdoc
I also want to add a method to all <b>Hash</b>es.
=end
class Hash
=begin rdoc
Allow <b>Hash</b>es to be subtracted from each other.
=end
❷ def -(hash_with_pairs_to_remove_from_self) *A Subtraction Method for Hashes; The **`delete`** Method*
output = self.dup
hash_with_pairs_to_remove_from_self.each_key do |k|
output.delete(k)
end
output
end
end
❸ class CurrencyConverter
BASE_URL = 'http://currencysource.com/RSS'
CURRENCY_CODES = {
'EUR' => 'Euro',
'CAD' => 'Canadian Dollar',
'CNY' => 'Chinese Yuan',
'INR' => 'Indian Rupee',
'MXN' => 'Mexican Peso',
'USD' => 'US Dollar',
}
RATES_DIRECTORY = 'extras/currency_exchange_rates'
def initialize(code='USD') *The `has_key?` and `fail` Methods*
unless CURRENCY_CODES.has_key?(code)
fail "I know nothing about #{code}"
end
@base_currency = code
@name = CURRENCY_CODES[code]
end
❹ def output_rates(mult=1, try_new_rates=true)
rates = get_rates(try_new_rates)
save_rates_in_local_file!(rates)
return get_value(mult, rates) + "\n"
end
private
❺ def download_new_rates()
puts 'Downloading new exchange rates...'
begin ***`begin - rescue - end`***
raw_rate_lines = get_xml_lines()
rescue
puts 'Download failed. Falling back to local file.'
return nil
end
rates = Hash.new('')
comparison_codes = CURRENCY_CODES - { @base_currency => @name }
comparison_codes.each_key do |abbr|
rates[abbr] = get_rate_for_abbr_from_raw_rate_lines(
abbr,
raw_rate_lines
)
end
return rates
end
❻ def get_rates(try_new_rates)
return load_old_rates unless try_new_rates
return download_new_rates || load_old_rates
end
def get_rate_for_abbr_from_raw_rate_lines(abbr, raw_rate_lines)
regex = {
:open =>
/^\<title\>1 #{@base_currency} = #{abbr} \(/,
:close =>
/\)\<\/title\>\r\n$/
}
line = raw_rate_lines.detect { |line| line =~ /#{abbr}/ }
line.gsub(regex[:open], '').gsub(regex[:close], '').to_f
end
def get_value(mult, rates)
return "#{pluralize(mult, @name)} (#{@base_currency}) = \n" +
rates.keys.map do |abbr|
"\t#{pluralize(mult * rates[abbr], CURRENCY_CODES[abbr])} (#{abbr})"
end.join("\n")
end
=begin rdoc
get_xml_lines is able to read from a URI with the open-uri library.
This also could have been implemented with the RSS library
written by Kouhei Sutou <kou@cozmixng.org> and detailed at
http://www.cozmixng.org/~rwiki/?cmd=view;name=RSS+Parser%3A%3ATutorial.en
=end
❼ def get_xml_lines() *XML*
open("#{BASE_URL}/#{@base_currency}.xml").readlines.find_all do |line|
line =~ /1 #{@base_currency} =/
end
end
❽ def load_old_rates()
puts "Reading stored exchange rates from local file #{rates_filename()}"
rates = YAML.load(File.open(rates_filename)) ***`YAML.load`***
fail 'no old rates' unless rates
return rates
end
def pluralize(num, term)
(num == 1) ? "#{num} #{term}" : "#{num} #{term}s"
end
❾ def rates_filename()
"#{RATES_DIRECTORY}/#{@base_currency}.yaml"
end
=begin rdoc
Store new rates in an external YAML file.
This is a side-effect akin to memoization, hence the bang.
=end
❿ def save_rates_in_local_file!(rates)
return unless rates
File.open(rates_filename, 'w') { |rf| YAML.dump(rates, rf) } ***`YAML.dump`***
end
end
它是如何工作的
这个文件与之前的文件有何不同?由于在❶处有一些额外的注释和require语句,CurrencyConverter 类的定义被延迟到❸。我还打开了 Hash 类,并给它一个减法方法,由❷处的减号标识。这个新方法接受另一个 Hash,并返回原始 Hash,其中不包含在参数 Hash 中找到的任何配对。可以这样想:如果merge是 Hash 的加法,那么这个方法就是 Hash 的减法。我想一个更好的替代名称可能是demerge或unmerge。
在我们的 CurrencyConverter 类(❸)中,我们有两个新的常量:BASE_URL,用于下载全新的汇率,以及RATES_DIRECTORY,用于存储下载后的汇率。类的initialize方法接受一个货币code,这样来自其他国家的用户可以更容易地定义他们自己的本地转换器。(它假设没有参数时使用美元。)如果它收到一个它不理解的货币code,它不应该继续,所以我们使用fail命令使其跳出整个程序,这会导致程序停止运行。output_rates方法(❹)在需要时也会尝试获取新汇率,将汇率保存在本地文件中,并执行我们已从上一个脚本中了解到的操作。
它是如何获取新利率的?get_rates 方法(❻)显示它要么加载旧利率,要么下载新利率。如果它尝试download_new_rates(❺)但失败,它将再次回退到其旧利率。默认情况下,它会下载新利率,所以让我们看看download_new_rates。
在一些解释性打印之后,我们得到一个begin语句,它启动一个代码块,表示尝试做一些事情,如果尝试失败,则回退到其他代码。我们试图调用get_xml_lines方法。如果失败,我们将通过puts向用户解释下载失败并返回nil。end语句告诉我们与begin相关的代码块已经结束。return nil允许我们在get_xml_lines方法失败时在get_rates中回退到旧汇率。
那么get_xml_lines方法做什么?它在❷处定义,并找到所有在给定 XML 文件中,基本货币单位以等号出现的一行。这些行告诉我们我们的汇率。让我们看看这些 XML 文件中的一个样子。以下是我从www.currencysource.com/RSS/USD.xml下载的文件中的几行。
<item>
<title>1 USD = ARS (3.017607)</title>
<link>http://www.currencysource.com/tables/USD/1X_USD.htm</link>
<description><![CDATA[As of Thursday, May 04, 2006...<br>1 U.S. Dollar (USD) =
3.017607 Argentine Peso (ARS)<br><br>Call 1-877-627-4817 for 'LIVE'
assistance.<br><br>Source: IMF<br><br>Aggregated and published by
CurrencySource.com<br>'Rated #1 in Currency Exchange']]></description>
<pubDate>Sun, 08 Oct 2006 06:00:04 CST</pubDate>
</item>
<item>
<title>1 USD = AUD (1.342818)</title>
<link>http://www.currencysource.com/tables/USD/1X_USD.htm</link>
<description><![CDATA[As of Sunday, October 08, 2006...<br>1 U.S. Dollar (USD)
= 1.342818 Australian Dollar (AUD)<br><br>Call 1-877-627-4817 for 'LIVE'
assistance.<br><br>Source: IMF<br><br>Aggregated and published by
CurrencySource.com<br>'Rated #1 in Currency Exchange']]></description>
<pubDate>Sun, 08 Oct 2006 06:00:04 CST</pubDate>
</item>
如果你还不熟悉 XML,你在这里可以看到它由文本组成,其中各种内容被 标签 包围,这些标签是 < 和 > 字符内的文本片段。换行符没有意义。我们有 item 类型的两种定义,每种都有一个标题、一个链接、一个描述和一个 pubDate。这是我们正在搜索的内容。你会注意到 <title> 行包含关于基准货币与其他货币之间汇率直接陈述——在我的例子中,是阿根廷比索和澳大利亚元。
这种操作可能失败的原因是我们试图打开并对其调用 readlines 的文件不是一个本地文件,而是通过 URL 从互联网上检索的文件。我们在❶中要求的 open-uri 库修改了 open 命令,使我们能够打开 URL 以及本地文件。如果没有正常工作的互联网连接,open 将会失败,这意味着在 get_xml_lines 中将没有文件可以调用 readlines 方法。然而,如果我们的下载操作成功,我们将在 download_new_rates 中将内容赋值给 raw_rate_lines 变量。download_new_rates 方法的其余部分从原始行中提取汇率内容。
下载汇率信息
download_new_rates 方法首先定义一个名为 rates 的变量,它是一个哈希表。我们在 download_new_rates 方法中给 Hash.new 传递一个参数,这样当在哈希表中找不到给定键时,返回的值就不再是 nil,而是传递给 Hash.new 的参数(在我们的例子中是空字符串)。就我们的目的而言,我们想要找到 comparison_codes,这是所有与货币及其代码相关的成对信息,但不包括 @base_currency。^([15]) 然后,我们遍历每个键,即与匹配货币相关的缩写或代码,并调用 get_rate_for_abbr_from_raw_rate_lines 方法,该方法从 raw_rate_lines 变量中获取给定缩写的汇率。
get_rate_for_abbr_from_raw_rate_lines 方法定义在 get_rates 定义之后,即❻。regex 变量是一个哈希表,它存储了一些表示我们关心的内容(实际汇率值)的开头和结尾的正则表达式。我们检测包含插值 abbr 值的第一行,然后通过将每个正则表达式替换为空字符串来去除开头和结尾的 regex 值。然后,我们通过 to_f 方法返回我们剩下的浮点版本。这就是与 abbr 参数匹配的货币的汇率。
我们通过下载获得了我们的费率,这意味着我们可以在initialize函数中将它们保存到本地文件中。如果没有费率,我们立即退出save_rates_in_local_file!(❿)并且不进行任何操作。这样做的原因是,如果获取费率时出现问题,我们不希望覆盖我们之前使用此脚本时存储的良好数据。假设一切顺利,我们以rates_filename为名打开一个新文件进行写入,它看起来像是一个变量。实际上,它是一个方法,定义在❾处。它返回类似“extras/currency_exchange_rates/USD.yaml”或“extras/currency_exchange_rates/CAD.yaml”的内容,具体取决于你的基础货币。它是一个方法,因为它完全依赖于@base_currency的值。
注意
一些编程学校可能会在initialize方法中定义一个实例变量@rates_filename,就像我们为@base_currency和@name所做的那样。相反,我们也可以像对待rates_filename一样对待@name,定义一个名为name的方法,它简单地返回CURRENCY_CODES[@base_currency]的值。任何一种方法都是有用的。使用实例变量(“急切”方法)更快,但与彼此关系密切的不同变量可能会失去同步,尤其是在更复杂的程序中。使用方法(“懒惰”方法)较慢,因为它每次都必须重新计算其返回值——但这也意味着你的变量不会在这个案例中失去一致性。
无论是一个实例变量还是一个方法,我们对rates_filename的主要关注点是它是一个可以写入的文件名。我们使用YAML.dump进行写入,它接受两个参数;第一个是一个将被转换为 YAML 并写入第二个参数的数据结构,第二个参数是一个文件对象。让我们打开extras/currency_exchange_rates/USD.yaml并查看我们写了什么。
---
EUR: 0.789639
INR: 45.609987
CNY: 7.890017
MXN: 11.062366
CAD: 1.126398
这就是USD.yaml的全部内容。它代表一个单一的哈希表,其键是货币代码,值是浮点数。你会注意到换行符很重要,尽管这个例子没有显示,缩进也是如此。你可以在www.yaml.org上学习很多关于 YAML 的知识,但我发现YAML.dump是学习如何在 YAML 中表示事物的一个很好的方法。如果你将一个你理解的数据结构传递给YAML.dump,你可以阅读生成的.yaml 文件以查看正确的表示形式。然后你可以以某种特定的方式更改数据结构,使用YAML.dump重新写入,并比较结果。这非常有用。
无论如何,我们现在已经使用save_rates_in_local_file!将汇率数据以 YAML 格式存储在外部文件中。我们仍然可以使用rates变量,因此我们调用get_value方法,该方法与上一个脚本中的方法相同。
如果你无法下载新的费率怎么办?
在后续对脚本的调用中,我们可能无法下载新的汇率,正如之前提到的。因此,让我们再次查看get_rates方法,并假设我们告诉脚本不要下载新的汇率(使用try_new_rates参数的false值)或者下载尝试失败。无论哪种情况,我们都需要从存储的 YAML 文件中获取汇率。
load_old_rates方法位于❽。它通知用户将尝试从本地文件读取。从 YAML 文件中获取实际数据几乎不可能更简单:你只需调用YAML.load,并给它一个文件参数,在我们的例子中是调用File.open在rates_filename上的结果。YAML.load的结果是存储在外部文件中的任何数据结构,所以我们将其分配给一个名为rates的变量。然后我们确保在继续之前能够将数据读入rates,最后返回rates。
运行脚本
在所有这些解释之后,我们终于可以看到脚本在 irb 中的实际运行情况,使用irb -r currency_converter2.rb。
结果
irb(main):001:0> usd = CurrencyConverter.new
=> #<CurrencyConverter:0xb7bfb498 @name="US Dollar", @base_currency="USD">
irb(main):002:0> inr = CurrencyConverter.new('INR')
=> #<CurrencyConverter:0xb7bef990 @name="Indian Rupee", @base_currency="INR">
irb(main):003:0> usd.output_rates(1)
Downloading new exchange rates...
=> "1 US Dollar (USD) = \n\t45.609987 Indian Rupees (INR)\n\t0.789639 Euros
(EUR)\n\t11.062366 Mexican Pesos (MXN)\n\t7.890017 Chinese Yuans (CNY)\n\
t1.126398 Canadian Dollars (CAD)\n"
irb(main):004:0> inr.output_rates(1)
Downloading new exchange rates...
=> "1 Indian Rupee (INR) = \n\t0.017313 Euros (EUR)\n\t0.242543 Mexican Pesos
(MXN)\n\t0.172989 Chinese Yuans (CNY)\n\t0.021925 US Dollars (USD)\n\t0.024696
Canadian Dollars (CAD)\n"
irb(main):005:0> usd.output_rates(1, false)
Reading stored exchange rates from local file extras/currency_exchange_rates/USD.yaml
=> "1 US Dollar (USD) = \n\t0.789639 Euros (EUR)\n\t45.609987 Indian Rupees
(INR)\n\t7.890017 Chinese Yuans (CNY)\n\t11.062366 Mexican Pesos (MXN)\n\
t1.126398 Canadian Dollars (CAD)\n"
irb(main):006:0> inr.output_rates(100, false)
Reading stored exchange rates from local file extras/currency_exchange_rates/
INR.yaml
=> "100 Indian Rupees (INR) = \n\t1.7313 Euros (EUR)\n\t17.2989 Chinese Yuans
(CNY)\n\t24.2543 Mexican Pesos (MXN)\n\t2.4696 Canadian Dollars (CAD)\n\
t2.1925 US Dollars (USD)\n"
❶ irb(main):007:0> inr.output_rates(100, (not true))
Reading stored exchange rates from local file extras/currency_exchange_rates/
INR.yaml
=> "100 Indian Rupees (INR) = \n\t1.7313 Euros (EUR)\n\t24.2543 Mexican Pesos
(MXN)\n\t17.2989 Chinese Yuans (CNY)\n\t2.1925 US Dollars (USD)\n\t2.4696
Canadian Dollars (CAD)\n"
你可以看到我们可以轻松地为特定货币定义转换器;然后我们可以告诉output_rates方法尝试下载新汇率或不下载,这取决于可选的第二个参数是否评估为false。在❶行,你可以看到我传递了(not true)只是为了说明这一点。你也会注意到,带有特殊字符(如换行符和制表符)的返回值以与我们插入它们时相同的方式表示这些字符,而打印这些返回值会导致它们被解释,使得打印输出更美观,或者至少更容易阅读。
修改脚本
此脚本依赖于BASE_URL目录层次结构的保持不变。如果它发生变化,你需要相应地更新❽处的get_xml_lines()。我们还将更深入地探讨一些函数式编程主题。一旦你熟悉了下一章中介绍的lambda,你可以用接受RATES_DIRECTORY和@base_currency作为参数的 lambda 替换rates_filename方法。
^([15]) 我们移除了@base_currency,因为它在给出特定货币与其自身之间的汇率时没有用——汇率总是正好是一。
章节总结
本章有什么新内容?
-
Ruby 中的指数运算
-
当方法操作不可行时返回
nil -
更多递归和退出条件
-
模块和继承
-
Hash.merge -
类变量
-
Array.detect(“找到第一个”) -
减法哈希
-
使用
fail退出整个脚本 -
begin—rescue—end -
使用
open-uri下载 -
使用正则表达式解析 XML 文件
-
使用
YAML.dump写入 YAML 文件 -
使用
YAML.load从 YAML 文件中读取
这几乎就像是这一章根本不是关于数字的——我们涵盖了大量通用的有用信息,特别是模块、类变量以及使用 XML 或 YAML(或两者兼用)进行的外部数据存储和检索。在前两章中,我们已经做了一些函数式编程,但我们将深入探讨第六章(第六章)中的深度 lambda 魔法。
第六章. 使用块和 Procs 的函数式编程

Ruby 有两个主要祖先:Smalltalk 和 Lisp.^([16]) 从 Smalltalk,Ruby 获得了其强大的面向对象特性,我们在此之前已经深入探讨了。从 Lisp,Ruby 得到了几个来自 函数式编程 的想法,这是一种非常数学化的编程方法,具有一些显著的特点。首先,变量往往只定义一次,之后不会改变它们的值。此外,函数往往简单、抽象,并用作其他函数的构建块;与非函数式方法相比,函数(执行操作)和 数据(函数操作的对象)之间的界限通常模糊。函数还倾向于通过返回值来完成工作,而不是产生副作用——在 Ruby 术语中,以感叹号结尾的方法较少见。
Ruby 对函数式编程的支持广泛且令人兴奋。让我们深入探讨。
#20 我们的第一条 lambda (make_incrementer.rb)
此脚本探讨了 Ruby 如何创建应被视为对象的函数。在 Ruby 中,每个“事物”都是一个对象,因此将函数视为对象的概念在概念上并不奇特。在 Ruby 中,我们使用 lambda 命令来做到这一点,它接受一个块。让我们在 irb 中看看这个例子。
irb(main):001:0> double_me = lambda { |x| x * 2 }
=> #<Proc:0xb7d1f890@(irb):1>
irb(main):002:0> double_me.call(5)
=> 10
您可以通过第一行的返回值看到,调用 lambda 的结果是类 Proc 的一个实例。Proc 是 procedure 的缩写,虽然大多数对象是由它们 是什么 来定义的,但 Procs 可以主要被认为是根据它们 做什么 来定义的。Procs 有一个名为 call 的方法,它告诉 Proc 实例执行其操作。在我们的 irb 示例中,我们有一个名为 double_me 的 Proc 实例,它接受一个参数并返回该参数的两倍。在第二行,我们看到将数字 5 输入到 double_me.call 中,结果返回值为 10,正如您所期望的那样。创建其他执行其他操作的 Procs 很容易。
irb(main):003:0> triple_me = lambda { |x| x * 3 }
=> #<Proc:0xb7d105bc@(irb):3>
irb(main):004:0> triple_me.call(5)
=> 15
由于 Procs 是对象,就像 Ruby 中的其他一切一样,我们可以像对待任何其他对象一样对待它们。它们可以是方法的返回值,可以是 Hash 的键或值,可以是其他方法的参数,以及任何对象可以成为的其他东西。让我们看看演示这一点的脚本。
代码
#!/usr/bin/env ruby
# make_incrementer.rb
❶ def make_incrementer(delta)
return lambda { |x| x + delta } *Procs*
end
❷ incrementer_proc_of = Hash.new()
[10, 20].each do |delta|
incrementer_proc_of[delta] = make_incrementer(delta)
end
❸ incrementer_proc_of.each_pair do |delta,incrementer_proc|
puts "#{delta} + 5 = #{incrementer_proc.call(5)}\n" *Calling Procs*
end
❹ puts
❺ incrementer_proc_of.each_pair do |delta,incrementer_proc| *The **`each_pair`** Method*
❻ (0..5).to_a.each do |other_addend|
puts "#{delta} + #{other_addend} = " +
incrementer_proc.call(other_addend) + "\n"
end
end
它是如何工作的
在 ❶ 我们定义了一个名为 make_incrementer 的方法。它接受一个名为 delta 的单个参数,并返回一个 Proc(通过 lambda 创建),该 Proc 将 delta 添加到其他某个东西上,用 x 表示。那是什么东西?我们尚不清楚。这正是此方法的精确点——它允许我们定义一个可以多次使用不同参数执行的操作,就像任何其他函数一样。
我们可以在脚本的其他部分看到这是如何有用的。在 ❷ 我们定义了一个名为 incrementer_proc_of 的新 Hash。对于 10 和 20 这两个值,我们使用 10 或 20(在 make_incrementer 方法中作为 delta 的值)创建一个增量器,并将结果 Proc 分配给 incrementer_proc_of Hash。从 ❸ 开始,我们使用 each_pair 方法从 Hash 中读取每个 delta 和 Proc 对,然后使用 puts 打印一行,描述该 delta 值以及调用其 Proc 并以 5 作为参数的结果。
我们使用 puts 打印一个空格(只是为了便于阅读输出),最后输出另一组数据。这次我们为名为 other_addend 的值添加了另一个循环;这是一个变量,其作用类似于循环中的静态值 5(❸)。让我们用 ruby -w make_incrementer.rb 运行这个程序并查看输出。
结果
20 + 5 = 25
10 + 5 = 15
20 + 0 = 20
20 + 1 = 21
20 + 2 = 22
20 + 3 = 23
20 + 4 = 24
20 + 5 = 25
10 + 0 = 10
10 + 1 = 11
10 + 2 = 12
10 + 3 = 13
10 + 4 = 14
10 + 5 = 15
在空行之前的两行显示了第一个循环(使用静态值 5 作为加数)的输出,而其余的输出显示了第二个循环的结果,该循环使用 other_addend 变量。注意,each_pair 不会按键排序,这就是为什么我的输出中 delta 值为 20 出现在第一位。根据你实现的 Ruby 版本,你可能会先看到一个 delta 值为 10。
现在你已经知道了如何创建 Procs。让我们学习如何使用它们做一些比仅仅展示自己更有用的事情。
^([16]) 这是一个可能存在争议的陈述。在 RubyConf 上,我曾问过 Matz 他认为哪些语言对 Ruby 影响最大。他的回答是“Smalltalk 和 Common Lisp”。Ruby 社区的其他人(其中许多人是前 Perl 用户)强调 Ruby 与 Perl 的明显相似性。可能最安全的说法是,Ruby 源自 Smalltalk 和 Lisp,虽然它与 Perl 很像,但 Perl 更像是一个阿姨或叔叔。
#21 使用 Procs 进行过滤(matching_members.rb)
到目前为止,我们已经看到,要创建一个 Proc,我们需要用描述该 Proc 应该做什么的块调用 lambda。这会让你认为 Procs 和块之间存在特殊的关系,确实如此。我们的下一个脚本演示了如何用 Procs 代替块。
代码
#!/usr/bin/env ruby
# matching_members.rb
=begin rdoc
Extend the built-in <b>Array</b> class.
=end
class Array
=begin rdoc
Takes a <b>Proc</b> as an argument, and returns all members
matching the criteria defined by that <b>Proc</b>.
=end
❶ def matching_members(some_proc) *Procs as Arguments*
find_all { |i| some_proc.call(i) }
end
end
❷ digits = (0..9).to_a
lambdas = Hash.new()
lambdas['five+'] = lambda { |i| i >= 5 }
lambdas['is_even'] = lambda { |i| (i % 2).zero? }
❸ lambdas.keys.sort.each do |lambda_name|
❹ lambda_proc = lambdas[lambda_name]
❺ lambda_value = digits.matching_members(lambda_proc).join(',')
❻ puts "#{lambda_name}\t[#{lambda_value}]\n"
end
它是如何工作的
在这个脚本中,我们打开 Array 类以添加一个名为 matching_members(❶)的新方法。它接受一个名为 some_proc 的 Proc(见下面的注释)作为参数,并返回调用 find_all 的结果,正如其名称所暗示的,它会找到所有满足块条件的成员。在这种情况下,块中的条件是调用 Proc 参数并将数组成员作为 call 的参数传递给数组的结果。在我们完成新方法的定义后,我们在 lambdas Hash 中设置了适当的名称,并在 ❷ 处设置了我们的 digits 数组和 Procs。
注释
一些同事嘲笑我使用的变量和方法名称——比如 some_proc。我认为名称应该是非常具体的,比如 save_rates_to_local_file!,或者明确通用,比如 some_proc。对于真正通用的操作,我经常使用像 any_proc 或 any_hash 这样的变量名,这明确地告诉你对这些操作进行的操作旨在对任何进程或哈希表都很有用。
在 ❸ 处,我们遍历每个排序的 lambda_name,在 ❹ 处,我们将每个进程提取出来作为名为 lambda_proc 的变量。然后我们在 ❺ 处根据该进程描述的条件 find_all digits 数组的成员,并在 ❻ 处输出适当的消息。
运行脚本
让我们用 ruby -w matching_members.rb 来看看它的实际效果。
结果
five+ [5,6,7,8,9]
is_even [0,2,4,6,8]
在每种情况下,我们根据一些特定的条件过滤 digits 数组的成员。希望你会发现我为每个进程选择的名称与该进程所做的工作相匹配。five+ 进程对任何大于或等于五的参数返回 true.^([17]) 我们可以看到,对每个数字依次调用 five+ 都返回正确的数字。同样,is_even 进程过滤其输入,只对偶数返回 true,其中 偶数 定义为模二等于零。同样,我们得到了正确的数字。
当我们想要根据多个标准进行过滤时会发生什么?我们可以用一个进程进行一次过滤,将结果赋给一个数组,然后根据第二个标准过滤那个结果。这是完全有效的,但如果我们有未知数量的过滤条件怎么办?我们想要一个 matching_members 的版本,它可以接受任意数量的进程。这就是我们的下一个脚本。
^([17]) 它通过表达式 i >= 5 的隐式布尔评估来完成这个操作。
#22 使用进程进行复合过滤(matching_compound_members.rb)
在这个脚本中,我们将使用任意数量的进程来过滤数组。和之前一样,我们将打开数组类,这次添加两个方法。再次,我们将基于简单的数学测试过滤数字。让我们看看源代码,看看有什么不同。
代码
#!/usr/bin/env ruby
# matching_compound_members.rb
=begin rdoc
Extend the built-in <b>Array</b> class.
=end
class Array
=begin rdoc
Takes a block as an argument and returns a list of
members matching the criteria defined by that block.
=end
❶ def matching_members(&some_block) *Block Arguments*
find_all(&some_block)
end
=begin rdoc
Takes an <b>Array</b> of <b>Proc</b>s as an argument
and returns all members matching the criteria defined
by each <b>Proc</b> via <b>Array.matching_members</b>.
Note that it uses the ampersand to convert from
<b>Proc</b> to block.
=end
❷ def matching_compound_members(procs_array)
procs_array.map do |some_proc|
# collect each proc operation
❸ matching_members(&some_proc)
❹ end.inject(self) do |memo,matches|
# find all the intersections, starting with self
# and whittling down until we only have members
# that have matched every proc
❺ memo & matches *Array Intersections*
end
❻ end
end
# Now use these methods in some operations.
❼ digits = (0..9).to_a
lambdas = Hash.new()
lambdas['five+'] = lambda { |i| i if i >= 5 }
lambdas['is_even'] = lambda { |i| i if (i % 2).zero? }
lambdas['div_by3'] = lambda { |i| i if (i % 3).zero? }
lambdas.keys.sort.each do |lambda_name|
lambda_proc = lambdas[lambda_name]
lambda_values = digits.matching_members(&lambda_proc).join(',')
❽ puts "#{lambda_name}\t[#{lambda_values}]\n"
end
❾ puts "ALL\t[#{digits.matching_compound_members(lambdas.values).join(',')}]"
它是如何工作的
我们首先定义一个名为 matching_members 的方法(❶),就像之前一样。然而,这次我们的参数被命名为 some_block 而不是 some_proc,并且它前面有一个与(&)。为什么?
块、进程和与(&)
与(&)是 Ruby 表达块和进程的方式,这在方法参数中非常有用。你可能记得,块只是分隔符(如花括号 { “I’m a block!” } 或 do 和 end 关键字 do “I’m also a block!” end)之间的代码片段。进程是通过 lambda 方法从块创建的对象。它们中的任何一个都可以传递给方法,而与(&)就是用来将一个用作另一个的方式。让我们在 irb 中测试一下。
irb(main):001:0> class Array
irb(main):002:1> def matches_block( &some_block ) *& Notation for Blocks and Procs*
irb(main):003:2> find_all( &some_block )
irb(main):004:2> end
irb(main):005:1> def matches_proc( some_proc )
irb(main):006:2> find_all( &some_proc )
irb(main):007:2> end
irb(main):008:1> end
=> nil
我们打开 Array 类并添加一个名为 matches_block 的方法;这个方法接收一个带有 & 前缀的块,实际上复制了现有 find_all 方法的功能,并调用它。我们还添加了另一个名为 matches_proc 的方法,它再次调用 find_all,但这次接收一个 Proc。然后我们尝试使用它们。
irb(main):009:0> digits = (0..9).to_a
=> [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
irb(main):010:0> digits.matches_block { |x| x > 5 }
=> [6, 7, 8, 9]
irb(main):011:0> digits.matches_proc( lambda { |x| x > 5 } )
=> [6, 7, 8, 9]
matches_block 方法尽职尽责地接收一个块并将其传递给 find_all 方法,在传递过程中使用 & 符号进行转换——一次是在输入时,再次是在传递给 find_all 时。matches_proc 方法接收一个 Proc 并将其传递给 find_all,但它只需要使用 & 符号转换一次。
你可能认为我们可以省略 & 符号,只需将块参数视为标准变量,就像下面的 irb 例子中那样。
irb(main):001:0> class Array
irb(main):002:1> def matches_block( some_block )
irb(main):003:2> find_all( some_block )
irb(main):004:2> end
irb(main):005:1> end
=> nil
irb(main):006:0> digits = (0..9).to_a
=> [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
irb(main):007:0> digits.matches_block { |x| x > 5 }
ArgumentError: wrong number of arguments (0 for 1)
from (irb):7:in `matches_block'
from (irb):7
from :0
但这样是不行的,正如你所看到的。Ruby 会跟踪给定方法、块或 Proc 期望的参数数量(称为 arity),当出现不匹配时会抱怨。我们的 irb 示例期望一个“真实”参数,而不仅仅是块,当它没有得到一个参数时会抱怨。
注意
ArgumentError 的核心是块类似于“部分”或“未出生”的块,需要使用 *lambda 方法将其转换为完整的 Proc,这样才能作为方法的真实参数使用。一些方法,如 *find_all,可以处理块参数,但这些块参数与常规参数的处理方式不同,并且不计入“真实”参数的数量。我们将在讨论 *willow_and_anya.rb 脚本时再详细说明。现在,请注意,我们新的 *matching_members* 版本接收一个块而不是 Proc*。
通过 map 使用每个 Proc 进行过滤
我们还在 ❷ 处定义了一个名为 matching_compound_members 的新方法。matching_compound_members 方法接收一个名为 procs_array 的 Array 参数,并将对 matching_members 的调用映射到 procs_array 的每个 Proc 元素上;在映射的同时,将元素转换为带有 & 符号的块。这导致了一个 Array,其每个成员都是一个包含所有与 Proc 定义的条件匹配的原始 Array 成员的 Array。困惑吗?看看 irb。
irb(main):001:1> class Array
irb(main):002:1> def matching_compound_members( procs_array )
irb(main):003:2> procs_array.map do |some_proc|
irb(main):004:3* find_all( &some_proc )
irb(main):005:3> end
irb(main):006:2> end
irb(main):007:1> end
=> nil
irb(main):008:0> digits.matching_compound_members( [ lambda { |x| x > 5 },
lambda { |x| (x % 2).zero? }])
=> [[6, 7, 8, 9], [0, 2, 4, 6, 8]]
在第一行到第七行,我们将 matching_members 的简短版本添加到所有 Array 中。我们在第八行调用它,发现结果是包含多个 Array 的 Array。第一个子数组是所有大于五的数字——第一个 Proc 的结果。第二个子数组是所有偶数数字——第二个 Proc 的结果。这就是 matching_compound_members 内部的 map (❹) 结尾时的内容。
使用 inject 寻找交集
我们没有就此止步。接下来,我们在那个数组数组上调用我们的老朋友inject方法。你可能记得inject会连续执行操作并具有中间结果的记忆。这对我们非常有用。inject方法接受一个可选的非块元素作为其记忆的初始状态。在我们的脚本中,我们使用self(❹),这意味着记忆状态将是过滤之前的self数组。我们还说,map操作结果中的每个成员都将被称为matches。这很有意义,因为matches变量代表在map操作的特定阶段找到匹配的 Proc 的初始数组成员。
数组交集
在❺处,我们在memo上调用一个之前未见过的方法。这个方法恰好是用和号字符表示的,但它与将块和 Proc 相互转换无关;它更多地与集合数学有关。
irb(main):001:0> digits = (0..9).to_a
=> [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
irb(main):002:0> evens = digits.find_all { |x| (x % 2).zero? }
=> [0, 2, 4, 6, 8]
irb(main):003:0> digits & evens
=> [0, 2, 4, 6, 8]
irb(main):004:0> half_digits = digits.find_all { |x| x < 5 }
=> [0, 1, 2, 3, 4]
irb(main):005:0> evens & half_digits
=> [0, 2, 4]
你能猜出这个和号代表什么吗?它代表两个复合数据集的交集。这基本上意味着找出属于我自己的同时也属于这个其他东西的所有成员。当我们在这个inject方法中调用它时,我们确保一旦一个给定的数组元素未能通过一个测试,它就不再作为下一个测试的候选者。这是因为inject方法的记忆(由名为memo的变量表示)会自动设置为每次inject方法迭代的返回值。在❻处,当我们完成所有的map和inject操作后,我们只剩下那些通过procs_array参数中每个 Proc 定义的测试的原数组成员。由于 Ruby 返回方法中评估的最后表达式,matching_compound_members返回一个包含self中通过每个由procs_array成员表示的测试的所有成员的数组。
在进行了一些与上一个脚本类似的设置后,我们在❽和❾处使用puts输出结果。让我们看看它是如何工作的。
结果
div_by3 [0,3,6,9]
five+ [5,6,7,8,9]
is_even [0,2,4,6,8]
ALL [6]
我们对从零到九的数字调用每个过滤 Proc,每次都得到正确的成员。我们最终输出前缀ALL,后面跟着通过所有测试的成员。数字六是唯一一个从零到九能被三整除、大于或等于五且为偶数的数字。因此,它是最终输出中的唯一成员。
修改脚本
尝试使用lambda定义你自己的 Proc。你可以将它们添加到❺处的部分,或者替换一些现有的 Proc。你也可以自由地更改创建digits数组时使用的范围。digits中的更大值范围可以帮助展示更多过滤 Proc 之间的复杂关系。
#23 返回 Proc 作为值(return_proc.rb)
让我们进一步看看如何使用由另一个函数生成的数据来使用 Proc。它与make_incrementer.rb脚本非常相似。
代码
#!/usr/bin/env ruby
# return_proc.rb
❶ def return_proc(criterion, further_criterion=1)
proc_of_criterion = { *Procs as Hash Values*
'div_by?' => lambda { |i| i if (i % further_criterion).zero? },
'is?' => lambda { |i| i == further_criterion }
}
# allow 'is_even' as an alias for divisible by 2
❷ return return_proc('div_by?', 2) if criterion == ('is_even')
❸ proc_to_return = proc_of_criterion[criterion]
fail "I don't understand the criterion #{criterion}" unless proc_to_return
return proc_to_return
end
❹ require 'boolean_golf.rb'
# Demonstrate calling the proc directly
❺ even_proc = return_proc('is_even') # could have been ('div_by', 2)
div3_proc = return_proc('div_by?', 3)
is10_proc = return_proc('is?', 10)
❻ [4, 5, 6].each do |num|
puts %Q[Is #{num} even?: #{even_proc[num].true?}] *Making Strings with **`%Q`***
puts %Q[Is #{num} divisible by 3?: #{div3_proc[num].true?}]
puts %Q[Is #{num} 10?: #{is10_proc[num].true?}]
❼ printf("%d is %s.\n\n", num, even_proc[num].true? ? 'even' : 'not even')
end
# Demonstrate using the proc as a block for a method
❽ digits = (0..9).to_a
even_results = digits.find_all(&(return_proc('is_even')))
div3_results = digits.find_all(&(return_proc('div_by?', 3)))
❾ puts %Q[The even digits are #{even_results.inspect}.] *The **`inspect`** Method*
puts %Q[The digits divisible by 3 are #{div3_results.inspect}.]
puts
结果
如果我们使用命令ruby -w return_proc.rb来调用它,我们会得到以下输出,所有这些都是正确的。
Is 4 even?: true
Is 4 divisible by 3?: false
Is 4 10?: false
4 is even.
Is 5 even?: false
Is 5 divisible by 3?: false
Is 5 10?: false
5 is not even.
Is 6 even?: true
Is 6 divisible by 3?: true
Is 6 10?: false
6 is even.
The even digits are [0, 2, 4, 6, 8].
The digits divisible by 3 are [0, 3, 6, 9].
它是如何工作的
我们从❶开始定义了一个名为return_proc的方法,它接受一个必需的criterion和一个可选的further_criterion,假设为单个值。然后它定义了一个名为proc_of_criterion的哈希,其键与特定的标准匹配,值是与每个标准对应的 Proc。然后它允许调用者使用别名is_even在❷中表示能被二整除。它是通过在别名使用时递归地调用自身,并使用参数div_by?和2来实现的。
假设没有使用is_even别名,该方法尝试读取在❸处使用的适当 Proc;如果它得到一个它不理解的标准,则失败.^([18]) 如果它通过了这一点,我们知道该方法理解了它的标准,因为它找到了一个要使用的 Proc。然后它返回这个 Proc,适当地称为proc_to_return。
现在我们知道return_proc名副其实,它返回一个 Proc。让我们使用它。在❹处,我们require了我们第一个脚本之一,boolean_golf.rb。你可能还记得,那个脚本为每个对象添加了true?和false?方法。这将在我们接下来的几行中很有用。在❺处,我们定义了三个可以测试数字特定条件的 Proc。然后我们在❻开始的each块中使用这些 Proc。对于整数4, 5和6,我们测试了偶数性、能被三整除以及等于十。我们还使用了在line_num.rb脚本中看到的printf命令和主要的三元运算符,这两者都发生在❷。
Proc.call(args) 与 Proc[args]
注意,我们在调用我们的 Proc 时使用了不同的语法——我们根本不使用call方法。我们可以简单地使用方括号内我们本应使用的任何参数,这就像使用call方法一样。让我们在 irb 中验证这一点。
irb(main):001:0> is_ten = lambda { |x| x == 10 }
=> #<Proc:0xb7d0c8a4@(irb):1>
irb(main):002:0> is_ten.call(10)
=> true
irb(main):003:0> is_ten[10]
=> true
irb(main):004:0> is_ten.call(9)
=> false
irb(main):005:0> is_ten[9]
=> false
我选择在这些例子中使用方括号语法是为了简洁。到目前为止,我已经展示了如何使用直接从return_proc方法返回的 Proc。但我们可以做其他事情,例如在块和 Proc 之间进行转换。
使用 Proc 作为块
从❽到脚本的结尾,我们看到我们可以将return_proc的输出(我们知道它是一个 Proc)转换为带有&的块,而无需在任何地方存储这个 Proc。在定义了我们常用的digits数组之后,我们两次调用find_all,分别将结果赋值给even_results和div3_results。记住,find_all接受一个块。&可以将任何求值结果为 Proc 的表达式转换为块,而(return_proc('is_even'))就是一个返回(求值)为 Proc 的表达式。因此,我们可以将表达式(return_proc('is_even'))强制转换为find_all的有效块。我们这样做,通过puts输出结果在❾。
inspect 方法
注意,我们在每一组结果上调用一个新的方法inspect,以保留我们通常与数组成员关联的括号和逗号。inspect方法返回被调用对象的字符串表示形式。它与我们已经看到的to_s方法略有不同。让我们在 irb 中检查一下。
irb(main):001:0> digits = (0..9).to_a
=> [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
irb(main):002:0> digits.to_s=
> "0123456789"
irb(main):003:0> digits.inspect
=> "[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]"
你可以看到,inspect的输出比to_s的输出更漂亮一些。它还保留了更多关于它被调用对象的类型信息。
你现在应该已经非常熟悉调用进程(Procs),将它们传递给其他函数,从哈希中读取它们,以及将它们转换为块,无论是使用lambda还是传递给方法。现在让我们来看看在其他的lambda中嵌套lambda。
^([18]) 如果你要修改或扩展这个方法,你只需向proc_of_criterion哈希中添加更多选项即可。
#24 嵌套 lambda
让我们简要回顾一下进程。进程只是可以被当作数据的函数,这是函数式编程语言所说的一等函数。函数可以创建进程;我们看到了make_incrementer和return_proc都返回不同类型的进程。考虑到所有这些,什么阻止我们创建一个在被调用时返回另一个进程的进程呢?什么都没有。
在下面的make_exp例子中,我们创建了将参数提升到某个指定幂的特定版本的进程。这个幂是外部lambda接受的exp参数,它被描述为自由变量,因为它不是内部lambda的显式参数。
返回的内部lambda有一个名为x的绑定变量。它是绑定因为它是内部lambda的显式参数。这个变量x是将会被提升到指定幂的数字。这个例子很短,每个阶段的返回值都非常重要,所以我们将整个操作都在 irb 中完成。
代码
irb(main):001:0> digits = (0..9).to_a
=> [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
irb(main):002:0> make_exp_proc = lambda { |exp| lambda { |x| x ** exp } } *Nested Lambdas*
=> #<Proc:0xb7c97adc@(irb):2>
irb(main):003:0> square_proc = make_exp_proc.call(2)
=> #<Proc:0xb7c97b18@(irb):2>
irb(main):004:0> square_proc.call(5)
=> 25
irb(main):005:0> squares = digits.map { |x| square_proc[x] }
=> [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]
它是如何工作的
我们看到到目前为止,make_exp_proc是一个进程,它在被调用时返回一个进程。这个返回的进程将它的参数提升到make_exp_proc初始调用中使用的指数。由于在我们的例子中,我们用2调用make_exp_proc,我们创建了一个将它的参数平方的进程,恰当地称它为square_proc。我们还看到,平方进程可以在映射操作中用于数字数组,并且返回正确的平方值。
irb(main):006:0> cube_proc = make_exp_proc.call(3)
=> #<Proc:0xb7c97b18@(irb):2>
irb(main):007:0> cube_proc.call(3)
=> 27
irb(main):008:0> cubes = digits.map { |x| cube_proc[x] }
=> [0, 1, 8, 27, 64, 125, 216, 343, 512, 729]
我们还在例子中的其他部分看到,make_exp_proc是灵活的,可以接受除了2之外的参数。它用3作为参数时工作得很好,产生一个立方进程,我们可以像使用平方进程一样使用它。
到目前为止,我们的进程(Procs)倾向于实现简单的数学运算,比如加法、乘法或指数运算。但进程和其他函数一样,可以输出任何类型的值。让我们继续到下一个脚本,它使用处理字符串的进程。
#25 文本进程(willow_and_anya.rb)
当我计划这本书的函数式编程章节时,我正在观看乔斯·韦登的《Buffy the Vampire Slayer》的 DVD。我提到这一点是因为我脑子里想着进程和块,我恰好遇到了两个很好的基于文本的 lambda 操作的例子。在一个名为“Him”的集中,讨论了一个“爱情咒语”、“反-(爱情咒语)咒语”和“反-(反-(爱情咒语)咒语)咒语”。这是一个通过简单函数进行连续修改的绝佳例子。在另一个名为“Same Time, Same Place”的集中,有一个对话展示了简单的变量替换。这两个都是简单函数的绝佳例子,是探索 Ruby 中进程如何根据我们选择创建它们而有所不同的好场所。以下是源代码。
注意
显然,你不需要喜欢《Buffy the Vampire Slayer》就能从阅读这些例子中受益。脚本修改的具体内容基本上是任意的。
代码
这段代码由三个不同的文件组成:两个必要的类各一个,以及一个单独的脚本,该脚本可以直接执行。
Him 类
#!/usr/bin/env ruby -w
# him.rb
❶ class Him
EPISODE_NAME = 'Him'
BASE = 'love spell'
ANTIDOTE_FOR = lambda { |input| "anti-(#{input}) spell" } *Constant Procs*
❷ def Him.describe() *Class Methods*
return <<DONE_WITH_HEREDOC
In #{EPISODE_NAME},
Willow refers to an "#{ANTIDOTE_FOR[BASE]}".
Anya mentions an "#{ANTIDOTE_FOR[ANTIDOTE_FOR[BASE]]}".
Xander mentioning an "#{ANTIDOTE_FOR[ANTIDOTE_FOR[ANTIDOTE_FOR[BASE]]]}"
might have been too much.
DONE_WITH_HEREDOC
end
end
SameTimeSamePlace 类
#!/usr/bin/env ruby -w
# same_time_same_place.rb
❸ class SameTimeSamePlace
EPISODE_NAME = 'Same Time, Same Place'
=begin rdoc
This Hash holds various procedure objects. One is formed by the generally
preferred Kernel.lambda method. Others are created with the older Proc.new
method, which has the benefit of allowing more flexibility in its argument
stack.
=end
❹ QUESTIONS = {
:ternary => Proc.new do |args|
state = args ? args[0] : 'what'
location = args ? args[1] : 'what'
"Spike's #{state} in the #{location}ment?"
end,
:unless0th => Proc.new do |*args|
args = %w/what what/ unless args[0]
"Spike's #{args[0]} in the #{args[1]}ment?"
end,
:nitems => Proc.new do |*args| *Flexible Arity with **`Proc.new`***
args.nitems >= 2 || args.replace(['what', 'what'])
"Spike's #{args[0]} in the #{args[1]}ment?"
end,
:second_or => Proc.new do |*args|
args[0] || args.replace(['what', 'what'])
"Spike's #{args[0]} in the #{args[1]}ment?"
end,
:needs_data => lambda do |args|
"Spike's #{args[0]} in the #{args[1]}ment?"
end
}
❺ DATA_FROM_ANYA = ['insane', 'base']
❻ def SameTimeSamePlace.describe()
same_as_procs = [
SameTimeSamePlace.yield_block(&QUESTIONS[:nitems]),
QUESTIONS[:second_or].call(),
QUESTIONS[:unless0th].call(),
SameTimeSamePlace.willow_ask,
]
return <<DONE
In #{EPISODE_NAME},
Willow asks "#{QUESTIONS[:ternary].call(nil)}",
#{same_as_procs.map do |proc_output|
'which is the same as "' + proc_output + '"'
end.join("\n ")
}
Anya provides "#{DATA_FROM_ANYA.join(', ')}", which forms the full question
"#{SameTimeSamePlace.yield_block(DATA_FROM_ANYA, &QUESTIONS[:needs_data])}".
DONE
end
=begin rdoc
Wrapping a lambda call within a function can provide
default values for arguments.
=end
❼ def SameTimeSamePlace.willow_ask(args = ['what', 'what'])
QUESTIONS[:needs_data][args]
end
=begin rdoc
Passing a block as an argument to a method
=end
❽ def SameTimeSamePlace.yield_block(*args, &block)
# yield with any necessary args is the same as calling block.call(*args)
yield(*args) *The **`yield`** Method*
end
end
willow_and_anya.rb 脚本
#!/usr/bin/env ruby -w
# willow_and_anya.rb
%w[him same_time_same_place].each do |lib_file| *Arrays with **`%w`***
require "#{lib_file}"
end
[Him, SameTimeSamePlace].each do |episode|
❾ puts episode.describe()
end
它是如何工作的
这个脚本执行一些复杂的操作。让我们逐个考虑每个类,然后再看看使用它们的单独脚本。
Him 类:使用 lambda 创建进程
我们在❶处定义了一个名为 Him 的类。它有三个常量:自己的 EPISODE_NAME、一个 BASE 项目和一个用于创建 ANTIDOTE_FOR 某物的 lambda 操作.^([19]) 它有一个类方法 Him.describe (❷),该方法通过 here doc 返回一个长字符串。记住,你可以用 some_proc.call(args) 或 some_proc[args] 来调用一个进程。在这种情况下,我们再次使用较短的括号版本。我们将报告名为 Willow 的角色指的是基础咒语的解毒剂。她的同伴 Anya 然后提到了那个解毒剂的解毒剂。Whedon 在他的节目中避免了另一个调用创建解毒剂的进程,但我们的方法将继续,输出解毒剂的解毒剂的解毒剂。
SameTimeSamePlace 类:创建进程的 lambda 替代方案
我们接下来的类将探索更多选项。SameTimeSamePlace 从❸开始,并在❹处立即定义了一个名为 QUESTIONS 的哈希常量。它的键是符号,值是进程。到目前为止,我们总是使用 lambda 方法创建进程,但我们知道进程是 Proc 类的实例。传统上,你可以通过在类上调用 new 方法来创建一个实例。让我们在 irb 中试试。
irb(main):001:0> is_even_proc1 = lambda { |x| (x % 2).zero? }
=> #<Proc:0xb7cb687c@(irb):1>
irb(main):002:0> is_even_proc2 = Proc.new { |x| (x % 2).zero? }
=> #<Proc:0xb7cacb4c@(irb):2>
irb(main):003:0> is_even_proc1.call(7)
=> false
irb(main):004:0> is_even_proc2.call(7)
=> false
irb(main):005:0> is_even_proc1.call(8)
=> true
irb(main):006:0> is_even_proc2.call(8)
=> true
这看起来工作得很好,每个 Proc 都按预期工作。在实际应用中,通过 lambda 和通过 Proc.new 创建的 Proc 之间几乎没有区别。Proc.new 在处理参数方面更为灵活,我们很快就会看到这一点。现在,请注意,在 QUESTIONS 哈希中,:ternary 键的值是一个 Proc,它会询问名叫 Spike 的人是否在某个 location(这个位置也不是已经知道的或静态的)有某种 state(这个状态也不是已经知道的或静态的)。
备注
不要被这个脚本的表面上的愚蠢所迷惑。实际上,它澄清了 Ruby 的 Proc 在参数和参数数量方面的某些非常有趣的行为。后来使用这些技术在现实世界中有用任务的脚本包括转换温度和为广播电台播放音频文件的脚本。
Proc.new 的灵活参数
接下来,我们将开始探索 Proc.new 在 :unless0th 符号键上的更多用途。你会注意到这个 Proc 的 *args 参数前面有一个星号。这个选项适用于使用 Proc.new 创建的 Proc,但不适用于使用 lambda 创建的 Proc。它表示带星号的参数是可选的。在 :unless0th Proc 中立即设置 args 的值,如果它在零索引处没有值;然后我们输出与 :ternary 版本相同的问题。唯一的区别是,这个版本的 args 数组是可选的。注意,我们使用带有斜杠分隔符的 %w 创建双 "what" 默认数组。这是一种创建单词数组的非常方便的方法。
对于 :nitems 符号键,我们再次使用可选的 *args 与 Proc.new。这个版本与 :unless0th 版本之间的唯一区别在于测试 args 的方式。在这个版本中,我们在 args 数组上调用 nitems 方法,它返回非 nil 项的数量。这个数字需要是两个或更多;如果不是,这意味着我们没有足够的元素,因此我们将 args 替换为之前 Procs 中的默认两套 "what",就像之前一样。
对于 :second_or 符号键,我们看到在可选的 args 中使用 Proc.new 创建的另一个 Proc。这个版本只是测试 args 数组中的第二个项目是否可以读取。如果无法读取,我们将像在 :nitems 版本中那样替换 args。
最后,我们像以前一样使用 lambda 创建一个 Proc。由于 lambda Proc 的参数不是可选的,我们用符号 :needs_data 来标识这个 Proc。注意,这使得 Proc 的内部结构更简单。它返回其输出值,我们假设它得到了它需要的东西。在定义了我们的 Proc 之后,最后一个需要数据,我们可能需要一些数据。我们的来源仍然是 Anya,我们在 ❺ 处定义了她的 DATA_FROM_ANYA 数组。
接下来是SameTimeSamePlace.describe方法(❻)。它不接受任何参数,并定义了一个名为same_as_procs的局部数组变量。它的第一个元素是通过将QUESTIONS哈希中与:nitems键关联的进程作为参数调用SameTimeSamePlace.yield_block(定义在❽)的返回值。所有这些都通过&符号转换成了一个块。我们还没有看到yield_block方法,但它接受两个参数:*args和&block。第一个表示所有你的常规参数,第二个表示你得到的任何块。
区块、参数和 yield
记得我提到过块不被视为“真实”参数吗?使用&符号是显式引用用于调用方法的块的方式。由于我们有参数组,无论它们是什么,我们也有块,我们可以通过block.call(*args)来调用它。这种方法是可行的,但我们还有另一种选择。Ruby 有一个名为yield的方法,意味着使用传递给yield的任何参数调用你收到的任何块。当你对这个脚本感到舒适时,尝试用block.call(*args)替换yield_block中的yield行。这根本不会改变脚本的行为。让我们在 irb 中验证一些内容。
irb(main):001:0> def yield_block(*args, &block)
irb(main):002:1> yield(*args)
irb(main):003:1> end
=> nil
irb(main):004:0> yield_block(0) { |x| x + 1 }
=> 1
irb(main):005:0> yield_block("I am a String") { |x| x.class }
=> String
irb(main):006:0> yield_block("How many words?") { |x| x.split(' ').nitems }
=> 3
irb(main):007:0> yield_block(0, 1) { |x,y| x == y }
=> false
irb(main):008:0> yield_block(0, 1) { |x,y| x < y }
=> true
方便吧?yield_block方法完全通用,接受任意数量的常规参数和任意块,并使用这些参数执行(或yield)该块。这是一个非常强大的技术。
现在我们已经理解了我们的脚本如何在SameTimeSamePlace.describe(❻)中使用yield_block方法。same_as_procs的下一个两个元素是从QUESTIONS哈希中通过call方法提取的进程的返回值。我们的最后一个元素是SameTimeSamePlace.willow_ask(❼)的返回值。这个方法为使用lambda创建的、需要特定数量参数的进程提供了一个解决方案。willow_ask将此类进程的调用包裹在一个传统方法中,该方法接受一个可选参数。该参数被强制设置为进程期望的任何值,在它到达进程之前。这是处理进程参数的另一种选择。
这就是我们的same_as_procs数组元素的结束。现在让我们使用它。我们在SameTimeSamePlace.describe(❻)中返回一个长的here doc字符串。这个here doc字符串由几行组成。第一行调用QUESTIONS[:ternary]进程,并显式传递一个nil参数。这将导致我们的state和location变量在进程内部被设置为默认值。接下来的四行输出是将字符串输出器映射到same_as_procs的元素的结果。记住,这些元素是它们各自进程的返回值,而不是进程本身。它们已经在被放入数组之前被评估过了。
在here doc的最后几行报告了 Anya 提供的数据,定义为常量数组DATA_FROM_ANYA(❺)。我们调用yield_block方法,传入DATA_FROM_ANYA作为“真实”参数,以及从 Proc 转换成块并返回的QUESTIONS[:needs_data]的值。然后我们关闭here doc并结束SameTimeSamePlace.describe方法。
在willow_and_anya.rb中使用 Him 和 SameTimeSamePlace
在主运行脚本willow_and_anya.rb中,我们首先做的是require每个需要的lib_file。然后我们遍历每个类,通过episode这个名字来引用,并描述这个场景(❾),正如之前讨论的那样,在每个具体案例中实现。
运行脚本
让我们看看执行ruby -w willow_and_anya.rb返回的输出。
结果
In Him,
Willow refers to an "anti-(love spell) spell".
Anya mentions an "anti-(anti-(love spell) spell) spell".
Xander mentioning an "anti-(anti-(anti-(love spell) spell) spell) spell"
might have been too much.
In Same Time, Same Place,
Willow asks "Spike's what in the whatment?",
which is the same as "Spike's what in the whatment?"
which is the same as "Spike's what in the whatment?"
which is the same as "Spike's what in the whatment?"
which is the same as "Spike's what in the whatment?"
Anya provides "insane, base", which forms the full question
"Spike's insane in the basement?".
这关于一些相当深奥的编程主题的数据有很多。恭喜你一直坚持到现在。如果你真的对这一切是如何工作的感到好奇,我有一些问题要你思考。
操纵脚本
你会如何使用inject来复制Him.describe的连续lambda输出?以下是我想到的。也许你能找到更好的替代方案。
def Him.describe2(iterations=3)
(1..iterations).to_a.inject(BASE) do |memo,output|
ANTIDOTE_FOR[memo]
end
end
另一个问题你可能觉得很有趣,那就是为什么describe方法被附加到类上,而不是实例上。原因是第❾处的episode变量代表一个类,而不是一个实例。如果我们想使用实例方法,我们需要创建Him或SameTimeSamePlace的实例,而不是直接在每个类上调用describe方法。
^([19]) 我在书中提到过,lambda可以成为优秀的类常量。现在你可以看到它是如何实现的。
章节总结
本章有什么新内容?
-
使用
lambda创建 Proc -
将 Proc 作为方法的参数
-
将块作为方法的参数,包括你自己的新方法
-
将 Proc 作为一等函数使用
-
inspect方法 -
在其他
lambda中嵌套lambda -
Proc.new -
yield方法
我有一个坦白要讲。我非常喜欢面向对象编程,但关于 Ruby 函数式遗产的这一章是我迄今为止写得最有意思的。函数式编程在学术界已经受到尊重几十年了,它开始从计算机编程行业的人和其他对它感兴趣的人那里获得一些应得的关注。现在我们知道了函数式编程的一些技术,让我们来使用它们,甚至尝试优化它们,这是我们下一章的主题。
第七章。使用、优化和测试函数式技术

本章展示了简单问题的递归和其他功能性解决方案,以及我们可以测试和改进这些解决方案的一些方法。两个非常常见的编程主题,展示了函数式编程,是阶乘和斐波那契数学序列——很大程度上是因为它们很容易用递归方式来描述.^([20])
一个给定正数的阶乘是该数从 1 到该数的所有整数的乘积,因此 factorial(3) = 3 x 2 x 1,factorial(5) = 5 x 4 x 3 x 2 x 1,依此类推。这可以一般地表示为:
factorial(x) = x x (x – 1) x (x – 2) … 1
斐波那契序列是无限的,但你可以查看其中的一段。斐波那契序列中 0 的值是 0,1 的值是 1。后续的值是计算出来的,而不是预设的。斐波那契序列中给定索引的数是前两个数的和。因此,斐波那契序列开始如下:0, 1, 1, 2, 3, 5, 8, 13, 21, 34,依此类推。对于大于 1 的数的斐波那契值的一般公式可以表示为 Fibonacci(x) = Fibonacci(x-1) + Fibonacci(x-2)。
如果你认为阶乘和斐波那契的一般定义看起来是递归的,你是正确的。我们将查看生成这两种类型的数字的 Ruby 代码,使用递归。
#26 基本阶乘和斐波那契数(factorial1.rb 至 fibonacci5.rb)
对递归和其他函数式技术的最常见的批评是它们资源密集。这些阶乘或斐波那契脚本的每个新版本都添加了一些旨在优化代码或提高速度的功能。在某些情况下,这些功能导致了非常显著的性能提升,但在其他情况下,它们要么未能提高代码性能,有时甚至使代码变得更差。这些尝试未能提高速度的地方往往与它们成功的地方一样有趣。程序员中有一个古老的谚语:过早优化是万恶之源.^([21]) 在阅读这些示例时请记住这一点。
代码
对于本章,我们将查看一些成对的简短脚本。这是factorial1.rb:
#!/usr/bin/env ruby
# factorial1.rb
class Integer
def fact()
❶ return 1 if (self.zero?) or (self == 1)
❷ return self * (self-1).fact
end
end
这是fibonacci1.rb:
#!/usr/bin/env ruby
# fibonacci1.rb
class Integer
def fib()
❸ return 0 if (self.zero?)
return 1 if self == 1
❹ return (self-1).fib + (self-2).fib
end
end
工作原理
对于 factorial1.rb 和 fibonacci1.rb,我们为所有整数添加了一个新方法:分别是 fact 或 fib。在两种情况下,我们都有退出条件,即返回零或一。对于阶乘,当 self 是零或一时,我们返回 1,使用谓词 zero?(❶)来测试零。对于斐波那契数列,我们在 ❸ 处返回零或一。在 ❷ 或 ❹ 处,我们返回适当的计算值:self 乘以比 self 小一的阶乘(❷),或者前两个斐波那契数的和(❹),分别符合我给出的阶乘和斐波那契数的定义。这两个脚本都是简单、准确产生我们想要的数学过程的简单方法。让我们使用 irb 来查看结果。注意,我们可以使用多个 -r 标志来 require 多个库文件.^([22])
结果
$ irb -r factorial1.rb -r fibonacci1.rb
irb(main):001:0> 3.fact
=> 6
irb(main):002:0> 4.fact
=> 24
irb(main):003:0> 5.fact
=> 120
3 的阶乘是 3 x 2 x 1,即 6,所以这是可以的。6 x 4 是 24,24 x 5 是 120。所以我们的 fact 方法似乎工作得很好。接下来是斐波那契数列。
irb(main):004:0> 3.fib
=> 2
irb(main):005:0> 4.fib
=> 3
irb(main):006:0> 5.fib
=> 5
斐波那契数列的前七个值是 0, 1, 1, 2, 3, 5, 和 8。第零个数字(位于 0 索引的数字)是 0,第一个是 1,第二个也是 1,第三个是 2,第四个是 3,第五个是 5。我们的 fib 方法似乎也工作得很好。
暗中修改脚本
我们如何提高这个脚本的效率?我们有几个选择。我将依次概述每个选择,并讨论每个更改的可能动机,但我们将等到测试它们(因此,看到我们假设的结果)时再进行。
注记
修改计算机程序以改进它而不改变其外部行为称为重构。这正是我们在这些脚本中所做的,因为我们没有改变给定输入的阶乘或斐波那契值——我们只是改变了返回相同值的方式(以及可能的速度)。重构是一个迷人的话题;你可以在 refactoring.com 或马丁·福勒的《重构:改善现有代码的设计》(Addison-Wesley Professional, 1999)中了解更多信息。单元测试是我们将在本章后面描述的,当重构时是一个关键的工具,我将在那一节中解释。
使用 include?(factorial2.rb 和 fibonacci2.rb)
这里有一个变体,它通过 include? 方法来决定返回什么,从而消除了运行两个单独的测试来找出 self 是否为零或一的需求。动机是,进行单个测试可能比进行两个单独的测试要快。同样,我将展示阶乘和斐波那契脚本的修改。注意,❺ 行与初始脚本中的 ❶ 或 ❸ 有何不同。
#!/usr/bin/env ruby
# factorial2.rb
class Integer
def fact()
❺ return 1 if [0, 1].include?(self)
return self * (self-1).fact
end
end
这里是斐波那契脚本:
#!/usr/bin/env ruby
# fibonacci2.rb
class Integer
def fib()
❺ return self if [0, 1].include?(self)
return (self-1).fib + (self-2).fib
end
end
将返回值或返回 self 数组作为参数传递(factorial3.rb 和 fibonacci3.rb)
在这些变体中,我们有一个名为 returns1 或 returns_self 的数组,它定义了阶乘或斐波那契测试的返回值。在两种情况下,数组都是 [0, 1],因为零和一是我们用来在两种测试中计算其他值的规则中的值。这种变体的动机是认为,一次性创建一个数据结构(如 returns1)并传递它可能比每次对 fact() 或 fib() 进行新的递归调用时重新创建我们的 [0, 1] 数组要快。注意我们如何在每个脚本中的❻处将 returns1 或 returns_self 定义为每个方法的参数,然后随后用于我们的退出条件测试以及递归调用的显式参数(❷)。
#!/usr/bin/env ruby
# factorial3.rb
class Integer
❻ def fact(returns1 = [0, 1])
return 1 if returns1.include?(self)
❼ return self * (self-1).fact(returns1)
end
end
这里是斐波那契版本:
#!/usr/bin/env ruby
# fibonacci3.rb
class Integer
❻ def fib(returns_self = [0, 1])
return self if returns_self.include?(self)
❼ return (self-1).fib(returns_self) + (self-2).fib(returns_self)
end
end
将 RETURNS1 或 RETURNS_SELF 设为类常量(factorial4.rb 和 fibonacci4.rb)
将 returns1 或 returns_self 作为参数似乎有些荒谬,原因之一是它总是相同的值,[0, 1]。不变的事物是理想的常量,所以让我们在两个脚本中都尝试一下。我们将在每个脚本中的❽处定义一个具有适当名称的常量,并在我们的方法测试中使用它。请注意,不再需要将常量作为参数传递给递归方法调用,就像我们在上一个变体中的❷所做的那样。
#!/usr/bin/env ruby
# factorial4.rb
class Integer
❽ RETURNS_1_FOR_FACTORIAL = [0, 1]
def fact()
return 1 if RETURNS_1_FOR_FACTORIAL.include?(self)
return self * (self-1).fact
end
end
这里是斐波那契版本:
#!/usr/bin/env ruby
# fibonacci4.rb
class Integer
❽ RETURNS_SELF = [0, 1]
def fib()
return self if RETURNS_SELF.include?(self)
return (self-1).fib() + (self-2).fib()
end
end
结果的缓存(factorial5.rb 和 fibonacci5.rb)
我们脚本中的一个未检查到的缺陷是它们很愚蠢。听起来很严厉,但这是公平的.^([23]) 它们不断地重复相同的计算。为了举例,让我们假设我们已经调用了整数 5 的 fib() 方法,并且 fib() 如 fibonacci4.rb 中定义,这是我们最新的斐波那契脚本变体。会发生什么?
最有趣的第一件事是,每当我们的 5 被实例化时,它都有一个名为 RETURNS_SELF 的类常量,定义为数组:[0, 1]。接下来我们调用 fib() 在我们的 5.RETURNS_SELF 上,它不包含 5,所以我们接着在表达式 (5-1) 上调用 fib(),这当然是整数 4,并将它的返回值添加到调用 fib() 在值 (5-2) 上的结果,也称为整数 3。然后我们发现 RETURNS_SELF 也不包含 4,所以我们接着在表达式 (4-1) 上调用 fib(),这是整数 3,并将它的返回值添加到调用 fib() 在值 (4-2) 上的结果,也称为整数 2。我们继续递归地这样做,直到我们得到一个在 RETURNS_SELF 数组中找到的 self 值。
做这件事的主要问题是,我们不断重新计算像3.fib()这样的方法。在最初的5.fib()调用中,我们不得不以(self-2).fib()的形式计算它,当我们的self值为4时,我们不得不以(self-1).fib()的形式计算它。所有这些重复计算之所以成为问题,是因为无论3.fib()是以(5-2).fib()还是(4-1).fib()的形式被调用,它都给出相同的结果——在底层它是同一件事。如果有一种方法可以调用一次像3.fib()这样的东西,然后记住其值以供后续调用,那岂不是很好?
确实存在这样的技术。它被称为memoization,这是使递归程序更有效地使用处理器时间的关键方法之一。看看我们新的脚本变体,它们利用了 memoization 的优势。在这两种变体中,我们在❾处定义了一个适当命名的数组,用于存储迄今为止的 memoized 结果。我们已经在returns1数组中定义了0和1的起始结果,这是我们在早期示例中做的。然后我们在❿处使用这个 memoized 结果数组(无论是@@factorial_results还是@@fibonacci_results),使用||=运算符设置数组中self索引的值,如果还没有值的话。由于 Ruby 方法总是返回最后一个评估的表达式,我们不需要单独的设置和返回操作。现在,无论何时我们需要较低self的fact或fib值,我们都可以直接读取它。❿处的||=运算符将数组中的元素评估为true并简单地返回它,而不进行新的赋值.^([24])
memoization 的补充是 lazy evaluation。很少有语言默认实现这一功能,Haskell 是其中最广为人知的例外。大多数语言使用eager evaluation,即表达式尽可能早地被评估,至少在进入方法或函数时是这样。Lazy evaluation允许表达式在需要其值之前不进行评估。对于阶乘和斐波那契操作的好处是,对较大数字的操作可以等到对较小数字的操作完成后再进行,这可以加快整个过程。Ruby 中有一个用于 lazy evaluation 的库,位于moonbase.rydia.net/software/lazy.rb。
#!/usr/bin/env ruby
# factorial5.rb
class Integer
❾ @@factorial_results = [1, 1] # Both 0 and 1 have a value of 1
def fact()
❿ @@factorial_results[self] ||= self * (self-1).fact *Memoization*
end
def show_mems()
@@factorial_results.inspect
end
end
斐波那契版本是:
#!/usr/bin/env ruby
# fibonacci5.rb
class Integer
❾ @@fibonacci_results = [1, 1] # Both 0 and 1 have a value of 1
def fib()
❿ @@fibonacci_results[self] ||= (self-1).fib + (self-2).fib
end
end
这应该足够进行测试了。请注意,这个最后的阶乘脚本还包括一个名为show_mems的方法,您可以使用它来检查 memoization 的状态。如果您愿意,您可以为fibonacci5.rb添加自己的等效方法。接下来是测试。
^([20]) 这是个很好的地方来提及尾递归。如果一个函数或方法可以轻松地从递归(在高级抽象层次上对人类读者友好)转换为迭代(对计算机硬件更友好),那么它就是尾递归。Ruby 解释器目前不会进行这种转换。我提到这一点是因为我们将在本章中进行大量的递归操作。
^([21]) 通常归功于唐纳德·克努特,如果有的话,他是一位计算机编程天才。
^([22]) 由阶乘和斐波那契操作产生的整数可能会变得相当大。幸运的是,Ruby 允许你将它们都当作整数来处理,透明地执行所需的任何操作,无论是使用大数(Bignums)还是固定数(Fixnums),而无需你担心这些问题。
^([23]) 可能当批评针对作者而不是脚本时,批评会更加公正。毕竟,脚本只是按照我的指示行事。为了公平起见,我编写它们是为了展示失败的优化尝试。
^([24]) 我们的 Perl 朋友在这里使用 ||= 的做法,他们称之为 Orcish Maneuver。如果你好奇,可以在 perl.plover.com/TPC/1998/Hardware-notes.html 上查找。这个名字既是一个双关语,也反映了 Perl 社区中《指环王》粉丝的普遍性。
#27 基准测试和性能分析(tests/test_opts.rb)
在这里,我们将讨论两种测试代码执行速度的不同方法。基准测试衡量代码的整体速度,而性能分析则提供了更多关于代码不同部分执行时间相对关系的详细信息。
基准测试
之前的变体都展示了修改基础代码的方法,希望使其更快。这里我们将测试我们的假设,找出真正起作用的是什么。我将它们存储在一个名为 tests 的目录中,这意味着我使用 ruby -w tests/test_opts.rb 来运行它们。
代码
#!/usr/bin/env ruby
# test_opts.rb
=begin comment
Run this without warnings to avoid messages about method redefinition,
which we are doing intentionally for this testing script.
=end
❶ require 'benchmark' *Benchmark Module*
include Benchmark
❷ FUNC_OF_FILE = {
'factorial' => 'fact',
'fibonacci' => 'fib',
}
UPPER_OF_FILE = {
'factorial' => 200,
'fibonacci' => 30,
}
❸ ['factorial', 'fibonacci'].each do |file|
❹ (1..5).to_a.each do |num|
❺ require "#{file}#{num}"
upper = UPPER_OF_FILE[file]
❻ bm do |test|
❼ test.report("#{file}#{num}") do
❽ upper.send(FUNC_OF_FILE[file])
end
end
end
end
如何工作
首先,我 require 一个名为 ‘benchmark’ 的文件(❶);紧接着的 include 命令将一个名为 Benchmark 的模块混合进来。这是我们脚本的功臣。它提供了一个测试程序中特定操作所需时间的功能。为了进行这些测试,我们需要设置一些常量,我们在❷处这样做。FUNC_OF_FILE 常量包含我们想要在每文件中调用的方法(或函数)的名称,而 UPPER_OF_FILE 确定了调用该函数的最大整数(即上限)。
在❸处,我们遍历每个file,在❹处,我们遍历每个num,即文件名后缀。然后我们在❺处需要特定的、动态生成的文件名。请注意,这将覆盖任何之前具有相同名称的方法定义。(这就是为什么我们将不带警告运行此脚本,因为文件开头的 RDoc 表明。)然后我们设置局部变量upper的值。在❻处,我们调用 Benchmark 模块提供的bm方法。它接受一个包含要运行的test的块的块。那个test有一个名为report的方法,正如其名所示,它会生成测试结果的报告。report方法还接受一个包含测试代码的块。那个块只包含一行❽。我们还没有看到send方法,但调用some_object.send(some_func_name, some_arg)与调用some_object.some_func_name(some_arg)相同。我将在第十章的to_lang.rb脚本中更详细地描述send。现在,只需理解它为每个文件调用所需的方法(无论是fact还是fib)即可。
运行脚本
你需要使用命令 ruby tests/test_opts.rb 来运行这个程序。注意,在这个特定情况下,我们避免了使用 -w 标志。原因是我们在重新定义方法,这会触发一个警告。由于我们是故意这样做并且清楚情况,所以在这个特定情况下,警告只是一个烦恼。
结果
这里是我的结果。你的结果可能会因机器的速度而有所不同。
user system total real
factorial1 0.016667 0.000000 0.016667 ( 0.002705)
user system total real
factorial2 0.000000 0.000000 0.000000 ( 0.001517)
user system total real
factorial3 0.000000 0.000000 0.000000 ( 0.001532)
user system total real
factorial4 0.000000 0.000000 0.000000 ( 0.001491)
user system total real
factorial5 0.000000 0.000000 0.000000 ( 0.001508)
user system total real
fibonacci1 8.416667 1.900000 10.316667 ( 6.207565)
user system total real
fibonacci2 11.316667 1.866667 13.183333 ( 8.567413)
user system total real
fibonacci3 9.066667 1.816667 10.883333 ( 6.809812)
user system total real
fibonacci4 9.233333 1.533333 10.766667 ( 6.520220)
user system total real
fibonacci5 0.000000 0.000000 0.000000 ( 0.000166)
基准测试输出显示了用户、系统、总时间和实际标签视角下使用的秒数。你可以在类 Unix 系统上通过命令 man time 了解这些标签的具体含义。现在,请记住,它们对于测量一个进程相对于另一个进程所花费的时间是有用的。在我的讨论中,我会提到 real 时间。你可以看到,在阶乘脚本之间几乎没有变化。这主要是因为阶乘操作相对简单,因为它是一个单一的递归乘法。对于斐波那契脚本,我们看到了更引人注目的数据,因为每个递归斐波那契操作会生成两个额外的斐波那契操作,除非它使用了记忆化。这种双重生成是为什么我将斐波那契操作的上限设置为比阶乘上限 200 要低得多的 30。
我们的测试显示,简单的fibonacci1.rb运行 30 次连续操作调用fib从一到五的数字大约需要 6.20 秒。当我们尝试在fibonacci2.rb中应用include?优化时(它需要大约 8.56 秒),情况实际上变得更糟,而在fibonacci3.rb中的参数优化(大约需要 6.81 秒)仅略有改善。直到我们在fibonacci5.rb中引入记忆化,运行时间才没有显著变化,花费的时间减少到不再显著。
这个故事有两个寓意。首先,我们学到的是,基于测试而不是直觉来优化代码以提高速度会更好。试图从一段代码中挤出一些更快的性能可能会让你在一个甚至不是你的速度瓶颈的领域浪费时间,这只会让你的代码更难阅读。第二个寓意是,记忆化(如在factorial5.rb和fibonacci5.rb中使用)是任何可能重复的递归操作的关键补充。
配置文件
当然,基准测试只是故事的一部分。如果你担心代码的速度,只知道它运行所需的总时间并不是特别有用。更有用的是配置文件提供的信息,它将你的代码分解成部分,并在更细致的细节级别提供速度报告。
Ruby 有一个名为profile的配置文件库。它可以像基准测试一样require,但它不要求像bm方法及其块那样的特定测试代码。只需通过-r标志包含profile,就可以自动将库应用于代码的执行。让我们用命令行执行我们编写的第一个脚本:
ruby -r profile -r 99bottles.rb -e 'wall = Wall.new(99); wall.sing_one_verse!
until wall.empty?'.
注意,我们只需使用-r标志来require``profile;我们的-e标志包含要执行的代码,这与我们在第二章中编写99bottles.rb时使用的 irb 会话类似。以下是其结果的极度简化版本:
2 bottles of beer on the wall, 2 bottles of beer
take one down, pass it around, 1 bottle of beer on the wall.
1 bottle of beer on the wall, 1 bottle of beer
take one down, pass it around, no more bottles of beer on the wall.
% cumulative self self total
time seconds seconds calls ms/call ms/call name
31.25 0.08 0.08 297 0.28 0.45 Wall#sing
18.75 0.13 0.05 99 0.51 2.53 Wall#sing_one_verse!
18.75 0.18 0.05 99 0.51 0.51 Wall#take_one_down!
12.50 0.22 0.03 297 0.11 0.11 Fixnum#==
6.25 0.23 0.02 100 0.17 0.17 Wall#empty?
6.25 0.25 0.02 297 0.06 0.06 Fixnum#>
6.25 0.27 0.02 99 0.17 0.17 Kernel.puts
0.00 0.27 0.00 1 0.00 0.00 Wall#initialize
0.00 0.27 0.00 5 0.00 0.00 Module#method_added
0.00 0.27 0.00 1 0.00 0.00 Class#inherited
0.00 0.27 0.00 99 0.00 0.00 IO#write
0.00 0.27 0.00 594 0.00 0.00 String#+
0.00 0.27 0.00 99 0.00 0.00 Fixnum#-
0.00 0.27 0.00 100 0.00 0.00 Fixnum#zero?
0.00 0.27 0.00 1 0.00 0.00 Class#new
0.00 0.27 0.00 296 0.00 0.00 Fixnum#to_s
0.00 0.27 0.00 1 0.00 0.00 Module#private
0.00 0.27 0.00 1 0.00 266.67 #toplevel
这份报告提供了大量有趣的信息,包括给定方法占用的总时间的百分比,该方法调用使用的原始秒数,每个方法的调用次数,以及每次调用所花费的毫秒数。这些数据在你试图提高执行速度时可以为你提供一些有用的信息。如果给定方法的调用次数很高,那么这个方法可能在循环中被多次调用。你可以通过仅预先运行该方法一次并将它的值传递到循环中以供使用来提高速度。你还可以尝试以不同的方式实现相同的操作,看看哪种方式运行得更快,等等。
操纵脚本
你可以用这些脚本尝试几种不同的变体。最简单的代码修改涉及更改每个file的upper_of_file中的上限值。你也可以尝试除了阶乘或斐波那契之外的运算。你还可以使用-r profile选项运行本书中的任何脚本。在编写它们时,我更注重教学法而不是速度,所以你可能会对这些标准脚本进行一些速度改进。现在让我们继续到功能性编程的实际应用,这应该会让你想起一些早期的脚本。
#28 温度转换(temperature_converter.rb)
在这个例子中,我们将编写一个转换脚本。这次,我们不会转换货币,而是将其他现实世界因素(如长度、质量、温度等)的单位进行转换。这里展示的版本只处理温度,但你可以从本书的配套网站上下载units_converter.rb;这是一个更全面的脚本,也处理长度、体积和质量。我们将专注于转换到和从英制和公制单位,但也会支持开尔文。让我们看看。
代码
#!/usr/bin/env ruby
# temperature_converter.rb
# See also GNU units at http://www.gnu.org/software/units/units.html
# Converts Metric/SI <-> English units.
=begin rdoc
Converts to and from various units of temperature.
=end
class Temperature_Converter
# every factor has some base unit for multi-stage conversion
# I allow either full or shortened name as the key
❶ BASE_UNIT_OF = {
'temperature' => 'K',
'temp' => 'K',
}
❷ C_TO_F_ADD = 32.0
F_TO_C_RATIO = 5.0/9.0
C_TO_K_ADD = 273.15
❸ C2K = lambda { |c| c + C_TO_K_ADD }
F2C = lambda { |f| (f - C_TO_F_ADD ) * F_TO_C_RATIO }
K2C = lambda { |k| k - C_TO_K_ADD }
C2F = lambda { |c| (c / F_TO_C_RATIO) + C_TO_F_ADD }
F2K = lambda { |f| C2K.call( F2C.call(f) ) } *Composition of Functions*
K2F = lambda { |k| C2F.call( K2C.call(k) ) }
❹ CONVERSIONS = {
# most units just need to get to the base unit
# have => {want => how_many_wants_per_have},
'C' => { 'K' => C2K },
'F' => { 'K' => F2K },
❺ # The base unit requires more conversion targets
'K' => {
'F' => K2F,
'C' => K2C,
},
}
OUTPUT_FORMAT = "%.2f"
❻ def convert(params)
conversion_proc =
CONVERSIONS[params[:have_unit]][params[:want_unit]] ||
get_proc_via_base_unit(params)
return "#{params[:have_num]} #{params[:have_unit]} = " +
"#{sprintf( OUTPUT_FORMAT, conversion_proc[params[:have_num]] )} " +
"#{params[:want_unit]}"
end
private
=begin rdoc
If there is no direct link between the known unit and the desired unit,
we must do a two-stage conversion, using the base unit for that factor
as a "Rosetta Stone."
=end
def get_proc_via_base_unit(params)
❼ base_unit = BASE_UNIT_OF['temperature']
❽ have_to_base_proc = CONVERSIONS[params[:have_unit]][base_unit]
❾ base_to_want_proc = CONVERSIONS[base_unit][params[:want_unit]]
❿ return lambda do |have|
base_to_want_proc.call( have_to_base_proc.call( have ) )
end
end
end
工作原理
这个脚本使用了一些我们还没有介绍的功能性技巧。让我们逐步查看代码。在❶处,我们定义了一个包含基本单位的BASE_UNIT_OF哈希表。请注意,温度和temp都是可接受的,并且脚本使用开尔文,作为绝对温度的科学单位,作为其内部温度单位。接下来,我们定义了一些有用的转换常数。我将它们分成了几个段落:定义的第一段(❷)包含简单的加法和乘法常数,而第二段(❸)使用lambda定义了将使用第一段中值的进程。温度转换比长度或质量的转换要复杂一些。
大多数单位转换都包含一个简单的乘法操作。如果你有 100 磅,想知道这是多少千克,你只需将 100 乘以 0.45。但是,要将华氏度和摄氏度之间的温度进行转换,你必须乘以并加上。通用公式是 F = ( C x 9/5 ) + 32。相反,C = ( F – 32 ) x 5/9。请注意,一度摄氏度和一度开尔文的大小相同(这意味着在它们之间转换不需要乘法),但它们相差 273.15,所以 0 摄氏度 = 273.15 开尔文,0 开尔文(绝对零度)= -273.15 摄氏度。这很冷。
在❸处,我们使用三个字符的名称定义了常数,这些名称暗示了它们所进行的温度转换类型;例如,K2C转换进程接受开尔文并返回相应的摄氏度。大多数这些都很直接,并实现了我在声明段落(❷)中描述的温度关系。
然而,F2K和K2F Proc 更引人注目。它们在自身内部使用先前定义的 Proc,然后使用call方法连续执行两阶段转换。F2K接受一些华氏值f,通过F2C.call(f)将其转换为摄氏度,然后使用这个摄氏值作为C2F.call()的参数。这种连续执行函数调用的通用操作称为组合。F2K组合了C2K和F2C,而K2F组合了C2F和K2C。这具有将操作分解成函数或方法相同的优点:你只需要有一个单一、明确的定义位置,就可以在构建更复杂的、依赖于早期定义的操作时调用该操作。
我们有一些有用的常量,包括与温度相关的 Proc。接下来是❹处的CONVERSIONS哈希。这是一个双层嵌套的哈希,最外层的键是我们拥有的单位。每个键都指向另一个哈希,其键表示我们想要转换到的单位,其值是必要的转换 Proc。如果我们有摄氏度,我们想要开尔文,我们的转换操作是CONVERSIONS[‘C’][‘K’],这是C2K Proc 常量。
注意
CONVERSIONS哈希的目的在于传入一些标识符并得到一些有用的输出,具体来说是执行所需单位转换所需的 Proc。这与面向对象中的工厂非常相似,这是一个根据接收到的参数创建其他对象的对象。我们的CONVERSIONS哈希是应用了相同概念到 Proc 的例子。
CONVERSIONS中的数据的第一段将每个因子的基单位转换为我们的基单位——在我们的例子中是开尔文。但是,如果有人要求一个最终输出不是我们的基单位呢?我们需要能够将基单位转换为所有其他单位,这正是❺处代码的下一部分的目的。它仍然是CONVERSIONS哈希的一部分,并且仍然遵循相同的结构{ have => { want => some_conversion_proc } },但它有两个转换目标而不是一个。我们使用OUTPUT_FORMAT关闭常量,这限制了我们的报告值只保留两位小数。
在❻处,我们定义了我们的主要方法,称为convert。它接受一个名为params的必选参数,并定义了一个名为conversion_proc的局部变量,其值为CONVERSIONS[params[:have_unit]][params[:want_unit]]或,如果失败,则为get_proc_via_base_unit(params)的输出。我们已经知道CONVERSIONS[‘C’][‘K’]的值是摄氏度到开尔文的 Proc。让我们在 irb 中验证这一点:
$ irb -r temperature_converter.rb
irb(main):001:0> tc = Temperature_Converter.new
=> #<Temperature_Converter:0xb7ccdb04>
irb(main):002:0> tc.convert( {:have_unit => 'C', :want_unit => 'K', :have_num
=> 15} )
=> "15 C = 288.15 K"
除了:have_units和:want_units之外,params中还有一个键,但应该相当明显。我们还需要告诉转换器我们有多少个单位,这正是:have_num的作用。这些结果看起来不错;它们是convert方法内部CONVERSIONS[params[:have_unit]][params[:want_unit]]有值的例子,这意味着它不需要使用get_proc_via_base_unit(params)。在获取到conversion_proc后,它返回你在 irb 中已经看到的输出,显示了已知数量和单位以及它转换成什么。
这很简单。但是当CONVERSIONS[params[:have_unit]][params[:want_unit]]没有可用值时会发生什么?这在将摄氏度转换为华氏度的例子中是正确的。在CONVERIONS['C']['F']中没有 Proc。这意味着我们的基本单位需要是已知的或期望的值吗?是的,但仅从最字面意义上的角度来说。不,从任何实际意义上来说,因为我们可以使用get_proc_via_base_unit方法通过组合两个已知的conversion_procs来创建自己的conversion_proc,就像我们在温度转换器中硬编码的那样。
如果我们的params请求的单元没有内置的转换 Proc,我们可以使用get_proc_via_base_unit,如前所述。在get_proc_via_base_unit内部,我们首先获取base_unit(❼)。然后我们通过从CONVERIONS中获取 Proc 来创建have_to_base_proc,该 Proc 将用于将已知单位转换为base_unit(❽)。然后我们通过从CONVERIONS中获取 Proc 来创建base_to_want_proc,该 Proc 将用于将base_unit转换为我们要的单位(❾)。然后在❿处,我们将base_to_want_proc和have_to_base_proc组合起来,就像我们在❸部分为F2K和K2F所做的那样。我们本可以称我们的新 Proc 为have_to_want_proc,但我们只是return它,并在❻的convert方法中成为conversion_proc。
结果
让我们在 irb 中试试。今天纽约布法罗的温度是 65 华氏度(是的,真的),我和一位加拿大同事在谈论这个温度转换脚本。让我们从这里开始。
$ irb -r temperature_converter.rb
irb(main):001:0> tc = Temperature_Converter.new
=> #<Temperature_Converter:0xb7c75b5c>
irb(main):002:0> tc.convert( { :have_num => 65.0, :have_unit => 'F', :want_unit => 'C' } )
=> "65.0 F = 18.33 C"
irb(main):003:0> tc.convert( { :have_num => 0, :have_unit => 'K', :want_unit => 'F' } )
=> "0 K = -459.67 F"
这些例子应该能给你这个程序界面的一个概念。你也可以用其他对你有意义的转换来调用它。
操控脚本
正如我已经提到的,这本书的网站上有一个更复杂的脚本版本可供下载。如果你发现你想转换到或从我没有内置的单元,只需在CONVERSIONS中创建一个键/值对,将你的新单元转换为适当的基单位,另一个将基单位转换为你的新单元。这应该会给你转换到和从任何相对于你的新单元的能力。
我们还在 temperature_converter.rb 中使用了隐式组合——在第❸行进行定义,在第❿行进行使用。你可以修改脚本以包含一个显式的 compose 方法,该方法接受两个 Procs 并返回一个新的 Proc,该 Proc 按顺序执行每个操作。以下是在 irb 中的示例:
irb(main):001:0> def compose(inner_proc, outer_proc, *args)
irb(main):002:1> return lambda { |*args| outer_proc.call(inner_proc[*args]) }
irb(main):003:1> end
=> nil
irb(main):004:0> square = lambda { |x| x ** 2 }
=> #<Proc:0xb7cda048@(irb):4>
irb(main):005:0> inc = lambda { |x| x + 1 }
=> #<Proc:0xb7ccb8f4@(irb):5>
irb(main):006:0> square_then_inc = compose( square, inc )
=> #<Proc:0xb7ce5204@(irb):2>
irb(main):007:0> inc_then_square = compose( inc, square )
=> #<Proc:0xb7ce5204@(irb):2>
irb(main):008:0> square_then_inc.call(1)
=> 2
irb(main):009:0> square_then_inc.call(2)
=> 5
irb(main):010:0> inc_then_square.call(2)
=> 9
第 8 行给出了 2,因为 (1 ** 1) + 1 = 2。第 9 行给出了 5,因为 (2 ** 2) + 1 = 5。第 10 行给出了 9,因为 (2 + 1) ** 2 = 9。一旦你有了这个 compose 方法,你甚至可以将其用于从之前的 compose 调用返回的 Procs,允许你堆叠尽可能多的连续操作。
#29 测试 temperature_converter.rb (tests/test_temp_converter.rb)
到目前为止,我们的测试脚本相对原始,并且在很大程度上,我们一直在自己编写测试解决方案。这样做是愚蠢的,尤其是在计算机程序中,因为好的编程语言允许你以抽象的方式表达抽象概念,以及将代码库中的通用工具适应你的特定需求。
Ruby 有一个名为 Test::Unit 的通用测试库。以下代码允许你使用其功能来测试 temperature_converter.rb 脚本。
代码
#!/usr/bin/env ruby
# test_temp_converter.rb
❶ require 'temperature_converter'
require 'test/unit'
❷ class Tester < Test::Unit::TestCase *Unit Testing*
def setup
@converter = Temperature_Converter.new()
end
def test_temps()
❸ tests = {
'100.0 C = 212.00 F' => {
:have_num => 100.0,
:have_unit => 'C',
:want_unit => 'F',
},
'212.0 F = 100.00 C' => {
:have_num => 212.0,
:have_unit => 'F',
:want_unit => 'C',
},
'70.0 F = 294.26 K' => {
:have_num => 70.0,
:have_unit => 'F',
:want_unit => 'K',
},
'25.0 C = 298.15 K' => {
:have_num => 25.0,
:have_unit => 'C',
:want_unit => 'K',
},
}
general_tester( tests )
end
private
❹ def general_tester(tests)
❺ tests.each_pair do |result,test_args|
❻ assert_equal( result, @converter.convert( test_args ) )
end
end
end
结果
让我们运行它看看会发生什么。
$ ruby -w tests/test_temp_converter.rb
Loaded suite tests/test_temp_converter
Started
.
Finished in 0.001094 seconds.
1 tests, 4 assertions, 0 failures, 0 errors
我们的所有断言都通过了,没有失败或错误。这是个好消息。现在让我们看看这意味着什么。
注意
你可能听到的与测试相关的术语之一是 代码覆盖率,这是测试充分检查相关代码的程度。这可以用测试的总代码行数的百分比、测试的布尔评估的百分比和其他类似指标来定义。
在本章的早期,我提到了重构,这是一种在保持行为不变的同时清理代码实现的做法。单元测试在重构时非常有用,尤其是如果你使用具有高 入口/出口覆盖率 的测试,这意味着它们试图确保只要函数得到相同的输入,函数的所有输出都保持不变。这种类型的测试能让你保持重构的诚实性。
它是如何工作的
首先,我们需要访问我们将要测试的代码。幸运的是,我们已经遵循了良好的设计实践,并在名为 temperature_converter.rb 的库中定义了我们的代码,所以我们 require 了它和 test/unit 库,在第❶行。然后我们定义了一个新的类 Tester;,如你所见在第❷行,这个类是 Test::Unit::TestCase 的子类,这意味着它继承了 Test::Unit::TestCase 的所有方法和特性。
我们随后定义了一个名为 test_temps 的测试方法。它只是一个多级哈希 tests 的包装器,该哈希在 test_temps 中的❸处定义。你会注意到 tests 的每个键都是一个看起来像 Units_Converter.convert 输出的字符串;该键的值是一个哈希,你将其用作 Units_Converter.convert 的参数,以获取与该键匹配的输出。在 test_temps 中,我们随后将 tests 作为参数传递给一个名为 general_tester 的私有方法,我们在❹处定义了它。
general_tester 方法在 tests 哈希中的 each_pair 上循环,在❺处调用预期的结果 result 和生成该 result 所需的参数哈希 test_args。对于这些成对中的每一个,我们断言 result 和 @converter.convert(test_args) 是相等的,使用名为 assert_equal 的适当方法(❻)。这就是全部内容。
修改脚本
尝试在 tests 哈希中的一个更改。如果你只更改了键(在 general_tester 中变为 result),或者只更改了值(在 general_tester 中变为 test_args),则 assert_equal 的调用将失败,因为作为比较参数传递的两个项目将不再相等。你还可以向 tests 哈希中添加全新的元素,并使用你想要验证的新值。
此脚本只是展示了如何使用 Test::Unit 的一小部分。在命令行中输入 ri Test::Unit 以获取更多信息。您还可以浏览到 Ruby 标准库文档网站中的 www.ruby-doc.org/stdlib/libdoc/test/unit/rdoc。请注意,该文档的 HTML 是由 RDoc 生成的。
我已经提到,测试在重构过程中非常有用。测试的一个好起点是我在这里所做的工作,基于一组已知的输入参数预先推断出方法的结果。assert_equal 方法对于此类测试非常有用。还有其他一些方法可用,你可以在命令行中输入 ri Test::Unit::Assertions 来了解它们。值得注意的是 assert_instance_of,它检查其参数是否属于指定的类;assert_nil,它检查其参数是否为 nil;assert_raise,你可以用它来故意抛出异常(即破坏某些东西);以及 assert_respond_to,它检查给定的参数是否知道如何响应给定的指定方法。
章节回顾
本章有哪些新内容?
-
递归阶乘和斐波那契数列作为良好的性能分析候选者
-
重构
-
缓存
-
使用 Benchmark 进行测试
-
性能分析
-
转换温度
-
带有 Proc 值的哈希作为 Proc 工厂
-
进程的组成
-
使用
Test::Unit进行测试
再次强调,这需要很多理解。这个列表看起来很短,因为其中一些概念需要比我们在前几章中考虑的概念更多的思考。让我们继续到下一章,我们将编写一些处理 HTML 和 XML 的工具。
第八章:HTML 和 XML 工具

文本是网络的运行者。这对于编码在某种标记中的文本尤其如此,例如超文本标记语言(HTML)或可扩展标记语言(XML)。
即使从未听说过“标记”这个术语,非程序员也知道 HTML 是网站通常使用的标记语言。XML 在数据传输和数据存储方面变得越来越重要。在我编写这本书的章节时,我将它们保存为包含压缩 XML 文件的文件类型。我还使用了一种名为 DocBook 的 XML 类型(docbook.org)来撰写我的博士论文。总之,基于 XML 的标记无处不在。幸运的是,Ruby 可以理解、输出和操作 XML(以及 HTML)。
#30 清理 HTML(html_tidy.rb)
让我们从 HTML 开始。这种标记语言已经发布了几个编号版本,类似于不同版本的软件,自从蒂姆·伯纳斯-李在 20 世纪 90 年代中期在 CERN 制作了第一个网页以来,它已经走了很长的路。HTML 的最近版本是 XML 的子集,因此被称为 XHTML。然而,HTML 的早期版本并不那么有纪律性;它们允许对 HTML 进行非常自由的解释。特别是当人们刚开始学习如何使用 HTML 时,他们经常会拼凑出既不美观也不技术良好的页面。但是浏览器制造商不想为渲染内容不佳承担责任,因此他们使他们的浏览器非常宽容。
在短期内,允许非符合标准的 HTML 的做法非常好,因为它意味着更多的人可以查看更多内容。但从长远来看,这种自由性带来了一些负面影响,因为它允许网页设计师继续使用一些未经纠正的糟糕技术。现在有很多杂乱的 HTML,而且几乎没有理由增加混乱。我们想要一个工具,以确保我们的 HTML 符合规范。
注意
我假设你对 HTML 有基本的了解。如果没有,可以在w3schools.com/html/default.asp找到一份很好的指南。 如果你对 HTML 的各种版本及其与 XML 的关系感兴趣,请浏览万维网联盟(W3C)的 MarkUp 页面www.w3.org/MarkUp。 该页面还有一个链接到 html_tidy.rb 脚本所依赖的 HTML Tidy 程序。
已经有一个非常出色的程序可以完成大部分清理工作。它被称为 HTML Tidy,由 Dave Raggett 编写。它可在tidy.sourceforge.net找到,但它也预包装在许多 GNU/Linux 发行版中。鉴于没有必要重新发明轮子,我编写了html_tidy.rb来使用 Raggett 的程序并添加一些我想要的功能。让我们看看代码。
代码
#!/usr/bin/env ruby
# html_tidy.rb
# cleans up html files
❶ EMPTY_STRING = ''
SIMPLE_TAG_REPLACEMENTS = {
#closers
/\<\/b\>/i => '</strong>', ***`=>`** Operator*
/\<\/i\>/i => '</em>',
/\<\/strong\><\/td\>/i => '</th>',
/\<\/u\>/i => '</div>',
#openers
/\<b\>/i => '<strong>',
/\<i\>/i => '<em>',
/\<td\>\<strong\>/i => '<th>',
/\<u\>/i => '<div style="text-decoration: underline;">',
# again, more as appropriate
}
TIDY_EXTENSION = '.tidy'
TIDY_OPTIONS = '-asxml -bc' # possible add -access 3
❷ UNWANTED_REGEXES = [
/^<meta name=\"GENERATOR\" content=\"Microsoft FrontPage 5.0\">$/,
/^ *$/,
/^\n$/,
# more as appropriate
]
❸ def declare_regexes_and_replacements()
replacement_of = Hash.new()
UNWANTED_REGEXES.each do |discard|
replacement_of[discard] = EMPTY_STRING
end
return replacement_of.merge(SIMPLE_TAG_REPLACEMENTS)
end
=begin rdoc
This lacks a ! suffix because it duplicates the argument and
returns the changes made to that duplicate, rather than overwriting.
=end
❹ def perform_replacements_on_contents(contents)
output = contents.dup
replacement_of = declare_regexes_and_replacements()
❺ replacement_of.keys.sort_by { |r| r.to_s }.each do |regex|
replace = replacement_of[regex]
❻ output.each { |line| line.gsub!(regex, replace) }
end
return output
end
=begin rdoc
This has the ! suffix because it destructively writes
into the filename argument provided.
=end
❼ def perform_replacements_on_filename!(filename)
❽ if (system('which tidy > /dev/null'))
new_filename = filename + TIDY_EXTENSION
system("tidy #{TIDY_OPTIONS} #{filename} > #{new_filename} 2> /dev/null") *Standard Error*
❾ contents = File.open(new_filename, 'r').readlines()
new_contents = perform_replacements_on_contents(contents)
File.open(new_filename, 'w') { |f| f.puts(new_contents) }
else
puts "Please install tidy.\n"
end
end
❿ ARGV.each do |filename|
perform_replacements_on_filename!(filename)
end
工作原理
我们首先在❶处定义了一些常量。EMPTY_STRING应该是显而易见的,而SIMPLE_TAG_REPLACEMENTS是一个哈希表,其键是正则表达式,其值是对应键应该替换成的值。你会注意到,你需要在正则表达式中的某些字符前加上反斜杠(\)——这是因为一些字符在正则表达式中具有特殊含义。你已经看到了一些例子,其中?表示“零个或一个我之前提到的任何东西”和*表示“零个或多个我之前提到的任何东西”。同样,\表示“将我之后跟随的任何东西视为一个字面字符,而不是一个特殊的正则表达式字符”。
为什么我要进行这些特定的替换?<b>和<i>标签仍然被广泛使用,但它们不符合 Web Accessibility Initiative (WAI)的标准。我已经设置了这个脚本,用适当的标签替换它们,以实现相同的目标,但不会歧视视觉障碍人士。我还将/<td><\strong>/替换为<th>,因为我发现人们经常通过在表格单元格中添加格式而不是将单元格作为真正的标题来创建“几乎”的表头。最后,我移除了<u>标签,因为它没有任何意义,即使它创建了一个下划线。它只是一个没有语义意义的视觉格式化标签,这是不允许的。格式化是样式表的作用——标记本身应该只包含内容。因此,我用带有下划线样式的<div>替换了<u>。我既对开标签也对应闭标签进行了所有这些替换。
注意
网络可访问性很重要:这些修复帮助视觉障碍人士浏览网络。 html_tidy.rb 脚本修复了我的错误,至少在这些特定情况下。如果你好奇,可以在 W3C 的网络可访问性委员会页面上了解更多关于可访问性和其重要性的信息(www.w3c.org/WAI)*。
我们继续添加更多的常量,包括一些TIDY_OPTIONS。在命令行中执行man tidy以查看这些选项的功能。这些选项反映了我的偏好,但一旦你熟悉了脚本的操作,你当然可以对这些常量进行一些修改。在❷处,我们有一个名为UNWANTED_REGEXES的数组常量。这个名字听起来很严厉,但有些东西我就是不想出现在我的 HTML 中。其中之一是一个<meta>标签,微软的 FrontPage 有时会将其添加到文件中。我也不希望只有空白行的行(由/^ *$/匹配)或完全空白的行(由/^\n$/匹配)。正如注释所暗示的,你可以向这个哈希表中添加内容。
第一个方法,declare_regexes_and_replacements,在❸处。它通过遍历UNWANTED_REGEXES并将SIMPLE_TAG_REPLACEMENTS与UNWANTED_REGEXES结合,创建一个名为replacement_of的 Hash,其键是UNWANTED_REGEXES的元素,其值都是空字符串。这很有意义——如果正则表达式不受欢迎,我们希望将其替换为空字符串。然后declare_regexes_and_replacements方法返回合并后的 Hash,它由SIMPLE_TAG_REPLACEMENTS和我们的新replacement_of Hash 组成.^([25])
接下来是❹和perform_replacements_on_contents方法。它接受一个参数,不出所料,这个参数叫做contents,然后立即使用dup方法对其进行复制,并将结果命名为output。接着,它调用declare_regexes_and_replacements方法(定义在❸),获取返回值,我们已知这个返回值是一个名为replacement_of的 Hash。为了简单起见,我们将在perform_replacements_on_contents内部保持这个 Hash 的名称不变。在❺处,我们使用sort_by方法对replacement_of的键进行排序,该方法接受一个块。字符串知道如何比较自身以进行排序,而正则表达式则不知道。因此,我们将每个正则表达式键转换为字符串以进行排序。
注意
字符串知道如何比较自身与其他字符串,因为 String 有一个 <=> 方法,并且 String 的一个祖先模块是 Comparable。^([26]) Comparable 使用<=>方法来实现其他比较操作符,例如 ==, <=, >=,等等。如果你创建了一个新类并希望它可排序,给它一个名为* <=> 的方法,想出如何以有意义的方式实现它,然后混入 Comparable。你将获得大量的排序价值,同时付出最小的努力,并使你的对象更有用。
在html_tidy.rb的早期版本中,我没有在❺处包含排序,我偶尔会错过SIMPLE_TAG_REPLACEMENTS中描述的替换。原因是 Hash 键没有确定性顺序,所以有时我的程序会在替换</strong></td>为</th>之前将<b>替换为<strong>,但有时则不会。为了使我的程序更健壮,我需要添加一个替换</b></td>为</th>的 Hash 对,或者在对❺处的replacement_of使用时强制执行特定的顺序。我选择强制执行顺序,不仅因为它使程序更可靠,不仅因为我内心是个小霸王,还因为它使程序更简单。
我们对 replacement_of 的键进行排序,并在❺处循环遍历它们,依次将它们称为 regex。我们还想获取替换值,所以我们从哈希中读取它作为 replace。然后在 ❻ 处,我们循环遍历最终 output 的每一行,破坏性地使用 gsub! 将 regex 替换为 replace。现在 output 变量已经准备好返回。这就是我们 perform_replacements_on_contents 的方法。我们从哪里得到 contents?
perform_replacements_on_filename! 方法在 ❽。在 ❿,我们对其 ARGV 数组的每个元素调用它,我们将其称为 filename,因为我们将其作为单个参数传递给 perform_replacements_on_filename!。我们首先尝试执行 ‘which tidy > /dev/null’(❽)系统调用。不深入探讨 Unix 黑魔法,我会告诉你,当执行时,这个命令确定机器上是否安装了 tidy 的版本。
如果测试成功,我们知道我们可以使用 tidy。首先,我们定义一个 new_filename,它只是将 TIDY_EXTENSION 追加到旧的 filename 上。然后我们调用 tidy 本身,传递给它自己的 TIDY_OPTIONS(作为一个插值字符串)并在 filename 上调用它。我们将输出传递给 new_filename,并丢弃任何错误消息。现在 new_filename 文件包含了 tidy 本身所做的所有整理,但没有我们的附加更改。
注意
Unix shell 中的 > 字符仅仅意味着 将我的输出发送到以下文件名,所以* some_command > some_file 将 some_command 的输出写入名为 some_file 的文件。在 > 前面放置一个 2 使其应用于错误消息,而不是常规输出。Unix 将错误消息的输出称为 标准错误。名为 /dev/null 的文件仅仅意味着 无处,所以 some_command > some_file 2> /dev/null 意味着 将 some_command 的输出发送到 some_file,并且我不关心任何错误消息。
我们随后使用 File.open 和 readlines 方法在❾处读取 new_filename 的 contents。这个 contents 变量已经准备好用于 perform_replacements_on_contents,我们调用它,并将结果赋值给 new_contents。然后我们再次打开 new_filename 文件,这次是为了写入,并用 new_contents 替换其内容。
如果 which tidy 测试失败,我们知道我们心爱的 tidy 不存在,所以继续下去没有意义。我们只是要求用户安装 tidy。
运行脚本
我有一个示例文件在 extras/eh.html,所以我们可以使用命令 ruby -w html_tidy.rb extras/eh.html 来调用此脚本。以下是原始版本,extras/eh.html:
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html
lang="en"
xml:lang="en"
>
<head>
<meta http-equiv="refresh" content="10" />
<title>English Horn for No Clergy</title>
<style>
@import url('../css/noclergy.css');
h1, h2 { display: none; }
</style>
</head>
<body>
<div id="notation">
<h1>No Clergy:</h1>
<p style="text-align:center;">
<img src="../../png/eh-page1.png" />
</p>
</div>
<table>
<tr>
<td><b>I'm a header, but I don't know it.</b></td>
<td><u>I'm some underlined content.</u></td>
<td><i>I'm some italicized content.</i></td>
</tr>
</table>
<p>I'm an unclosed paragraph. The horrors.
</body>
</html>
结果
下面是新的版本,extras/eh.html.tidy:
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html lang="en" xml:lang="en" >
<head>
<meta name="generator" content=
"HTML Tidy for Linux/x86 (vers 1 September 2005), see www.w3.org" />
<meta http-equiv="refresh" content="10" />
<title>English Horn for No Clergy</title>
<style type="text/css">
/*<![CDATA[*/
@import url('../css/noclergy.css');
h1, h2 { display: none; }
/*]]>*/
</style>
<style type="text/css">
/*<![CDATA[*/
p.c1 {text-align:center;}
/*]]>*/
</style>
</head>
<body>
<div id="notation-id001">
<h1>No Clergy:</h1>
<p class="c1"><img src="../../png/eh-page1.png" /></p>
</div>
<table>
<tr>
<th>I'm a header, but I don't know it.</th>
<td><div style="text-decoration: underline;">I'm some underlined content.
</div></td>
<td><em>I'm some italicized content.</em></td>
</tr>
</table>
<p>I'm an unclosed paragraph. The horrors.</p>
</body>
</html>
注意 tidy 如何为自己添加了一个 <meta> 标签,并将样式信息包裹在 CDATA 标记内。它还定义了一个名为 c1 的段落类,用于我们的 text-align:center; 样式,该样式附加到自由浮动的 <p> 标签上。除了 tidy 所做的所有事情外,我们的脚本还做了我上面描述的事情。它将“几乎”标题的标签替换为 <th>,将下划线从不良的 <u> 标签转换为样式声明,并将 <i> 标签更改为 <em> 标签,使内容对音频浏览器(如盲人可能使用的)更友好。
修改脚本
我们能否使用 inject 而不是 each 来修改 ❸ 中的 declare_regexes_and_replacements,使其更具功能性?这里有一个方法:
def declare_regexes_and_replacements() *Hashes from Arrays with inject*
return UNWANTED_REGEXES.inject({}) do |h,discard|
h.merge( { discard => EMPTY_STRING } )
end.merge(SIMPLE_TAG_REPLACEMENTS)
end
在这个变体中,h 替代了 replacement_of,它是从一次 inject 迭代持续到下一次迭代的缓存哈希。每次迭代时,我们都将其与新的键值对(discard 作为键,指向 EMPTY_STRING)合并,因此我们最终得到一个包含要替换内容的哈希,所有替换都是 EMPTY_STRING——就像原始版本一样。然而,这次我们的临时变量完全限制在 inject 循环内。
我们能否简单地通过一个名为 perform_replacements_on_contents! 的方法就地修改 contents?当然可以。我只是想展示两种方法:一种破坏性方法(perform_replacements_on_filename!)和一种常规方法(perform_replacements_on_contents),然后我们将使用这些方法的输出进行演示。两者都可以是破坏性的或非破坏性的。如果你希望它们使用相同的方法,请随意修改脚本。
^([25]) 我通常觉得 Perl 有点马虎,但它的一个好处是存储哈希时使用偶数长度的数组,这使得你可以非常容易地将数组转换为哈希。Perl 中 UNWANTED_REGEXES.each 循环的等价物可能如下所示:my %replacement_of = map { $_ => EMPTY_STRING } @unwanted_regexes; 当然,在 Perl 中合并哈希会更麻烦,所以我仍然更喜欢 Ruby。如果你不知道 Perl,不必担心这些。
^([26]) 由于 Comparable 是一个模块,而不是一个类,它是通过混合而不是直接继承成为 String 的祖先。然而,String.ancestors 包含 Comparable,所以我在这里将其称为祖先。
#31 计数标签 (xml_tag_counter.rb)
XML 对其内部结构非常严格。它只能有一个顶级元素(称为 根元素),但这个根元素可以包含任意数量的元素,并且每个元素都可以包含任意数量的其他元素,递归地进行。我们想要一个可以在 XML 文件上运行的脚本,它会输出每个标签(或元素)在文档中出现的次数,无论它出现在多少层深——例如,我们想要找到所有的 <p> 标签,无论这些标签是否直接位于顶级 <html> 元素内,或者位于其他元素内,例如 <blockquote> 或 <div>。让我们看看。
代码
#!/usr/bin/env ruby
# xml_tag_counter.rb
=begin rdoc
This script uses the Rexml parser, which is written in Ruby itself.
Find out more at http://www.germane-software.com/software/rexml.
=end
❶ require 'rexml/document' *REXML*
class Hash
❷ =begin rdoc
Given that <b>self</b> is a <b>Hash</b> with keys of
XML tags and values of their respective counts in an
XML source file, sort by the tag count, descending.
Fall back to an ascending sort of the tag itself,
weighted half as strongly.
=end
def sort_by_tag_count()
self.sort do |a, b|
❸ ( (b[1] <=> a[1]) * 2 ) + (a[0] <=> b[0])
end
end
❹ =begin rdoc
Merge with another <b>Hash</b>, but add values rather
than simply overwriting duplicate keys.
=end
def merge_totals(other_hash) *Hashes as Histograms*
other_hash.keys.each do |key|
self[key] += other_hash[key]
end
end
❺ =begin rdoc
Your basic pretty formatter, returns a <b>String</b>.
=end
def pretty_report()
output = ''
sort_by_tag_count.each do |pair|
tag, count = pair
output += "#{tag}: #{count}\n"
end
return output
end
end # Hash
❻ =begin rdoc
Returns DOM elements of a given filename.
=end
def get_elements_from_filename(filename)
REXML::Document.new(File.open(filename)).elements()
end
❼ =begin rdoc
Returns a <b>Hash</b> with keys of XML tags and values
of those tags' counts within a given XML document.
Calls itself recursively on each tag's elements.
=end
def tag_count(elements)
❽ count_of = Hash.new(0) # note the default value of 0
elements.to_a.each do |tag|
count_of[tag.name()] += 1
❾ count_of.merge_totals(tag_count(tag.elements))
end
return count_of
end
❿ puts tag_count(get_elements_from_file(ARGV[0])).pretty_report()
它是如何工作的
这段脚本中的大部分工作来自于向 Hash 类添加新的方法。首先,在❶处,我们 require 了 rexml/document 库,这是一个 XML 处理库。然后,在❷处,我们开始使用 RDoc 解释 sort_by_tag_count 方法。RDoc 解释了该方法的目标,但让我们看看每个步骤。首先,self.sort 将一个 Hash 转换为一个 Array 的 Array。主 Array 的每个元素都是一个具有结构 [key, value] 的 Array。让我们在 irb 中展示这一点:
irb(main):001:0> h = { 0 => 1, 1 => 2 }
=> {0=>1, 1=>2}
irb(main):002:0> h.sort
=> [[0, 1], [1, 2]]
由于这是在名为 sort 的方法上下文中,所以 Array 的 Array 被排序。sort 方法接受一个块,这允许我们指定我们希望如何排序。我们在❸处通过表达式 ( (b[1] <=> a[1]) * 2 ) + (a[0] <=> b[0]) 来做这件事。这个表达式是什么意思?
首先,我们需要谈谈 sort。你会在❸之前的行中看到,我们在 sort 循环中识别变量为 a 和 b。这些名称是 sort 的传统名称,尽管 Ruby 允许你选择其他名称。我们的表达式在 b[1] 上调用 <=> 方法,以 a[1] 作为参数。然后它将这个结果乘以二,并加上调用 <=> 在 a[0] 上,以 b[0] 作为参数的结果。这应该澄清一切,对吧?
<=> 方法在 self 大于参数时返回 1,无论参数如何定义;在 self 小于参数时返回 -1,希望根据相同的标准;当它们相等时返回 0。当你创建自己的类并实现 <=> 方法时,请记住这一点。我们的项目规范从第 148 页的 #31 计数标签 (xml_tag_counter.rb)") 中说,我们的 sort_by_tag_count 的 Array 的 Array 的键是 XML 标签的名称,值是该标签在正在分析的文档中出现的次数。我们表达式的第一部分(被加倍的部分)只是一个基于标签计数的 sort,正如其名称所暗示的。我们之所以将 b[1] 放在 a[1] 之前,是因为我们希望按降序排序,所以最常见的标签排在前面。
当文档中出现两个不同的标签出现相同次数时会发生什么?这是表达式的第二部分。当标签计数相等时,我们希望然后按标签名称排序,这可能是 a[0] 或 b[0]。我们将这些放入常规顺序,其中 a 在 b 之前,因为我们希望按升序排序。我们的输出首先按降序标签计数排序,然后在给定的标签计数内按升序标签名称排序。为什么我们要对 <=> 的值加倍?
由于 <=> 总是返回 -1, 0 或 1,这对于按标签计数或标签名称排序都是正确的,因此我们需要以某种方式给标签计数排序更大的权重。加倍做得非常好,因为它增加了标签计数 sort 相对于标签名称 sort 的幅度,但对于标签计数的平局没有影响,因为零加倍仍然是零。我们的标签名称 sort 仍然有作用,只是比 sort_by_tag_count 的作用小。^([27])
我们现在知道如何 sort_by_tag_count,但我们还希望能够 merge 哈希,将另一个哈希作为参数,将它们的标签计数相加,并使新的配对成为结果中的配对。哈希已经有一个名为 merge 的方法,它接受一个哈希参数。这应该可以解决一切问题,对吧?遗憾的是,没有。现有的 merge 方法会 替换 任何现有的 key => value 对,以参数哈希中的内容。我们不想这样做——我们想保留它们共有的键,但将值相加。我们如何做到这一点?
在 Ruby 中,答案通常是,编写自己的方法并将其添加到现有类中。merge_totals 的 RDoc 从 ❹ 开始,解释了我们想要发生的事情。我们只是遍历 other_hash(作为参数传入的哈希)中的每个 key,并将该 key 的值添加到 self[key] 中。很简单。但是有一个问题。当 some_key 不是 some_hash 的键之一时,some_hash[some_key] 的值是什么?值是 nil,而 nil 不喜欢被添加。让我们看看在 irb 中会发生什么:
irb(main):001:0> h = { 0 => 1 }
=> {0=>1}
irb(main):002:0> h[1]
=> nil
irb(main):003:0> h[1] + 0
NoMethodError: undefined method '+' for nil:NilClass
from (irb):3
from :0
这不是很好。我们需要找到一种绕过这个问题的方法——但我们将在脚本中稍后处理。现在,要知道 merge_totals 将正确地添加符合 { tag => tag_count } 格式的 Hash 对象中标签的计数,当该标签存在时。
我们还有一个名为 pretty_report 的方法,可以添加到所有的 Hash 对象中(❺)。这个方法输出一个字符串,显示文档中每个标签及其计数。它是通过遍历由 sort_by_tag_count 从 ❷ 返回的数组数组中的每个 pair 来实现的,并创建一个 output 字符串,向其中添加一行,包含 tag,一个冒号,一个空格,tag 的 count,以及一个换行符。然后它 returns 这个字符串。这就是 Hash 中新方法的全部内容。
这个脚本还有两个没有作为方法附加到 Hash 上的函数:get_elements_from_filename(❻)和 tag_count(❼)。get_elements_from_filename 方法接受一个名为 filename 的参数,并实例化一个新的 REXML::Document,它接受一个文件实例作为参数。我们通过 File.open(filename) 提供那个文件实例。REXML::Document 实例有一个名为 elements 的方法,它为我们脚本的大部分工作做了,返回文件中的所有 XML 元素。
tag_count 方法接受那些 elements 作为参数,它在(❽)处实例化一个新的名为 count_of 的哈希,并将 0 传递给 new 方法。这个 0 参数设置了该哈希的默认值,这是当 count_of 缺少请求的键时返回的值。这个 0 的默认值是我们处理在 merge_totals 方法中尚未存在的标签计数问题的方法。self 哈希的默认值为零,所以当一个新标签进入 merge_totals(我们在 ❾ 处调用它)时,它假定该标签的 count_of 为 0。与 nil 不同,0 很高兴有另一个整数添加到它,所以我们的加法问题得到了解决。我们继续递归地调用 tag_counts 在每个 tag 内找到的 elements,然后它根据需要在自己的 elements 上调用 tag_counts(如果有的话)。这一切都在继续,使用 merge_totals 聚合标签计数。
注意
与 count_of 相似的 Hashes 通常会从具有默认值 0 或空字符串中受益。作为 histograms 的 count_of,像 count_of 一样,计算某物的出现次数,应该有默认值 0。其他一些哈希,由于某种原因累积字符串,可以有默认值空字符串。由于字符串知道与其他对象连接,脚本可以用 += 来累积字符串,就像我们的例子中使用整数作为哈希值一样。
在 ❿ 处,我们得到 tag_count 的输出,它期望 elements。我们通过在第一个命令行参数上调用 get_elements_from_filename 来获取这些 elements。由于 tag_count 返回一个哈希,那个返回值有 pretty_report 方法,它为 puts 方法提供参数,并为用户提供信息。
运行脚本
让我们使用文件 extras/eh.html.tidy,由 html_tidy.rb 脚本提供的修正后的输出。让我们尝试 ruby -w xml_tag_counter.rb extras/eh.html.tidy:
结果
这里是输出结果:
div: 2
meta: 2
p: 2
style: 2
td: 2
body: 1
em: 1
h1: 1
head: 1
html: 1
img: 1
table: 1
th: 1
title: 1
tr: 1
修改脚本
如果我们想让 sort_by_tag_count 返回一个哈希,而不是一个数组,我们可以从理论上创建一个类似的方法:
def sorted_by_tag_count()
# sort_by_tag_count returns an Array of Arrays...
sort_by_tag_count.inject({}) do |memo,pair|
tag, count = pair
memo.merge( { tag => count } )
end
# so we can re-Hash it with inject
end
问题在于所有的哈希对都是无序的。我们的新 sorted_by_tag_count 费尽心机调用 sort_by_tag_count,但随后重新哈希它,丢失了顺序。
如果我们想用 inject 来实现 pretty_report,这里有一个方法。注意方法变得稍微短了一些,而 output 变量变成了 inject 的内部变量。
def pretty_report()
sort_by_tag_count.inject('') do |output,pair|
tag, count = pair
output += "#{tag}: #{count}\n"
end
end
最后,我们不仅可以在第一个命令行参数上调用 get_elements_from_filename,还可以使用 ARGV.each 允许脚本连续分析多个文件。
^([27]) 为了转述乔治·奥威尔的《动物农场》,“所有 sorts 都是平等的,但有些比其他 sorts 更平等。”
#32 从 XML 中提取文本(xml_text_extractor.rb)
计算标签的出现次数是可以的,但 XML 是设计用来包含被标签包裹的文本的,它提供了一些比仅从内容中可用的组织性。然而,尽管如此,有时只获取文本内容也很方便。当我使用 DocBook 准备文档时,我发现自己在想使用拼写检查器。有一些拼写检查器是 XML 感知的,但另一种方法是在 XML 上运行文本提取器,并将输出传递给期望纯文本的拼写检查器。这个 xml_text_extractor.rb 正是这样的脚本。
代码
#!/usr/bin/env ruby
# xml_text_extractor.rb
❶ CHOMP_TAG = lambda { |tag| tag.to_s.chomp }
=begin rdoc
This script uses the Rexml parser, which is written in Ruby itself.
Find out more at http://www.germane-software.com/software/rexml
=end
❷ require 'rexml/document'
=begin rdoc
Returns DOM elements of a given filename.
=end
❸ def get_elements_from_filename(filename)
REXML::Document.new(File.open(filename)).elements()
end
=begin rdoc
Returns a <b>String</b> consisting of the text of a given XML document
with the tags stripped.
=end
❹ def strip_tags(elements)
❺ return '' unless (elements.size > 0)
❻ return elements.to_a.map do |tag|
❼ tag.texts.map(&CHOMP_TAG).join('') + strip_tags(tag.elements) *Mapping Procs onto Arrays*
❽ end.join('')
end
❾ puts strip_tags(get_elements_from_filename(ARGV[0]))
它是如何工作的
这个 xml_text_extractor.rb 脚本与 xml_tag_counter.rb 类似,尽管它更简单——具有讽刺意味的是,其输出可能更复杂。它从定义一个名为 CHOMP_TAG 的 Proc 常量开始,该常量接受一个参数并返回该参数表示的字符串的修剪版本。随后,它像 xml_tag_counter.rb 一样在 ❷ 处引入了 REXML 库。在 ❸ 处,它定义了自己的 get_elements_by_filename 版本,与 xml_tag_counter.rb 中的版本相同。
注意
这些脚本旨在展示技术,而不是作为生产代码。对于生产代码,将在多个地方使用的方法的定义应位于一个单独的库文件中,该文件被任何需要访问该方法的文件所引入。请原谅在这个例子中的重复,为了简单起见。
接下来,我们有 strip_tags 在 ❹ 处。与 xml_tag_counter.rb 中的 pretty_report 函数的设计进行对比。而不是采用更迭式的方法(例如,通过定义一个输出变量,使用 each 方法遍历一个数组,并将结果附加到输出变量上),它采用了一种更函数式的方法。它将一个操作映射到 elements(它称之为 tag)的每个成员上 ❻。这个操作本身是将 CHOMP_TAG Proc 映射到 tag.texts 的每个成员上 ❼。然后它使用空字符串分隔符将结果数组 join 在一起,并将 strip_tags 的递归调用结果附加到 tag 的 elements 上。map 的结果是数组,所以它使用空格字符将数组的元素 join 在一起,在返回之前(❽)。它还有一个退出条件,如果没有 elements,则 return 空字符串(❺)。
运行脚本
由于 strip_tags 返回的是由空格连接的 map 元素(这本身是一个字符串)或者空字符串,因此这个字符串可以很容易地在第❾处用 puts 打印。让我们看看 ruby -w xml_text_extracter.rb extras/eh.html.tidy 返回的输出。
结果
English Horn for No Clergy
/**/
@import url('../css/noclergy.css');
h1, h2 { display: none; }
/**/
/**/
p.c1 {text-align:center;}
/**/ No Clergy: I'm a header, but I don't know it. I'm some underlined
content I'm some italicized content I'm an unclosed paragraph. The horrors.
修改脚本
正如我提到的,可以在 xml_text_extractor.rb 和 xml_tag_counter.rb 中进行的一个更改是将共同的 get_elements_by_filename 方法放在一个单独的库文件中,这样 xml_text_extractor.rb 和 xml_tag_counter.rb 都可以通过 require 访问它。这个操作在重构社区中有一个名字:Pull Up Method。xml_text_extractor.rb 脚本还可以对 strip_tags 的输出进行按摩,去除空行和/或完全由空白字符组成的行,就像 html_tidy.rb 使用 UNWANTED_REGEXES 所做的那样。
#33 验证 XML(xml_well_formedness_checker.rb)
如果你的 XML 文件没有良好格式,那么世界上所有的 XML 处理都不会有任何好处。由于 XML 文档要么是良好格式的,要么不是,一个会 return true 或 false 的良好格式检查器似乎是一个理想的谓词方法。由于 XML 文档是内容为字符串的文件,我们将向 File 类和 String 类添加 well_formed_xml? 方法。
代码
#!/usr/bin/env ruby
# xml_well_formedness_checker.rb
=begin rdoc
This script uses the xml/dom/builder, written by YoshidaM.
=end
❶ require 'xml/dom/builder' *The DOM*
class File
❷ def well_formed_xml?()
read.well_formed_xml?
end
end
class String
❸ def well_formed_xml?()
builder = XML::DOM::Builder.new(0)
builder.setBase("./") *Root Element*
❹ begin
builder.parse(self, true)
❺ rescue XMLParserError
return false
end
❻ return true
end
end
❼ def well_formed?(filename)
❽ return unless filename
❾ return File.open(filename, 'r').well_formed_xml?
end
❽ puts well_formed?(ARGV[0])
工作原理
在第❶处,我们要求 XML::DOM::Builder 库文件,它是 Ruby 标准库的一部分。DOM 代表 Document Object Model,它是一种将 XML 文档表示为具有 elements 等方法的对象的方式,该方法返回在 self 时刻找到的元素——它可能是整个文档,也可能是文档中的子元素。我们已经在之前的脚本中使用 elements 与 REXML 库一起使用过了。
注意
程序员在大量使用 Ajax 或其他 JavaScript 时会非常熟悉 DOM。因为 JavaScript 最常见的用途是作为浏览器中的客户端脚本语言,JavaScript 程序经常发现自己需要处理 XML(尤其是 XHTML)数据。JavaScript 是一种优秀的语言,但名称非常误导,并且有一些糟糕的实现。它与 Ruby 共享类似的融合的面向对象/函数式遗产。
我们说我们将在 File 中添加一个 well_formed_xml? 断言,这正是我们在第❷处所做的。File 的 read 方法返回该文件的文本内容。我们知道我们希望将 well_formed_xml? 添加到所有字符串以及所有文件中,所以我们只需在 File 的 well_formed_xml? 方法中调用 read.well_formed_xml? 并假设字符串会完成它的任务,为我们提供它自己的 well_formed_xml? 版本。
我们不想让字符串成为骗子,因此我们在第❸处为字符串提供了它自己的 well_formed_xml? 断言。这委托了一些工作给 XML::DOM::Builder 库,实例化一个 Builder 并将其基础设置为 ‘./’,这代表 XML 文档的根元素。
注意
XML::DOM::Builder.new 的 0 个参数指示它忽略默认事件,这对我们的脚本没有影响。你可以在 raa.ruby-lang.org/gonzui/markup/xmlparser/lib/xml/dom/builder.rb?q=moduledef:XML 了解更多关于 XML::DOM::Builder 的信息。
然后,我们使用 begin 关键字在❹处开始一个块,这表示一个可能会以灾难性的方式失败,以至于它可能会完全退出程序。begin 关键字允许你捕获该错误并以某种智能方式处理它,而不会导致程序崩溃。我们要求我们的 builder 实例 parse 由 self 表示的 XML 内容,当然是在一个 String 实例内部。
这个解析操作可能会失败。潜在的灾难性错误有一个名为 XMLParserError 的类型,因此我们在❺处使用 rescue 关键字来捕获该特定错误类型,防止它杀死整个程序。由于我们的谓词测试 XML 的良好格式,XMLParserError 指示文档不是良好格式。因此,在发生 XMLParserError 时,我们应该 return false。如果我们从 begin 块中退出而没有进入 rescue 部分,这意味着没有错误,所以我们可以在❻处安全地 return true。
我们将使用一个接受 filename 参数的 well_formed? 函数来完成 xml_wellformedness_checker.rb 脚本,该函数在❺处创建。对于 nil filename,它 returns 一个隐式的 nil。然后我们 return 对通过打开 filename 创建的 File 实例调用 well_formed_xml?。最后,❿ 通过 puts 将 well_formed? 的调用结果打印给用户。
运行脚本
我们知道 extras/eh.html.tidy 中有一个良好格式的 XML 文件,因为我们已经运行了 html_tidy.rb 来修复它。我们还知道 extras/eh.html 有一个未关闭的段落标签,这将使其不是良好格式。让我们看看 xml_wellformedness_checker.rb 的表现如何。
结果
ruby -w xml_well_formedness_checker.rb extras/eh.html.tidy
true
$ ruby -w xml_well_formedness_checker.rb extras/eh.html
false
$ ruby -w xml_well_formedness_checker.rb xml_well_formedness_checker.rb
false
$ ruby -w xml_well_formedness_checker.rb
nil
extras/eh.html.tidy 文件是良好格式的 XML,因此它正确地报告了 true。extras/eh.html 和 xml_wellformedness_checker.rb 文件要么不是良好格式的 XML,要么根本不是 XML,因此它们正确地报告了 false。如果我们不带 filename 调用 xml_wellformedness_checker.rb,它将返回 nil,正如我们在❽处所期望的那样。
操纵脚本
在 filename 参数上调用一个名为 well_formed? 的独立函数实际上只是为了演示目的。在生产代码中,这个脚本更可能被用来向 String 类添加另一个方法 well_formed_xml_filename?,实现方式与 well_formed? 相同,只是它会使用 self 代替 filename。或者,在打开特定 XML 文件的任何代码中,可以在执行依赖于文件内容为良好格式 XML 的任何操作之前,使用 File 的 well_formed_xml? 方法来检查该文件。
章节总结
本章有什么新内容?
-
整理 HTML/XML 标记
-
使用
2>将输出重定向到标准错误 -
网络无障碍性倡议
-
<=>方法与 Comparable 模块 -
使用
REXML和XML::DOM::Builder处理 XML -
使用正则表达式操作 XML 文档
-
使用
inject从数组中生成哈希 -
作为直方图的哈希
-
将过程映射到数组上
-
文档对象模型
-
begin和rescue关键字
我们关于 XML 处理的脚本就到这里了。我希望这些示例脚本不仅本身有用,而且还能给你一些想法,关于如何修改或扩展它们以适应这里未展示的新任务。现在,我们将继续到下一章,第九章。正如其名所示,它的脚本更加详细,并且将继续介绍一些新的功能技术。
第九章. 更复杂的工具和技巧(第一部分)

本章是两章中第一章,探讨了 Ruby 中更复杂的操作。这一章主要涉及文本操作和更大规模的搜索,而下一章将详细说明一种重要的功能技术,它以非常强大的方式扩展了抽象的选项。现在,让我们直接深入学习一些文本处理技术。
#34 在《圣经》或《白鲸》中寻找代码(els_parser.rb)
此脚本分析大型文本中的一种现象,称为等距字母序列(ELSes)。这些序列通常被称为圣经代码或托拉代码,这主要归因于迈克尔·德罗辛(Michael Drosnin)在其著作《圣经代码》(The Bible Code,Simon & Schuster,1997)中对它们的描述,他在书中考察了希伯来圣经。一个ELS是一组字母(Ruby 会称之为 String),在源文本中有一个已知的起始点,一个已知的长度和一个已知的跳值,即构成该 ELS 的字母之间的距离。你可以通过说“从这个报纸文章的第 23 个字母开始,每隔 8 个字母添加一个,直到你有 11 个字母”来构建一个 ELS。这 11 个字母的字符串将是一个 ELS。德罗辛的工作表明,具有特定意义的 ELSes(通常由于与它们所抽取的文本的相关性或由于对未来事件(如暗杀)的准确预测)在特定宗教文本中的出现频率高于随机频率。
我的 els_parser.rb 脚本也使用了澳大利亚国立大学(The Australian National University)的布伦丹·麦凯(Professor Brendan McKay)的研究成果。cs.anu.edu.au/~bdm/dilugim/torah.html。麦凯在自己的研究(可在上述链接找到)中寻找文本如《战争与和平》和《白鲸》中的 ELSes,从而得出结论,德罗辛所指的圣经代码在希伯来圣经中的出现频率并不比由于随机性可预期的频率更高。由于我不会读希伯来语,因此我选择分析赫尔曼·梅尔维尔(Herman Melville)的《白鲸》英文版而不是希伯来圣经。我从 Project Gutenberg (www.gutenberg.org) 下载了文本到 extras/moby_dick.txt。els_parser.rb 脚本允许你选择一个文本和一组描述潜在 ELSes 的输入参数;然后 els_parser.rb 将报告是否存在与描述匹配的 ELSes。
代码
#!/usr/bin/env ruby *ELS*
# els_parser.rb
require 'palindrome2.rb'
# I want all Strings to have the private letters_only
# method from this file.
class String
=begin rdoc
This provides a public method to access the private letters_only
method we required from palindrome2.rb.
=end
def just_letters(case_matters)
❶ letters_only(case_matters)
end
end
=begin rdoc
A text-processing parser that does ASCII-only
Equidistant Letter Sequence analyses similar to that described
at http://en.wikipedia.org/wiki/Equidistant_letter_sequencing
For my example, I use Moby Dick taken from
Project Gutenberg, http://www.gutenberg.org.
=end
class ELS_Parser
❷ DEFAULT_SEARCH_PARAMS = {
:start_pt => 4500,
:end_pt => nil, # assumes the end of the String to search when nil
:min_skip => 126995,
:max_skip => 127005,
:term => 'ssirhan',
}
def initialize(filename, search_params=nil)
@contents = prepare(filename)
@filename = filename
reset_params(search_params || DEFAULT_SEARCH_PARAMS)
end
def reset_params(search_params)
@search_params = search_params
@search_params[:end_pt] ||= (@contents.size-1)
# ||= for :end_pt allows nil for 'end of file'
return self # return self so we can chain methods
end
=begin rdoc
Performs an ELS analysis on the <i>filename</i> argument, searching for
the term argument, falling back to the default.
=end
❸ def search(term=@search_params[:term])
@search_params[:term] = term
reversed_term = term.reverse
warn "Starting search within #{@filename} " + ***`$DEBUG`***
"using #{@search_params.inspect}" if ($DEBUG)
❹ final_start_pt = @search_params[:end_pt] - @search_params[:term].size
@search_params[:start_pt].upto(final_start_pt) do |index|
@search_params[:min_skip].upto(@search_params[:max_skip]) do |skip|
❺ candidate = construct_candidate(index, skip)
❻ if (candidate == @search_params[:term])
return report_match(skip, index)
end
if (candidate == reversed_term)
return report_match(skip, index, 'reversed ')
end
end
end
❼ return report_match(false, false)
end
private
❽ def construct_candidate(index, skip)
output = ''
0.upto(@search_params[:term].size-1) do |char_index|
new_index = (index + (char_index * (skip + 1)))
return '' if (new_index >= @contents.size)
output += @contents[new_index].chr *The **`chr`** Method*
end
return output
end
=begin rdoc
Creates a 'letters only' version of the contents of a <i>filename</i>
argument in preparation for ELS analysis. Assumes case-insensitivity.
=end
❾ def prepare(filename, case_matters=false)
File.open(filename, 'r').readlines.to_s.just_letters(case_matters)
end
=begin
Either report the variables at which a match was found, or report
failure for this set of search params.
=end
❿ def report_match(skip, index, reversed='')
return "No match within #{@filename} using " +
@search_params.inspect unless index
return "Match for #{@search_params[:term]} " +
"#{reversed}within #{@filename} " +
"at index #{index}, using skip #{skip}"
end
end # ELS_Parser
工作原理
els_parser.rb 脚本仅处理字母,忽略空白和标点符号。我们知道字符串也可以包含非字母字符,例如空白、数字、标点符号等;因此,我们需要一个方法来从字符串中移除所有非字母字符。幸运的是,我们已经有这样一个方法——letters_only,它在 palindrome2.rb 中定义。通过在 els_parser.rb 的顶部使用 require,我们可以轻松地利用 letters_only。然而,palindrome2.rb 将 letters_only 定义为一个 private 方法,而(正如将变得清楚的那样),我们希望它作为一个公共方法可用。我们能做什么呢?一种方法,即 els_parser.rb 在❶处所做的方法,是定义一个新的公共方法 just_letters,它仅仅是为了调用现有的 private 方法 letters_only。
just_letters 方法用于字符串,但我们需要一个新的类 ELS_Parser 来进行整体搜索管理。ELS_Parser 有一个名为 DEFAULT_SEARCH_PARAMS 的哈希常量❷。对于 :start_pt 和 :end_pt 符号键的值分别代表搜索的最早和最晚字符索引。:term 的值是要搜索的文本。最后,:min_skip 和 :max_skip 的值是在搜索期间跳过的最小和最大字母数(即跳过的字母数)。为什么这些特定的默认值?它们可以是任何值,但我采取了捷径,从麦凯的网页(cs.anu.edu.au/~bdm/dilugim/moby.html)中获取值,这些值已知与《白鲸记》文本中的特定匹配相对应。
注意一些细微的差异——我的值是 0 基础的(其中跳过值为 0 表示 移动到下一个字母),而麦凯将移动到下一个字母定义为跳过值为 1。在起始点方面也有类似的差异。他还使用负跳过值来完成向后搜索,而 els_parser.rb 在反向术语上使用正跳过搜索。
例如,在字符串 'abcdefgh',我们将其称为 contents,使用 :start_pt 为 0、:term 为 'abc' 和 :min_skip 为 0 的 ELS 搜索将找到匹配项,因为字符串 'abc' 在 contents 中从 0 开始存在(正好在开头)且跳过值为 0。同样,‘ceg’ 将在 contents 中从 2 开始找到,跳过值为 1,而 ‘heb’ 将从 1 开始找到,跳过值为 2,但作为一个反向字符串。如果你将这些概念大大扩展,使用更长的搜索词,更大的 contents(如圣经或《白鲸记》),以及更大的起始、结束和跳过值,你将开始理解 ELS 分析的基本原理。
在定义 DEFAULT_SEARCH_PARAMS 之后,我们的 ELS_Parser 需要一个 initialize 方法,在其中它将定义实例变量 @contents 来保存要搜索的文本,以及 @filename 来存储它从读取 @contents 的文件名。
@contents 变量是调用 filename 上的 prepare 方法(定义在❾)的结果。prepare 方法接受一个必填的 filename 参数和一个可选的 case_matters 参数。它所做的一切就是打开一个新文件,使用 readlines.to_s 将其 contents 提取成一个 String,然后对这个 String 调用 just_letters 方法。这确保我们在将字符串存储到 @contents 之前,从字符串中移除不适当的字符。请注意,just_letters 方法可以接受一个用于大小写敏感性的可选参数。如果你对这个工作原理感到好奇,请记住 just_letters 只调用在 palindrome2.rb 中定义的 letters_only 方法,因此你可以参考该脚本进行进一步的学习。
initialize 方法还调用了定义在 initialize 之下的 reset_params 方法,它简单地将实例变量 @search_params 设置为传递给 initialize 的 search_params 参数,如果 :end_pt 值为 nil,则回退到 DEFAULT_SEARCH_PARAMS。它还将 :end_pt 值设置为回退到 @contents 的最后一个索引。这为 ELS_Parser 提供了一个方便的快捷方式:省略 :end_pt 自动意味着 搜索到 @contents 的末尾。
接下来是❸处的 search。它允许一个可选的 term 参数,该参数会根据需要自动更新 @search_params[:term]。由于 search 被设置为寻找倒序 term 以及正常顺序 term,我们立即定义了 reversed_term。我们还使用 warn 方法报告搜索开始,如果 $DEBUG 为真,该方法将写入 标准错误,而不是 标准输出。$DEBUG 通常作为 ruby 的命令行选项设置,这意味着当你使用 -d 或 --debug 标志执行 ruby 时,$DEBUG 为真。你可能还记得 html_tidy.rb 中的标准错误。在那个脚本中,我们将标准错误发送到 /dev/null,这意味着我们不在乎它。在这里,我们有一个专门设计用于发送到标准错误的消息。
在标准错误警告之后,我们在❹处定义了 final_start_pt。要了解 final_start_pt 的用途,让我们回到我们的 contents = ‘abcdefgh’ 搜索示例。如果我们以 :start_pt 为 100 搜索 ‘hiccup’ 会怎样?在我们的 contents 中甚至没有 100 个字母,所以具有该 :start_pt 值的搜索会自动失败。我们不想让这种情况发生,我们想要找出可能工作的最大起始索引,并确保 :start_pt 不大于该值。
这甚至比那还要复杂。我们的搜索词总是包含字母,而这些字母会占用空间。如果我们从 @contents 的末尾开始太近,即使有相对较低的跳过值,我们也可能没有足够的空间。我们需要为正在搜索的术语留出足够的空间,我们将该术语存储在 @search_params[:term] 中,因此我们相应地设置 final_start_pt。
在设置 final_start_pt 之后,我们进入两个嵌套循环——一个是在 index 从最低到最高起始点,另一个是使用 skip 指向每个从最低到最高的跳过值。在这些循环中我们做的第一件事是在❺处使用 index 和 skip 将 construct_candidate 返回的表达式分配给 candidate,construct_candidate 定义在❽处。construct_candidate 方法接受现有的 index 和 skip 值,并生成一个与正在搜索的术语长度相同的 String。对于 @contents 为 ‘abcdefgh’ 的情况,construct_candidate(2, 1) 生成 ‘ceg’,其中 @search_params[:term] 有三个字符。如果请求的 new_index 超过了 @contents String,construct_candidate 方法返回空 String。我们的 final_start_pt 限制应该防止这种情况发生,但这是一个额外的安全检查。
注意
construct_candidate 方法也使用了 chr 方法,因为从 String 中提取单个字符会给你该字符的 ASCII 值。
你可以在 irb 中测试这个:
irb(main):001:0> s = 'abcde'
=> "abcde"
irb(main):002:0> s[0]
=> 97
irb(main):003:0> s[0].chr
=> "a"
在建立我们的 candidate 之后,我们想看看它是否是一个成功的匹配,我们从❻开始这样做。如果它匹配,我们 return 调用 report_match 的结果,其中 skip 和 index 作为参数。然而,我们还想知道我们的 candidate 是否与 reversed_term 匹配,而不是按常规顺序的术语,所以我们再次调用 report_match,同样使用 skip 和 index 作为参数,但我们也添加了 String ‘reversed ’。最后,在❼,如果我们已经遍历了所有适当的 skip 和 index 循环而没有返回任何内容,我们返回调用 report_match 的两个显式 false 参数的结果。这仅仅意味着我们从未找到匹配项,无论是正向还是反向。
我们需要了解 report_match 的工作原理。它在❿处定义,并接受 skip、index 和一个可选的 reversed String 参数,如前所述。如果 index 是 false,report_match 返回一个 String,通知用户没有找到匹配项。否则,它返回成功匹配的详细信息。请注意,reversed 根据需要添加 String ‘reversed ’(包括尾随空格)。
运行脚本
我们可以用另一个名为 demo_els_parser.rb 的脚本进行测试。以下是它的代码:
#!/usr/bin/env ruby
# demo_els_parser.rb
require 'els_parser.rb'
moby_dick = ELS_Parser.new('extras/moby_dick.txt')
puts moby_dick.search() # assumes 'ssirhan'
puts moby_dick.reset_params( {
:start_pt => 93060,
:end_pt => nil, # assumes 'to the end'
:min_skip => 13790,
:max_skip => 13800,
:term => 'kennedy'
} ).search()
puts moby_dick.reset_params( {
:start_pt => 327400,
:end_pt => nil, # 'to the end' again
:min_skip => 0,
:max_skip => 5,
:term => 'rabin'
} ).search()
puts moby_dick.reset_params( {
:start_pt => 104620,
:end_pt => 200000, # not to the end
:min_skip => 26020,
:max_skip => 26030,
:term => 'mlking'
} ).search()
结果
这是调用此脚本的结果:
ruby -w --debug demo_els_parser.rb
Starting search within extras/moby_dick.txt using {:end_pt=>924955,
:min_skip=>126995, :max_skip=>127005, :term=>"ssirhan", :start_pt=>4500}
Match for ssirhan within extras/moby_dick.txt at index 4546, using skip 126999
Starting search within extras/moby_dick.txt using {:end_pt=>924955,
:min_skip=>13790, :max_skip=>13800, :term=>"kennedy", :start_pt=>93060}
Match for kennedy within extras/moby_dick.txt at index 93062, using skip 13797
Starting search within extras/moby_dick.txt using {:end_pt=>924955,
:min_skip=>0, :max_skip=>5, :term=>"rabin", :start_pt=>327400}
Match for rabin reversed within extras/moby_dick.txt at index 327500, using
skip 3
Starting search within extras/moby_dick.txt using {:end_pt=>200000,
:min_skip=>26020, :max_skip=>26030, :term=>"mlking", :start_pt=>104620}
Match for mlking reversed within extras/moby_dick.txt at index 104629, using
skip 26025
修改脚本
我们可以通过在执行过程中检查搜索词并返回空字符串(如果它不匹配)来显著提高 construct_candidate 的速度——这是在构建候选词时应用返回保护概念的实例。在我们定义 final_start_pt 的地方,我们也可以以类似的方式限制 :max_skip,或者在请求了不可能的搜索参数时报告错误。
注意
还有比我在这里所做的方法更好的方法来包含 letters_only 方法,那就是使用一个名为 mixin 的概念。跳转到 to_lang.rb 脚本,在 第十章 中查看 mixin 的实际应用。
#35 将字符串转换为狐狸(methinks.rb)
这个脚本基于理查德·道金斯 《盲眼钟表匠》(W.W. Norton,1996)中的一个程序。该程序演示了一个简化的无性自然选择模型,从一个由随机字符组成的字符串开始,并连续地对其进行突变以产生与父代不同的“子代”。然后程序选择“最佳”子代字符串(意味着最接近目标字符串 methinksitislikeaweasel,这是来自 《哈姆雷特》 的引用)作为下一代父代。这个过程一直持续到父代字符串与目标字符串匹配。
让我们在 Ruby 中实现道金斯的进程。
注意
道金斯编写这个程序是为了展示一种累积选择版本,它故意比现实世界的现实达尔文自然选择更简单。批评者认为这个程序是一个次优模型,最突出的批评是它过于简化,它无法失败,并且有一个预设的目标,这使得它比自然选择更适合作为人工选择的模型。请参阅第 175 页的 Hacking the Script,以获取修改此版本程序以使其成为现实世界达尔文选择更好模型的通用建议。
代码
#!/usr/bin/env ruby
# methinks.rb
=begin rdoc
Recreate Richard Dawkins' Blind Watchmaker program, in which a purely
random string is mutated and filtered until it matches the target string.
=end
❶ class Children < Array *Inheritance*
def select_fittest(target)
inject(self[0]) do |fittest,child|
child.fitter_than?(fittest, target) ? child : fittest
end
end
end
❷ class String
ALPHABET = ('a'..'z').to_a
LETTER_OFFSET = 'a'[0]
PARAMS = {
:generation_size => 20,
:mutation_rate => 10,
:display_filter => 5,
:mutation_amp => 6
}
TARGET = 'methinksitislikeaweasel'
@mutation_attempts ||= 0
❸ def deviance_from(target) *Differences between Strings*
deviance = 0
split('').each_index do |index|
deviance += (self[index] - target[index]).abs
end
return deviance
end
def fitter_than?(other, target)
deviance_from(target) < other.deviance_from(target)
end
❹ def mutate(params)
split('').map do |char|
mutate_char(char, params)
end.join('')
end
❺ def mutate_until_matches!(target=TARGET, params=PARAMS)
return report_success if (self == target)
report_progress(params)
@mutation_attempts += 1
children = propagate(params)
fittest = children.select_fittest(target)
replace(fittest)
mutate_until_matches!(target, params)
end
❻ def propagate(params)
children = Children.new()
children << self
params[:generation_size].times do |generation|
children << self.mutate(params)
end
return children
end
❼ def report_progress(params)
return unless (@mutation_attempts % params[:display_filter] == 0)
puts "string ##{@mutation_attempts} = #{self}"
end
def report_success()
puts <<END_OF_HERE_DOC
I match after #{@mutation_attempts} mutations
END_OF_HERE_DOC
return @mutation_attempts
end
=begin rdoc
Replace self with a <b>String</b> the same length as the
<i>target</i> argument, consisting entirely of lowercase
letters.
=end
❽ def scramble!(target=TARGET)
@mutation_attempts = 0
replace( scramble(target) )
end
def scramble(target=TARGET)
target.split('').map do |char|
ALPHABET[rand(ALPHABET.size)]
end.join('')
end
private
=begin rdoc
Limit 'out of bounds' indices at end points of the ALPHABET.
=end
❾ def limit_index(alphabet_index)
alphabet_index = [ALPHABET.size-1, alphabet_index].min
alphabet_index = [alphabet_index, 0].max
return alphabet_index
end
❿ def mutate_char(original_char, params)
return original_char if rand(100) > params[:mutation_rate]
variance = rand(params[:mutation_amp]) - (params[:mutation_amp] / 2)
# variance with amp of 6 now ranges from -3 to 2,
variance += 1 if variance.zero? # therefore move (0..2) up to (1..3)
alphabet_index = (original_char[0] + variance - LETTER_OFFSET)
alphabet_index = limit_index(alphabet_index)
mutated_char = ALPHABET[alphabet_index]
return mutated_char
end
end
工作原理
我们首先定义了一个名为 Children 的新类 ❶。你会在类定义中注意到独特的 Children < Array,这表明了 Children 和 Arrays 之间的关系。这种关系是 继承。Children 继承自 Array,意味着它在所有方面都表现得像 Array,同时添加了我们赋予它的任何新特性。在我们的例子中,唯一的新特性是名为 select_fittest 的新方法,它使用 inject 来在 Children 中找到最适应的子代,这是通过 fitter_than? 方法定义的。
CHILDREN DON’T LIE
子类(或子类)与父类不同的另一个方面是类方法返回的表达式。当在子类的实例上调用时,它返回子类的名称:
$ irb -r methinks.rb
irb(main):001:0> a = Array.new
=> []
irb(main):002:0> c = Children.new
=> []
irb(main):003:0> a.class
=> Array
irb(main):004:0> c.class
=> Children
有些人可能认为这是显而易见的,但值得注意。
在定义了 Children 之后,我们在 ❷ 处打开字符串类。我们添加了几个常量,包括一个我们将称之为 ALPHABET 的字母数组,以及 LETTER_OFFSET。LETTER_OFFSET 常量需要一些解释。它表示字符为 ASCII 值,以确定某些字符串之间的匹配程度有多接近。将字母转换为数值值很方便,因为它允许我们使用基本的数学运算来找到“最合适”的子字符串。Ruby 通过将字符串视为一个数组并通过索引读取值来将字符转换为数值。让我们在 irb 中演示(chr 方法将 ASCII 值转换回字符串):
irb(main):001:0> s = 'abcde'
=> "abcde"
irb(main):002:0> s[0]
=> 97
irb(main):003:0> s[0].chr
=> "a"
irb(main):004:0> 'a'[0]
=> 97
irb(main):005:0> s[1]
=> 98
你可以看到字符串 'a'(在字符串 s 中的索引 0 处的字符)的 ASCII 值是 97,chr 方法将这个 ASCII 值转换回 'a',而 'b' 的 ASCII 值是 98。数字 97 是我们的 LETTER_OFFSET。敏锐的读者会注意到 LETTER_OFFSET 也是 'a' 在我们的 ALPHABET 中的索引。观察以下在 irb 中的内容:
irb(main):001:0> letters = ('a'..'z').to_a
=> ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o",
"p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z"]
irb(main):002:0> 's'[0]
=> 115
irb(main):003:0> 's'[0] - 'a'[0]
=> 18
irb(main):004:0> letters[18]
=> "s"
在一个字符上调用 [0] 并减去 LETTER_OFFSET(‘a’[0] 或 97)将给我们这个字符在 ALPHABET 数组中的索引。这在 mutate_char 方法(❿)中会非常有用,我们将在到达那里时讨论它。
我们接下来的两个常量是 PARAMS 和 TARGET。这两个常量为可能被可选参数覆盖的项目设置了默认值。PARAMS 是一个现在大家都很熟悉的带有符号键的哈希,其中每个值都决定了我们突变的具体行为。:generation_size 的值是子代数量,:mutation_rate 的值是突变发生的百分比概率,:display_filter 只设置程序运行时更新的频率,而 :mutation_amp 决定了给定突变可以有多强或多发散——基本上是一个衡量子代与父代差异的数值度量。
TARGET 是我们的默认最终目标:methinksitislikeaweasel。最后,在常量之后,我们有一个名为 @mutation_attempts 的单个类变量,它只是一个计数器,每次突变时都会增加。我们现在可以开始定义一些方法了。
我们要添加到字符串的第一个新方法是 deviance_from(❸)。它需要一个强制性的目标参数(默认回退到 TARGET 常量发生在 mutate_until_matches!(❺)中,这在代码中稍后,但被较早调用)。deviance_from 方法返回一个整数(deviance),它是两个字符串差异的数值度量。字符串中每个位置的每个字符差异都会使 deviance 增加一。以下是一些 irb 中的示例:
irb -r methinks.rb
irb(main):001:0> 'aaa'.deviance_from('aaa')
=> 0
irb(main):002:0> 'aaa'.deviance_from('aab')
=> 1
irb(main):003:0> 'aaa'.deviance_from('aac')
=> 2
irb(main):004:0> 'aaa'.deviance_from('bac')
=> 3
irb(main):005:0> 'aaa'.deviance_from('baq')
=> 17
这个方法对我们脚本很有用,因为我们试图模拟适者生存,我们需要能够衡量适应性。低于 target 的 deviance_from 表示适应性。紧接在 deviance_from 之下是 fitter_than?,这是一个简单的谓词,它比较 self 和另一个 String 的 deviance_from 值,两者相对于相同的 target。只有当 self 的 deviance_from 值更低时,它才返回 true,使 self 更适应。请参阅第 175 页的 Hacking the Script,了解如何完全消除此方法。
接下来是 mutate (❹)。它需要一个必填的 params 参数,如果需要,会回退到脚本操作中较早的 mutate_until_matches! (❺) 中的默认 PARAMS 常量。mutate 方法非常懒惰,因为它会将调用对象 split 成单个字符,并通过 map 对每个字符调用 mutate_char (❿)。
mutate_char 方法稍微复杂一些。它需要 original_char 和 params 的必填参数,如果 params 指示不应变异,则立即退出,这是通过一个随机百分比高于 params[:mutation_rate] 来确定的。假设它通过了 params 的测试,mutate_char 将会变异字符。首先,它声明一个 variance,这仅仅是基于 :mutation_amp 的变化量和方向。variance 的值范围从 +(:mutation_amp / 2) 到 -(:mutation_amp / 2),不包括零。它们最初从 -(:mutation_amp / 2) 变化到 +(:mutation_amp / 2) 以下,包括零,但执行 variance +=1 if variance.zero? 的那行代码确保了零或更高的值增加一个。
然后,它创建一个 alphabet_index 变量,使用之前讨论的 LETTER_OFFSET 来找到 original_char 在 ALPHABET 中的索引,加上任何适当的 variance。然后,它使用 limit_index 方法 (❾) 限制 alphabet_index,该方法将 alphabet_index 剪裁或截断到 ALPHABET 中最后一个索引的最大值和 0 的最小值,即 ALPHABET 中的第一个索引。由于它已经有了可靠的索引来从 ALPHABET 中读取,它就这样做了,将那个值放入一个名为 mutated_char 的变量中,然后返回它。
在mutate之后是mutate_until_matches!(❺),这是脚本的公共工作马。它接受可选的target和params参数,如果没有提供,则回退到之前讨论的其他方法中提到的 String 的TARGET和PARAMS常量。如果self与target完全匹配,我们希望report_success。如果没有匹配,我们希望report_progress。我们可以查看这两个方法,它们从❼开始。report_success方法使用puts显示在经过一定次数的尝试后它完全匹配,并且返回@mutation_attempts而不增加它。(没有必要增加它,因为没有发生新的变异。)report_progress方法在没有值的情况下返回,除非@mutation_attempts是params[:display_filter]的倍数(即相对于params[:display_filter]的余数为 0)。如果我们设置一个较低的显示过滤器,我们将有一个更健谈的变异过程。假设它应该输出,它使用puts显示在经过多少次@mutation_attempts之后self的状态。
在报告进度之后,mutate_until_matches!应该实际进行一些变异。它增加@mutation_attempts,然后创建一个名为children的新变量,这是propagate(❻)的输出。propagate方法接受一些params并实例化一个新的Children实例(❶),这意味着它能够访问select_fittest,这是数组所不具备的。它将self添加到children中,其效果是如果父母(self)比所有children都更适应,那么在这次之后,父母将再次成为生成下一批children的来源。propagate方法接着将一个孩子(它自身的变异版本)添加到children中,重复此操作次数等于params[:generation_size]。最后,它返回children,然后这些children将尝试在残酷的世界中找到自己的道路。
世界的残酷效应是通过儿童的select_fittest方法实现的。世界确实很残酷,因为只有一名儿童能够幸存,正如之前所讨论的。我们恰当地将最适应环境的儿童称为fittest,并用这个最适应环境的儿童来replace父母。然后mutate_until_matches!递归地调用自身,直到最终与target匹配。
还有两种方法尚未描述:scramble 和 scramble!(❽)。这两种方法都接受一个可选的 target 参数,默认为 TARGET。由于 scramble! 是破坏性的,它将 self 的 @mutation_attempts 设置为 0 并用非破坏性的 scramble 返回的值替换 self。scramble 方法将 target 在每个 char 处分割,并通过 map 创建一个新的 Array;新 Array 的每个成员都是来自 ALPHABET 的随机元素。请注意,我们甚至没有使用 char——我们只是使用 map 确保打乱的字符串与 target 具有相同的 size。然后 scramble 方法将随机字符的 Array 与空字符串连接起来,并返回结果字符串:一个与目标长度相同的字符串,由完全随机的字母组成。
运行脚本
让我们在 irb 中试试。
irb -r methinks.rb
irb(main):001:0> candidate = String.new.scramble!()
=> "rnvrtdldcgaxlsleyrmzych"
irb(main):002:0> candidate.mutate_until_matches!()
结果
string #0 = rnvrtdldcgaxlsleyrmzych
string #5 = okvpqekfcicsnsleysmzsci
string #10 = pkvnnekhdkdslrjeztmvseh
string #15 = pkvjnekjfmgslrjeytjrsei
string #20 = plvflekjhmislljettjosel
string #25 = oisfmejkimisllkeqtjlsel
string #30 = mfsgmgjnimislkkeotgjsel
string #35 = mfsglgjqimislkkeivfhsel
string #40 = mesgigkqiriskhleivffsel
string #45 = mesgikkqirislhleivfasel
string #50 = mesgikkqirislhkegvfasel
string #55 = metiilksitislhkegvfasem
string #60 = metiilksitislhkefvfasem
string #65 = meshinlsitislhkeaweasel
string #70 = methinlsitislhkeaweasel
string #75 = methinlsitislhkeaweasel
string #80 = methinlsitislikeaweasel
string #85 = methinlsitislikeaweasel
string #90 = methinlsitislikeaweasel
string #95 = methinlsitislikeaweasel
string #100 = methinlsitislikeaweasel
string #105 = methinlsitislikeaweasel
string #110 = methinlsitislikeaweasel
string #115 = methinlsitislikeaweasel
string #120 = methinlsitislikeaweasel
string #125 = methinlsitislikeaweasel
string #130 = methinlsitislikeaweasel
string #135 = methinlsitislikeaweasel
string #140 = methinlsitislikeaweasel
string #145 = methinlsitislikeaweasel
string #150 = methinlsitislikeaweasel
I match after 152 mutations
=> 152
在自己的机器上尝试,注意结果可能是随机的——有时脚本需要更多代数,有时则较少。如果你传入不同的值,可以得到截然不同的结果:
irb(main):005:0> candidate = String.new.scramble!('hello')
=> "wnwdi"
irb(main):006:0> candidate.mutate_until_matches!('hello')
string #0 = wnwdi
string #5 = onsdj
string #10 = lnpgj
string #15 = ijlkj
string #20 = hemlj
string #25 = hemll
string #30 = hemlo
I match after 34 mutations
=> 34
我们将在下一个脚本 methinks_meta.rb 中进一步探索这个程序。
操纵脚本
select_fittest 方法可以用 sort_by 来表达,而不是 inject。无论是在 inject 中的记忆化还是排序后的 Children 的零索引成员,返回的值都是相同的。使用 sort_by 还允许我们完全消除 fitter_than? 方法。
return sort_by do |child|
child.deviance_from(target)
end[0]
mutate_until_matches! 中的 replace 使其具有破坏性,使其名称以感叹号结尾是合适的。mutate_until_matches! 方法本可以完全通过将方法的最后两行替换为 return fittest.mutate_until_matches(target, params) 来实现纯函数式,尽管这样名称可能会误导,即使没有感叹号——在这种情况下,也许简单地命名为 get_match 会更好。此外,@mutation_attempts 变量不会在每次变异中保留。我们不得不修改 mutate_until_matches!(或 get_match 或它将拥有的任何其他新名称)以接受 mutation_attempts 作为可选参数,默认为第一个调用时的零。其处理方式将与 els_parser.rb 使用可选的 term 参数更新 @search_params[:term] 的方式非常相似。
以下代码如何阻止我们实现 propagate 方法(❻)?
return [self] +
(1..params[:generation_size]).to_a.map do |gen|
self.mutate(params)
end
主要问题是 propagate 返回的值将是一个 Array,而不是 Children,这意味着它将无法访问我们添加到 Children(我们的 Array 子类)中的 select_fittest 方法。我们可以通过消除 Children < Array(❶)的子类化来使用我们新的 propagate 定义,并简单地将 select_fittest 方法添加到所有 Arrays 中。
你也可以修改此程序,使其成为一个更复杂的累积选择的更准确模型,例如现实世界的达尔文选择。这样的程序将会有多个竞争的“物种”字符串,一些表示食物供应(这将有限供应,并被繁殖过程消耗),多个程序员未预设的潜在成功的目标,等等。这些变化将允许一些字符串的后代无法产生具有竞争力的后代(因此灭绝),而其他字符串的后代则会繁荣昌盛,就像现实世界中的生物一样。
#36 将字符串的变异变为狐狸(methinks_meta.rb)
此脚本使用之前的脚本,即methinks.rb,所以在尝试此脚本methinks_meta.rb之前,请确保你理解了那个脚本的工作原理。此脚本使用与methinks.rb中使用的类似的技术来找到methinks.rb的“最佳”输入参数。
之前脚本的性能(匹配目标所需的生成次数)可能会在每次运行之间有很大的差异。影响我们结果变化的主要有两个因素:第一个因素是任意起始参数的集合。我们发现,达到目标hello比达到目标methinksitislikeaweasel要容易得多。使用其他值对于:mutation_rate或其他参数也有影响。第二个因素是程序运行过程中随机变化的不可预测性。随着时间的推移,经过多次运行后,概率定律将导致第二个因素变得越来越不重要——无论如何,随机性是给定问题的一部分。我们的任意起始参数至关重要。我们如何决定它们应该是什么?
注意
改变:display_filter对达到目标所需的生成次数没有影响,只会影响程序报告其自身进度有多频繁。此外,计算机上可以实现真正的随机数生成——通常是通过测量放射性元素的衰变或监听麦克风的噪音——但我们的“随机”数生成实际上是伪随机。伪随机数来自一个有模式的过程,这使得它们不适合用于压力测试或密码学等重型应用。尽管如此,对于我们的脚本来说,它们已经足够随机了。此伪随机注意事项适用于本书中所有随机数。
随机输入参数的任意集合是我们字符串变异效率面临的主要问题。幸运的是,如果我们看到了理想的参数集合,我们就能识别出来,并且我们可以很容易地根据彼此之间的关系对参数集合进行优劣评估,因为我们有一个简单的方式来衡量成功:达到目标字符串所需的生成次数很少。我们已经有了一种处理候选者以达到给定目标的方法——它被称为methinks.rb。
正如我们可以在 第二十四部分 嵌套 lambda 中看到的那样(见第 111 页),我们可以创建一个返回另一个 Proc 的 Proc,我们也可以创建一个在更高层次上操作的 mutator——不仅变异字符串,还变异这些字符串的变异。我们可以定义 fitter 为 需要更少的代数才能达到目标,插入一些参数,然后开始。我们新的脚本 methinks_meta.rb 将(伪)随机改变任意输入参数,并通过这个适应性标准进行筛选,以找到更好的输入参数。让我们看看代码。
代码
#!/usr/bin/env ruby
# methinks_meta.rb
❶ require 'methinks'
class Hash
❷ def get_child()
new_hash = {}
each_pair do |k,v|
new_hash[k] = (rand(v) + (v/2))
end
new_hash[:display_filter] = 5
return new_hash
end
end # Hash
###
❸ class Meta_Mutator
NEW_TARGET = 'ruby'
MAX_ATTEMPTS = 2
TARGET = NEW_TARGET || String::TARGET
def initialize()
@params_by_number_of_mutations = {}
end
❹ def mutate_mutations!(params, did_no_better_count=0)
return if did_no_better_count > MAX_ATTEMPTS
num = update_params_by_number_of_mutations!(params)
return mutate_mutations!(
@params_by_number_of_mutations[best_num],
get_no_better_count(num, did_no_better_count)
)
end
❺ def report()
@params_by_number_of_mutations.sort.each do |pair|
num, params = pair
puts sprintf("%0#{digits_needed}d", num) +
" generations with #{params.inspect}"
end
end
private
❻ def best_num()
@params_by_number_of_mutations.keys.sort[0] || nil
end
❼ def digits_needed()
@params_by_number_of_mutations.keys.max.to_s.size
end
❽ def get_children(params, number_of_children = 10)
(0..number_of_children).to_a.map do |i|
params.get_child()
end
end
❾ def get_no_better_count(num, did_no_better_count)
return did_no_better_count if (num == best_num)
did_no_better_count + 1
end
❿ def update_params_by_number_of_mutations!(params)
children = get_children(params)
number_of_mutations = nil
children.each do |params|
candidate = String.new.scramble!(TARGET)
number_of_mutations = candidate.mutate_until_matches!(TARGET, params)
@params_by_number_of_mutations[number_of_mutations] = params.dup
end
return number_of_mutations
end
end # Meta_Mutator
###
params = {
:generation_size => 200,
:mutation_rate => 30,
:display_filter => 5,
:mutation_amp => 7
}
mm = Meta_Mutator.new()
mm.mutate_mutations!(params)
mm.report()
它是如何工作的
由于我们正在执行使用 methinks.rb 的操作,我们在 ❶ 处 require 那个文件。然后我们立即打开 Hash 类,在 ❷ 处添加一个名为 get_child 的新方法。这个 get_child 方法也可以命名为 mutate 或 reproduce,它对给定 Hash 的所有值进行随机变异。它假设这些值是整数,因此可以使用 rand 方法进行变异——在这种情况下,从给定值的一半到给定值的 1.5 倍。由于 :display_filter 值对适应性没有影响,我们只需强制将其设置为 5。我们通过迭代 self 并在写入 new_hash 之前进行必要的更改来构建 new_hash,然后返回 new_hash 来完成变异。
注意
我们已经提到过get_child方法假设了其所有 Hash 的值都是整数。它还假设 Hash 中有一个名为 :display_filter 的键。这个假设在我们的脚本中运行良好,但如果 get_child 方法要成为常用库的一部分,我们就必须让它与其他程序友好地协作。程序员可以避免对不合适的 Hash 使用此方法,但更好的解决方案是,当程序员打开现有类并添加新方法时,负责使新方法更加健壮。一个生产就绪版本的 get_child 将会检查 Hash 的值是否可以执行数值加法,并在执行我们示例中描述的操作之前检查是否存在 :display_filter 键。
接下来,我们在❸处创建我们的Meta_Mutator类。它有几个常量。NEW_TARGET常量定义了一个不同的目标字符串。这主要是为了方便拥有更短的目标字符串,从而使程序的运行时间更短。MAX_ATTEMPTS常量定义了我们应该在放弃并尝试新参数集之前尝试打败先前最适应的变异尝试的最大次数。TARGET可以是我们的NEW_TARGET,也可以是从methinks.rb中熟悉的String::TARGET。这种定义允许我们轻松覆盖TARGET,同时仍然有一个默认值,并且不需要在以后不断更改代码以实现不同的目标——我们只需始终使用TARGET。Meta_Mutator类还有一个预期的initialize方法,它不接受任何参数,并为@params_by_number_of_mutations定义一个空的哈希。我们将在稍后看到这个实例变量的实际应用。
接下来是位于❹处的公共方法mutate_mutations!。请注意,它具有破坏性,并接受两个参数:一个必需的params哈希,以及一个可选的整数did_no_better_count,默认值为零,这对于初始运行来说是有意义的。它有一个返回守卫,允许在did_no_better_count超过允许的MAX_ATTEMPTS时提前退出。假设它应该继续,它将调用update_params_by_number_of_mutations!(定义在❿),传入params参数,并将返回值放入局部变量num中。
让我们跳到❿处,看看update_params_by_number_of_mutations!做了什么。它使用get_children(定义在❽)创建一些子代。然后get_children创建一个数组,通过map操作将调用get_child在params哈希上的操作映射到一个具有与请求的number_of_children(假设为10)成员数相同的数组。update_params_by_number_of_mutations!方法然后遍历这些children中的每一个,对每一个调用params。它构建一个新的candidate,并通过在candidate上调用mutate_until_matches!(来自methinks.rb)来确定达到TARGET所需的number_of_mutations。我们现在有了衡量适应度的指标和达到该适应度水平的params。我们更新@params_by_number_of_mutations,将number_of_mutations键的值设置为params,正如@params_by_number_of_mutations的名称所暗示的那样。然后它返回通过mutate_until_matches!这次遍历所需的number_of_mutations。
在mutate_mutations!(❹)中,我们递归地再次调用mutate_mutations!,这次将@params_by_number_of_mutations中的“最适应”结果作为第一个参数,将调用get_no_better_count(num, did_no_better_count)的结果作为第二个参数。
best_num 方法在❻处定义,它很简单。@params_by_number_of_mutations 的键是需要达到目标所需的突变数。由于它们是整数,最低(因此是“最适应”)的值将是排序后的结果数组的第一个元素。我们可以通过 [0] 轻松地得到它。get_no_better_count 方法在❾处定义;它只接受现有的 num 和 did_no_better_count 作为其唯一参数。如果这次迭代的 num 是 best_num,则返回 0 并重置 did_no_better_count。否则,它返回 did_no_better_count + 1。
mutate_mutations! 的内容到此为止。还有一个公开的方法 report,它在❺处定义。它遍历 @params_by_number_of_mutations 中的每一对,通过 puts, inspect,字符串插值和定义在❷处的 digits_needed 方法输出结果。它简单地取 @params_by_number_of_mutations 的所有键,找到最大值,并将该最高整数转换为字符串 to_s。该字符串的 size 方法返回字符数,这是我们用于显示的 digits_needed 所需的字符数。
我们不仅可以计算值,还可以报告它们。我们在 methinks_meta.rb 的底部建立默认的 params,实例化一个 Meta_Mutator,并调用其 mutate_mutations! 和 report 方法。让我们看看结果。
注意
这个脚本并不是为了展示正确的统计分析。你的结果可能会根据初始条件而高度变化。为了准确测量不同版本之间改进(或缺乏改进)的程度,你应该对每个版本进行多次运行,并验证你所看到的不同是否具有统计学意义。但这超出了本书的范围。如果这个程序激发你编写操纵其他程序的程序,那么它已经完成了它的任务。
运行脚本
$ ruby -w methinks_meta.rb
结果
string #0 = onfi
string #5 = ppbm
string #10 = rtbq
string #15 = rubv
I match after 18 mutations
string #0 = tfjc
string #5 = uuar
I match after 9 mutations
string #0 = qmsi
string #5 = rqln
string #10 = rugv
I match after 13 mutations
string #0 = yuqa
string #5 = uupf
... (several lines removed)...
string #0 = umsv
string #5 = rupy
I match after 10 mutations
string #0 = vclv
string #5 = rlay
I match after 8 mutations
04 generations with {:generation_size=>243, :mutation_rate=>25,
:mutation_amp=>11, :display_filter=>5}
08 generations with {:generation_size=>251, :mutation_rate=>28,
:mutation_amp=>7, :display_filter=>5}
09 generations with {:generation_size=>234, :mutation_rate=>31,
:mutation_amp=>10, :display_filter=>5}
10 generations with {:generation_size=>112, :mutation_rate=>15,
:mutation_amp=>7, :display_filter=>5}
11 generations with {:generation_size=>162, :mutation_rate=>26,
:mutation_amp=>7, :display_filter=>5}
12 generations with {:generation_size=>118, :mutation_rate=>30,
:mutation_amp=>5, :display_filter=>5}
13 generations with {:generation_size=>100, :mutation_rate=>24,
:mutation_amp=>3, :display_filter=>5}
14 generations with {:generation_size=>191, :mutation_rate=>29,
:mutation_amp=>5, :display_filter=>5}
15 generations with {:generation_size=>146, :mutation_rate=>22,
:mutation_amp=>8, :display_filter=>5}
17 generations with {:generation_size=>161, :mutation_rate=>14,
:mutation_amp=>7, :display_filter=>5}
18 generations with {:generation_size=>112, :mutation_rate=>18,
:mutation_amp=>3, :display_filter=>5}
22 generations with {:generation_size=>277, :mutation_rate=>40,
:mutation_amp=>4, :display_filter=>5}
24 generations with {:generation_size=>112, :mutation_rate=>41,
:mutation_amp=>4, :display_filter=>5}
27 generations with {:generation_size=>120, :mutation_rate=>24,
:mutation_amp=>3, :display_filter=>5}
36 generations with {:generation_size=>140, :mutation_rate=>17,
:mutation_amp=>4, :display_filter=>5}
我们的最佳结果是 {:generation_size=>243, :mutation_rate=>25, :mutation_amp=>11, :display_filter=>5},在仅经过四代后就有匹配。再次强调,:display_filter 并不重要,真正起作用的是其他三个参数。你可以多次运行 methinks_meta.rb,看看你的最佳值是否似乎围绕每个重要参数的给定值范围波动。然后你可以重置 methinks_meta.rb 底部的默认 params,并继续进行,直到你想要停止。
操纵脚本
如果我们希望结果始终按字母顺序显示 params 键,我们可以用以下代码覆盖所有哈希的内置 inspect 方法:
def inspect()
'{' + keys.sort_by do |k|
k.inspect
end.map do |k|
"#{k.inspect} => #{self[k].inspect}"
end.join(', ') + '}'
end
本章摘要
本章的任务是使用你在更广泛层面上已经学到的技术。然而,仍然有一些新的概念或方法。
-
等距字母序列和更大规模的文本搜索
-
从字符串中提取单个字符
-
chr方法 -
使用
methinks.rb模拟自然选择 -
子类化(
Children < Array)和继承 -
计算字符串之间的差异
-
select_fittest:inject与sort_by的比较 -
真实随机与伪随机
-
使用
methinks_meta.rb进行元变异 -
通过重写
inspect进行字母排序
我们接下来的章节是两章中较为复杂程序的第二章。虽然这一章主要扩展了我们已经学过的概念,但下一章将使用一种令人兴奋的新类型抽象,称为回调。让我们开始吧。
第十章。更复杂的工具和技巧,第二部分

在本章中,我将描述一个重要的功能技术,称为回调,其中通用方法使用 Proc 来确定其具体结果。我们之前已经多次见过这种情况,因为它直接嵌入到许多 Ruby 方法中。假设我们想要将列表中的每个数字都翻倍。这很简单。我们只需使用 [0, 1, 2].map { |x| x * 2 } 并得到 [0, 2, 4] 作为结果。如果我们想找到所有大于 1 的数字,我们使用 [0, 1, 2].find_all { |x| x > 1 } 并得到 [2]。
在这两种情况下,我们只是在使用像 map 或 find_all 这样的通用方法,这些方法接受一个块,如 { |x| x * 2 } 或 { |x| x > 1 },并根据该块的结果生成输出。map 方法对其调用对象中的每个成员执行块的运算,而 find_all 返回一个只包含通过块描述的测试的成员的集合。在这两种情况下,具体内容完全由块确定。从概念上讲,这就是回调的全部。让我们看看一个使用 Proc 而不是块来描述回调的具体有用示例。
#37 夜间 DJ(radio_player1.rb)
我的一个朋友有着非常丰富多彩的就业历史。他曾是一名 DJ 和电台总经理,是一名工会组织者,在日本是一名记者和翻译,还是一名专业夜总会音乐家.^([28]) 当他经营一家爵士电台时,他遇到了一个问题:他的电台像许多爵士电台一样,严重依赖志愿者和自动化,电台运营商会设置一个自动化的计算机系统在夜间播放声音文件。缺点是系统没有日志记录,所以如果有人在凌晨 2:47 听到了喜欢的东西,运营商无法找出具体是哪首曲子。没有人会在电台接电话,第二天早上也没有记录播放了哪些声音文件,因此没有人能在任何人到来之前追踪到那天早上播放的具体内容。
出现了 radio_player1.rb 和 radio_player2.rb。这些程序展示了这种问题的解决方案。radio_player1.rb 脚本让我们从基础知识开始,包括解释 Ruby 如何使用回调,而 radio_player2.rb 执行真正的重头戏,包括日志记录。请注意,radio_player1.rb 并没有真正进行回放,它只是展示了这些技术。
代码
#a/usr/bin/env ruby
# radio_player1.rb
❶ PLAY_FILE_PROC = lambda do |filename| *Callbacks*
puts "I'm playing #{filename}."
end
❷ DONT_PLAY_FILE_PROC = lambda do |filename|
puts "I'm not playing #{filename}. So there."
end
❸ class RadioPlayer
❹ DIRS_TO_IGNORE = ['.', '..', 'CVS'] *CVS*
❺ PICK_FROM_DIR_PROC = lambda do |dir, callback_proc, dir_filter|
puts "I'm inside #{dir}" if $DEBUG
❻ (Dir.open(dir).entries - DIRS_TO_IGNORE).sort.each do |filename|
❼ if ((filename =~ dir_filter) or not dir_filter)
item = "#{dir}/#{filename}"
puts "#{item} passes the filter" if $DEBUG
❽ if File.directory?(item)
puts "#{item} is a directory" if $DEBUG
PICK_FROM_DIR_PROC.call(
item, callback_proc, dir_filter
)
else
puts "#{item} is a file" if $DEBUG
callback_proc.call(item)
end
end
end
end
❾ def self.walk(dir, callback_proc, dir_filter=nil)
puts
puts "I'm walking #{dir} using filter #{dir_filter.inspect}" if $DEBUG
PICK_FROM_DIR_PROC.call(dir, callback_proc, dir_filter)
end
end
❿ dir = 'extras/soundfiles'
callback = (ARGV[0] == 'play') ? PLAY_FILE_PROC : DONT_PLAY_FILE_PROC
dir_filter = ARGV[1] ? Regexp.new(ARGV[1]) : nil
RadioPlayer.walk(dir, callback, dir_filter)
puts
工作原理
首先,我们定义我们的回调为进程常量。在 ❶ 处,我们有 PLAY_FILE_PROC,在 ❷ 处,我们有 DONT_PLAY_FILE_PROC。由于 radio_player1.rb 只是一个演示脚本,这两个进程只是报告它们将要做什么,而不是真正地做任何事情。把它们看作是“干运行”测试示例。在 ❸ 处,我们定义了一个新的类 RadioPlayer。我们很快会详细介绍这个类,但现在,如果我们跳到 ❿,我们会更容易理解这个脚本是如何工作的。
我们定义了一个名为 dir 的变量,其值为 ‘extras/soundfiles’。这就是我存储本示例所使用的音频文件的地方;它类似于包含广播电台歌曲、声音剪辑、电台标识等内容的目录。然后,我们设置了一个名为 callback 的变量的值。它存储了适当的进程,即 PLAY_FILE_PROC 或 DONT_PLAY_FILE_PROC。如果脚本的第一个参数 (ARGV[0]) 是 ‘play’,则使用 PLAY_FILE_PROC。否则,它使用 DONT_PLAY_FILE_PROC。接下来,我们定义了一个名为 dir_filter 的变量,它可以是定义的 RegExp 实例或 nil。正如其名所示,它过滤主 dir 音频文件目录内的目录。如果 dir_filter 是 nil,则不进行过滤,并假设 dir 的全部内容都可用于播放。然后,我们调用 RadioPlayer 类的 walk (❾) 方法,并传递参数 dir, callback 和 dir_filter。
self.walk 方法接受三个参数:dir, callback_proc 和 dir_filter。前两个是必需的,而 dir_filter 是可选的,默认为 nil。它使用 puts 打印一个空行,如果脚本带有 -d 标志(将 $DEBUG 设置为 true),则 self.walk 还会打印一些样板文本,表明它在做什么。然后,它执行一个对名为 PICK_FROM_DIR_PROC 的进程常量的 call 调用,使用相同的三个参数——dir, callback_proc 和 dir_filter。
现在,为了理解这意味着什么,我们将描述 RadioPlayer 类在 ❸ 处。它有两个常量:DIRS_TO_IGNORE 和 PICK_FROM_DIR_PROC。DIRS_TO_IGNORE (❹) 列出了脚本不应关心的目录。它包括当前目录 (‘.’)、上一级目录 (‘..’) 和由 CVS 使用的目录。
注意
并发版本系统 (CVS) 是一个跟踪文件不同版本的程序。它最常用于软件开发。您可以在www.nongnu.org/cvs上了解更多关于它的信息。
RadioPlayer 中的第二个常量是 PICK_FROM_DIR_PROC (❺),它是一个从目录中选择进程。我们以通常的方式使用 lambda 创建它,并定义它接受三个参数:dir, callback_proc 和 dir_filter。这些对应于我们在脚本底部 ❿ 描述的 walk (❾) 的三个参数。
现在,我们可以看到这些参数最终被用于什么。PICK_FROM_DIR_PROC 常量包含几个调试行,如果 $DEBUG 设置为 true,则 puts 一个给定消息。我不会详细说明每一个,因为它们应该相当容易理解。我们首先根据 dir 内的 entries 对 each 排序的 filename 进行循环,减去 DIRS_TO_IGNORE(❻)。接下来,我们验证 filename 是否与 dir_filter 通过正则表达式测试匹配,或者没有设置 dir_filter(❼)。假设我们应该继续,我们将插值字符串 “#{dir}/#{filename}” 赋值给一个名为 item 的局部变量。我们将频繁使用 item,因此一次性设置并重用它比每次都重新计算它更有价值。
接下来,我们使用 File.directory? 断言(❽)来确定 item 是否是一个目录。如果是目录,我们需要从该目录中选取,因此我们递归地调用 PICK_FROM_DIR_PROC,并传递参数 item, callback_proc, dir_filter。当前 item 的值现在成为新递归调用中 dir 的值,因此当我们到达递归调用中的 item 赋值时,该项由以下字符串组成:“#{top_dir}/{next_dir}/#{filename}”,等等。这会一直发生,直到我们到达一个非目录的 filename。那时会发生什么呢?
在这种情况下,我们在 ❽ 的 if 块中咨询 else 子句。在这里,我们最终以 item 作为参数调用 callback_proc。假设我们使用 PLAY_FILE_PROC 作为 callback_proc。因此,我们将输出一条消息,说明我们正在播放 filename。这发生在 self.walk 执行中的每个终端(非目录)filename(❾)。让我们看看它的实际效果。首先,让我们看看 extras/soundfiles 的内容:
$ ls -R extras/soundfiles/
extras/soundfiles/:
01-Neal_And_Jack_And_Me.ogg CVS legal promo
extras/soundfiles/CVS:
Entries Repository Root
extras/soundfiles/legal:
CVS legal1 legal2
extras/soundfiles/legal/CVS:
CVS Entries Repository Root
extras/soundfiles/legal/CVS/CVS:
Entries Repository Root
extras/soundfiles/promo:
CVS promo1 promo2
extras/soundfiles/promo/CVS:
CVS Entries Repository Root
extras/soundfiles/promo/CVS/CVS:
Entries Repository Root
除了我提到的那些 CVS 目录外,我们还有一个名为 01-Neal_And_Jack_And_Me.ogg 的文件位于顶层,一个名为 legal 的目录,包含文件 legal1 和 legal2,以及一个名为 promo 的目录,包含文件 promo1 和 promo2。现在,让我们用各种参数运行 radio_player1.rb。
结果
$ ruby -w radio_player1.rb
I'm not playing extras/soundfiles/01-Neal_And_Jack_And_Me.ogg. So there.
I'm not playing extras/soundfiles/legal/legal1\. So there.
I'm not playing extras/soundfiles/legal/legal2\. So there.
I'm not playing extras/soundfiles/promo/promo1\. So there.
I'm not playing extras/soundfiles/promo/promo2\. So there.
我们没有提供 ARGV[0],所以它假设 DONT_PLAY_FILE_PROC 作为回调。它也没有 dir_filter,所以它“不播放” extras/soundfiles 中的每个文件,除了我们告诉它忽略的目录——也许明确“不播放”声音文件是愚蠢的,但我只是想有一个可以明显显示它被调用的回调。让我们再看一些。
$ ruby -w radio_player1.rb play legal
I'm playing extras/soundfiles/legal/legal1.
I'm playing extras/soundfiles/legal/legal2.
在这里,ARGV[0] 是 ‘play’,而 ARGV[1] 限制了可用的文件,使其匹配 /legal/。它成功了。
$ ruby -w radio_player1.rb play
I'm playing extras/soundfiles/01-Neal_And_Jack_And_Me.ogg.
I'm playing extras/soundfiles/legal/legal1.
I'm playing extras/soundfiles/legal/legal2.
I'm playing extras/soundfiles/promo/promo1.
I'm playing extras/soundfiles/promo/promo2.
它又成功了。
操纵脚本
这个脚本的最低级修改就是使用 -d 命令行选项来调用它。这会告诉你脚本在任何给定点的位置,并且在你尝试不同的参数、使用 extras/soundfiles 创建自己的文件和目录,或者进行其他你认为合适的自定义时,可能会揭示一些有用的信息。
回调的优点在于,你可以通过简单地使用不同的回调来修改你的程序。你执行某些特定操作的方式的整体结构保持不变,而正在执行的具体操作可以改变,通常相当剧烈。我们将在下一个脚本中看到这个例子。
^([28]) 现在他博客和播客在 thejasoncraneshow.com。
#38 更好的夜间 DJ (radio_player2.rb)
这个脚本,radio_player2.rb,是 radio_player1.rb 的改进版。它不仅会播放声音文件,还会记录播放的具体时间,而不是使用占位符进程。
代码
#a/usr/bin/env ruby
# radio_player2.rb
❶ LOG_FILE = '/tmp/radio_player2.log'
❷ PLAYERS = {
'.mp3' => 'mpg321',
'.ogg' => 'ogg123',
'' => 'ls'
}
❸ # these are variables, local to Kernel.
# They work just as well as constants.
play_file_proc = lambda do |filename| *Callbacks*
❹ ext = File.extname(filename)
❺ system("#{PLAYERS[ext]} #{filename}") if PLAYERS[ext]
❻ File.open(LOG_FILE, 'a') do |log|
log.puts([Time.now, filename].join("\t") + "\n")
end
end
dont_play_file_proc = lambda do |filename|
puts "I'm not playing #{filename}. So there."
end
class RadioPlayer
DIRS_TO_IGNORE = ['.', '..', 'CVS']
PICK_FROM_DIR_PROC = lambda do |dir, callback_proc, dir_filter|
(Dir.open(dir).entries - DIRS_TO_IGNORE).sort.each do |filename|
if ((filename =~ dir_filter) or not dir_filter)
item = "#{dir}/#{filename}"
if File.directory?(item)
PICK_FROM_DIR_PROC.call(
item, callback_proc, dir_filter
)
else
callback_proc.call(item)
end
end
end
end
def self.walk(dir, callback_proc, dir_filter=nil)
puts
PICK_FROM_DIR_PROC.call(dir, callback_proc, dir_filter)
end
end
dir = 'extras/soundfiles'
callback = (ARGV[0] == 'play') ? play_file_proc : dont_play_file_proc
dir_filter = ARGV[1] ? Regexp.new(ARGV[1]) : nil
RadioPlayer.walk(dir, callback, dir_filter)
puts
它是如何工作的
对于本节,我只会详细说明 radio_player1.rb 和 radio_player2.rb 之间的变化。第一个变化是在❶处定义 LOG_FILE 常量。正如你所期望的,这是 radio_player2.rb 将日志消息写入的文件名。接下来,我们在❷处声明一个名为 PLAYERS 的哈希常量,其键为特定类型声音文件的文件扩展名,值为在 Unix 系统上播放这些类型文件可能使用的程序名称。
接下来,我们在❸处定义我们的进程,这次作为变量而不是常量。没有特别的原因要使用变量而不是常量,正如注释所注明的。我只是想展示这两种方法都可以很好地为我们服务。除了是变量而不是常量之外,播放进程实质上也有所不同。
play_file_proc 作为闭包,将内部的 PLAYERS 哈希绑定在其自身。它在❹处建立了 filename 参数的扩展名(因此,类型)为 ext。然后它尝试使用 system 在❺处播放该文件名,但仅当 PLAYERS 哈希有适合该文件扩展名的适当播放器时。我确保 PLAYERS 有一个没有文件扩展名的条目,这样 radio_player2.rb 仍然可以演示它是否在播放没有文件扩展名的虚拟文件,如 legal1 和 promo2。由于我只是想展示虚拟文件,我决定使用 Unix 命令 ls,它只是列出文件,作为 PLAYERS 中使用的适当值。
radio_player2.rb脚本也在play_file_proc中记录回放。在❻处,它使用‘a’作为File.open的第二个参数打开一个新文件用于追加。然后它将那个日志文件称为log,并使用log的puts方法将当前的Time和正在播放的filename,用制表符分隔,然后跟一个回车符。每次我们使用radio_player2.rb时,我们都可以检查LOG_FILE的内容,以查看播放了什么。
唯一的其他区别是移除了调试信息,并且用小写变量名而不是大写常量名来引用进程。让我们看看这个版本的实际效果。
结果
让我们尝试回放所有内容的基本操作。
$ ruby -w radio_player2.rb play
Audio Device: OSS audio driver output
Playing: extras/soundfiles/01-Neal_And_Jack_And_Me.ogg
Ogg Vorbis stream: 2 channel, 44100 Hz
Title: Neal and Jack and Me
Artist: King Crimson
Album: Beat
Date: 1982
Track number: 01
Tracktotal: 08
Genre: Prog Rock
Composer: Belew, Bruford, Fripp, Levin
Musicbrainz_albumid: 5ddbe867-ebce-445d-a175-d90516e426da
Musicbrainz_albumartistid: b38225b8-8e5f-42aa-bcdc-7bae5b5bdab3
Musicbrainz_artistid: b38225b8-8e5f-42aa-bcdc-7bae5b5bdab3
Musicbrainz_trackid: 30a23275-11ef-4f07-bdc8-0192ae34e67d
Done.
extras/soundfiles/legal/legal1
extras/soundfiles/legal/legal2
extras/soundfiles/promo/promo1
extras/soundfiles/promo/promo2
那个命令行调用使用ogg123和PLAYERS中为.ogg扩展名指定的适当值通过system调用播放了 Ogg 文件(再次,来自我最喜欢的乐队 King Crimson),然后使用ls和没有扩展名的文件的适当PLAYERS值播放了其他文件。
现在,让我们使用模拟回放进行过滤。
$ ruby -w radio_player2.rb play legal
extras/soundfiles/legal/legal1
extras/soundfiles/legal/legal2
再次,没有模拟回放。
$ ruby -w radio_player2.rb dont legal
I'm not playing extras/soundfiles/legal/legal1\. So there.
I'm not playing extras/soundfiles/legal/legal2\. So there.
注意,回放只列出虚拟文件,而非回放则执行完整的dont_play_file_proc,包括不成熟的So there.后缀。
操纵脚本
LOG_FILE的值是 Unix 特定的。Windows 用户(或其他人)当然可以将该文件名更改为更适合其操作系统的名称。此外,如果您更喜欢一个更健壮的虚拟文件系统,可以为它们分配自己的扩展名,例如dummy,并更改PLAYERS,使‘ls’的键为这个新扩展名。
#39 按名称编号(to_lang.rb)
在之前的脚本中,特别是在第 75 页的第十六部分 添加逗号到数字(commify.rb)")和第 81 页的第十七部分 罗马数字(roman_numeral.rb)")中,我们讨论了数字可以以各种方式表示。这两个脚本都展示了将整数作为字符串表示的有意义的方法,除了方便但微不足道的不同to_s方法。这个脚本to_lang.rb通过将整数表示为字符串,这些字符串表示了这些数字在两种现实世界语言中的发音:英语和西班牙语。
代码
这段代码被拆分为三个独立的文件,原因我将在第 198 页的工作原理中详细说明。
representable_in_english.rb
=begin rdoc
This is intended for use with to_lang.rb
=end
❶ module Representable_In_English
=begin rdoc
Return a <b>Hash</b> whose keys are <b>Integer</b>s and whose values
are the words representing the same values.
=end
❷ def create_english()
need_ones_in_english.merge(dont_need_ones_in_english)
end
❸ def special_replacements_in_english(num_as_string)
add_hyphens_to_tens(num_as_string).strip
end
❹ def to_english() *Syntactic Sugar*
to_lang('english')
end
❺ alias :to_en :to_english
❻ private
❼ def add_hyphens_to_tens(num_as_string)
num_as_string.sub(/ty/, 'ty-').sub(/-?- ?/, '-')
end
❽ def need_ones_in_english()
return {
10 ** 9 => 'billion',
10 ** 6 => 'million',
10 ** 3 => 'thousand',
100 => 'hundred',
}
end
❾ def dont_need_ones_in_english()
return {
90 => 'ninety',
80 => 'eighty',
70 => 'seventy',
60 => 'sixty',
50 => 'fifty',
40 => 'forty',
30 => 'thirty',
20 => 'twenty',
19 => 'nineteen',
18 => 'eighteen',
17 => 'seventeen',
16 => 'sixteen',
15 => 'fifteen',
14 => 'fourteen',
13 => 'thirteen',
12 => 'twelve',
11 => 'eleven',
10 => 'ten',
9 => 'nine',
8 => 'eight',
7 => 'seven',
6 => 'six',
5 => 'five',
4 => 'four',
3 => 'three',
2 => 'two',
1 => 'one',
0 => '',
}
end
end
接下来将是一个非常相似的文件,也存储模块/混合定义。唯一有意义的区别是语言的选择:这个文件详细介绍了西班牙语,而不是英语。
representable_in_spanish.rb
=begin rdoc
This is intended for use with to_lang.rb
=end
❶ module Representable_In_Spanish
=begin rdoc
Return a <b>Hash</b> whose keys are <b>Integer</b>s and whose values
are the words representing the same values.
=end
❷ def create_spanish()
need_ones_in_spanish.merge(dont_need_ones_in_spanish)
end
❸ def special_replacements_in_spanish(num_as_string)
add_hyphens_to_tens(num_as_string).strip
end
❹ def to_spanish() *Syntactic Sugar*
to_lang('spanish')
end
❺ alias :to_es :to_spanish
❻ private
❼ def add_hyphens_to_tens(num_as_string)
num_as_string.sub(/ta/, 'ta-').sub(/-?- ?/, '-')
end
❽ def need_ones_in_spanish()
return {
10 ** 12 => 'billon',
10 ** 9 => 'mil millones',
10 ** 6 => 'millon',
10 ** 3 => 'mil',
100 => 'ciento',
}
end
❾ def dont_need_ones_in_spanish()
return {
90 => 'noventa',
80 => 'ochenta',
70 => 'setenta',
60 => 'sesenta',
50 => 'cincuenta',
40 => 'cuarenta',
30 => 'treinta',
20 => 'veinte',
19 => 'diecinueve',
18 => 'dieciocho',
17 => 'diecisiete',
16 => 'dieciseis',
15 => 'quince',
14 => 'catorce',
13 => 'trece',
12 => 'doce',
11 => 'once',
10 => 'deiz',
9 => 'nueve',
8 => 'ocho',
7 => 'siete',
6 => 'seis',
5 => 'cinco',
4 => 'cuatro',
3 => 'tres',
2 => 'dos',
1 => 'uno',
0 => '', # 'cero'
}
end
end
最后,我们有直接赋予整数在口语中自我表示能力的代码。它通过使用上述模块来实现,您将看到。
to_lang.rb
#!/usr/bin/env ruby -w
# to_lang.rb
=begin rdoc
Implement representation of numbers in human languages:
1 => 'one',
2 => 'two',
etc.
This is an generalized extension of ideas shown for the
specific case of roman numerals in roman_numeral.rb
Note that similar work has already been done at
http://www.deveiate.org/projects/Linguistics/wiki/English
This version focuses only on converting numbers to multiple
language targets, and pedantically considers "and" to be
the pronunciation of the decimal point.
=end
class Integer
❶ require 'representable_in_english' *Requiring Our Own Mixins*
require 'representable_in_spanish'
❷ include Representable_In_English
include Representable_In_Spanish
❸ EMPTY_STRING = ''
SPACE = ' '
❹ @@lang_of ||= Hash.new()
❺ def need_ones?(lang) *The **`send`** Method*
send("need_ones_in_#{lang}").keys.include?(self)
end
❻ def to_lang(lang)
return EMPTY_STRING if self.zero?
@@lang_of[lang] ||= send("create_#{lang}")
base = get_base(lang)
mult = (self / base).to_i
remaining = (self - (mult * base))
raw_output = [
mult_prefix(base, mult, lang),
@@lang_of[lang][base],
remaining.to_lang(lang)
].join(SPACE)
return send(
"special_replacements_in_#{lang}",
raw_output)
end
❼ private
❽ def get_base(lang)
return self if @@lang_of[lang][self]
@@lang_of[lang].keys.sort.reverse.detect do |k|
k <= self
end
end
❾ def mult_prefix(base, mult, lang)
return mult.to_lang(lang) if mult > 1
return 1.to_lang(lang) if base.need_ones?(lang)
return EMPTY_STRING
end
end
工作原理
让我们逐一检查每个文件。由于representable_in_english.rb和representable_in_spanish.rb非常相似,我们可以同时处理它们。
两个 Mixins
representable_in_english.rb和representable_in_spanish.rb都是mixins,这是 Ruby 用来给不同祖先的类提供共享行为的机制,就像给蝙蝠和鸟都赋予飞行的能力一样。在我们的情况下,我们不是赋予生物飞行的能力,而是赋予我们将 mixins 混合到对象中的能力,使其能够用某些人类语言(在这种情况下是英语和西班牙语)表示自己。
我们在representable_in_english.rb和representable_in_spanish.rb中定义了适当的模块,位于❶。在这个例子中,我将保持这两个文件中的代码编号提示并行。在❷处,我们定义了create_english或create_spanish方法。这两个方法的目的是返回一个哈希,其键是整数,其值是这些整数在模块语言中的表示。生成的哈希对将形成我们的基本案例,我们将非常类似于在第五章中使用的roman_numeral.rb脚本中使用它们。然后,在❸处,我们定义了一个特殊替换方法,根据语言定制并命名。每种语言都可能有一些特殊处理,甚至超出了我们通过create_english或create_spanish返回的哈希差异所能做到的。到目前为止,我们只需要为具有十位数的数字添加连字符。为了完成这个任务,我们调用在❼处定义的add_hyphens_to_tens方法。
在❹和❺处,我们添加了一些程序员所说的语法糖,或者是对语言语法的简化。术语语法糖可能有负面含义,但并不一定。它通常指的是程序员用来更轻松地完成常用技术的一种快捷方式,例如使用alias添加方法别名。正如我们的示例所示,向 Ruby 添加语法糖相对容易。我们可以通过调用to_lang(很快将在to_lang.rb中定义)并使用适当的lang参数来添加像to_english或to_spanish这样的方法。我们还可以使用alias使to_en指向to_english,to_es指向to_spanish。
我们的一些方法可以是私有的,所以我们声明❻。我们已经讨论了add_hyphens_to_tens❼,因此我们可以继续到need_ones_in_english和need_ones_in_spanish❽。这个方法返回一个哈希,其键是整数,其值是这些整数在模块语言中的表示。这应该听起来很熟悉。使这个哈希中的对值得注意的是它们共有的一个特征:当实际上只有一个这样的数字时,它们都需要(在适当的语言中)前缀one。例如,数字100在英语中读作one hundred。
“当然!”你可能这么想。然而,对比need_ones_in_english❽和dont_need_ones_in_english❾返回的哈希表。在❾处创建的哈希表的整数键不需要*one*前缀。例如,你不会说*one twenty*来表示20,所以我们需要一种方法来区分需要前缀的数字和不需要的数字。❽和❾处的不同方法是我们这样做的方式。当我们想要它们全部在一起,并且我们不在乎前缀问题时,我们可以简单地merge这两个哈希表。这正是我们将在to_lang.rb文件中做的,我们即将要检查这个文件。
主代码
在to_lang.rb中,我们首先打开整数类,因为我们想为整数添加新的行为。在❶处,我们require了刚刚讨论的混合文件,并在❷处,我们在整数类中include了它们,这样所有的整数都将获得混合文件中定义的方法,包括别名。我们还想定义一些常量,主要是为了方便文本操作,所以在❸处定义了它们。通过定义一个名为@@lang_of的类变量来关闭预方法部分,在❹处。它是一个哈希表,最终将存储来自混合标记的❽和❾的两个哈希表的合并结果。由于我们使用||=定义它,它只定义在第一个实例化的整数中,然后它被所有整数共享。
在❺处,我们定义了一个名为need_ones?的谓词,它接受一个lang参数,并根据lang参数简单地调用need_ones_in_english(在representable_in_english.rb中定义)或need_ones_in_spanish(在representable_in_spanish.rb中定义),适当地处理lang参数。调用方法定义在哪个文件中并不重要,因为它们都在to_lang.rb的❷处被包含。
我们的主要工作方法to_lang出现在❻处;这个方法接受一个单一的、必须的lang参数。如果self是零,它将提前返回EMPTY_STRING。这意味着如果我们调用0.to_lang('english'),我们将得到一个空字符串作为结果,而不是字符串‘zero’。(有关如何更改此方法的详细信息,请参阅第 202 页的 Hacking the Script。)假设情况应该继续下去,to_lang然后设置@@lang_of[lang]的值。@@lang_of类变量已经在第一个整数实例化时被声明为一个哈希表,但只作为一个没有键或值的哈希表。放入@@lang_of[lang]的值是调用名为send的方法的结果,该方法的参数是“create_#{lang}”,你应该能认出这是一个插值字符串。
send 方法可以接受任意数量的参数,其中第一个参数必须是一个表达式,该表达式评估为方法名。然后,它使用其余参数调用该方法。这允许你做我们在这里做的事情,即动态调用一个你还不了解其名称的方法。你可以通过在 lang 参数上进行测试来解决这个问题,而且有很多方法可以做到这一点。你不必使用像 create_english 或 create_spanish 这样的传统方法,而可以使用 Procs 作为哈希值,就像我们在 第六章 中多次做的那样。你也可以这样做:
@@lang_of = if (lang == 'english')
create_english()
else
create_spanish()
end
注意,我们利用了 Ruby 中所有语句都返回最后一个评估表达式的特点,包括 if 语句。你有多种不同的方法可以调用一个你不知道其名称的方法,但关键是这不需要那么困难。Ruby 为我们提供了 send 方法,这是一个非常实用且合适的方法。
在这一点上,@@lang_of[lang] 将包含 need_ones_in_english 和 dont_need_ones_in_english(对于英语)或 need_ones_in_spanish 和 dont_need_ones_in_spanish(对于西班牙语)合并的结果的哈希。让我们从 send 方法中汲取灵感,将其表示为 “need_ones_in#{lang}” 和 “dont_need_ones_in#{lang}”。然后,我们想要创建一些局部变量,称为 base, mult 和 remaining。
base 变量是 @@lang_of[lang] 中最高的整数键,等于或小于 self。我们通过 get_base 方法获得它,该方法在❽处定义,它通过 detect 方法(我喜欢将其视为“查找第一个”)在 @@lang_of[lang] 的逆序版本中找到第一个等于或小于 self 的键。它还包含一个返回守卫,如果 self 实际上是 @@lang_of[lang] 的键之一,则返回 self。
mult 变量简单地表示 base 可以进入 self 的次数,向下取整到最接近的整数。remaining 变量是剩余的部分。然后,在执行任何已提到的特殊替换之前,我们想要创建一个名为 raw_output 的字符串,该字符串将包含最终的输出。raw_output 字符串将包含表示 (base * mult) 的内容,一个空格,然后是对剩余部分进行递归调用 to_lang 的结果(remaining.to_lang(lang))。
我们通过构建一个数组来实现这一点。第一个元素是名为 mult_prefix 的方法的输出,该方法定义在❾处;它接受 base、mult 和 lang 作为参数。如果 mult 大于一,我们知道我们需要一个前缀:数字 200 读作 two hundred,所以我们需要 two。如果 base 需要一个一(如前所述,与 need_ones? 断言相关),我们知道我们需要一个作为前缀的 one,例如在 one hundred 或 one thousand 中。最后,在所有其他情况下,我们返回一个空字符串作为前缀,所以数字 20 读作 twenty 而不是 one twenty,而 5 读作 five 而不是 one five。这就是多个前缀以及我们最终输出的第一部分.^([30])
接下来,我们需要 lang 中发音的 base。我们通过 @@lang_of[lang][base] 来获取它。最后,我们需要数字的其余部分,我们通过 remaining.to_lang(lang) 来获取。这会递归地发生,较小的整数调用 to_lang 并附加其结果,直到 base 为 0。然后 to_lang 由于其返回保护而返回空字符串,整个输出在第一次调用 remaining.to_lang 时连接在一起。
这就是数组。你会注意到 to_lang 在一个 SPACE 上连接这个数组,所以 raw_output 中的单词由空格分隔,这是正常的。在我们完成之前,我们想在 raw_output 上调用我们的特殊替换方法(适用于 lang 的任何一种),并返回执行该操作的结果。由于我们再次有一个依赖于 lang 的方法名,我们将使用 send。
结果
让我们试一试。我编写了一个简单的测试脚本,名为 test_lang.rb,我将其存储在 tests 目录中。它再次使用了 Test::Unit::TestCase,方式类似于我们在第七章(第七章。使用、优化和测试功能技术)中测试温度转换器的方式。以下是它的代码:
#!/usr/bin/env ruby
# test_lang.rb
require 'to_lang'
require 'test/unit'
class Tester < Test::Unit::TestCase
def test_langs()
tests = {
'en' => {
1 => 'one',
5 => 'five',
9 => 'nine',
11 => 'eleven',
51 => 'fifty one',
100 => 'one hundred',
101 => 'one hundred one',
257 => 'two hundred fifty seven',
1000 => 'one thousand',
1001 => 'one thousand one',
90125 => 'ninety thousand one hundred twenty five',
},
'es' => {
1 => 'uno',
5 => 'cinco',
9 => 'nueve',
11 => 'once',
51 => 'cincuenta-uno',
100 => 'uno ciento',
101 => 'uno ciento uno',
257 => 'dos ciento cincuenta-siete',
1000 => 'uno mil',
1001 => 'uno mil uno',
90125 => 'noventa-mil uno ciento veinte cinco',
}
}
%w[ en es ].each do |lang|
general_tester( tests, lang )
end
end
private
def general_tester(tests, lang)
tests[lang].each_key do |num|
assert_equal( num.send("to_#{lang}"), tests[lang][num] )
end
end
end
下面是它的输出:
Loaded suite tests/test_lang
Started
.
Finished in 0.004543 seconds.
1 tests, 22 assertions, 0 failures, 0 errors
破解脚本
我们可以将 to_lang 修改为允许发音为零,而不是返回 EMPTY_STRING 常量。为了做到这一点,并且仍然与递归一起工作,我们需要将另一个可选参数发送到 to_lang,该参数跟踪递归深度(我们执行了多少层递归)。我们只关心区分对 to_lang 的第一次调用和其他调用。如果我们返回 EMPTY_STRING,则 self 为零并且是 to_lang 的第一次调用;我们可以在所有其他情况下跳过返回保护。我们还需要在 dont_need_ones_in_english 和 dont_need_ones_in_spanish 中更改 0 的值。
^([29]) 注意,我们的 to_english 和 to_spanish 的定义本质上是对 to_lang 的柯里化,通过做出假设(即转换成哪种语言)来创建新的柯里方法,这些方法更容易调用(即需要更少的参数)。
^([30]] 当然,所有这些具体的例子都假设是英语。当 lang 是 'spanish' 时,用西班牙语术语替换。
#40 优雅的 Maps 和 Injects (symbol.rb)
我将以一个我甚至没有写的微小脚本来结束这一章。我当然希望我写了,因为它非常实用,尤其是在使你的 map, inject 和类似方法的使用更加优雅方面。这是一个最好的语法糖的例子,它直接来自 Ruby 扩展项目 extensions.rubyforge.org。这个脚本和该网站上所有的脚本都遵循与 Ruby 本身相同的许可条款,这就是我能在这一章中使用它的原因。[1] 代码非常简单。
代码
#!/usr/bin/env ruby ***`Symbol.to_proc`***
class Symbol
def to_proc()
Proc.new { |obj, *args| obj.send(self, *args) }
end
end
这有什么用?它让你可以使用 uc_words = lc_words.map(&:upcase) 来完成与 uc_words = lc_words.map { |word| word.upcase } 相同的事情。在两种情况下,uc_words 变量现在都包含了 lc_words 中所有单词的大写版本。正如我说的,这基本上只是语法糖,但它非常、非常棒且巧妙。
它是如何工作的
首先,这个脚本使用 Proc.new 创建了一个 Proc,它接受一个名为 obj 的对象和可变数量的 args。记得从 to_lang.rb 中,obj.send(methodname) 与 obj.methodname 相同,所以这些是等效的,有一个数组 a:
a.send(push, some_item)
a.push(some_item)
剩余的参数(用 *args 表示)也会传递给 obj,它正在使用 each 或 map 或其他迭代方法。
其次,你可能还记得之前关于如何使用 ampersand (&) 在 Procs 和 blocks 之间进行转换的讨论,但我们也可以使用 ampersand 将更多东西转换为 Procs。这样做会调用一个名为 to_proc 的方法,你可以看到我们已经覆盖了它。我们最终使用双字符前缀 &:,因为冒号已经是 Symbol 的前缀。当我们使用表达式 &:some_name 时,我们的意思是 由名为 some_name 的 Symbol 的 to_proc 方法返回的表达式。
结果
让我们在 irb 中看看它的实际应用。
irb -r symbol.rb
irb(main):001:0> digits = (0..9).to_a
=> [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
irb(main):002:0> digits.inject(&:+)
=> 45
irb(main):003:0> digits.map(&:inspect)
=> ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]
irb(main):004:0> require 'to_lang'
=> true
irb(main):005:0> digits.map(&:to_en)
=> ["", "one", "two", "three", "four", "five", "six", "seven", "eight",
"nine"]
操纵脚本
这个脚本已经是一个非常优雅的技巧。请注意,你需要使用 Proc.new 而不是 lambda,因为你希望它能够处理可变数量的 args。
^([31]] 这些术语在 www.ruby-lang.org/en/about/license.txt 中有明确的说明)
章节摘要
这一章有什么新内容?
-
回调
-
CVS
-
混入(Mixins)的实际应用
-
通过
send调用具有变量名称的方法 -
语法糖
-
Symbol.to_proc
这就是这一章的全部内容。它更多地关注于熟悉事物的应用,而不是全新的概念,但它仍然引入了几个新颖的想法。下一章将专注于网络编程,这是 Ruby 已经变得相当受欢迎的领域。
第十一章。CGI 和网页

Ruby 作为一种特别适合网页编程的语言受到了很多关注,尤其是在 Rails 开发框架的背景下。有些人甚至将 Ruby 归类为一种网页语言,暗示它不是一个完整的通用编程语言。我希望前几章至少在某种程度上说服了读者这一说法是错误的。
话虽如此,Ruby 对于网页开发非常有用,它确实有一些特性使其比(例如)视频游戏编程更适合网页编程。Ruby 在一个非常高的抽象级别上运行,为程序员提供了一个庞大的工具集来使用,并且它执行代码的速度比某些其他语言要慢。这些特性使 Ruby 非常适合网页开发,因为开发速度通常至关重要,但程序执行速度通常不如其他类型的程序,如实时动作视频游戏重要。
Rails 开发框架在将 Ruby 推向越来越广泛的受众方面发挥了重要作用。有人说它是 Ruby 的“杀手级应用”,类似于 Perl 的 CPAN 或 GNU 项目的 gcc。这是一本关于 Ruby 的通用书籍,而不是 Rails 的书籍,但 Rails 的重要性足以让它拥有自己的章节。(由于我们将使用 RubyGems,Ruby 的包管理系统,来安装 Rails,因此本书还包括一个关于 RubyGems 的章节。)
你需要等待两章才能了解 Rails。除了知道如何使用 RubyGems 安装它之外,到那时你也应该对一般性的网页程序有所了解——这就是本章的目的。如果你是网页应用程序的老手,你可以自由地跳过这一章,尽管你可能发现其中的一些特定脚本新颖有趣,即使你已经知道它们是如何工作的。
通用网关接口
网页编程最常见的方法是通用网关接口(CGI)。CGI 并不是一种编程语言;它是一套规则,程序在网页上运行时需要遵循这些规则,无论每个程序可能用哪种特定的语言编写。CGI 允许多个文件之间友好地协作,即使这些文件是用不同的编程语言编写的,但它们都存在于一个更大的网络应用程序中。
在单个网络应用程序中使用多种语言相当常见。我提到 Ruby 的高度抽象性使其适合于网络编程。然而,有时你可能真的想在网络程序中使用别人用另一种语言(比如 Python)编写的库。如果你使用 CGI,你可以在 Python 中编写你的网络应用程序的一部分,以便使用那个库。你也可能有一个对速度要求很高的网络应用程序部分,因此你可以用 C 编写这部分以提高执行速度,其余部分用 Ruby 编写以提高开发速度。这正是保罗·格雷厄姆和他的同事选择在他们的公司 Viaweb 中使用 Lisp 和 C 的组合的原因,Viaweb 最终成为了 Yahoo! Stores。他们之所以能够这样做,是因为 CGI 规范适用于多种语言。
准备和安装
在我们开始使用 Ruby 和 CGI 之前,我们需要做一些工作来使我们的 Web 服务器准备就绪。为了本章的目的,我将专注于在类 Unix 环境中为 Apache Web 服务器设置 CGI。Apache 是最受欢迎的 Web 服务器,类 Unix 操作系统是最常见(也是最稳定)的服务器操作系统。
你可以在httpd.apache.org获取 Apache Web 服务器的副本,或者你可以使用包管理器来安装它。(Mac OS X 预装了 Apache。)我在 Ubuntu 系统上使用了apt-get,如下所示:
apt-get install apache2 apache2-doc
Reading package lists... Done
Building dependency tree... Done
The following extra packages will be installed:
apache2-common apache2-mpm-worker apache2-utils libapr0 libpcre3 ssl-cert
Suggested packages:
lynx www-browser
The following NEW packages will be installed:
apache2 apache2-common apache2-doc apache2-mpm-worker apache2-utils libapr0
libpcre3 ssl-cert
0 upgraded, 8 newly installed, 0 to remove and 5 not upgraded.
Need to get 3555kB of archives.
After unpacking 16.4MB of additional disk space will be used.
Do you want to continue [Y/n]? Y
我回答了Y。你可以看到我选择了 Apache Web 服务器的apache2版本。安装 Apache 后,你还会想安装mod_ruby的包,它允许 Ruby 程序在 Web 服务器中运行。当我展示使用mod_ruby的脚本时,我会解释它的好处。你可以在基于 Debian 的系统上通过输入apt-get install libapache2-mod-ruby liberuby来安装mod_ruby。现在安装完成,让我们开始我们的第一个简单的 CGI 脚本。
#41 一个简单的 CGI 脚本 (simple_cgi.rb)
这个脚本相当简单,但展示了如何使用 Ruby 进行 CGI 的基本方法,并介绍了 Ruby 的名为cgi的库。你需要将这个脚本放在你的系统 cgi-bin 目录下。在我的系统上,它是/usr/lib/cgi-bin/,但你的系统位置可能不同。然后你可以浏览到 http://localhost/cgi-bin/simple_cgi.rb,因为你的 Web 服务器将通过 http://localhost/cgi-bin/提供对 cgi-bin 目录内容的访问。
注意
你还需要给simple_cgi.rb设置755权限,这意味着它的所有者可以做任何事情,而其他人可以读取和执行它,但不能写入(更改)它。更多信息,请参阅man chmod**。
在我们开始脚本之前,你也应该浏览到 http://localhost/。如果你看到的是告诉你 Apache 安装正确的页面,或者是一个目录中的文件列表,那么你的 web 服务器可能正在工作。如果你没有看到这两者中的任何一个,请查阅 Apache 文档(可在 http://httpd.apache.org/docs 获取)以诊断问题。如果你的 web 服务器正在工作,你可以继续到脚本。
代码
#!/usr/bin/env ruby
# simple_cgi.rb
❶ require 'cgi' *Requiring CGI.rb*
❷ class Simple_CGI
❸ EMPTY_STRING = ''
TITLE = 'A simple CGI script'
def display()
❹ cgi = CGI.new('html4')
❺ output = cgi.html do
cgi.head do
cgi.title { TITLE }
end +
cgi.body do
cgi.h1 { TITLE } +
❻ show_def_list(cgi)
end
end
❼ cgi.out { output.gsub('><', ">\n<") }
end
private
❽ def get_items_hash()
{
'script' => ENV['SCRIPT_NAME'],
❾ 'server' => ENV['SERVER_NAME'] || %x{hostname} || EMPTY_STRING,
'software' => ENV['SERVER_SOFTWARE'],
'time' => Time.now,
}
end
❿ def show_def_list(cgi)
cgi.dl do
items = get_items_hash.merge(cgi.params)
items.keys.sort.map do |term|
definition = items[term]
"<dt>#{term}</dt><dd>#{definition}</dd>\n"
end.join( EMPTY_STRING )
end
end
end
Simple_CGI.new.display()
它是如何工作的
我们在脚本中做的第一件事是在❶处require cgi库。然后我们在❷处定义一个名为Simple_CGI的类,并在❸处定义常量EMPTY_STRING和TITLE。接下来,在display方法(在❹处),我们创建了一个名为cgi的CGI实例,用html4来定义它,这是CGI所了解的 HTML 版本之一。我们将使用cgi来创建一个simple_cgi.rb将要输出的 HTML 文档。
CGI的实例有几个接受块的方法,这些方法的名称与它们将要创建的标签相同。每个 HTML 文档都需要一个<html>标签,所以我们包括它(在❺处)。出于我将要解释的原因,我想将<html>标签的内容存储在一个名为output的临时局部变量中。我们可以遍历我们想要创建的 HTML 文档,使用cgi的适当方法打开新的标签(如head, title, h1等)。正如你所看到的,使用块实现了分层嵌套,同一级别的标签(兄弟标签)使用+方法连接。
你会注意到在cgi.body中,它创建了我们输出结果中的<body>标签,我在❻处使用了一个名为show_def_list的方法(定义在❿处)。这主要是为了避免cgi方法的嵌套层级过多,但它也执行其他任务。让我们在❿处检查它。它输出一个定义列表,正如你所期望的那样,使用cgi.dl与一个块。为此,它从名为items的哈希中提取terms和它们的definitions,分别用<dt>和<dd>标签包裹。
items哈希是由get_items_hash(❽)的输出与cgi.params合并而成的。cgi.params哈希代表查询字符串,所以如果你浏览到 http://localhost/cgi-bin/simple_cgi.rb?key1=value1&key2=value2,cgi.params将会是{‘key1’ => ‘value1’, ‘key2’ => ‘value2’}。get_items_hash方法返回一个哈希,代表一些我认为可能值得展示的值,例如脚本名称、服务器等。一般来说,脚本只是从机器的环境变量中读取,使用ENV哈希的值。在❾处,哈希中‘server’的值比其他值稍微复杂一些。它尝试像其他值一样从ENV中读取,如果需要,回退到系统执行的hostname命令,最后回退到EMPTY_STRING。然后这个哈希作为方法中的最后一个评估表达式隐式返回。
在 ❽ 处,我们调用 cgi.out,给它一个包含对 output 变量进行轻微调整的块,使用 gsub。我首先承认这有点不寻常。通常,您会使用包含 cgi.html 和我用来填充 output 变量的所有其他方法的块来调用 cgi.out。我为什么这样做呢?有两个相关的原因。
第一个原因是 cgi.out 并非纯粹的功能性:它不会返回一个使用 puts 打印的值。相反,它自己进行输出。第二个原因是 cgi 的方法在标签之间不会引入换行符。这对于速度优化是有好处的,因为每个新字符,即使是换行符,都会稍微增加传输的内容。然而,这并不使生成的 HTML 源代码非常易于阅读。我喜欢可读的 HTML 源代码,所以我在 ❽ 处使用 gsub 在相邻标签之间引入换行符。如果您不介意您的 HTML 都连成一行,那么当然,将您的 cgi.html 和类似的调用放在 cgi.out 的块中。
我们到目前为止所讨论的一切都是在 display 方法中。我们在脚本的最后一行直接调用它,在一个匿名的 Simple_CGI 新实例上。实际上没有必要将它实例化为一个变量,如下所示:
scgi = Simple_CGI.new
scgi.display()
然而,如果您更习惯这样做,也没有理由不这样做。让我们看看它是如何工作的。
结果
在您的系统上,浏览到 http://localhost/cgi-bin/simple_cgi.rb 并查看您得到的结果。它应该大致类似于 图 11-1。
注意,软件值可能会不同,除非您也在使用相当标准的 Ubuntu 系统,时间显然会有很大差异。您可以看到,标签显示的是页面标题,即 一个简单的 CGI 脚本(与大的粗体标题相同)。除非您故意将文件名从 simple_cgi.rb 改为其他名称或浏览到除 localhost 之外的主机名,否则不应有差异的值是脚本和服务器。敏锐的读者也会看到我还有一个打开的 Apache 网站标签页。

图 11-1. simple_cgi.rb 的输出
现在我们尝试稍微改变一下查询字符串,使用 http://localhost/cgi-bin/simple_cgi.rb?lang=Ruby。我不会麻烦展示新的截图,但你现在应该会看到定义列表中有五个条目而不是四个。新的一条是键 lang,其值为 Ruby。这是由于 cgi.params 是 show_def_list 中的项目 Hash 的一部分,当我们使用查询字符串 lang=Ruby 时,cgi.params 变为 { lang=>Ruby },然后它就是 items 中的其中一个对。
现在我们尝试在查询字符串中为已经出现在项目中的某个键提供一个显式值,使用 URL http://localhost/cgi-bin/simple_cgi.rb?lang=Ruby&server=some_other_server_name。你应该仍然看到键lang的值为Ruby,但除此之外,server的值不再是localhost,而是some_other_server_name。这种情况发生的原因是cgi.params是merge的参数,它覆盖了在调用merge的哈希中已经存在的任何冲突的键值对。因此,cgi.params中的任何内容都优先。
漏洞脚本
这是一个简单的脚本,展示了 CGI 的基础知识。你可以以无数种方式对其进行修改和扩展。一个建议是将其与currency_converter2.rb的一部分结合使用。例如,你可以显示时间,就像这个脚本已经做的那样,并接受要转换的货币以及转换的金额作为参数。许多人也使用 CGI 在机器上执行系统调用并显示结果,展示机器上正在运行的过程、磁盘空间使用情况以及其他对系统管理员感兴趣的信息。
#42 Mod Ruby (mod_ruby_demo.rhtml 和 mod_ruby_demo.conf)
CGI 对于许多应用来说都很棒。然而,有时你可能希望有一些主要是 HTML 的文件,只有部分子部分需要由你的编程语言,比如 Ruby,来执行。如果有一个 HTML 标签意味着“现在开始 Ruby 代码”,之后你可以添加一些 Ruby 代码,然后使用另一个标签表示“Ruby 代码完成,回到普通的 HTML”,那岂不是很好?
对于许多语言来说,都存在这样的系统。这是 PHP 语言的默认行为,Perl 和 Python 等其他语言也有类似的系统。为 Ruby 实现这一功能的系统之一是eRuby,它将通过mod_ruby软件直接嵌入到 Web 服务器中。
CGI 的一个问题是速度。当有人发起一个需要动态 CGI 执行的 Web 请求时,该请求会启动一个新的 Ruby 解释器;^([32])然后该解释器评估 CGI 程序,将其值返回给 Web 服务器进程,并关闭。对于下一个 CGI 请求,整个过程从头开始。所有这些都需要时间。mod_ruby和类似系统所做的就是在后台始终运行一个 Ruby 解释器,随时准备评估脚本并将结果返回给 Web 服务器,但无需为每个脚本启动和关闭一个独立的ruby进程。这使得 Web 服务器启动稍微慢一些,因为它需要做更多的事情,但只需几个请求之后,它就能节省大量的机器开销。
在代码中,您会看到 <% 和 %>,这是表示 将我的内容解释为 Ruby,而不是 HTML 的开始和结束标签。但首先我们需要设置 Apache,使其知道如何处理 mod_ruby。我们已经安装了 mod_ruby 软件包,但我们需要一个配置文件。那就是下面的 mod_ruby_demo.conf。
代码
mod_ruby_demo.conf
<IfModule mod_ruby.c> *An Apache Config File*
# for Apache::RubyRun
RubyRequire apache/ruby-run
# for Apache::ERubyRun
RubyRequire apache/eruby-run
# handle *.rcss as eruby files.
❷ <Files *.rcss>
AddType text/css .rcss
AddType application/x-httpd-ruby *.rb
SetHandler ruby-object
RubyHandler Apache::ERubyRun.instance
</Files>
# handle *.rhtml as eruby files.
❷ <Files *.rhtml>
AddType text/html .rhtml
AddType application/x-httpd-ruby *.rb
SetHandler ruby-object
RubyHandler Apache::ERubyRun.instance
</Files>
RubyRequire auto-reload
</IfModule>
此文件不是 Ruby 代码——它使用 Apache 的配置文件格式。将此文件放在 /etc/apache2/mods-available/ 中,并在 /etc/apache2/mod-enabled/ 中创建一个符号链接^([33])。如果您使用的是 Apache 版本 1.X(例如 1.3,它仍然很受欢迎),您将在 /etc/apache/httpd.conf 文件中添加此文件的內容。正如我之前提到的 cgi-bin 目录,这些特定的文件和目录位置适用于我的系统,但您的可能不同。
mod_ruby_demo.rhtml
此文件应该更像是 HTML 和 Ruby 代码的奇怪混合体。
<!DOCTYPE html
PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xml:lang="en" lang="en">
<head>
<title>Mod Ruby</title>
<style>
code {
background-color: #ddf;
color: #f00;
padding: 0.3em;
}
</style>
</head>
<body>
<h1>Mod Ruby</h1>
<p>
The eRuby command below should print <q>Hello, world!</q>
</p>
<p>
❸ <q><% print "Hello, world!" %></q> ***`%`** tags*
</p>
<p>
❹ Welcome to <em><%= ENV['SERVER_NAME'] %></em>. If you see a server name,
❺ <%= 'e' + 'Ruby' %> is probably working.
</p>
<p>
❻ The current time is <%= Time.now %>.
</p>
<p>
<%
❼ def function_within_mod_ruby(input)
"#{input} was passed through a function.\n"
end
print function_within_mod_ruby("Some sample input")
print '<br />'
print function_within_mod_ruby("Some other sample input")
%>
</p>
</body>
</html>
将此文件放置在可通过网络浏览的位置。我将假设它在 http://localhost/mod_ruby/,使其可通过 http://localhost/mod_ruby/mod_ruby_demo.rhtml 访问。
工作原理
希望地,mod_ruby_demo.conf 将是完全透明的。当然,我在开玩笑,但在这个阶段,如果您不理解这个文件的所有内容,并不是关键。了解 Apache 配置文件是很好的,您当然可以从 Apache 网站或各种 Apache 相关的书籍中学到很多,但对我们来说重要的是 ❶ 和 ❷。在 ❶,我们声明具有 .rcss 扩展名的文件应被解释为 Ruby 文件。在 ❷,我们对具有 .rhtml 扩展名的文件做了同样的声明。
注意
为什么使用这些扩展名?为动态解释的文件定义文件扩展名,通常使用正常的扩展名,并在前面加上代表所用编程语言的额外字母,这是一种相当常见的做法。例如,.rhtml 用于生成 HTML 输出的 Ruby 文件,.rcss 用于生成 CSS 样式表的 Ruby 文件,等等。您有时也可能看到 .phtml 文件,它集成了 Perl 或 PHP,甚至 .mhtml 文件,它使用的是用 Perl 编写的软件 Mason*。
mod_ruby_demo.conf 的内容到此为止。在 mod_ruby_demo.rhtml 中,我们有一些额外的关注点。它应该看起来像标准的 HTML,直到 ❸。在那个点上,我们看到这一行:<q><% print “Hello, world!” %></q>。<% 和 %> 是我之前提到的 将我的内容解释为 Ruby 标签,所以那些标签内的任何内容都将被解释为 Ruby 代码。在这种情况下,我们要求 Ruby 打印 ‘Hello, world!’,它确实这样做了,将打印的输出包含在最终的 HTML 中。
你可能预期我们经常想要打印出将被整合到 HTML 中的输出。不断地使用print语句会很繁琐,所以有一个快捷方式,你可以在❹处看到。如果你使用一个初始的代码标签<%=, Ruby 就会假设你想要打印出评估后的表达式。在❹处,我们在一个<em>标签内整合了ENV[‘SERVER_NAME’]的值。为了展示在<%=和%>之间的内容可以是任何表达式,在❺处,我们连接了两个字符串,只关心结果。
打印的输出也不一定是简单的字面表达式。在❻处,我展示了方法调用的值,在这种情况下结果是当前本地时间。最后,在❼处,我们在.rhtml 文件中定义了一个全新的方法,名为function_within_mod_ruby,之后任何时候都可以使用,如代码所示。
结果
当我通过自己的 web 服务器调用这个脚本时,我得到了图 11-2 中显示的结果。

图 11-2. mod_ruby的输出
你的结果中的时间显然会有所不同,但那应该是唯一的区别,除非你通过除localhost之外的名字浏览你的机器,或者你将mod_ruby_demo.rhtml放在不同的目录下或给它不同的名字。
操纵脚本
这个脚本是一个修改的游乐场。你可以在那些<%或<%=标签内放置任何你想要的 Ruby 表达式。尝试使用require,无论是你知道是标准库的一部分的文件(如cgi)还是你自己的文件。这种技术让你可以将所有真正的“东西”定义为类,放在.rb 库文件中,而将.rhtml 文件保留用于显示。
^([32]) 或者是 CGI 程序使用的语言的解释器。
^([33]) 你可以在 Unix shell 中使用ln -s命令创建一个符号链接。
#43 CSS 样式表,第一部分 (stylesheet.rcss)
拥有.rhtml 文件是很好的——它们允许你动态生成你想要的任何可见 HTML。但你可以使用mod_ruby做更多的事情。任何设计良好的现代网站的大部分内容将是其样式表。网页设计师必须处理的一个挫折是各种浏览器之间不完整或不兼容的 CSS 支持。对于这些挫折有很多潜在的解决方案,你可以在像www.richinstyle.com或alistapart.com这样的网站上找到。对于程序员来说,一个明显的解决方案是确定某人正在使用哪种浏览器(通过ENV[‘USER_AGENT’]),并为该用户提供一个针对其特定浏览器的定制样式表。
这是一个非常好的解决方案,在网络上被无数次地付诸实践。然而,还有一个解决方案。为什么不将样式表本身做成一个动态的 .rcss 文件呢?采用这种方法,样式表就变成了多态的,这是从面向对象编程中借用的一个术语。每个浏览器都会通过名称引用相同的样式表,然后接收针对该浏览器恰好合适的内容。以下是一个例子。
代码
/*
This file outputs CSS data customized by user_agent via eruby.
There is a blog entry about some similar ideas at
http://blog.airbladesoftware.com/2006/12/11/cssdryer-dry-up-your-css
*/
<%
# define functions
❶ def alpha_width(user_agent)
width =
if (user_agent =~ /Windows/)
11.8 if (user_agent =~ /Opera/)
11.8 if (user_agent =~ /MSIE 6/)
14 if (user_agent =~ /MSIE/)
11.8
elsif (user_agent =~ /Palm/)
5
else
11.8
end
❷ return %Q[\twidth:#{width}em;]
end
❸ def beta_width(user_agent)
width =
if (user_agent =~ /Windows/)
15.8 if (user_agent =~ /Opera/)
15.8 if (user_agent =~ /MSIE 6/)
18 if (user_agent =~ /MSIE/)
15.8
elsif (user_agent =~ /Palm/)
7
else
15.8
end
❹ return %Q[\twidth:#{width}em;]
end
❺ def margin_left(user_agent)
margin =
if (user_agent =~ /Mac/)
3 if (user_agent =~ /Opera/)
1 if (user_agent =~ /MSIE/)
2.5 if (user_agent =~ /Safari/)
2 if (user_agent =~ /Gecko/)
2.7
elsif (user_agent =~ /Windows/)
1.5
else
2 if (user_agent =~ /Opera/)
2 if (user_agent =~ /onqueror/)
1.8 if (user_agent =~ /Galeon/)
2.5
end
❻ return %Q[margin-left:-#{margin}em;]
end
%>
❼ li { <%= margin_left(ENV['HTTP_USER_AGENT']) %> }
#navAlpha {
position:absolute;
❽ <%= alpha_width(ENV['HTTP_USER_AGENT']) %>
top:2em;
left:2em;
border:0.5em double #333;
background-color:#ada;
padding:1em;
z-index:2;
}
#navBeta {
position:absolute;
❾ <%= beta_width(ENV['HTTP_USER_AGENT']) %>
top:2em;
right:2em;
border:0.5em double #333;
background-color:#ada;
padding:1em;
z-index:1;
}
它是如何工作的
与 mod_ruby_demo.rhtml 类似,这主要是一个具有其他格式(在这种情况下是一个 CSS 样式表)的文件,它恰好包含一些 Ruby 代码。我们在 ❶ 处定义了一个名为 alpha_width 的新函数,该函数确定一个名为 width 的局部变量的值,最终在遵循 CSS 格式的文本中返回它 ❷。请注意,这个函数利用了 Ruby 中的 if 语句也会返回值的这一事实,在这种情况下,将那个值赋给 width。我们在 ❸ 处对 beta_width 做了类似的事情,它返回自己的 CSS 格式化输出 ❹。最后,我们在 ❺ 处定义了 margin_left,它返回 ❻ 的 CSS。
注意
为什么是那些特定的函数?我发现最让我沮丧的 CSS 支持差异涉及边距和填充以及列表项的左边距,所以我就做了这些函数。比我更了解 CSS 的人可能已经找到了更优雅的解决方案,但有时,当为时已晚时,一个相当不错的解决方案比一个完美的解决方案更好。这个脚本的目的也是为了证明多态样式表技术是可以做到的,但这并不是它应该被做到的方式。如果你非常关心 CSS,你可以使用这种技术来完成更大的事情。
然后,我们在列表元素的 CSS 声明中使用 margin_left 的输出 ❼。样式表还定义了两个 ID,即 #navAlpha 和 #navBeta,它们只是列 div 的标识符。在 #navAlpha ❽ 中,我们使用 alpha_width 的输出作为 #navAlpha 的宽度,在 ❾ 中,我们对 #navBeta 做了类似的事情。
结果
这里是使用 Ubuntu 系统上的 Mozilla Firefox 浏览 stylesheet.rcss 时的输出:
/*
This file outputs CSS data customized by user_agent via eruby.
There is a blog entry about some similar ideas at
http://blog.airbladesoftware.com/2006/12/11/cssdryer-dry-up-your-css
*/
li { margin-left:-2.5em; }
#navAlpha {
position:absolute;
width:11.8em;
top:2em;
left:2em;
border:0.5em double #333;
background-color:#ada;
padding:1em;
z-index:2;
}
#navBeta {
position:absolute;
width:15.8em;
top:2em;
right:2em;
border:0.5em double #333;
background-color:#ada;
padding:1em;
z-index:1;
}
你会注意到,适当的值被插值在 li 和 width CSS 声明之间。由于这个文件的全部目的就是为不同的浏览器提供不同的输出,所以你的结果可能会有所不同。
黑客脚本
对于这个脚本,有很多黑客选项。其中一个是我们下一个脚本,stylesheet2.rcss。
#44 CSS 样式表,第二部分(stylesheet2.rcss)
在许多方面,这个脚本只是 stylesheet.rcss 的一个美化版黑客。我主要将其分离出来,以便进行比较。这两个文件之间的主要区别在于 stylesheet2.rcss 将 width 值概括为一个单一函数。
代码
/*
This file outputs CSS data customized by user_agent using eruby.
*/
<%
# define functions
❶ def width(type, user_agent)
❷ small = {
'alpha' => 11.8,
'beta' => 15.8,
}
❸ large = {
'alpha' => 14,
'beta' => 18,
}
❹ palm = {
'alpha' => 5,
'beta' => 7,
}
❺ width =
if (user_agent =~ /Windows/)
small[type] if (user_agent =~ /Opera/)
small[type] if (user_agent =~ /MSIE 6/)
large[type] if (user_agent =~ /MSIE/)
small[type]
elsif (user_agent =~ /Palm/)
palm[type]
else
small[type]
end
return %Q[\twidth:#{width}em;]
end
def margin_left(user_agent)
margin =
if (user_agent =~ /Mac/)
3 if (user_agent =~ /Opera/)
1 if (user_agent =~ /MSIE/)
2.5 if (user_agent =~ /Safari/)
2 if (user_agent =~ /Gecko/)
2.7
elsif (user_agent =~ /Windows/)
1.5
else
2 if (user_agent =~ /Opera/)
2 if (user_agent =~ /onqueror/)
1.8 if (user_agent =~ /Galeon/)
2.5
end
return %Q[margin-left:-#{margin}em;]
end
%>
li { <%= margin_left(ENV['HTTP_USER_AGENT']) %> }
#navAlpha {
position:absolute;
<%= width('alpha', ENV['HTTP_USER_AGENT']) %>
top:2em;
left:2em;
border:0.5em double #333;
background-color:#ada;
padding:1em;
z-index:2;
}
#navBeta {
position:absolute;
<%= width('beta', ENV['HTTP_USER_AGENT']) %>
top:2em;
right:2em;
border:0.5em double #333;
background-color:#ada;
padding:1em;
z-index:1;
}
它是如何工作的
在 ❶ 处,我们定义了通用的 width 函数,您现在会看到它现在接受两个参数:与之前一样,用户代理,但还包括我们正在为它生成 width 的列的 type。然后我们有针对 small(❷)、large(❸)和 palm(❹)的单独的 Hash。Palm 设备始终使用它们自己的 Hash,而其他浏览器则根据特定的用户代理使用 small 或 large Hash。然后在 ❺ 处,我们确定 width。^([34]) type 只是已经决定的 Hash 的键。其他一切都是与 stylesheet.rcss 相同的,除了现在对 alpha_width 或 beta_width 的调用现在是 width 的调用,如之前所述。
结果
如前所述,这是我的设置下的输出。
/*
This file outputs CSS data customized by user_agent using eruby.
*/
li { margin-left:-2.5em; }
#navAlpha {
position:absolute;
width:11.8em;
top:2em;
left:2em;
border:0.5em double #333;
background-color:#ada;
padding:1em;
z-index:2;
}
#navBeta {
position:absolute;
width:15.8em;
top:2em;
right:2em;
border:0.5em double #333;
background-color:#ada;
padding:1em;
z-index:1;
}
这个输出基本上与 stylesheet.rcss 的输出相同,只是没有初步注释。
漏洞脚本
正如我之前提到的,如果对 CSS 有更深入理解的人,可以真正定制这个脚本,使其做一些奇妙的事情。毫无疑问,完成这个脚本所做事情的方法有很多种,但它的目的是以粗略的方式展示技术。希望您觉得它有用。
^([34]) 不要因为存在一个名为 width 的函数和一个内部也称为 width 的局部变量而感到困惑。函数外部无法访问变量,函数知道在自动递归调用自身之前检查是否存在名为该名称的变量。
章节总结
本章有哪些新内容?
-
使用 Ruby 进行 CGI 脚本
-
cgi库 -
cgi.params -
mod_ruby -
.rhtml 和 .rcss 文件
-
Apache 配置文件
本章仅对使用 Ruby 或其他语言进行的 CGI 编程进行了初步探讨。其目的是让您熟悉使用 Ruby 与 web 服务器和浏览器交互。大多数基于 Ruby 的网络编码都使用了 Rails 框架,我们很快就会涉及到。但首先,我们将使用 RubyGems 系统安装 Rails,这就是我们下一章的主题。
第十二章。RubyGems 和 Rails 准备

在本章中,我们将讨论 Ruby 的包管理系统 RubyGems,以及 Ruby 最突出的 Web 开发框架 Rails。这些相对独立的主题被放在一起,因为安装 Rails 的“官方”方法是使用 RubyGems 软件。到本章结束时,您将能够通过 RubyGems 安装 Rails,并理解构成 Rails 应用程序的基本结构和目的的文件。
RubyGems
良好的操作系统都有良好的包管理系统——软件可以跟踪该操作系统所需或提供的其他软件,并确保一切保持最新。Mac OS X 有软件更新,Windows 有 Windows 更新,而各种 GNU/Linux 版本有 RPM、YUM 和我最喜欢的 APT 等程序。良好的编程语言也有类似的程序,这些程序为程序员和其他用户提供访问用该语言编写的庞大软件库的途径。Perl 有综合 Perl 存档网络 (cpan.org),Python 有 Cheese Shop (cheeseshop.python.org/pypi),而 Ruby 有 RubyGems (rubygems.org)。
注意
最新的 RubyGems 信息可在 docs.rubygems.org 获取。本章旨在为您提供一个基本介绍,并帮助您启动 Rails。如果您对 RubyGems 感到好奇(我希望您会),我强烈建议将 docs.rubygems.org 设为您的常规网络访问之一。
RubyGems 已经成为创建独立 Ruby 软件的事实标准方法(尤其是库和程序员工具),供 Ruby 社区中的其他人使用。使用此系统,您可以轻松使用其他程序员的软件来简化您的工作,同时也可以分享您自己的工作,这可能会使其他程序员的某些工作变得更简单。通过 RubyGems 将软件打包成一个单一单元的每个软件包称为 gem,用户可以使用名为 gem 的相应命令来操作这些 gem。
安装 RubyGems
您需要浏览到 rubyforge.org/frs/?group_id=126 下载 RubyGems 的最新版本。文件提供 TGZ 和 Zip 格式,以及 gem 文件(用于在您已安装 RubyGems 后更新)和补丁文件。在此示例中,我已下载 rubygems-0.9.2.tgz。解压缩下载的文件,并使用命令 ruby setup.rb 运行新创建目录中找到的 setup.rb 程序。您可能需要以 root 用户身份执行此操作(如果适用于您的操作系统)。您可以通过输入 gem -v 来查看您现在系统上安装的 RubyGems 版本。
使用 RubyGems
如果您不带参数运行gem,它应该返回类似于以下内容:
RubyGems is a sophisticated package manager for Ruby. This is
a basic help message containing pointers to more information.
Usage:
gem -h/--help
gem -v/--version
gem command [arguments...] [options...]
Examples:
gem install rake
gem list --local
gem build package.gemspec
gem help install
Further help:
gem help commands list all 'gem' commands
gem help examples show some examples of usage
gem help <COMMAND> show help on COMMAND
(e.g. 'gem help install')
Further information:
http://rubygems.rubyforge.org
列出已安装和可安装的宝石
通过执行gem list --local,您可以看到哪些宝石已经安装到您的系统上。以下是我在机器上安装 RubyGems 后立即运行gem list --local的结果:
*** LOCAL GEMS ***
sources (0.0.1)
This package provides download sources for remote gem installation
sources宝石使您能够通过维护有关它们的检索信息来安装其他宝石。我们可以通过gem query --remote查询这些信息,它输出一个非常长的可用宝石列表,这里以高度截断的形式显示:
*** REMOTE GEMS ***
abstract (1.0.0)
a library which enable you to define abstract method in Ruby
ackbar (0.1.1, 0.1.0)
ActiveRecord KirbyBase Adapter
action_profiler (1.0.0)
A profiler for Rails controllers
安装宝石
每个单独的宝石都可以通过命令gem install --remote some_gem_name进行安装。作为一个例子,让我们使用gem install --remote rails来安装rails宝石。
Install required dependency rake? [Yn]
Install required dependency activesupport? [Yn]
Install required dependency activerecord? [Yn]
Install required dependency actionpack? [Yn]
Install required dependency actionmailer? [Yn]
Install required dependency actionwebservice? [Yn]
Successfully installed rails-1.2.2
Successfully installed rake-0.7.1
Successfully installed activesupport-1.4.1
Successfully installed activerecord-1.15.2
Successfully installed actionpack-1.13.2
Successfully installed actionmailer-1.3.2
Successfully installed actionwebservice-1.2.2
Installing ri documentation for rake-0.7.1...
Installing ri documentation for activesupport-1.4.1...
Installing ri documentation for activerecord-1.15.2...
Installing ri documentation for actionpack-1.13.2...
Installing ri documentation for actionmailer-1.3.2...
Installing ri documentation for actionwebservice-1.2.2...
Installing RDoc documentation for rake-0.7.1...
Installing RDoc documentation for activesupport-1.4.1...
Installing RDoc documentation for activerecord-1.15.2...
Installing RDoc documentation for actionpack-1.13.2...
Installing RDoc documentation for actionmailer-1.3.2...
Installing RDoc documentation for actionwebservice-1.2.2...
我对所有确认请求都回答了Y。您可以看到,RubyGems 系统足够智能,足以知道哪些宝石需要其他宝石,并且它会自动安装您请求的宝石的依赖项。我们现在有一个功能齐全的 Rails 系统,我们将在下一章中对其进行探索。
注意
在我安装的时候,有一些关于 actionpack的 ri 和 RDoc 文档的轻微警告。它们对代码的功能没有影响,并且可能在您阅读这本书的时候已经过时了,所以我已经从本章的输出示例中省略了它们。
更新宝石
您可以使用gem update命令更新系统上现有的宝石。一个好的先决条件是使用gem outdated查询是否有任何宝石需要更新。我在一个有一些过时宝石的系统上运行了这个命令,并得到了以下结果:
Bulk updating Gem source index for: http://gems.rubyforge.org
activerecord (1.15.1 < 1.15.2)
rails (1.2.1 < 1.2.2)
actionwebservice (1.2.1 < 1.2.2)
rubygems-update (0.9.1 < 0.9.2)
actionpack (1.13.1 < 1.13.2)
actionmailer (1.3.1 < 1.3.2)
activesupport (1.4.0 < 1.4.1)
使用gem update rails(作为 root 用户)更新宝石产生了以下输出:
Updating installed gems...
Bulk updating Gem source index for: http://gems.rubyforge.org
Attempting remote update of rails
Install required dependency activesupport? [Yn]
Install required dependency activerecord? [Yn]
Install required dependency actionpack? [Yn]
Install required dependency actionmailer? [Yn]
Install required dependency actionwebservice? [Yn]
Successfully installed rails-1.2.2
Successfully installed activesupport-1.4.1
Successfully installed activerecord-1.15.2
Successfully installed actionpack-1.13.2
Successfully installed actionmailer-1.3.2
Successfully installed actionwebservice-1.2.2
Installing ri documentation for activesupport-1.4.1...
Installing ri documentation for activerecord-1.15.2...
Installing ri documentation for actionpack-1.13.2...
Installing ri documentation for actionmailer-1.3.2...
Installing ri documentation for actionwebservice-1.2.2...
Installing RDoc documentation for activesupport-1.4.1...
Installing RDoc documentation for activerecord-1.15.2...
Installing RDoc documentation for actionpack-1.13.2...
Installing RDoc documentation for actionmailer-1.3.2...
Installing RDoc documentation for actionwebservice-1.2.2...
Gems: [rails] updated
我特意选择了rails作为要更新的宝石,因为它是一个具有许多依赖项的宝石,正如我们在第一次安装时所了解的那样。因此,更新它展示了如何自动更新请求的宝石及其依赖项。Rails 依赖于 Active Record(一个提供高级数据库访问工具的软件包),因此更新rails宝石也会自动更新activerecord宝石,如上图中更新会话结果所示。所有其他rails依赖项以类似的方式更新。
注意
在更新您的宝石之后,您可能希望在您的每个 Rails 应用程序目录中执行 rake rails:update。这确保了您已经生成的任何应用程序文件都将更新以适应宝石的变化,同样也是如此。
了解更多关于 RubyGems 的信息
您可以通过在您的机器上使用gem help和gem help some_specific_command来随时了解更多关于gem命令的信息。这些信息将始终是最新的,并且针对您的系统。
Rails 准备
现在你应该对 RubyGems 有了足够的了解,可以用来安装 Rails。接下来的这一章将使你对 Rails 有足够的了解,以便你可以在下一章开始创建 Rails 应用程序。本书介绍了 Rails(侧重于一般设计哲学,而不是 API 的详尽列表),但认为几章内容就能给予 Rails 应有的关注是愚蠢的。关于 Rails 的权威文本是 Dave Thomas、David Heinemeier Hansson(Rails 的创造者)和其他人合著的《Agile Web Development with Rails》,现在已出到第二版(Pragmatic Bookshelf,2006 年)。Rails 社区的成员也对 David Alan Black 的《Ruby for Rails》(Manning Publications,2006 年)给予了高度评价。
什么是 Rails?
根据其网站rubyonrails.org的描述,Rails 是“一个针对程序员幸福感和可持续生产力的优化开源 Web 框架。它通过优先考虑约定而非配置来让你编写优美的代码。”该网站将其描述为“不会伤害的 Web 开发。”这意味着什么?
由于它优化了程序员的幸福感和专注于编写优美代码的能力,Rails 完全符合 Ruby 本身的设计哲学。Rails 为其行为选择了合理的默认值,只要你愿意遵循这些约定,你的程序员工作就会相对容易。Rails 为你提供了快速生成 Web 应用程序骨架的快捷方式和工具;它允许你将每段代码放置在目录结构中合理的位置,该位置适合代码预期执行的任务。这为你提供了干净、有序、可重用的代码,帮助你快速、高效、无痛苦地开发应用程序。
安装 Rails 的其他选项
使用 RubyGems 并不是安装 Rails 的唯一方式。还有其他几种你可能想要使用的选项,例如,如果你没有跟随文本,或者你只是希望通过其他方法安装 Rails。然而,需要注意的是,作为 gem 的安装是推荐的方式。
通过操作系统包管理器
一些操作系统的包管理器,例如 APT,提供 Rails 作为可安装的包。例如,在我的 Ubuntu 系统中,命令apt-cache search rails显示(在其他包中)以下结果:
rails - MVC ruby based framework geared for web application development
如果你想要使用 Rails,但不需要最新的尖端版本,并且你想要避免安装 RubyGems(无论出于什么原因),这个选项可能对你来说是个不错的选择。
从源代码安装
就像任何免费或开源软件一样,总有从源代码安装的选项。你可以浏览到rubyonrails.org/down获取最新推荐的源代码 tarball。
预包装
还有一些预包装的 Rails 版本可用。对于 Windows,有 Instant Rails(instantrails.rubyforge.org),对于 Mac OS X,有 Locomotive(locomotive.sourceforge.net)。这两款软件都可以帮助您开始使用 Rails。请注意,这些软件包与默认 Rails 设置之间可能存在细微的配置差异。在遇到这种情况时,咨询您选择的预包装应用程序的网站可能是您的最佳选择。
数据库
Rails 需要访问数据库程序才能正常工作。通常,这个数据库是 MySQL(mysql.com),尽管其他选项也是可能的。如果您通过 gem 安装或通过操作系统的包管理器安装,您应该安装 MySQL。这可能是通过操作系统的包管理器完成的最简单的方法。预包装的 Rails 安装程序,如 Instant Rails 和 Locomotive,通常包含自己的预配置数据库;然而,对于所有后续的例子,我将假设您已将 Rails 作为 gem 安装,并且您正在使用 MySQL。
Rails 应用程序的结构
Rails 遵循一种名为模型-视图-控制器(MVC)的软件设计哲学(或模式),这是由挪威计算机科学家 Trygve Reenskaug 在 20 世纪 70 年代末在施乐帕克研究中心工作时开发的。它最初是与传统的图形用户界面(GUI)相关联而开发的,但 MVC 最近在 Web 开发中变得非常流行。这个模式的基本内容如下。模型代表数据,是某种类型的对象。视图是展示数据的方式,无论是直接向用户展示,还是向其他计算机展示,或者以任何其他类型的输出形式。控制器是交通警察或管理者,它跟踪任何请求的操作、查询或操作模型以获取或更新数据,并给视图提供所需的信息,以便按需格式化数据。
让我们通过一个例子来帮助解释这个模式。在我们的第二个货币转换脚本(第十九部分 货币转换高级(currency_converter2.rb)
create public/javascripts/application.js
create doc/README_FOR_APP
create log/server.log
create log/production.log
create log/development.log
create log/test.log
会自动为您创建大量的目录和文件。我们将简要介绍`app, doc, lib, log, public, script`和`test`目录。`app`目录包含您为应用程序编写的代码:模型、视图、控制器等。`doc`目录包含文档。`lib`目录最初不创建任何内容,但您会在这里放置通用库文件(例如,向现有类添加新方法的扩展)。`log`目录包含应用程序日志文件,这些文件根据生产、开发和测试进行了分割。`public`目录包含在网页浏览器中查看或使用的非 Ruby 文件,例如静态 HTML 文件、图像、JavaScript 文件、CSS 样式表等。`script`目录包含供应用程序的开发者或管理员运行的有用的小程序。`test`目录包含允许您轻松自动化应用程序测试的文件。
我们将简要讨论这些文件和目录的作用,但首先,让我们测试我们的应用程序以确保它运行正常。
### 查看您的 Rails 应用程序
执行`cd rails_sample_app`以导航到新创建的目录`rails_sample_app`,然后执行命令`ruby script/server`。这将启动 Rails 内置的用于开发目的的 web 服务器。你应该会看到类似以下输出的内容:
$ ruby script/server Starting WEBrick
=> Booting WEBrick...
=> Rails application started on http://0.0.0.0:3000
=> Ctrl-C to shutdown server; call with --help for options
[2007-02-10 12:43:54] INFO WEBrick 1.3.1
[2007-02-10 12:43:54] INFO ruby 1.8.4 (2005-12-24) [i486-linux]
[2007-02-10 12:43:54] INFO WEBrick::HTTPServer#start: pid=27162 port=3000
您的具体情况可能不同。`pid`值几乎肯定不同,您的 Ruby 版本可能是一个更新的版本。您的 web 服务器也可能不是 WEBrick,而是一个像 Mongrel 这样的新 web 服务器(WEBrick 是一个作为默认回退的较老 web 服务器)。不太可能不同的是端口:3000。您可以通过在本地机器上浏览该端口来测试您的应用程序(只需将您喜欢的网页浏览器指向 http://localhost:3000)。图 12-1 显示了我在我的 Ubuntu 机器上使用 Epiphany 网页浏览器查看该 URL 时的样子。

图 12-1. 在网页浏览器中查看您的应用程序,URL 为 http://localhost:3000
如您所见,此页面提供了有关如何设置您的应用程序的有用信息,以及指向 Web 上 Rails 相关信息的链接。两个特别感兴趣的是,数据库连接在 YAML 文件`config/database.yml`中描述,以及`script/generate`可用于帮助您生成模型和控制器。
### 生成应用程序的基本知识
在`rails_sample_app`目录中,执行命令`ruby script/generate model ExchangeRate`,遵循我们之前提到的 MVC 示例。我们告诉 Rails 我们想要*生成*一些东西,我们想要制作的东西是*模型*,模型的名称是*ExchangeRate*。以下是结果:
ruby script/generate model ExchangeRate Making a Model
exists app/models/
exists test/unit/
exists test/fixtures/
create app/models/exchange_rate.rb
create test/unit/exchange_rate_test.rb
create test/fixtures/exchange_rates.yml
create db/migrate
create db/migrate/001_create_exchange_rates.rb
除了一些有用的测试和数据库相关文件外,此命令创建了一个名为`app/models/exchange_rate.rb`的 Ruby 文件,其内容如下:
class ExchangeRate < ActiveRecord::Base
end
看起来似乎没有多少内容,但外表可能会欺骗人。模型遵循 Rails 的命名约定,即使用驼峰命名法(CamelCase)为类名和下划线分隔的多单词小写(multi_word_separated_lowercase)为文件名。您还会注意到`ExchangeRate`是`ActiveRecord::Base`的子类。Active Record 是赋予 Rails 直观数据库交互功能的软件。它定义了模型(即类),每个实例代表数据库表中的一个记录。不仅如此,表的名称总是类的复数形式。因此,`exchange_rate.rb`文件定义了一个名为`ExchangeRate`的类,每个实例都存储在名为`exchanges_rates`的数据库表中。这一切都在幕后发生——Rails 为您自动完成,甚至足够智能,知道`person`的复数是`people`,`baby`的复数是`babies`等等。
### 注意
*Active Record 提供了所谓的对象关系映射 (ORM)。ORM 允许类的特定实例在数据库表中表示一条记录。对于那些好奇的人来说,表中的每个字段也代表相应类中的一个实例变量。我们将在下一章中更详细地讨论这个话题*。
我们的模式目前还没有做任何事情。我们不会在下一章开始添加到模型中,所以让我们使用命令 `ruby script/generate controller server_access rss html ascii yaml` 创建一个控制器,告诉 Rails 这次我们想要生成一个控制器,并且它应该有视图 `rss, html, ascii` 和 `yaml`。以下是结果:
ruby script/generate controller server_access rss html ascii yaml Making a Controller
exists app/controllers/
exists app/helpers/
create app/views/server_access
exists test/functional/
create app/controllers/server_access_controller.rb
create test/functional/server_access_controller_test.rb
create app/helpers/server_access_helper.rb
create app/views/server_access/rss.rhtml
create app/views/server_access/html.rhtml
create app/views/server_access/ascii.rhtml
create app/views/server_access/yaml.rhtml
这个输出稍微复杂一些。我们看到现在我们在 `app/controllers/` 中有一个名为 `server_access_controller.rb` 的控制器,现在在 `app/views/server_access/` 中有文件 `rss.rhtml, html.rhtml, ascii.rhtml` 和 `yaml.rhtml`。让我们看看 `app/controllers/server_access_controller.rb`。
class ServerAccessController < ApplicationController
def rss
end
def html
end
def ascii
end
def yaml
end
end
我们现在看到了另一个类定义,这次是从 `ApplicationController` 继承下来的,以及几个名称与请求的视图名称匹配的空方法。现在将您的浏览器指向 http://localhost:3000/server_access,看看那里有什么 (图 12-2).

图 12-2. 未知操作
发生了什么?我们浏览到了我们的 Rails 应用程序相同的顶级 URL,这次添加了我们的新 `server_access` 控制器的名称,而我们的应用程序提出了抗议。让我们尝试使用 URL http://localhost:3000/server_access/rss。我的结果是 图 12-3。
然后尝试使用 http://localhost:3000/server_access/ascii 作为 URL (图 12-4).

图 12-3. RSS 视图的默认结果

图 12-4. ASCII 视图的默认结果
在后两种情况下,Rails 告诉我们哪个文件提供了请求的 URL 的内容。我们浏览到顶级 Rails 应用程序,并提供控制器名称作为第一个目录,并将视图名称作为 URL 中的下一个元素。这意味着在我们的第一个示例(http://localhost:3000/server_access)中,我们没有提供视图。Rails 将如何解释这一点?
类似于目录中的默认 HTML 文件是 `index.html`,当没有明确提供时使用的默认视图是 `index`。然而,`ServerAccessController` 没有名为 `index` 的方法,因此它提出了抗议。别担心——我们将在下一章的示例应用程序中创建一个 `index` 视图。
然而,URL http://localhost:3000/server_access/ascii 使用了我们已知存在的视图 `ascii`,其结果指示我们查看 `app/views/server_access/ascii.rhtml` 文件。让我们这样做。
ServerAccess#ascii
Find me in app/views/server_access/ascii.rhtml
```这里仅包含足够的 HTML 内容,以提供可见信息。文件告诉我们控制器名称是类,视图名称是方法,使用 # 符号来表示这是一个实例方法(在 Ruby 社区中,# 符号通常用于区分实例方法和类方法)。要编写真正的 Rails 应用程序,我们将开始填充这些空类、方法和 HTML 模板文件,并连接到数据库以检索我们的信息。
由于我们的视图文件是 .rhtml 文件,而不仅仅是静态的 .html 文件,因此我们可以使用与我们在 mod_ruby 上下文中已经看到的类似的技术,将 Ruby 代码直接放入我们的 .rhtml 文件中.^([36]) 这将在下一章中变得至关重要。
^([35]) Reenskaug 博士正在线,请访问 heim.ifi.uio.no/~trygver。
^([36]) 技术上,mod_ruby 使用 eRuby,而 Rails 使用 erb;它们是两种在标记中嵌入 Ruby 代码的不同方法,但在实践中非常相似。
章节回顾
本章有哪些新内容?
-
软件包管理
-
安装 RubyGems
-
安装、更新和查询特定的 gem 软件包文件
-
Rails 基础
-
安装 Rails
-
MVC 模式
-
ORM 基础
-
查看您的第一个 Rails 应用程序
-
生成模型和控制器
-
作为默认视图使用的
index视图 -
使用
#区分实例方法和类方法 -
视图用的 HTML 模板文件
仅凭这些信息,你就可以创建一些执行各种动态任务的应用程序。然而,Rails 的真正力量来自于其访问数据库和操作内容的能力。这将是下一章和最后一章的重点。
第十三章。一个简单的 Rails 项目

在上一章中,你安装了 Rails 并熟悉了 Rails 应用程序内部结构的基本知识。在本章中,我们将创建一个稍微复杂一些的 Rails 应用程序——它从数据库中检索给定数据类型的多个实例,并对这些实例进行迭代以进行展示。我们还将探讨一些在 Rails 中组织代码的更复杂方法。
创建应用程序
对于我们的目的,任何简单的应用程序都足够了。我选择创建一个相册应用程序,它将展示我婚礼的一些照片。它将能够以缩略图的形式列出所有照片,并附带描述性文字,同时还能以更详细的方式展示每张单独的图片。它还将提供导航工具,使用户能够在列表中跳转。所有这些都将通过 HTML 完成,这是 Web 的默认展示格式。此外,该应用程序还将提供一个 RSS 订阅源(我们在currency_converter2.rb中用作数据源的 XML 格式),用于描述所有图片。
初始创建
我们将在适当的目录下使用命令rails photo_album创建我们的应用程序(称为photo_album)。然后输入cd photo_album和ruby script/server来启动应用程序。我们可以通过浏览到 http://localhost:3000 来验证 Rails 是否正在运行,就像我们在第 230 页的查看你的 Rails 应用程序中做的那样。
准备数据库
对于这个应用程序,我将使用 MySQL 数据库。我将假设你可以在你的机器上运行 MySQL,并且能够执行简单的查询。如果不是这样,你可能需要通过一本专门介绍该主题的书籍来复习 MySQL,例如乔治·里斯、兰迪·贾伊·亚加尔和蒂姆·金合著的《管理和使用 MySQL》(O’Reilly,2002 年)。如果你使用的是 MySQL 以外的数据库,我将假设你能够凭借数据库供应商提供的文档以及rubyonrails.org上的文档,自己解决 Rails 应用程序中存在的细微差异。
你可能需要做的一件事是修改config/database.yml文件,尤其是如果你使用的是 MySQL 以外的数据库程序。我不得不编辑socket:的值,使其变为/var/run/mysqld/mysqld.sock。如果你收到错误No such file or directory - /tmp/mysql.sock,那么最可能的原因是套接字描述不匹配。Rails 正在寻找位于/tmp/mysql.sock的 MySQL 套接字文件,你需要将其设置为正确的文件位置。你可以在类 Unix 操作系统中使用以下命令(最好是作为 root 用户)来查找套接字文件的位置:find / mysqld.sock | grep mysqld.sock。
添加数据
照片应用与第十二章 Chapter 12 中的简单结构示例不同,因为它在数据库中有真实数据,我们假设现在正在处理这些数据。管理 Rails 数据(尤其是像我们这样的简单测试数据)的最方便方法之一是使用迁移。Rails 中的 迁移 是 Ruby 中数据的描述,根据需要创建和删除。让我们看看我们的迁移文件 db/migrate/001_create_photos.rb:
class CreatePhotos < ActiveRecord::Migration *Migrations*
❶ COLUMN_NAMES = [:description, :image_path, :title, :photographer]
❷ SAMPLE_PHOTOS = [
{
:title => 'Tonawanda Creek',
:description => 'A waterway in Tonawanda, NY.',
:image_path => '001_creek.jpg',
:photographer => 'Vince',
},
{
:title => 'Travis',
:description => %q[My friend Travis. His wife Laura's head is partly in
view as well.],
:image_path => '002_travis.jpg',
:photographer => 'Vince',
},
{
:title => 'Liam & Ducks',
:description => 'My nephew Liam with some ducks.',
:image_path => '003_liam.jpg',
:photographer => 'Vince',
},
]
❸ def self.up
❹ create_table :photos do |t|
COLUMN_NAMES.each { |c| t.column c, :text }
end
❺ SAMPLE_PHOTOS.each do |sp|
p = Photo.create(sp)
p.save!
end
end
❻ def self.down
drop_table :photos
end
end
在 ❶ 和 ❷,我们为 COLUMN_NAMES 和 SAMPLE_PHOTOS 定义了常量,我们使用它们进行数据插入。COLUMN_NAMES 应该很明显,SAMPLE_PHOTOS 的每个元素都是一个表示数据库记录的哈希,其中每个键是列名称的符号表示,值是数据库字段中的数据。在 ❸,我们定义了 self.up 方法,它包含在我们执行迁移时将运行的代码。
self.up 中的最重要的任务之一是创建表,这是在 ❹ 处完成的。create_table 方法接受一个用于表名的符号参数和一个描述对该表应执行什么操作的块。在我们的情况下,create_table 遍历 COLUMN_NAMES,为表 t 创建一个名为当前 c 值的列,类型为文本。
注意
我们数据库的所有表字段都是文本类型。如果我们有更复杂的数据,具有不同类型,我们可能会用名为 COLUMNS 的哈希来替换 Array COLUMN_NAMES,其中每个键将是列的名称,每个键的值将是列的数据类型。
在 ❺,我们创建了一个名为 p 的新 Photo 实例;它基于 SAMPLE_PHOTOS 的每个成员,我们将其称为 sp。然后我们为每个版本的 p 执行 save!,将其数据存储到数据库表中。在 ❻,我们显示当迁移完成后,:photos 表将被删除。我们使用命令 rake db:migrate 执行迁移。让我们检查结果。
注意
请注意,save!* 带有感叹号,因为它具有破坏性(因为它会保存到数据库)。此外,运行 rake db:migrate 将运行你定义的任何迁移,以使迁移保持最新。我们只有一个,所以只有一个运行*。
== CreatePhotos: migrating
====================================================
-- create_table(:photos)
-> 0.1226s
== CreatePhotos: migrated (0.3359s)
===========================================
迁移成功。我们可以通过查询 MySQL(或你使用的任何数据库)来双重检查。
注意
在提示符下,我输入了我为特定的 MySQL 安装已经设置的密码。你的密码是你已经选择的,或者可能是未设置的。这取决于你在机器上安装 MySQL 的具体方式。
echo 'select * from photo_album_development.photos' | mysql -uroot -p
Enter password:
id description image_path title photographer
1 A waterway in Tonawanda, NY. 001_creek.jpg Tonawanda Creek Vince
2 My friend Travis. His wife Laura's head is partly in view as well.
002_travis.jpg Travis Vince
3 My nephew Liam with some ducks. 003_liam.jpg Liam & Ducks Vince
现在我们可以看到,数据库中已经有了我们 Rails 应用程序使用的数据。让我们继续创建应用程序的其他部分。
创建模型和控制器
正如你在上一章中已经看到的,Rails 使得创建模型、控制器和视图变得非常容易。对于照片相册应用程序,我们将创建一个名为Photo的模型和名为Album和Feed的控制器。
创建 Photo 模型
在photo_album目录中,执行ruby script/generate model photo,这将创建模型文件app/models/photo.rb。
创建 Album 和 Feed 控制器
接下来,在photo_album目录中,执行ruby script/generate controller album index show和ruby script/generate controller feed images。这些命令创建了带有index和show视图的 Album 控制器以及带有images视图的 Feed 控制器,这些视图由app/controllers和app/views子目录中的多个文件实现。
分析应用程序
现在我们已经创建了应用程序的基本框架,让我们来看看它是如何工作的。将这一节视为与之前章节中的代码或工作原理类似。
分析 Photo 模型
我们的照片相册应用程序有一个基本的数据块,由名为 Photo 的模型表示。让我们添加一些代码到现有的内容中,并探索它的功能。编辑app/models/photo.rb以匹配以下内容:
class Photo < ActiveRecord::Base
=begin explain
Closely follows Object-Relational Model, each instance is
also a record in the table called 'photos'.
=end
❶ def next_id()
return Photo.minimum(:id) if last_id?
next_id = @attributes['id'].to_i.succ
next_id.succ! until Photo.find(next_id)
next_id.to_s
end
❷ def prev_id()
return Photo.maximum(:id) if first_id?
prev_id = (@attributes['id'].to_i - 1)
prev_id = (prev_id - 1) until Photo.find(prev_id)
prev_id.to_s
end
private
❸ def last_id?()
@attributes['id'] == Photo.maximum(:id).to_s
end
❹ def first_id?()
@attributes['id'] == Photo.minimum(:id).to_s
end
end
在❶和❷处,我们有next_id和prev_id方法,分别。在它们内部,我们自由使用内置的 Rails 方法。其中之一是minimum方法,它对所有模型都可用;它接受一个符号参数,用于确定该模型的最小状态将基于哪个属性。另一个方法是find方法,它是 SQL 中 SELECT 语句的包装器,它接受用于过滤的特定参数。Rails 中还有@attributes实例变量,它是一个哈希,其键是数据库表中的字段名,其值是模型特定实例的该列内容。例如,代表数据库记录 ID 为2的 Photo 实例的@attributes['id']将等于2。
在❸和❹处,我们还有两个私有谓词,分别告诉我们我们的 Photo 实例是否是具有last_id?和first_id?的那个。我们通过使用已知的最大和最小id值进行一些简单的等式测试来实现这一点。请注意,从maximum和minimum返回的id值是整数,而存储在@attributes中的值是字符串。因此,photo.rb模型根据需要大量使用to_i和to_s方法。
分析控制器
现在我们已经理解了我们的 Photo 模型,我们需要以某种方式与之交互。这就是一个或多个控制器的工作。我们的照片相册应用程序有两个控制器,Album 和 Feed,每个控制器都有自己的视图。
分析 Album 控制器
与我们对照片模型所做的一样,让我们向专辑控制器添加代码并探索它的功能。编辑app/controllers/album_controller.rb以匹配以下内容:
class AlbumController < ApplicationController
=begin explain
This metaprogramming directive allows us to define a specific
helper called FooterHelper in app/helpers/footer_helper.rb
that can be shared among multiple Controllers.
=end
❶ helper :footer
=begin explain
As with HTML files, this is the default implicit behavior.
all_photos is found in app/controllers/application.rb
=end
❷ def index()
@photos = all_photos()
end
=begin explain
Set up any instance variables to be used in the View
or Helper, such as @photo here.
=end
❸ def show()
@photo = Photo.find(params[:id])
end
end
在 MVC 应用程序中,按照惯例,album_controller.rb将负责以与我们照片专辑相关的方式操纵和处理数据。在这种情况下,album_controller.rb的方法通常将重定向到另一个文件中定义的内容,或者简单地提供一个有用的快捷方式。
对于这个演示 Rails 应用程序,我想要一个 HTML 页脚,它能在专辑控制器中的多个页面保持一致性。那么问题来了,如何实现这个功能以及在哪里放置相应的代码。一个答案可能是在每个包含页脚的视图中复制必要的代码,但这不是好的设计。更好的选择是将页脚创建代码放在合适的控制器中,并在需要的地方简单地调用该代码。
然而,有些情况下你可能希望代码位于基础控制器之外。如果你想在多个控制器中实现一个通用功能怎么办?在 Rails 应用程序中,每个控制器都是下一个文件(我们将要查看的app/controllers/application.rb)的子类,所以将代码放在那个文件中是一个选择。另一个选择是使用 Rails 所称的助手(Helpers)。助手是 MVC 框架的附加组件,类似于我们在第十章(第十章)中的to_lang.rb中使用的混合概念。在album_controller.rb的第❶处,我们可以从 RDoc 中看到我们的页脚相关代码在一个名为app/helpers/footer_helper.rb的独立文件中,我们可以在album_controller.rb中通过简单地包含行helper :footer来使用这段代码。如果我们有一个助手在app/helpers/credit_card_authorization_helper.rb中,我们可以在控制器中使用helper :credit_card_authorization的行来使用其代码,依此类推。按照真正的面向对象风格,这允许我们根据问题域或主题在单独的文件中组织代码,在需要的地方使用它们,而不必担心具体的实现。当然,当我们到达app/helpers/footer_helper.rb时,我们将讨论页脚代码的实现,但album_controller.rb不需要关心那么详细的级别。
注意
助手被定义为模块,就像传统的混合一样。这个应用程序在助手文件中有大量的代码,我将在讨论控制器之后简要描述。
除了在❶处包含辅助器之外,我们还在❷和❸处定义了与 index 和 show 视图对应的方法。❸处的 show 方法仅仅是内置 Rails 方法 find 的快捷方式。在这种情况下,它接受一个参数 id,这是传递到 Web 应用程序中的参数,我们可以通过 params[:id] 获取它。这就是我们展示特定请求的图片的方式。❷处的 index 方法(正如我们从第十二章中知道的那样)是当没有明确提供时默认调用的方法。它只是在控制器中创建一个名为 @photos 的实例变量。为此,它调用一个名为 all_photos 的方法,该方法定义在我们的下一个文件 app/controllers/application.rb 中。
注意
Rails 应用程序中的 params 哈希等同于 cgi.params,我们在第十一章中的 simple_cgi.rb 脚本中看到过。
分析应用程序控制器
任何 Rails 应用程序中的 application.rb 文件描述了所有控制器的超类。如果你想要在所有控制器中实现真正通用的行为或特性,这个位置就是放置它们的地方。
注意
请注意,你可以将代码模块化(即,按主题分解为辅助器)并仍然使其通用。只需将代码组织到辅助器中,然后在 app/controllers/application.rb 中通过 helper 行包含所有这些辅助器。很简单。
编辑 app/controllers/application.rb 以匹配以下内容:
# Filters added to this Controller apply to all Controllers in the
application.
# Likewise, all the methods added will be available for all Controllers.
class ApplicationController < ActionController::Base
# Pick a unique cookie name to distinguish our session data from others'
session :session_key => '_photo_album_session_id'
=begin explain
Now all_photos() can be used in any other Controller.
=end
❶ def all_photos()
Photo.find(:all)
end
end
我们在这个文件中所做的所有事情就是定义了❶处的 all_photos 方法。这在某种程度上可能有点愚蠢,因为它只提供了一种稍微简短的方式来调用 Photo.find(:all)。然而,这主要是一个演示应用程序,它确实显示了 all_photos 现在可以在应用程序的任何地方的任何控制器中使用。
注意
会话信息是自动的,它帮助 Rails 区分同时使用应用程序的多个用户。例如,我可以使用相册的 index 视图浏览整个照片列表,而你同时可以更详细地查看相册的第二个照片的 show 视图。
分析 Feed 控制器
相册控制器不是我们唯一的控制器。我还想提供有关这些图像信息的 RSS 源,Feed 控制器就是我们实现这一点的途径。就像 album_controller.rb 一样,它从 app/controllers/application.rb 继承,所以它可以使用 all_photos 方法。
编辑 app/controllers/feed_controller.rb 以匹配以下内容:
class FeedController < ApplicationController
❶ CONTENT_TYPE = 'application/rss+xml'
=begin explain
all_photos() found in app/controllers/application.rb
=end
❷ def images()
@photos = all_photos()
@headers['Content-Type'] = CONTENT_TYPE
end
end
在❶处,我们定义了一个用于 CONTENT_TYPE 的常量,声明了适合 RSS 订阅的内容。然后在❷处,我们声明了唯一的方法 images。它就像 album_controller.rb 一样设置了 @photos 实例变量,并且也设置了 @headers[‘Content-Type’]。正如你可能预想的,@headers 变量是用来定义应用程序输出的 HTTP 头的变量。在继续到视图之前,让我们看看我们的 Helper 文件中发生了什么。
解构 Helper
模型、控制器和视图并不是 Rails 应用中唯一的文件类型。在我们讨论照片模型时,我提到了 Helper 的概念,但现在我们将深入探讨它们。
解构专辑 Helper
编辑 app/helpers/album_helper.rb 以匹配以下内容:
module AlbumHelper
❶ CONFIRM_MESSAGE = %q[Are you sure you want to see the full list?]
NUMBER_OF_ROW_TYPES_FOR_DISPLAY = 3
LISTING_HEADER_COLUMNS =<<END_OF_HERE_DOC
<tr>
<th>Image</th>
<th>Description</th>
</tr>
END_OF_HERE_DOC
❷ IMAGE_STYLE = {
:base => 'margin-bottom: 0.5em; padding: 0.5em;',
:thumb => 'height:48px; width:64px;'
}
=begin explain
Outputs a CSS classname used for prettification.
=end
❸ def row_class_from_index(i)
'row' + ((i % NUMBER_OF_ROW_TYPES_FOR_DISPLAY) + 1).to_s
end
❹ def show_listing_header_columns()
LISTING_HEADER_COLUMNS
end
❺ def show_photo(photo)
image_tag(
photo.image_path,
:alt => "Photo of #{photo.title}"
)
end
❻ def show_thumbnail_for_list(photo)
image_tag(
photo.image_path,
:alt => "Photo of #{photo.title}",
:style => IMAGE_STYLE[:thumb]
)
end
❼ def page_title()
@photo ? @photo.title : controller.action_name
end
❽ def title_with_thumbnail(photo)
[h(photo.title), show_thumbnail_for_list(photo)].join(
ApplicationHelper::HTML_BREAK
)
end
end
在❶处,我们开始定义一些有用的常量,包括我们的一位老朋友,一个具有符号键的哈希表,在❷处。在❸处,我们定义了一个名为 row_class_from_index 的方法,其 RDoc 解释说它仅仅输出表示适当 CSS 类的文本。这允许我们轻松地更改行的 CSS 样式,并且模运算使其重复。在❹处,我们有一个名为 show_listing_header_columns 的方法,它简单地返回相应的常量。在❺处的 show_photo 方法使用了内置的 Rails 方法 image_tag,它将图像的位置(即 img 标签的 src 属性)作为第一个参数。第二个参数是一个哈希表,其键可以是任何额外的 img 属性,其值将用作相应 img 属性的值。位置(即 img src)是 photo 的 image_path,由于所有 img 标签都应该有一个 alt 属性,我们提供了这个属性,并使用基于 photo 的 title 的适当标识字符串。在❻处,我们定义了 show_thumbnail_for_list,这是一个用于展示的 Helper 方法,它与 show_photo 非常相似。它唯一的区别是包含了一个 style 属性,其值是 IMAGE_STYLE[:thumb]。
注意
重构此代码(即在不改变其整体行为的情况下改变其内部结构)的一个简单方法是将 show_photo 和 show_thumbnail_for_list 合并成一个单一的 show_photo 方法,该方法接受一个可选的第三个参数,该参数声明照片是否是缩略图。你可以在 Martin Fowler 的书籍 Refactoring (Addison-Wesley Professional, 1999)中了解更多关于重构过程的信息*。
接下来,在第❼行,我们定义了page_title。如果有@photo存在,page_title将返回那个@photo的title。如果没有@photo,它将回退到controller的action_name。这意味着什么?@photo.title应该是直接的。action_name基本上是 View 的名称。这意味着当我们使用index View(或action_name)浏览,并且还没有使用show View 选择一个特定的照片进行更详细查看时,page_title将简单地是index。
最后,在第❽行,我们定义了title_with_thumbnail。它使用了几个其他方法和常量。Rails 有一个内置的方法叫做h,它为 HTML 展示格式化其输入。例如,h(&)返回&。这在我们的应用中很有用,因为我们有一个标题为Liam & Ducks的照片,但我们不希望这个和号破坏输出的 HTML 有效性。title_with_thumbnail方法使用了我们自制的show_thumbnail_for_list,并用join将其与page_title连接起来,使用我们在ApplicationHelper中定义的HTML_BREAK。
WHERE TO PUT CODE: CONTROLLER OR HELPER (OR ELSEWHERE)?
如您所见,专辑助手的大小明显大于它所帮助的控制器。你什么时候应该在控制器中放代码,什么时候应该在助手中放代码?这是一个好问题。当没有明确的主题划分时,例如下面的footer_helper.rb,回答就变得更加困难。当某件事真正与数据的基本性相关,例如直接与 ids 相关的照片方法时,它可能属于模型。当某件事完全是展示特定时,它可能可以放在适当的视图中。然而,在视图中放置过多的动态内容被认为是不良风格。任何比遍历一组项目更复杂的代码都应该抽象成一个方法,而不是放在视图中本身。
这就留下了控制器或助手的选择。如您所见,我倾向于有一个相当稀疏、极简的控制器——批评者可能会说这使我的助手变得过于繁忙。其他程序员可能会直接在控制器中放置许多方法,几乎不使用助手。还有一些人可能会将任何与缩略图图像相关的内容拆分到另一个名为thumbnail_helper.rb的助手中。
有很多选择。只要你不把与展示相关的代码放在你的模型中,并且你保持你的视图相对没有实际做事情的代码(而主要是包含展示内容的代码),你很可能做得很好。
分析ApplicationHelper
我们已经看到album_helper.rb在ApplicationHelper中期望有一个对HTML_BREAK的定义。让我们看看它是如何做到的。
编辑app/helpers/application.rb以匹配以下内容:
# Methods added to this helper will be available to all templates in the
application.
module ApplicationHelper
HTML_BREAK = '<br />'
end
这里没有太多内容——基本上只是我们预期的常量定义。为什么它在这里定义,而不是在 AlbumHelper 中定义?因为我们还需要在 FooterHelper 中使用它。请注意,虽然控制器自动继承自 ApplicationController,但助手类并不自动继承自 ApplicationHelper。
解构 FeedHelper
编辑 app/helpers/feed_helper.rb 以匹配以下内容:
module FeedHelper
❶ AUTHOR = 'Kevin C. Baird'
DESCRIPTION = %q{Photos from Jenn and Kevin's Wedding}
ICON = {
:url => 'rails.png',
:width => 77,
:height => 69,
}
LANGUAGE = 'en-us'
❷ LINK_OPTIONS_DEFAULTS = {
:only_path => false,
:controller => 'album',
}
❸ LINK_OPTIONS = {
:index => LINK_OPTIONS_DEFAULTS.merge( { :action => 'index' } ),
:show => LINK_OPTIONS_DEFAULTS.merge( { :action => 'show' } ),
}
RSS_OPTIONS = {
'version' => '2.0',
'xmlns:dc' => 'http://purl.org/dc/elements/1.1/'
}
TITLE = 'Baird/Cornish Wedding Photos'
❹ def feed_description()
h( DESCRIPTION )
end
❺ def rss_url_for_image(image)
return url_for( FeedHelper::LINK_OPTIONS[:index] ) unless image
url_for( FeedHelper::LINK_OPTIONS[:show].merge( { :id => image } ) )
end
end
在❶处,我们开始进行我们通常的常量声明。这包括在❷处对 LINK_OPTIONS_DEFAULTS 的声明,它存储了跨多种类型链接的通用信息,以及在❸处对 LINK_OPTIONS 的声明,它使用这些默认值并添加了一对键为 :action 的值,可以是 :index 或 :show,具体取决于如何调用。这些 :index 和 :show 值当然代表了专辑控制器中的视图,因为 LINK_OPTIONS_DEFAULTS 中 :controller 的值表示。FeedHelper 还定义了几个其他常量,这些常量的值对 RSS 流很有用。
在❹处,有一个名为 feed_description 的方法,它只是将 DESCRIPTION 常量的值通过 Rails 内置方法 h 传递,我们之前已经见过,该方法用于格式化 HTML 展示。最后,rss_url_for_image 在❺处是我们围绕 Rails 方法 url_for 构建的包装器,它的工作方式正如其名。(它在 api.rubyonrails.org/classes/ActionView/Helpers/UrlHelper.html#M000484 中有更详细的描述。)如果没有传递 image,rss_url_for_image 返回适用于 :index 视图的 url_for 的 LINK_OPTIONS。如果有 image,rss_url_for_image 返回适用于 :show 视图的 url_for 的 LINK_OPTIONS,并包含要显示的 image 的 :id。
注意
rss_url_for_image 的行为,根据是否有 image 而有所不同,类似于之前提出的将 AlbumHelper.show_photo 和 AlbumHelper.show_thumbnail_for_list 结合起来的潜在融合。
解构 FooterHelper
编辑 app/helpers/footer_helper.rb 以匹配以下内容:
module FooterHelper
❶ BAR_SEPARATOR = %q[ | ]
RSS = {
:icons => %w[feed-icon16x16.png xmlicon.png],
:link_options => {
:action => %q[images],
:controller => %q[feed],
}
}
❷ def show_footer()
'<p id="rails_img_wrapper">' +
[rails_link_to_top, rss_icon_links].join(
ApplicationHelper::HTML_BREAK
) + '</p>'
end
private
❸ def rails_link_to_top()
link_to( *The **`link_to`** and **`image_tag`** Methods*
image_tag(
'rails.png',
:alt => 'Home',
:border => 0,
:id => 'rails_img',
:style => AlbumHelper::IMAGE_STYLE[:base]
), :controller => 'album'
)
end
❹ def rss_icon_links()
RSS[:icons].map do |icon|
link_to(
image_tag(
icon,
:alt => 'RSS Feed',
:class => 'xmlicon'
), RSS[:link_options]
)
end.join(BAR_SEPARATOR)
end
end
在❶处,我们定义了两个常量,BAR_SEPARATOR 和 RSS。BAR_SEPARATOR 常量是一个简单的分隔符,用于展示,而 RSS 是另一个包含符号键的哈希,分别详细说明了与 :icons 和 :link_options 相关的信息。在这些定义中,我使用了 %q[] 而不是单引号来定义 BAR_SEPARATOR,仅作为一个提醒,选项是可用的.^([37])
在第❷行,我们的主要公共方法 show_footer 只返回私有方法 rails_link_to_top 和 rss_icon_links 的输出,这些输出通过我们之前看到的 HTML_BREAK 常量连接,并且全部包裹在一个具有 id rails_img_wrapper 的 HTML 段落标签中。我们以传统的方式创建段落标签——通过输出纯文本。你仍然可以在 Rails 中这样做,尽管 url_for 和 image_tag 等方法的可用性使得这种做法变得不常见。
那么,私有方法都做了些什么呢?第❸行的 rails_link_to_top 方法只是使用 Rails 内置的 link_to 创建了一个链接,它接受链接参数和一个描述要使用的 :controller 的 Hash:在这个例子中是 'album'。如果需要,这个 Hash 也可以描述 :action。第❹行的 rss_icon_links 方法将一个操作映射到 RSS[:icons] 的每个成员上。这个操作也是一个对 link_to 的调用,其中链接的图像是 RSS[:icons] 中的当前元素(称为 icon),而描述 :controller 和 :action 的 Hash 总是 RSS[:link_options]。map 操作的结果数组然后通过 BAR_SEPARATOR 连接。
拆解专辑控制器的视图
现在,让我们转向视图。由于我们已经在模型、控制器或各种助手中的方法中定义了应用的大部分内容,所以我们的视图应该相当稀疏。视图文件与之前看到的文件不同,因为它们是 .rhtml 文件(类似于 mod_ruby_demo.rhtml),而不是纯 Ruby .rb 文件。这也是不建议在视图文件中包含太多动态 Ruby 内容的原因之一(除了良好的应用设计原则之外)。在 Ruby 中调试 Ruby 相对容易,但当你需要在 Ruby 和 HTML 之间来回切换时,这就不那么容易了。
拆解索引视图
编辑 app/views/album/index.rhtml 以匹配以下内容:
❶ <!--
row_class_from_index()
show_listing_header_columns()
show_thumbnail_for_list()
all in app/helpers/album_helper.rb
@photos derived from AlbumController's index method
-->
<h1>Listing photos</h1>
<table>
❷ <%= show_listing_header_columns() %>
❸ <% @photos.each_with_index do |photo,i| -%>
<tr>
❹ <td class="<%= row_class_from_index(i) %>">
❺ <%=
link_to(
title_with_thumbnail(photo),
:action => 'show',
:id => photo.id
)
%>
</td>
❻ <td class="<%= row_class_from_index(i) %>">
❼ <%= photo.description %>
</td>
</tr>
❽ <% end %>
</table>
<hr />
❾ <%= show_footer %>
在第❶行,我们有 HTML 注释解释了我们在这个文件中使用的函数的位置。然后文件继续使用普通、不出所料的 HTML。你可能想知道为什么没有 <html>, <head> 或 <body> 标签。对于这个答案,你将不得不等到我们介绍布局的概念,并在本章后面描述 app/views/layouts/album.rhtml 文件。
Ruby 首次出现在第❷行,是一个对 show_listing_header_columns 的调用,我们知道(并且我们的 HTML 注释提醒我们)这个方法定义在 app/helpers/album_helper.rb 中。这允许视图调用一个名字就能说明其功能的函数,而不必担心其实现。接下来,在第❸行,我们将遍历 @photos 中的每个 photo,以及它的索引,我们将称之为 i。你会注意到 each_with_index 行以 -%> 结束 Ruby 转义,而不是仅仅 %>。这告诉 Rails 在解释输出中不应该有自动的换行符。这里不是特别关键,但你可以想象这在一个 <pre> 标签内可能非常有用。
我们对每个photo做什么?我们将在一个表格中展示它,将row_class_from_index(i)的 CSS 类应用于每个❹的<td>元素。该<td>元素中的内容将是始于❺的多行 Ruby 调用的结果。它的值是对title_with_thumbnail上的link_to调用的结果,指向‘show’ :action并显示由photo.id标识的照片。
除了缩略图<td>单元格之外,我们还想再有一个包含照片描述的<td>单元格。这始于❻,通过另一个对row_class_from_index的调用。它的<td>单元格只包含photo.description在❽。然后我们用end在❸关闭each_with_index调用。最后,在❾我们调用show_footer,这已经在footer_helper.rb中讨论过。
分析 show 视图
现在我们来看看show视图,它以更详细的方式显示特定的照片。编辑app/views/album/show.rhtml以匹配以下内容:
<!--
image_tag is built in to Rails
prev_id and next_id are in app/models/photo.rb
show_photo is in app/helpers/album_helper.rb
-->
<table id="dark_bg">
<tr>
<td>
<div class="photo">
❶ <%= show_photo( @photo ) %>
</div>
</td>
<td class="desc_wrapper">
<div class="description">
❷ <h1><%= h(@photo.title) %></h1>
❸ <p class="description"><%= @photo.description %></p>
</div>
</td>
</tr>
</table>
<hr style="clear:both;" />
<ul class="navlinks">
❹ <li><%=
link_to 'First',
:action => 'show',
:id => Photo.minimum(:id)
%></li>
❺ <li><%=
link_to 'Previous',
:action => 'show',
:id => @photo.prev_id %></li>
❻ <li><%=
link_to 'Next',
:action => 'show',
:id => @photo.next_id %></li>
❼ <li><%=
link_to 'Last',
:action => 'show',
:id => Photo.maximum(:id) %></li>
<!-- You have the option of some GUI helpers in the optional parameters
hash -->
<!-- like :confirm for a JS confirm box -->
❽ <li><%= link_to(
'Full List',
{ :action => 'index' },
{ :confirm => AlbumHelper::CONFIRM_MESSAGE }
) %></li>
<!--
See RSS[:link_options] in app/helpers/footer_helper.rb
for how to link across multiple Controllers
-->
</ul>
❾ <%= show_footer %>
我们再次从一些 HTML 提醒注释开始。真正的 Ruby 代码首次出现在❶——这是一个从 AlbumHelper 调用show_photo的方法,传递了@photo,它是与用于调用show视图的id参数匹配的特定照片实例。然后在❷,我们通过h格式化方法传递@photo的title,在❸,我们将@photo的description包裹在一个适当类别的段落标签中。
在一条水平线下面,我们有一个无序列表,列表中的每一项都是对link_to方法的调用。在❹,我们提供了一个名为‘First’的链接,它通过minimum :id显示照片。在❺,链接目的地通过文本‘Previous’显示具有前一个id的照片,在❻,目的地通过文本‘Next’显示具有next_id的 Photo。在❼,它显示‘Last’照片,定义为具有最大id的照片。
到目前为止的所有链接都格式化为简单的<a href>样式,但还有其他选项可用。例如,Rails 提供了许多内置方法来执行一些常见的 JavaScript 操作。其中之一是确认框,它会通过一个询问你确认某些问题的框来中断你的浏览。我确信你在浏览时已经见过它们,但图 13-1 显示了在 Ubuntu 的 Epiphany 浏览器中的一个。
在❽处编写的代码为我们创建了这个框。选择取消会使它不执行任何操作,而选择确定则会使它像标准链接一样继续执行,这次链接到``index :action。描述这一功能的代码还向link_to添加了第二个 Hash,键为:confirm,值为从AlbumHelper::CONFIRM_MESSAGE常量中获取。请注意,此链接提供了用于 Hash 的括号分隔符,以显示哪些对与哪个 Hash 相对应。确认框链接的文本是‘完整列表’,因为它带我们回到了index视图。在更多的 HTML 注释之后,我们看到在❾处调用了show_footer。

图 13-1. 由 Rails 自动生成的确认框
分析 Feed 控制器中的图像视图
通常,我所说的关于 Album 控制器视图的内容也适用于 Feed 控制器视图。相同的基本设计原则适用。然而,有一些细微的差别。Feed 控制器更轻量级,责任更少。它也只有一个视图,我们即将探讨。
如前所述,Album 不是我们唯一的控制器。我们还想使用 Feed 在 RSS Feed 中显示我们的图像。让我们看看这是如何实现的。编辑app/views/feed/images.rxml以匹配以下内容。请注意,文件扩展名是.rxml 而不是.rhtml,因为我们正在为 RSS Feed 创建 XML 而不是常规 HTML。
=begin explain
The various FeedHelper:: Constants are in app/helpers/feed_helper.rb,
as are the feed_description() and rss_url_for_image() methods.
=end
❶ xml.instruct! *Outputting XML*
❷ xml.rss( FeedHelper::RSS_OPTIONS ) do
❸ xml.channel do
xml.title FeedHelper::TITLE
xml.language FeedHelper::LANGUAGE
xml.link rss_url_for_image( nil )
xml.pubDate Time.now
xml.description feed_description()
❹ xml.image do
xml.title FeedHelper::TITLE
xml.link rss_url_for_image( nil )
xml.url FeedHelper::ICON[:url]
xml.width FeedHelper::ICON[:width]
xml.height FeedHelper::ICON[:height]
xml.description feed_description()
end
❺ @photos.each do |image|
xml.item do
xml.title image.title
xml.link rss_url_for_image( image )
xml.description h( image.description )
xml.pubDate Time.now
xml.guid rss_url_for_image( image )
xml.author FeedHelper::AUTHOR
# image.photographer could also be the author
end
end
end
end
此文件使用了一个名为 XML::Builder 的项目(rubyforge.org/projects/builder),这是一个内置到 Rails 中的 XML 生成库。在❶处,我们调用xml.instruct!,这开始了 XML 文档。由于 XML::Builder 与 Rails 的关系,xml变量是可用的,我们事先不需要做任何事情。然后在❷处,我们通过调用xml.rss并使用FeedHelper::RSS_OPTIONS来设置我们的 RSS Feed。每个 RSS Feed 都有一个channel,我们在❸处建立它,还有一个相关的image,我们在❹处定义它。
我们 RSS Feed 中的内容(或文章)是每张照片及其相关的描述性文本。在❺处,我们使用FeedController的images方法中的@photos,逐个遍历它们,依次称它们为image。然后我们创建一个xml.item,传递一个定义每个适当特征的块。请注意,其中许多都可以表示为一个常量(例如FeedHelper::TITLE)或方法调用的结果(例如rss_url_for_image,带或不带image参数)。
分析 Album 控制器布局
记得我第一次谈到app/views/album/index.rhtml并提到该文件缺少某些预期的 HTML 内容,比如<html>标签吗?花点时间思考一下。你可能会期望这样的内容出现在每个视图的.rhtml 文件中,但那会产生大量的重复内容。重复正是程序员试图避免的,因此我们应该找到其他解决方案来解决这个问题。一种方法是在控制器或助手中定义方法,如doctype_tag, html_tag, head_tag等,类似于 Rails 已经为我们提供的image_tag方法。
这将是一个合理的做法,但不可避免的是,正在创建的内容格式与特定类型的视图紧密绑定,最常见的是 HTML。我们已经有.rhtml 文件来专门实现这个目的。我们难道不应该找到一种方法来拥有某种.rhtml 模板吗?
这正是布局的作用。它们将视图输出包裹在模板中。编辑app/views/layouts/album.rhtml以匹配以下内容:
<!--
This (app/views/layouts/album.html)
is a "wrapper" that encloses all Views for the
Album Controller.
-->
<html lang="en-us">
<head>
<title>
❶ Album: <%= page_title %>
</title>
❷ <%= stylesheet_link_tag('master') %> *CSS Link Tag*
❸ <%= stylesheet_link_tag(controller.action_name) if controller %>
</head>
<body>
<!--
"yield :layout" outputs the View's results, whichever it is.
-->
❹ <%= yield :layout %>
</body>
</html>
在❶处,我们使用app/helpers/album_helper.rb中的page_title来设置<title>。在❷和❸处,我们使用 Rails 内置的stylesheet_link_tag方法来包含样式表。我们总是想要master.css样式表,如果控制器有action_name,我们也想要相关的样式表。最后,在❹处,我们看到yield :layout。这会做什么?
我们已经知道,在接收block_argument参数的方法中使用yield与block_argument.call的作用相同。这与此类似,只是请求的视图的输出取代了块。这相当于说总是将请求的内容包裹在我里面,并将请求的内容放置在这个位置。
注意
如果你已经了解 Rails,你知道还有其他解决这个问题的方法,比如使用部分视图,这种方法是从底部向上解决问题,而不是从顶部向下。如果你感兴趣,可以阅读wiki.rubyonrails.org/rails/pages/Partials。
使用 CSS
master.css 样式表在整个应用程序中都被使用,每个动作都会自动包含一个与同名的样式表(参见 Dissecting the Album Controller’s Layout 中app/views/layouts/album.rhtml的❷和❸)。当我们使用show视图浏览时,我们会使用show.css样式表,例如。如果你对 CSS 感兴趣,你可以在像csszengarden.com这样的网站上了解更多。样式表master.css, public.css和index.css可以在本书的网站上下载。
^([37]) 例如,你可能想使用%q[]而不是引号,如果要定义的字符串包含了引号;一些程序员可能只是更喜欢使用%q[]。
使用应用程序
到目前为止,我们已经有了一个照片专辑应用程序,以及对其组件部分如何组织以及它们如何单独工作以及作为整体一部分工作的相当不错的理解。现在让我们看看这个应用程序的实际运行情况,首先在网页浏览器中打开它。
图 13-2 展示了当使用 Epiphany 网络浏览器查看时,专辑控制器的默认动作的外观。在其他图形浏览器(如 Firefox 或 Internet Explorer)中,其外观应该只有细微的差异。图 13-3 展示了专辑控制器 show 视图显示的第一张图片的外观。
图 图 13-4 和 图 13-5 展示了 Feed 控制器的 images 视图的外观。图 图 13-4 再次展示了它在 Epiphany 浏览器中的样子,而 图 13-5 展示了它在 Akregator 中的样子,Akregator 是一个专门设计用于查看 RSS 源的程序。

图 13-2. 浏览专辑控制器

图 13-3. 显示专辑控制器中的第一张图片

图 13-4. 使用 Epiphany 浏览 Feed 控制器中的 RSS 图片

图 13-5. 使用 Akregator RSS 阅读器浏览
学习更多关于 Rails
本章只是触及了 Rails 的表面。我仅仅描述了一些 Helper 方法(如 image_tag 和 link_to),甚至还没有涉及到 ActiveRecord 在多个模型之间创建关系、Rails 中的单元测试、Rails 中的表单、用户创建和认证、会话处理等话题。即便如此,这已经是试图专注于 Ruby 而非 Rails 的 Ruby 书籍中最长的章节——而且我甚至不得不在上一章中描述 Rails 应用程序的基本结构。在 Rails 中有很多东西要学习,你可以在 rubyonrails.org 上阅读更多内容。只是不要忘记,Ruby 除了 Rails 之外还有很多东西可以提供,正如本书的其他章节所展示的那样。
章节回顾
本章有哪些新内容?
-
使用 Rails 与 MySQL
-
使用迁移文件添加数据
-
创建模型
-
创建多视图控制器
-
向模型和控制器添加方法
-
ApplicationController 超类
-
使用助手
-
ApplicationHelper 模块
-
MVC 与控制器和助手的关系
-
将视图创建为 .rhtml 文件
-
使用 Rails 内置的 Helper 方法进行常见的 JavaScript 操作
-
使用布局并整合 [view].rhtml 的结果
-
使用按视图类型模块化的样式表
我希望这本书已经给你提供了一些关于 Ruby 编程的有用信息。我试图发挥我认为该语言最大的优势:可读性、高度抽象(以及扩展这种抽象到更高层次的极大便利性)、内部一致性,以及概念上的优雅。所有这些 Ruby 的特性,无论你是否在 Rails 中工作,都依然存在。如果你发现自己正在使用 Rails,不要忘记,除了 each 之外,你仍然可以使用 map 和 inject。
感谢阅读。
附录 A. Ruby 与其他语言如何比较?
描述某物的最佳方式之一就是谈论它不是什么。本附录描述了 Ruby 与其他一些流行语言之间的相似之处和不同之处。
C
尽管它不是最古老的语言,但在许多程序员的脑海中,C是语言的鼻祖。为了讨论的目的,我们将更多地关注 Ruby 和 C 之间的差异,而不是相似之处。C 是过程式的,这意味着其程序被设想为一系列按时间顺序逐步执行的指令:这样做,然后这样做,然后那样做。C 既不是面向对象的,也不是函数式的,尽管与之密切相关的语言 C++和 Objective C 是面向对象的。C 无疑有函数,这些是可重用的代码片段,接受各种输入并返回各种输出,但它们通常不是纯函数式的。C 函数通常依赖于除了函数严格输入之外的其他信息,并且它们有副作用,这意味着对给定函数的第二次调用不一定会产生与第一次调用相同的结果。C 函数通常返回表示其成功的值,依靠副作用来完成其主要目的。
C 语言相较于 Ruby 的优势包括执行速度更快、更被更多人熟悉、更广泛的部署,以及其代码编译而非解释带来的额外好处。(注意,编译与解释之争是其自身的圣战。)当一个 C 程序编译时,你知道它至少通过了可靠性方面的一个特定基准。
Ruby 相较于 C 的优势包括更快的开发周期、灵活性、概念上的优雅性和可配置性。如果你对结合 Ruby 和 C 的优势感兴趣,可以从探索 RubyInline 项目开始,该项目作为rubyinlinegem 提供,或通过www.zenspider.com/ZSS/Products/RubyInline获取.^([38])
C 还有所谓的强静态类型。这意味着 C 中的变量被定义为某种数据类型(例如整数 42),并且它们将始终保持这种类型。这就是静态部分。如果你想要将整数 42 表示为浮点数,例如 42.0,你需要通过类型转换函数来传递它。Ruby 也是强类型(要求程序员在使用之前将整数转换为浮点数),尽管它是动态的,这意味着变量可以改变类型。C 缺乏类似 Ruby 的 irb。
^([38]) 我很幸运在 2005 年的 RubyConf 上看到了 RubyInline 的作者 Ryan Davis 的演示。这是一段非常令人印象深刻的代码,我强烈推荐给任何对结合 Ruby 和 C 感兴趣的人。
Haskell
Haskell 被列入这些语言列表中,表明函数范式对这本书来说是多么重要。Haskell 是一个由委员会设计并于 1998 年发布的纯函数语言。它有几个迷人的特性,最值得注意的是 惰性求值,这意味着给定数据的值不需要在需要使用之前(甚至有意义)就知道。这允许程序员用无限序列来定义事物,例如所有整数的集合。
Haskell 是用于 Pugs 的语言,它是新 Perl 6 语言的实现,有些人认为这比 Perl 本身更能吸引人们对 Haskell 的注意。Haskell 有一个类似于 Ruby 的 irb 的交互式环境,但它不允许在导入的外部文件中定义函数,而 irb 允许对类、方法和函数进行完整定义。像 C 语言一样,Haskell 既有强类型也有静态类型。Haskell 是一种非常适合教授纯函数技术的优秀语言,也适合通用编程。你可以在 haskell.org 上了解更多关于它的信息。
Java
为了这次讨论的目的,Sun Microsystems 的 Java 是一种中等复杂性的面向对象语言,类似于 C 语言。Java 既有强类型也有静态类型。在某种程度上,Java 比 Ruby 更面向对象:在 Java 中编码的程序员必须为自己的程序使用面向对象范式。另一方面,Java 在实现其内置功能方面比 Ruby 更少面向对象。例如,要在 Java 中获取整数 100 的绝对值,你会这样做:
Math.abs(100)
这意味着程序员想要使用一个与 Math 相关的名为 abs 的方法,对整数 100 执行该方法的功能。在 Ruby 中,等效的操作如下所示:
100.abs
使用 Ruby 的方法,程序员只需让数字 100 报告其自身的绝对值。这种方法在 Ruby 中很常见,它假设每份数据都知道如何最好地处理对其自身的操作。这种方法的优点是,相同的符号可以用于不同的(但概念上相关的)操作。例如,+ 符号表示数字的简单加法,而对于字符串,则表示连接,如第一章所述(Chapter 1)。
Java 通常也是编译的,而不是解释的,一般使用一种称为 字节码 的特殊编译类型,这与 Parrot、Python 和 Ruby 2.0 等项目使用的方法相同.^([39]) 还有一个有趣的项目叫做 JRuby (jruby.codehaus.org),这是一个用 Java 编写的 Ruby 实现。Java 的详细信息可以在 java.sun.com 上找到。Java 规范是由 Guy Steele 编写的,尽管他没有创建这种语言本身(Lisp 同行 James Gosling 创建了它)。当他编写 Java 规范时,Steele 已经是 Scheme 的共同创造者,而 Scheme 被认为是 Lisp 最概念上纯粹的版本。
^([39]) 你可以在 www.parrotcode.org 上了解更多关于 Parrot 的信息;我将在附录的后面部分介绍 Python。
Lisp
作为 Ruby 最显赫的祖先之一,Lisp 在本节中应占有一席之地。Lisp 被称为“最聪明的滥用计算机的方式。”^([40]) 它应该被正确理解为一系列语言或语言规范,而不是单一的语言。它也足够多样化,足以抵抗许多分类尝试,但就我们的目的而言,Lisp 可以主要被视为具有弱、动态类型的函数式语言。著名的 Lisp 程序员 Paul Graham 在 paulgraham.com/diff.html 描述了“Lisp 的不同之处”,值得注意的是,Ruby 与 Lisp 共享所有这些特性,除了 Lisp 的独特语法。
Lisp 的语法(或者说是其缺乏语法)可能是它最引人注目的特征,乍一看。Lisp 代码由被括号包围的数据块组成。这些数据块被称为 列表,它们赋予了 Lisp 这个名字(这个名字来源于 LISt Processing).^([41]) 语法能够以语言内部的数据结构表示,这是 Lisp 最具定义性的特征。可以说,如果另一种语言实现了这一相同功能,那么它本身可能不是一个独特的语言,而仅仅是 Lisp 的另一种方言.^([42]) 可以提出一个很好的论点,Ruby 尝试从 Lisp 中汲取概念,并在一个更用户友好的框架中呈现它们,同时利用面向对象的好想法以及良好的文本处理。Matz 说:“有些人可能会说 Ruby 是 Lisp 或 Smalltalk 的拙劣模仿,我承认这一点。但 Ruby 对普通人来说更友好。”^([43]) Ruby 在很大程度上得益于 Lisp,以及许多其他语言,它强大的文本处理能力很大程度上归功于接下来的这种语言,Perl。
^([40]) 荷兰计算机科学 Edsger Dijkstra 说的是这句话;你可以在 Paul Graham 编译的这些以及其他有趣的引语中找到 www.paulgraham.com/quo.html。
^([41]) 批评者认为这个特性使得 Lisp 成为了 Lots of Irritating Superfluous Parentheses 的更合适的缩写。Perl 的创造者 Larry Wall 建议 Lisp 代码的美学吸引力就像“燕麦粥里夹杂着指甲剪屑”。显然,Lisp 在公共关系方面存在一些问题。
^([42]) 相关联,Philip Greenspun 在 philip.greenspun.com/ 的第十条编程规则是:“任何足够复杂的 C 或 Fortran 程序都包含了一个临时、非正式指定的、充满错误且运行缓慢的半 Common Lisp 实现。”
^([43]) 这句话也来自 Paul Graham 的网站,www.paulgraham.com/quotes.html。
Perl
Perl 以其口号 TMTOWTDI 而闻名,这是一种极其灵活且实用的语言——它无疑对 Ruby 和编程,总体上产生了影响。TMTOWTDI 代表 There’s More Than One Way To Do It,这是一种设计哲学,Perl 确实体现了这一点。它在强调正则表达式的重要性方面的作用足以让它获得历史地位。Perl 由 Larry Wall 于 1987 年发明,其主要目的是执行类似于 shell、awk 和 sed 等以 Unix 为中心的语言的职能。Perl 专注于 Unix 系统管理这类任务的易用性,并且在网络应用中也得到了广泛的使用。Perl 的初始设计是过程式的,但近年来它越来越多地朝着函数式方向发展。它也可以用于面向对象编程,这并不是它原本的设计目的,也不是它最理想的应用场景——但即便在 Perl 中这种可能性都存在,这也证明了其灵活性。
Perl 正在开发新版本(参见 Haskell 下的 Pugs 讨论),这让我想起了 Ruby。对我来说,这算是一种赞扬。Perl 具有弱动态类型,和 Ruby 一样,它是解释型的。它被称为 瑞士军刀链锯 和编程语言的 吉普车,可以在 perl.com 找到。
PHP
PHP 是另一种流行的解释型语言,它使用弱动态类型,非常适合网络应用。事实上,有些人错误地认为 PHP 只能用于网络应用。从技术上讲,它是一个完整、通用的编程语言,尽管它有几个特性使其在网页开发中更为流行。这里讨论的许多语言都可以在网页内嵌入代码,只要代码用适当的标签与页面其他部分区分开来。PHP 的不同之处在于,即使它用于命令行任务,这些任务永远不会接近 web 服务器,它也必须用这样的标签来界定。它具有弱动态类型和解释性。
PHP 和 Ruby 都具有相对较多的内置函数这一特点。尽管 PHP 具有广泛的适用性,但其主要焦点无疑是让相对缺乏经验的程序员能够快速生成动态网页内容。PHP 的网络集成是其最频繁使用(如果不是其设计)的一个重要部分,因此当与它们自己的网络集成系统(如 Perl 和 Mason,或 Ruby 和 eRuby 或 Rails)结合使用时,通常最好将其与其他编程语言进行比较。PHP 的创造者拉斯马斯·勒德夫(Rasmus Lerdorf)于 1995 年开始着手开发最终成为 PHP 的项目。你可以在 php.net 上了解更多信息。
Python
Python 是一种与 Ruby 非常相似的语言。其创造者,“终身仁慈独裁者”吉多·范罗苏姆(Guido van Rossum)在 1990 年代初发明它时,以英国喜剧团体蒙提·派森(Monty Python)的名字命名。它具有与 Ruby 非常相似的强大动态类型和类似的简洁语法,这得益于其使用具有语义意义的空白。在 Python 中,函数、代码块或语句不需要有显式的行尾标记(通常是分号)。Ruby 使用结束标记的方式也相当少,尽管不如 Python 的程度。
Python 和 Ruby 在一个领域中的差异非常显著,那就是灵活性。Python 明确拥抱了“应该只有一个——最好是只有一个——明显的方法来做这件事”的想法,当输入命令 import this 时,Python 的交互式提示符会报告这一想法以及其他想法。
Python 和 Ruby 之间有一种有趣的关系。Python 最近添加了几个新功能,这些功能大量借鉴了 Ruby 和 Lisp 的思想,并且在此写作时,与 Ruby 相关的书籍销量也普遍超过与 Python 相关的书籍销量。显然,我希望能继续看到与 这本书 相关的趋势。Python 和 Ruby 好像是一对有争议的兄弟姐妹,希望他们能继续相互挑战和激励,取得卓越的成就。Python 主义者居住在 python.org。
Smalltalk
Smalltalk 是一种由艾伦·凯(Alan Kay)领导的团队在施乐帕克研究中心(Xerox PARC)发明的完全面向对象的编程语言。虽然 Simula 通常被认为是第一种面向对象的编程语言,但 Smalltalk 在推广面向对象方面发挥了重要作用。Ruby 在两个主要领域大量借鉴了 Smalltalk:一切皆对象的概念,以及方法作为传递给对象的信使的概念。
说什么“一切都是对象”是什么意思?我们在 Java 讨论中简要地提到了这一点。在许多语言中,真正的“对象”状态是为更大或更复杂的事物保留的,而语言的基本部分不被视为对象。这就是为什么在 Java 中,编码者必须从 Math 命名空间内部调用abs方法来获取整数 100 的绝对值。Ruby 从 Smalltalk 继承的“一切都是对象”的理念,使得询问整数 100 报告其自己的绝对值的方法更加一致。我们还在第十章的to_lang.rb脚本中探讨了将方法实现为消息的好处。第十章。
查看 Smalltalk 的更多信息,请访问smalltalk.org和squeak.org。
Ruby 与其他语言的比较总结
总结来说,Ruby 是解释型语言,而不是编译型语言,这使得它在开发时快速,但在运行时较慢。它是面向对象和函数式的,而不是过程式的。它具有强大的动态类型,而不是弱类型或静态类型,并且它只为布尔测试自动转换类型。它内置了正则表达式支持,并且语法清晰易读。在理论和实践中,它都是一种通用编程语言。它拥有大量的内置方法,并且允许你轻松地添加、修改和扩展这些方法。像其祖先 Lisp 一样,Ruby 有一个真实且可用的nil值,并且它将除了nil和false之外的所有值视为true。Ruby 编程也非常有趣,并且不会妨碍你。
[31] ↩︎


浙公网安备 33010602011771号