None-全-

None(全)

前言

没有标题的图片

从远处看似复杂的东西,仔细看往往会发现其实非常简单。

初看之下,学习如何使用 Ruby 似乎相当简单。世界各地的开发者都认为 Ruby 的语法优雅而直接。你可以以非常自然的方式表达算法,然后只需在命令行中输入ruby并按下回车,你的 Ruby 脚本就开始运行了。

然而,Ruby 的语法是表面上简单的;实际上,Ruby 运用了来自复杂语言如 Lisp 和 Smalltalk 的复杂思想。在此基础上,Ruby 是动态的;通过元编程,Ruby 程序可以自我检查并修改自己。在这层看似简单的外壳下,Ruby 是一个非常复杂的工具。

通过仔细观察 Ruby——通过学习 Ruby 本身是如何在内部工作的——你会发现一些重要的计算机科学概念支撑着 Ruby 的众多特性。通过学习这些概念,你将更深入地理解在使用该语言时,内部到底发生了什么。在这个过程中,你将了解到构建 Ruby 的团队是如何期望你使用这门语言的。

Ruby 显微镜下将向你展示,当你运行一个简单的程序时,Ruby 内部发生了什么。你将学到 Ruby 是如何理解和执行你的代码的,并且通过大量的图示,你将建立一个关于 Ruby 在创建对象或调用块时所做的事情的心理模型。

适合谁阅读本书

Ruby 显微镜下并不是一本面向初学者的 Ruby 学习指南。我假设你已经知道如何编写 Ruby 程序,并且每天都在使用它。已经有许多优秀的书籍教 Ruby 基础知识;世界不需要再来一本。

尽管 Ruby 本身是用 C 语言编写的,这是一种混乱且底层的语言,但阅读本书并不需要任何 C 语言的编程知识。Ruby 显微镜下将为你提供 Ruby 工作原理的高级概念理解,而无需你了解如何编写 C 语言程序。在本书中,你会看到数百个图示,这些图示使得 Ruby 内部实现的底层细节变得易于理解。

注意

熟悉 C 的读者将会发现一些 C 代码片段,这些片段能更具体地帮助你理解 Ruby 内部发生了什么。我还会告诉你这些代码的来源,帮助你更容易地开始研究 C 代码。如果你对 C 代码的细节不感兴趣,可以跳过这些部分。

使用 Ruby 来进行自我测试

不管你的理论多么美丽,不管你多么聪明。如果它与实验不符,那它就是错的。

— 理查德·费曼

想象一下,如果整个世界像一个大型计算机程序一样运作。为了解释自然现象或实验结果,像理查德·费曼这样的物理学家只需查阅这个程序。(科学家的梦想成真!)但当然,宇宙并非如此简单。

幸运的是,要发现 Ruby 是如何工作的,我们只需要阅读它的内部 C 源代码:这是一种描述 Ruby 行为的理论物理学。就像麦克斯韦方程式解释电力和磁性一样,Ruby 的内部 C 源代码解释了当你传递一个参数给方法或将模块包含在类中时发生了什么。

然而,像科学家一样,我们需要进行实验,确保我们的假设是正确的。在了解 Ruby 内部实现的每一部分之后,我们将进行实验,使用 Ruby 来测试自己!我们将运行小的 Ruby 测试脚本,查看它们是否产生预期的输出,或者是否以我们预期的速度运行。我们将找出 Ruby 是否真的按理论中所说的那样表现。而且,由于这些实验是用 Ruby 编写的,你可以自己尝试。

哪种 Ruby 实现?

Ruby 由松本行弘(Yukihiro “Matz” Matsumoto)于 1993 年发明,原始的标准版本 Ruby 通常被称为 Matz 的 Ruby 解释器(MRI)。本书的大部分内容将讨论 MRI 是如何工作的;基本上,我们将学习 Matz 是如何实现自己的语言的。

多年来,许多 Ruby 的替代实现被编写出来。一些实现,如 RubyMotion、MacRuby 和 IronRuby,是为了在特定平台上运行而设计的。其他实现,如 Topaz 和 JRuby,是用 C 以外的编程语言构建的。有一个版本,Rubinius,是用 Ruby 本身构建的。现在,Matz 本人正在开发一个名为 mruby 的更小版本 Ruby,旨在嵌入到其他应用程序中运行。

我在第十章第十一章第十二章 中详细探讨了 Ruby 的实现 JRuby 和 Rubinius。你将了解它们如何使用不同的技术和理念来实现同一种语言。通过研究这些替代 Ruby,你将对 MRI 的实现获得更多的视角。

概述

第一章中,你将学习 Ruby 如何解析你的 Ruby 程序。这是计算机科学中最吸引人的领域之一:计算机语言如何足够智能,理解你给它的代码?这种智能到底由什么组成?

第二章 解释了 Ruby 如何使用编译器将你的程序转换成另一种语言,然后再运行它。

第三章 讲解了 Ruby 用来运行你的程序的虚拟机。这个机器里面是什么?它是如何工作的?我们将深入研究这个虚拟机,找出答案。

第四章 继续描述 Ruby 的虚拟机,着重讲解 Ruby 如何实现控制结构,如if...else语句和while...end循环。还探讨了 Ruby 是如何实现方法调用的。

第五章 讨论了 Ruby 中对象与类的实现。对象和类是如何关联的?我们在一个 Ruby 对象内部会发现什么?

第六章 考察了 Ruby 模块及其与类的关系。你将学会 Ruby 如何在你的代码中查找方法和常量。

第七章 探索了 Ruby 对哈希表的实现。事实证明,MRI 使用哈希表来存储大量的内部数据,而不仅仅是你在 Ruby 哈希对象中保存的数据。

第八章 揭示了 Ruby 最优雅且最有用的特性之一——块,是基于最初为 Lisp 开发的一个理念。

第九章 讨论了 Ruby 开发者面临的最难题之一。通过研究 Ruby 如何在内部实现元编程,你将学会如何有效地使用元编程。

第十章 介绍了 JRuby,这是一个用 Java 实现的 Ruby 版本。你将学到如何通过 Java 虚拟机(JVM)使你的 Ruby 程序运行得更快。

第十一章 讲解了 Ruby 最有趣、最具创新性的实现之一:Rubinius。你将学会如何定位并修改 Rubinius 中的 Ruby 代码,查看特定 Ruby 方法的工作原理。

第十二章 以垃圾回收(GC)为主题作结,这是计算机科学中最神秘、最令人困惑的话题之一。你将看到 Rubinius 和 JRuby 使用与 MRI 不同的 GC 算法。

通过研究 Ruby 内部实现的所有这些方面,你将更深入地理解当你使用 Ruby 复杂的功能集时,发生了什么。就像安东尼·范·列文虎克在 17 世纪通过早期显微镜首次看到微生物和细胞一样,深入 Ruby 内部,你将发现一系列有趣的结构和算法。加入我,跟随我一探究竟,看看是什么赋予了 Ruby 生命!

第一章. 词法分析与解析

没有标题的图片

你的代码在 Ruby 执行之前,需要经历一段漫长的过程。

你认为 Ruby 在运行你的代码之前会读取并转换多少次代码?一次?两次?

正确答案是三次。每次你运行一个 Ruby 脚本——无论是一个大型 Rails 应用、一个简单的 Sinatra 网站,还是一个后台工作任务——Ruby 都会将你的代码拆解成小块,然后以不同的格式重新组合起来,三次!从你输入 ruby 到开始在控制台看到实际输出之前,你的 Ruby 代码会经历一段漫长的过程——这是一段涉及多种不同技术、技巧和开源工具的旅程。

图 1-1 展示了这个过程的高层次概况。

你的代码在 Ruby 中的执行过程

图 1-1. 你的代码在 Ruby 中的执行过程

首先,Ruby 词法分析你的代码,这意味着它读取你代码文件中的文本字符,并将其转换为 词法单元,即 Ruby 语言中的单词。接下来,Ruby 解析这些词法单元;也就是说,它将这些词法单元组合成有意义的 Ruby 语句,就像我们将单词组合成句子一样。最后,Ruby 将这些语句编译成低级指令,并使用虚拟机稍后执行这些指令。

我将在第三章中介绍 Ruby 的虚拟机,称为“另一个 Ruby 虚拟机”(YARV)。但首先,在本章中,我将描述 Ruby 用来理解你代码的词法分析和解析过程。之后,在第二章中,我将展示 Ruby 如何通过将你的代码翻译成完全不同的语言来编译你的代码。

注意

在本书的大部分内容中,我们将学习 Ruby 的原始标准实现,称为 Matz 的 Ruby 解释器(MRI),它是由松本行弘(Yukihiro Matsumoto)于 1993 年发明的。除了 MRI 之外,还有许多其他 Ruby 实现,包括 Ruby Enterprise Edition、MagLev、MacRuby、RubyMotion、mruby 等等。稍后,在第十章、第十一章 和第十二章中,我们将探讨这两种替代的 Ruby 实现:JRuby 和 Rubinius。

路线图

  • 词法分析:构成 Ruby 语言的单词

    • parser_yylex 函数
  • 实验 1-1:使用 Ripper 对不同的 Ruby 脚本进行标记化

  • 解析:Ruby 如何理解你的代码

    • 理解 LALR 解析算法

    • 一些实际的 Ruby 语法规则

    • 阅读 Bison 语法规则

  • 实验 1-2:使用 Ripper 对不同的 Ruby 脚本进行解析

  • 总结

标记:构成 Ruby 语言的单词

假设你编写了一个简单的 Ruby 程序并将其保存为名为 simple.rb 的文件,见 示例 1-1")。

示例 1-1. 一个非常简单的 Ruby 程序 (simple.rb)

10.times do |n|
  puts n
end

示例 1-2 展示了你在命令行执行程序后看到的输出。

示例 1-2. 执行 示例 1-1")

$ **ruby simple.rb**
0
1
2
3
--*snip*--

当你输入 ruby simple.rb 并按下回车键时,会发生什么?除了常规的初始化、处理命令行参数等,Ruby 做的第一件事是打开 simple.rb 并读取代码文件中的所有文本。接下来,它需要理解这些文本:你的 Ruby 代码。它是如何做到这一点的呢?

在读取 simple.rb 后,Ruby 会遇到如 图 1-2 中所示的一系列文本字符。(为了简化说明,这里只显示第一行文本。)

simple.rb 中的第一行文本

图 1-2. simple.rb 中的第一行文本

当 Ruby 遇到这些字符时,它会将其标记化。也就是说,它通过逐个字符地扫描这些字符,将它们转换成一系列它理解的标记或单词。在 图 1-3 中,Ruby 从第一个字符的位置开始扫描。

Ruby 开始标记化你的代码。

图 1-3. Ruby 开始标记化你的代码。

Ruby 的 C 源代码包含一个循环,每次读取一个字符,并根据该字符的内容进行处理。

为了简化起见,我将标记化描述为一个独立的过程。实际上,我接下来描述的解析引擎在需要新标记时会调用这个 C 语言的标记化代码。标记化和解析是两个独立的过程,但实际上是同时发生的。现在,我们继续看看 Ruby 如何标记化你 Ruby 文件中的字符。

Ruby 意识到字符 1 是一个数字的开始,并继续遍历后面的字符,直到找到一个非数字字符。首先,在图 1-4 中,它找到了一个 0。

Ruby 移步到第二个文本字符。

图 1-4. Ruby 移步到第二个文本字符。

然后再次前进,在图 1-5 中,Ruby 找到了一个句点字符。

Ruby 找到了一个句点字符。

图 1-5. Ruby 找到了一个句点字符。

Ruby 实际上将句点字符视为数字字符,因为它可能是浮动点数值的一部分。在图 1-6 中,Ruby 移步到下一个字符t

Ruby 找到了第一个非数字字符。

图 1-6. Ruby 找到了第一个非数字字符。

现在,Ruby 停止遍历,因为它找到了一个非数字字符。由于句点后没有更多的数字字符,Ruby 将句点视为一个单独标记的一部分,并回退一个字符,如图 1-7 所示。

Ruby 回退一个字符。

图 1-7. Ruby 回退一个字符。

最后,在图 1-8 中,Ruby 将它找到的数字字符转换为程序中的第一个标记,称为tINTEGER

Ruby 将前两个文本字符转换为标记。

图 1-8. Ruby 将前两个文本字符转换为tINTEGER标记。

Ruby 继续遍历代码文件中的字符,将它们转换为标记,并根据需要将字符分组。第二个标记,如图 1-9 所示,是一个单一字符:句点。

Ruby 将句点字符转换为标记。

图 1-9. Ruby 将句点字符转换为一个标记。

接下来,在图 1-10 中,Ruby 遇到词语 times 并创建了一个标识符 token。

Ruby 对词语 times 进行分词。

图 1-10. Ruby 对词语 times 进行分词。

标识符是你 Ruby 代码中的词语,它们不是保留字。标识符通常指的是变量、方法或类的名称。

接下来,Ruby 看到了 do 并创建了一个保留字 token,正如在图 1-11 中所示的 keyword_do

Ruby 创建了一个保留字 token:keyword_do。

图 1-11. Ruby 创建了一个保留字 token:keyword_do

保留字是 Ruby 中具有重要意义的关键字,因为它们提供了语言的结构或框架。它们被称为 保留字,因为你不能将它们作为普通标识符使用,尽管你可以将它们用作方法名、全局变量名(例如 $do)或实例变量名(例如 @do@@do)。

在 Ruby 的 C 代码内部,维护着一个保留字常量表。示例 1-3 显示了按字母顺序排列的前几个保留字。

示例 1-3. 首批保留字,按字母顺序排列

alias
and
begin
break
case
class

parser_yylex 函数

如果你熟悉 C 语言并且有兴趣了解 Ruby 分词代码文件的详细方式,可以查看你版本的 Ruby 中的 parse.y 文件。.y 扩展名表示 parse.y 是一个 语法规则文件,其中包含一系列 Ruby 解析器引擎的规则。(我将在下一节讨论这些内容。)parse.y 是一个非常庞大且复杂的文件,包含超过 10,000 行的代码!

目前忽略语法规则,搜索一个名为 parser_yylex 的 C 函数,约在文件的三分之二处,大约是第 6500 行。这个复杂的 C 函数包含了实际上对你的代码进行分词的代码。仔细查看,你应该能看到一个非常大的 switch 语句,它从示例 1-4 中展示的代码开始。

示例 1-4. Ruby 中的 C 代码,用于从你的代码文件中读取每个字符

 retry:
 last_state = lex_state;
 switch (c = nextc()) {

nextc() 函数 返回代码文件文本流中的下一个字符。可以把这个函数看作是前面图示中的箭头。lex_state 变量 保存关于 Ruby 当前正在处理的代码状态或类型的信息。

大型 switch 语句检查代码文件中的每个字符,并根据字符的不同采取不同的操作。例如,示例 1-5 中显示的代码检查空白字符,并通过跳回到 retry 标签处来忽略它们,该标签位于 示例 1-4 中 switch 语句的上方

示例 1-5。此 C 代码检查代码中的空白字符并忽略它们。

  /* white spaces */
case ' ': case '\t': case '\f': case '\r':
case '\13': /* '\v' */
  space_seen = 1;
--*snip*--
  goto retry;

Ruby 的保留字定义在名为 defs/keywords 的文件中。如果你打开这个文件,你将看到 Ruby 所有保留字的完整列表(参见 示例 1-3 中的部分列表)。keywords 文件由一个名为 gperf 的开源包使用,生成可以快速高效地在表中查找字符串的 C 代码——在这个案例中是保留字的表。你可以在 lex.c 中找到生成的 C 代码,它定义了一个名为 rb_reserved_word 的函数,该函数在 parse.y 中被调用。

有关词法分析的一个最终细节:Ruby 并没有使用 C 程序员常用的 Lex 词法分析工具,这通常与像 Yacc 或 Bison 这样的语法分析器生成工具一起使用。相反,Ruby 核心团队手动编写了 Ruby 的词法分析代码,无论是出于性能原因,还是因为 Ruby 的词法分析规则需要 Lex 无法提供的特殊逻辑。

最后,正如在 图 1-12 中所示,Ruby 将剩余的字符转换为词法单元。

Ruby 完成了对第一行文本的词法分析。

图 1-12。Ruby 完成了对第一行文本的词法分析。

Ruby 会继续逐步处理你的代码,直到它对整个 Ruby 脚本进行了词法分析。此时,它已经第一次处理了你的代码,将其拆解并以完全不同的方式重新组合起来。你的代码最初是一个文本字符流,Ruby 将其转换成一个词法单元流,这些词法单元是它之后将组合成句子的单词。

实验 1-1:使用 Ripper 对不同的 Ruby 脚本进行词法分析

现在我们已经了解了词法分析的基本概念,让我们看看 Ruby 实际上是如何对不同的 Ruby 脚本进行词法分析的。毕竟,不了解 Ruby 是如何工作的,你怎么知道之前的解释是否正确呢?

事实证明,一款名为 Ripper 的工具使得查看 Ruby 为不同代码文件创建的标记变得非常简单。随着 Ruby 1.9 和 Ruby 2.0 的发布,Ripper 类允许你调用 Ruby 用于处理代码文件中的文本的相同标记化和解析代码。(Ripper 在 Ruby 1.8 中不可用。)

示例 1-6") 展示了使用 Ripper 的简单性。

示例 1-6。如何调用 Ripper.lex (lex1.rb)

    require 'ripper'
    require 'pp'
    code = <<STR
    10.times do |n|
      puts n
    end
    STR
    puts code
 pp Ripper.lex(code)

在从标准库中引入 Ripper 代码后,你可以通过将代码作为字符串传递给 Ripper.lex 方法来调用它 。 示例 1-7 展示了 Ripper 的输出。

示例 1-7。Ripper.lex 生成的输出

    $ **ruby lex1.rb**
    10.times do |n|
      puts n
    end
 [[[1, 0], :on_int, "10"],
    [[1, 2], :on_period, "."],
 [[1, 3], :on_ident, "times"],
    [[1, 8], :on_sp, " "],
    [[1, 9], :on_kw, "do"],
    [[1, 11], :on_sp, " "],
    [[1, 12], :on_op, "|"],
    [[1, 13], :on_ident, "n"],
    [[1, 14], :on_op, "|"],
    [[1, 15], :on_ignored_nl, "\n"],
    [[2, 0], :on_sp, "  "],
    [[2, 2], :on_ident, "puts"],
    [[2, 6], :on_sp, " "],
    [[2, 7], :on_ident, "n"],
    [[2, 8], :on_nl, "\n"],
    [[3, 0], :on_kw, "end"],
    [[3, 3], :on_nl, "\n"]]

每一行对应 Ruby 在你的代码字符串中找到的一个标记。在左侧,我们有行号(在这个简短的示例中是 123)和文本列号。接下来,我们看到标记本身以符号形式展示,例如 :on_int :on_ident 。最后,Ripper 显示与每个标记对应的文本字符。

Ripper 显示的标记符号与我在 图 1-2 到 图 1-12 中使用的标记标识符有所不同,后者展示了 Ruby 对 10.times do 代码的标记化。我使用了你在 Ruby 内部解析代码中会找到的相同名称,例如 tIDENTIFIER,而 Ripper 使用了 :on_ident

无论如何,Ripper 仍然会让你了解 Ruby 在你的代码中找到的标记,以及标记化的工作原理。

示例 1-8 展示了使用 Ripper 的另一个示例。

示例 1-8。另一个使用 Ripper.lex 的示例

$ **ruby lex2.rb**
10.times do |n|
  puts n/4+6
end
--*snip*--
 [[2, 2], :on_ident, "puts"],
 [[2, 6], :on_sp, " "],
 [[2, 7], :on_ident, "n"],
 [[2, 8], :on_op, "/"],
 [[2, 9], :on_int, "4"],
 [[2, 10], :on_op, "+"],
 [[2, 11], :on_int, "6"],
 [[2, 12], :on_nl, "\n"],
--*snip*--

这次 Ruby 将表达式 n/4+6 转换为一系列标记,方式非常直接。标记的顺序与代码文件中的顺序完全一致。

示例 1-9 展示了一个第三个稍微复杂一点的示例。

示例 1-9。运行 Ripper.lex 的第三个示例

    $ **ruby lex3.rb**
    array = []
    10.times do |n|
      array << n if n < 5
    end
    p array
    --*snip*--
     [[3, 2], :on_ident, "array"],
     [[3, 7], :on_sp, " "],
  [[3, 8], :on_op, "<<"],
     [[3, 10], :on_sp, " "],
     [[3, 11], :on_ident, "n"],
     [[3, 12], :on_sp, " "],
     [[3, 13], :on_kw, "if"],
     [[3, 15], :on_sp, " "],
     [[3, 16], :on_ident, "n"],
     [[3, 17], :on_sp, " "],
  [[3, 18], :on_op, "<"],
     [[3, 19], :on_sp, " "],
     [[3, 20], :on_int, "5"],
    --*snip*--

如你所见,Ruby 足够聪明,能够区分以下行中的<<<array << n if n < 5<<字符被转换为一个单一的操作符标记 ,而稍后出现的单一<字符则被转换为简单的小于操作符 。Ruby 的标记化代码足够智能,当它发现一个<时,它会向前查找第二个<字符。

最后,注意 Ripper 并不知道你给它的代码是否是有效的 Ruby 代码。如果你传入包含语法错误的代码,Ripper 会像往常一样将其标记化,并不会发出警告。检查语法是解析器的工作。

假设你忘记了块参数n后面的|符号 ,如示例 1-10 所示。

示例 1-10. 该代码包含语法错误。

    require 'ripper'
    require 'pp'
    code = <<STR
 10.times do |n
      puts n
    end
    STR
    puts code
    pp Ripper.lex(code)

运行此代码后,你将得到在示例 1-11 中显示的输出。

示例 1-11. Ripper 无法检测到语法错误。

$ **ruby lex4.rb**
10.times do |n
  puts n
end
--*snip*--
[[[1, 0], :on_int, "10"],
 [[1, 2], :on_period, "."],
 [[1, 3], :on_ident, "times"],
 [[1, 8], :on_sp, " "],
 [[1, 9], :on_kw, "do"],
 [[1, 11], :on_sp, " "],
 [[1, 12], :on_op, "|"],
 [[1, 13], :on_ident, "n"],
 [[1, 14], :on_nl, "\n"],
--*snip*--

解析:Ruby 如何理解你的代码

一旦 Ruby 将你的代码转换为一系列的标记,它接下来会做什么?它是如何理解并运行你的程序的?Ruby 是否只是按顺序执行每个标记?

不行。你的代码仍然有很长的路要走,才能让 Ruby 运行它。Ruby 代码执行过程中的下一步叫做解析,在这一步中,单词或标记被组合成对 Ruby 有意义的句子或短语。在解析时,Ruby 会考虑操作顺序、方法、块以及其他较大的代码结构。

但是,Ruby 是如何真正理解你通过代码传达的信息的呢?像许多编程语言一样,Ruby 使用了一个解析器生成器。Ruby 使用解析器处理标记,但解析器本身是通过解析器生成器生成的。解析器生成器将一系列语法规则作为输入,这些规则描述了标记将出现的预期顺序和模式。

最常用且最著名的解析器生成器是 Yacc(Yet Another Compiler Compiler),但 Ruby 使用的是 Yacc 的一个更新版本,叫做Bison。Bison 和 Yacc 的语法规则文件有.y扩展名。在 Ruby 的源代码中,语法规则文件是parse.y(前面已提到)。parse.y文件定义了你在编写 Ruby 代码时必须使用的实际语法和语法规则;它实际上是 Ruby 的核心,是定义 Ruby 语言本身的地方!

没有标题的图片

Ruby 使用 LALR 解析器

Ruby 并不使用 Bison 来实际处理词法单元;相反,它在构建过程中提前运行 Bison,以创建实际的解析器代码。实际上,解析过程分为两个独立的步骤,如图 1-13 所示。

在你运行 Ruby 程序之前,Ruby 构建过程会使用 Bison 从语法规则文件(parse.y)生成解析器代码(parse.c)。稍后,在运行时,这些生成的解析器代码会解析 Ruby 的词法分析器代码返回的词法单元。

Ruby 构建过程提前运行 Bison

图 1-13. Ruby 构建过程提前运行 Bison。

因为parse.y文件和生成的parse.c文件也包含了词法分析代码,图 1-13 中有一条从parse.c到左下角词法分析过程的对角箭头。(实际上,我即将描述的解析引擎每当需要新的词法单元时都会调用词法分析代码。)词法分析和语法解析过程实际上是同时进行的。

理解 LALR 解析算法

解析器代码是如何分析和处理传入的词法单元的?通过一种被称为LALRLook-Ahead Left Reversed Rightmost Derivation)的算法。使用 LALR 算法,解析器代码从左到右处理词法单元流,尝试将它们的顺序和出现的模式与parse.y中的一个或多个语法规则进行匹配。解析器代码在必要时还会“向前看”,以决定匹配哪个语法规则。

熟悉 Ruby 语法规则的最佳方式是通过一个例子。为了简化起见,我们现在先看一个抽象的例子。稍后,我会展示 Ruby 在解析你的代码时其实是以完全相同的方式工作的。

假设你要从西班牙语翻译:

Me gusta el Ruby. [短语 1]

翻译成英语:

我喜欢 Ruby。

假设你要翻译短语 1,你使用 Bison 从语法文件生成 C 语言解析器。使用 Bison/Yacc 语法规则语法,你可以编写如示例 1-12 所示的简单语法,其中左侧是规则名称,右侧是匹配的词法单元。

示例 1-12. 匹配西班牙语短语 1 的简单语法规则

SpanishPhrase : me gusta el ruby {
  printf("I like Ruby\n");
}

这个语法规则是这样说的:如果词法单元流的顺序是 megustaelruby——按照这个顺序——我们就匹配成功。如果匹配成功,Bison 生成的解析器将运行给定的 C 代码,printf 语句(类似于 Ruby 中的 puts)将打印翻译后的英语短语。

图 1-14 展示了解析过程的实际操作。

与语法规则匹配的令牌

图 1-14. 与语法规则匹配的令牌

上方有四个输入令牌,下面是语法规则。应该很清楚地看到有匹配,因为每个输入令牌直接对应语法规则右侧的一个项。我们匹配了SpanishPhrase规则。

现在让我们改进这个示例。假设你需要增强你的解析器,使其能够匹配短语 1 和短语 2:

Me gusta el Ruby. [短语 1]

和:

Le gusta el Ruby. [短语 2]

在英语中,短语 2 意味着“她/他/它喜欢 Ruby”。

示例 1-13 中的修改版语法文件可以解析这两个西班牙语短语。

示例 1-13. 这些语法规则匹配短语 1 和短语 2。

SpanishPhrase: VerbAndObject el ruby {
  printf("%s Ruby\n", $1);
};
VerbAndObject: SheLikes | ILike {
  $$ = $1;
};
SheLikes: le gusta {
  $$ = "She likes";
}
ILike: me gusta {
  $$ = "I like";
}

如你所见,这里有四个语法规则,而不是只有一个。此外,你还在使用 Bison 指令$$将子语法规则的值返回给父规则,并使用$1在父规则中引用子规则的值。

与短语 1 不同,解析器不能立即将短语 2 与任何语法规则匹配。

在图 1-15 中,我们可以看到elruby令牌匹配SpanishPhrase规则,但legusta不匹配。(最终,我们会看到子规则VerbAndObject确实匹配le gusta,但暂时先不讨论这个。)有了四个语法规则,解析器如何知道接下来应该尝试匹配哪些规则?以及针对哪些令牌进行匹配?

前两个令牌不匹配

图 1-15. 前两个令牌不匹配。

这里是 LALR 解析器的智能所在。如前所述,LALR 代表前瞻 LR 解析器,描述了解析器用于找到匹配语法规则的算法。我们稍后会讲解前瞻部分。现在,让我们从 LR 开始:

  • L(左)表示解析器在处理令牌流时是从左到右进行的。在这个示例中,顺序是legustaelruby

  • R(逆向最右推导)意味着解析器采取自底向上的策略,使用移位/归约技术,来找到匹配的语法规则。

以下是短语 2 的算法工作方式。首先,解析器获取输入令牌流,如图 1-16 所示。

输入令牌流

图 1-16. 输入令牌流

接下来,解析器将标记向左移动,创建了我称之为语法规则堆栈的结构,如图 1-17 所示。

解析器将第一个标记移到语法规则堆栈上。

图 1-17. 解析器将第一个标记移到语法规则堆栈上。

因为解析器只处理了标记le,所以它暂时将这个标记单独放在堆栈中。术语语法规则堆栈有些过于简化;虽然解析器使用堆栈,但它并不是将语法规则推入堆栈,而是将数字推入堆栈,表示它刚刚解析了哪条语法规则。这些数字,或称为状态,帮助解析器在处理标记时跟踪它已经匹配了哪些语法规则。

接下来,如图 1-18 所示,解析器将另一个标记向左移动。

解析器将另一个标记移到堆栈上。

图 1-18. 解析器将另一个标记移到堆栈上。

现在,堆栈左侧有两个标记。在这一点上,解析器停止并开始查找匹配的语法规则。图 1-19 展示了解析器匹配SheLikes规则的过程。

解析器匹配  规则并进行归约。

图 1-19. 解析器匹配SheLikes规则并进行归约。

这个操作被称为归约,因为解析器正在用一个匹配规则替换一对标记。解析器查看可用的规则并进行归约,或者应用单一的匹配规则。

现在解析器可以再次进行归约,因为有另一个匹配的规则:VerbAndObjectVerbAndObject规则之所以匹配,是因为它使用了OR|)操作符,匹配了任意一个SheLikes ILike的子规则。

你可以在图 1-20 中看到,解析器将SheLikes替换为VerbAndObject

解析器再次进行归约,匹配  规则。

图 1-20. 解析器再次进行归约,匹配VerbAndObject规则。

但请思考一下:解析器是如何知道该进行归约而不是继续移位标记的?此外,如果在现实中有很多匹配的规则,解析器又是如何知道使用哪一条规则的呢?它如何决定是移位还是归约?如果归约,它又是如何决定使用哪条语法规则进行归约的?

换句话说,假设此时过程中有多个匹配规则包括le gusta。解析器如何知道应该应用哪个规则,或者在寻找匹配之前是否应该先将el标记移入栈中?(见图 1-21)。

解析器如何知道是移位还是归约?

图 1-21. 解析器如何知道是移位还是归约?

这就是 LALR 中的前瞻部分的作用。为了找到正确的匹配规则,解析器会查看下一个标记。图 1-22 中的箭头表示解析器在查看el标记。

查看输入流中的下一个标记

图 1-22. 查看输入流中的下一个标记

此外,解析器还维护一个状态表,记录根据下一个标记是什么以及刚刚解析的语法规则,可能的结果。在本例中,表格将包含一系列状态,描述到目前为止已解析的语法规则以及根据下一个标记应该转到的状态。(LALR 解析器是复杂的状态机,它们在标记流中匹配模式。当你使用 Bison 生成 LALR 解析器时,Bison 会根据你提供的语法规则计算该状态表应包含的内容。)

在这个例子中,状态表将包含一条条目,指示如果下一个标记是el,解析器应该先使用SheLikes规则进行归约,然后再移位一个新标记。

与其浪费时间讨论状态表的细节(你可以在生成的parse.c文件中找到实际的 Ruby LALR 状态表),不如继续处理短语 2“Le gusta el Ruby”的移位/归约操作。在匹配VerbAndObject规则之后,解析器会将另一个标记向左移,如图 1-23 所示。

解析器将另一个标记推入栈中。

图 1-23. 解析器将另一个标记推入栈中。

此时,没有规则能够匹配,状态机将把另一个标记向左移(见图 1-24)。

解析器将另一个标记推入栈中。

图 1-24. 解析器将另一个标记推入栈中。

图 1-25 显示了在最终归约操作后,父语法规则 SpanishPhrase 如何匹配。

解析器匹配  规则——以及整个输入流!

图 1-25。解析器匹配 SpanishPhrase 规则——以及整个输入流!

我展示这个西班牙语到英语的示例是因为 Ruby 解析你的程序的方式完全相同!在 Ruby 的 parse.y 源代码文件中,你会看到数百条规则,这些规则定义了 Ruby 语言的结构和语法。有父规则和子规则,子规则返回的值是父规则可以引用的,方式和我们的 SpanishPhrase 语法规则使用 $$$1$2 等符号的方式完全相同。唯一的真正区别在于规模:我们的 SpanishPhrase 语法示例实际上是微不足道的。相比之下,Ruby 的语法非常复杂;它是一个错综复杂的父子语法规则系列,有时它们以循环、递归的方式相互引用。但这种复杂性意味着在 parse.c 中生成的状态表相当大。描述解析器如何处理符号并使用状态表的基本 LALR 算法,在我们的西班牙语示例和 Ruby 中都是相同的。

要了解 Ruby 的状态表有多复杂,你可以尝试使用 Ruby 的 -y 选项,该选项会在每次解析器从一个状态跳到另一个状态时显示内部调试信息。示例 1-14 显示了当你运行来自示例 1-1") 的 10.times do 示例时生成的部分输出。

示例 1-14。Ruby 可选地显示调试信息,展示解析器如何从一个状态跳到另一个状态。

$ **ruby -y simple.rb**
Starting parse
Entering state 0
Reducing stack by rule 1 (line 850):
-> $$ = nterm @1 ()
Stack now 0
Entering state 2
Reading a token: Next token is token tINTEGER ()
Shifting token tINTEGER ()
Entering state 41
Reducing stack by rule 498 (line 4293):
   $1 = token tINTEGER ()
-> $$ = nterm numeric ()
Stack now 0 2
Entering state 109
--*snip*--

一些实际的 Ruby 语法规则

让我们来看一些来自 parse.y 的实际 Ruby 语法规则。示例 1-15 包含了来自示例 1-1") 的简单 Ruby 脚本示例。

示例 1-15。来自示例 1-1") 的简单 Ruby 程序。

10.times do |n|
  puts n
end

图 1-26 显示了 Ruby 的解析过程是如何与这个脚本配合工作的。

右侧的语法规则与左侧的 Ruby 代码相匹配。

图 1-26. 右侧的语法规则与左侧的 Ruby 代码相匹配。

左侧是 Ruby 尝试解析的代码,右侧是来自 Ruby parse.y 文件的实际匹配语法规则(简化显示)。第一条规则,program: top_compstmt,是根语法规则,匹配整个 Ruby 程序。

当你继续向下查看时,你会看到一系列复杂的子规则,它们也匹配整个 Ruby 脚本:顶级语句、单一语句、表达式、参数,最后是主值。一旦 Ruby 的解析过程到达主语法规则,它会遇到一个包含两个匹配子规则的规则:method_callbrace_block。让我们首先来看一下 method_call(见图 1-27)。

10.times 匹配 method_call 语法规则。

图 1-27. 10.times 匹配 method_call 语法规则。

method_call 规则匹配 Ruby 代码中的 10.times 部分——也就是我们在 10 Fixnum 对象上调用 times 方法的地方。你可以看到,method_call 规则匹配另一个主值,接着是一个句点字符,再接着是一个 operation2 规则。

图 1-28 显示了 primary_value 规则首先匹配值 10

值 10 匹配 primary_value 语法规则。

图 1-28. 值 10 匹配 primary_value 语法规则。

然后,在图 1-29 中,operation2 规则匹配方法名 times

 方法名匹配  语法规则。

图 1-29. times 方法名匹配 operation2 语法规则。

Ruby 是如何解析传递给 times 方法的 do ... puts ... end 块内容的?它使用我们在图 1-26 中看到的 brace_block 规则。图 1-30 展示了 brace_block 规则的定义。

整个块匹配 brace_block 规则。

图 1-30. 整个块匹配 brace_block 规则。

我这里没有足够的空间详细解释其余的子语法规则,但你可以看到这一规则如何依次包含一系列其他匹配的子规则:

  • keyword_do 匹配保留字 do

  • opt_block_param 匹配块参数 |n|

  • compstmt 匹配块本身的内容,puts n

  • keyword_end 匹配 end 保留字。

阅读 Bison 语法规则

为了让你体验 Ruby 实际的 parse.y 源代码,看看 示例 1-16,它展示了 method_call 语法规则定义的一部分

示例 1-16. Ruby 的实际 method_call 语法规则来自 parse.y

 method_call        :
    --*snip*--
          primary_value '.' operation2
          {
          /*%%%*/
              $<num>$ = ruby_sourceline;
          /*% %*/
          }
        opt_paren_args
          {
          /*%%%*/
              $$ = NEW_CALL($1, $3, $5);
              nd_set_line($$, $<num>4);
          /*%
              $$ = dispatch3(call, $1, ripper_id2sym('.'), $3);
              $$ = method_optarg($$, $5);
          %*/
          }

和之前的西班牙语到英语的示例语法文件一样,你可以看到在语法规则的每个术语后都有复杂的 C 代码片段。示例 1-17 展示了其中的一个例子。

示例 1-17. Ruby 在 opt_paren_args 语法规则匹配时调用此 C 代码。

$$ = NEW_CALL($1, $3, $5);
nd_set_line($$, $<num>4);

Bison 生成的解析器将在目标 Ruby 脚本中的词法单元匹配到某个规则时执行这些代码片段中的一个。然而,这些 C 代码片段还包含 Bison 指令,如 $$$1,允许代码创建返回值并引用其他语法规则返回的值。最终我们会得到一个复杂的 C 和 Bison 指令混合体。

更糟糕的是,Ruby 在其构建过程中使用了一种技巧,将这些 C/Bison 代码片段分割成多个部分。其中一些部分由 Ruby 使用,而其他部分仅由 Ripper 工具使用,后者在 实验 1-1:使用 Ripper 对不同 Ruby 脚本进行词法分析 中使用。下面是这种技巧的工作方式:

  • 示例 1-16 中 /*%%%*/ 行和 /*% 行之间的 C 代码实际上是在 Ruby 构建过程中编译进 Ruby 中的。

  • 示例 1-16 中 /*%%*/ 之间的 C 代码在 Ruby 构建时会被丢弃。此代码仅供 Ripper 工具使用,该工具在 Ruby 构建过程中单独构建。

Ruby 使用这种非常令人困惑的语法,允许 Ripper 工具和 Ruby 本身在 parse.y 中共享相同的语法规则。

这些代码片段究竟在做什么呢?正如你可能猜到的那样,Ruby 使用 Ripper 代码片段来允许 Ripper 工具显示 Ruby 正在解析的内容。(我们接下来会尝试这个,在实验 1-2:使用 Ripper 解析不同的 Ruby 脚本中。)其中还有一些记账代码:Ruby 使用 ruby_sourceline 变量来跟踪每一部分语法对应的源代码行。

但更重要的是,Ruby 在解析你的代码时,实际使用的代码片段会创建一系列节点,或临时数据结构,这些节点构成了 Ruby 代码的内部表示。这些节点会保存在一种称为抽象语法树(AST)的树结构中(更多内容请参见实验 1-2:使用 Ripper 解析不同的 Ruby 脚本)。你可以在示例 1-17 中看到创建 AST 节点的一个示例,其中 Ruby 调用了 NEW_CALL C 宏/函数。这个调用创建了一个新的 NODE_CALL 节点,表示一个方法调用。(在第二章中,我们将看到 Ruby 如何最终将其编译成虚拟机可以执行的字节码。)

实验 1-2:使用 Ripper 解析不同的 Ruby 脚本

在实验 1-1:使用 Ripper 解析不同的 Ruby 脚本中,你学会了如何使用 Ripper 显示 Ruby 将你的代码转换成的标记,而且我们刚刚看到,parse.y 中的 Ruby 语法规则也包含在 Ripper 工具中。现在,让我们学习如何使用 Ripper 显示 Ruby 解析代码时的相关信息。示例 1-18 展示了如何实现这一点。

示例 1-18。如何调用 Ripper.sexp 的示例

    require 'ripper'
    require 'pp'
    code = <<STR
    10.times do |n|
      puts n
    end
    STR
    puts code
 pp Ripper.sexp(code)

这实际上是实验 1-1:使用 Ripper 解析不同的 Ruby 脚本中的完全相同的代码,只不过我们调用的是 Ripper.sexp ,而不是 Ripper.lex。运行该代码将产生示例 1-19 中所示的输出。

示例 1-19。Ripper.sexp 生成的输出

[:program,
  [[:method_add_block,
     [:call,
       [:@int, "10", [1, 0]], :".",
       [:@ident, "times", [1, 3]]],
     [:do_block,
       [:block_var,
         [:params, [[:@ident, "n", [1, 13]]],
                   nil, nil, nil, nil, nil, nil],
         false],
       [[:command,
          [:@ident, "puts", [2, 2]],
          [:args_add_block, [[:var_ref, [:@ident, "n", [2, 7]]]],
                            false]]]]]]]

你可以从这段晦涩的文本中看到一些来自 Ruby 脚本的片段,但所有其他符号和数组是什么意思呢?

事实证明,Ripper 的输出是你的 Ruby 代码的文本表示。当 Ruby 解析你的代码时,逐一匹配语法规则,它将代码文件中的分词转换为一个复杂的内部数据结构,称为抽象语法树(AST)。(你可以在阅读 Bison 语法规则中看到生成此结构的一些 C 代码。)AST 用于记录你的 Ruby 代码的结构和语法意义。

为了让你明白我的意思,看看图 1-31,它展示了 Ripper 为我们生成的部分输出的图形视图:在代码块内的puts n语句。

AST 中对应 puts n 的部分

图 1-31. AST 中对应puts n的部分

这个图对应于 Ripper 输出的最后三行,这三行在示例 1-20 中有重复展示。

示例 1-20. Ripper.sexp输出的最后三行

    [[:command,
    [:@ident, "puts", [2, 2]],
       [:args_add_block, [[:var_ref, [:@ident, "n", [2, 7]]]],
                         false]]]

正如在实验 1-1:使用 Ripper 对不同的 Ruby 脚本进行分词中所示,当我们展示来自 Ripper 的分词信息时,你可以看到源代码文件的行号和列号信息被显示为整数。例如,[2, 2] 表示 Ripper 在代码文件的第 2 行第 2 列找到了puts调用。你还可以看到,Ripper 为 AST 中的每个节点输出了一个数组——例如,[:@ident, "puts", [2, 2]]

现在你的 Ruby 程序开始对 Ruby“有意义”了。Ruby 不再只是一个简单的分词流(这可能代表任何东西),现在它有了一个详细的描述,告诉它你在写puts n时的真正意图。你看到的是一个函数调用(命令),后面跟着一个标识符节点,指示要调用的函数。

Ruby 使用args_add_block节点,因为你可以像这样将一个代码块传递给命令/函数调用。即使在这种情况下你没有传递代码块,args_add_block节点仍然会被保存到 AST 中。(另外,请注意,n标识符被记录为:var_ref,即变量引用节点,而不是简单的标识符。)

图 1-32 展示了更多来自 Ripper 的输出。

AST 中对应整个代码块的部分

图 1-32. AST 中对应整个代码块的部分

你可以看到 Ruby 现在理解到do |n| ... end是一个块,带有一个名为n的块参数。右边的puts n框代表 AST 的另一部分——之前展示过的puts调用的解析版本。

最后,图 1-33 展示了示例 Ruby 代码的完整 AST。

整个 Ruby 程序的 AST

图 1-33. 整个 Ruby 程序的 AST

这里,method add block意味着你正在调用一个方法,但带有一个块参数:10.times docall树节点显然代表实际的方法调用10.times。这是我们在前面 C 代码片段中看到的NODE_CALL节点。Ruby 通过 AST 节点的排列方式保存了它对你代码意图的理解。

为了澄清,假设你将 Ruby 表达式2 + 2传递给 Ripper,如示例 1-21 所示。

示例 1-21. 这段代码将显示2 + 2的 AST。

require 'ripper'
require 'pp'
code = <<STR
2 + 2
STR
puts code
pp Ripper.sexp(code)

运行这段代码会产生示例 1-22 中的输出。

示例 1-22. Ripper.sexp的输出,针对2 + 2

[:program,
  [[:binary,
     [:@int, "2", [1, 0]],
     :+,
     [:@int, "2", [1, 4]]]]]

正如你在图 1-34 中看到的,+用一个叫做binary的 AST 节点表示。

2 + 2 的 AST

图 1-34. 2 + 2的 AST

但看看当我将表达式2 + 2 * 3传递给 Ripper 时会发生什么,就像在示例 1-23 中那样。

示例 1-23. 显示2 + 2 * 3的 AST 的代码

require 'ripper'
require 'pp'
code = <<STR
2 + 2 * 3
STR
puts code
pp Ripper.sexp(code)

示例 1-24 显示你会得到一个额外的二元节点,表示*运算符,如下所示:

示例 1-24. Ripper.sexp的输出,针对2 + 2 * 3

    [:program,
     [[:binary,
       [:@int, "2", [1, 0]],
       :+,
    [:binary,
         [:@int, "2", [1, 4]],
         :*,
         [:@int, "3", [1, 8]]]]]]

图 1-35 展示了它的样子。

2 + 2 * 3 的 AST

图 1-35. 2 + 2 * 3的 AST

Ruby 足够聪明,能意识到乘法的优先级高于加法,但更有趣的是 AST 树结构如何捕捉操作顺序的信息。标记流2 + 2 * 3仅表示你在代码文件中写的内容。然而,保存到 AST 结构中的解析版本现在包含了你代码的含义——也就是说,Ruby 稍后执行代码时所需的所有信息。

最后一个提示:Ruby 实际上包含一些调试代码,可以显示有关 AST 节点结构的信息。要使用它,请运行带有parsetree选项的 Ruby 脚本(见示例 1-25)。

示例 1-25. 使用parsetree选项显示代码的 AST 调试信息。

$ **ruby --dump parsetree your_script.rb**

这将显示我们刚刚看到的相同信息,但parsetree选项应该显示来自 C 源代码的实际节点名称,而不是显示符号。(在第二章中,我也将使用实际的节点名称。)

摘要

在第一章中,我们研究了计算机科学中最吸引人的领域之一:Ruby 是如何理解你提供的文本——即你的 Ruby 程序。为了做到这一点,Ruby 将你的代码转换成两种不同的格式。首先,它将你的 Ruby 程序中的文本转换为一系列标记。接下来,它使用 LALR 解析器将输入流中的标记转换为一种称为抽象语法树的数据结构。

在第二章中,我们将看到 Ruby 将你的代码转换为第三种格式:一系列字节码指令,这些指令在程序实际执行时使用。

第二章:编译

没有标题的图片

Ruby 实际运行的代码与你原始的代码完全不同。

现在 Ruby 已经对你的代码进行了词法分析和语法解析,它准备好运行了吗?它是否终于会在我的简单 10.times do 示例中迭代 10 次?如果没有,Ruby 可能还需要做什么?

从 1.9 版本开始,Ruby 会在执行代码之前编译它。编译一词意味着将你的代码从一种编程语言转换为另一种语言。你的编程语言对你来说容易理解,而通常目标语言对计算机来说更容易理解。

例如,当你编译一个 C 程序时,编译器将 C 代码转换为机器语言,这是计算机的微处理器硬件能理解的语言。当你编译一个 Java 程序时,编译器将 Java 代码转换为 Java 字节码,这是 Java 虚拟机能够理解的语言。

Ruby 的编译器也没有什么不同。它将你的 Ruby 代码转换为 Ruby 虚拟机能够理解的另一种语言。唯一的区别是你不会直接使用 Ruby 的编译器;不像 C 或 Java,Ruby 的编译器会自动运行,你根本不会知道。在本章 第二章 中,我将解释 Ruby 是如何做到这一点的,以及它将你的代码转换为什么语言。

路线图

  • 没有 Ruby 1.8 的编译器

  • Ruby 1.9 和 2.0 引入了编译器

  • Ruby 如何编译一个简单脚本

  • 编译对块的调用

    • Ruby 如何遍历 AST
  • 实验 2-1:显示 YARV 指令

  • 本地表

    • 编译可选参数

    • 编译关键字参数

  • 实验 2-2:显示本地表

  • 总结

没有 Ruby 1.8 的编译器

Ruby 核心团队在版本 1.9 中引入了编译器。Ruby 1.8 及更早版本不包含编译器。相反,Ruby 1.8 会在标记化和解析过程完成后立即执行代码。它通过遍历 AST 树中的节点并执行每个节点来实现这一点。图 2-1 展示了另一种看待 Ruby 1.8 标记化和解析过程的方式。

图 2-1 的顶部显示了您的 Ruby 代码。其下是 Ruby 将您的 Ruby 代码转换成的不同内部格式。这些就是我们在第一章中看到的标记和 AST 节点——当您使用 Ruby 运行代码时,代码所呈现的不同形式。图表的下半部分展示了 Ruby 核心团队编写的代码:Ruby 语言的 C 源代码,以及 C 编译器将其转换成的机器语言。

在 Ruby 1.8 中,您的代码被转换为 AST 节点,然后被解释执行。

图 2-1. 在 Ruby 1.8 中,您的代码被转换为 AST 节点,然后被解释执行。

两个代码部分之间的虚线表示 Ruby 会解释您的代码。Ruby 的 C 代码(下半部分)读取并执行您的代码(上半部分)。Ruby 1.8 不会将您的代码编译或转换为 AST 节点之外的任何形式。在将其转换为 AST 节点后,它会继续遍历 AST 中的节点,按每个节点所代表的操作执行。

图表中间的空隙表明,您的代码从未完全编译成机器语言。如果您反汇编并检查 CPU 实际运行的机器语言,您将看不到直接映射到您原始 Ruby 代码的指令。相反,您会发现执行代码的指令,这些指令会进行标记化、解析和执行,换句话说,它们实现了 Ruby 解释器。

Ruby 1.9 和 2.0 引入了编译器

如果您已经升级到 Ruby 1.9 或 2.0,Ruby 仍然无法直接运行您的代码。它首先需要编译代码。

在 Ruby 1.9 中,Koichi Sasada 和 Ruby 核心团队引入了“另一个 Ruby 虚拟机”(YARV),它实际上执行您的 Ruby 代码。从高层次上看,这与 Java 虚拟机(JVM)的概念相同,JVM 被 Java 和许多其他语言所使用。(我将在第三章和第四章中更详细地介绍 YARV。)

使用 YARV 时(与 JVM 类似),你首先将代码编译成字节码,即虚拟机可以理解的一系列低级指令。YARV 和 JVM 之间的唯一区别如下:

  • Ruby 并没有将编译器作为一个独立的工具暴露给你。相反,它会自动将你的 Ruby 代码在内部编译成字节码指令。

  • Ruby 永远不会将你的 Ruby 代码完全编译成机器语言。正如你在图 2-2 中看到的那样,Ruby 会解释字节码指令。而 JVM 则可以通过其“热点”或即时编译器(JIT)将一些字节码指令编译成机器语言。

    图 2-2 展示了 Ruby 1.9 和 2.0 如何处理你的代码。

请注意,这次不同于图 2-1 中展示的过程,你的代码被转换成了第三种格式。在解析完符号并生成 AST 后,Ruby 1.9 和 2.0 会继续将代码编译成一系列低级指令,称为YARV 指令

使用 YARV 的主要原因是速度:由于采用了 YARV 指令,Ruby 1.9 和 2.0 比 Ruby 1.8 运行得要快得多。像 Ruby 1.8 一样,YARV 是一个解释器——只不过是一个更快的解释器。最终,Ruby 1.9 或 2.0 依然不会直接将你的 Ruby 代码转换成机器语言。图 2-2 中,YARV 指令和 Ruby 的 C 代码之间仍然存在差距。

Ruby 1.9 和 2.0 在解释之前会将 AST 节点编译成 YARV 指令。

图 2-2. Ruby 1.9 和 2.0 在解释之前会将 AST 节点编译成 YARV 指令。

Ruby 如何编译一个简单的脚本

在这一部分,我们将看看代码在 Ruby 中执行的最后一步:Ruby 如何将你的代码编译成 YARV 所期望的指令。通过一个编译的示例,我们来探索 Ruby 编译器的工作原理。示例 2-1 展示了一个简单的 Ruby 脚本,它计算了 2 + 2 = 4。

示例 2-1. 我们将编译的一个简单 Ruby 程序

puts 2+2

图 2-3 代码后生成的 AST") 展示了 Ruby 在对这个简单程序进行词法分析和解析后生成的 AST 结构。(这比我们在实验 1-2: 使用 Ripper 解析不同的 Ruby 脚本中看到的 Ripper 工具所呈现的 AST 更为详细。)

注意

图 2-3 代码后生成的 AST") 中显示的技术名称(NODE_SCOPENODE_FCALL* 等)来自实际的 Ruby C 源代码。为了简化起见,我省略了一些 AST 节点——特别是那些表示每个方法调用的参数数组的节点,在这个简单的例子中,这些数组只有一个元素。*

Ruby 解析代码后生成的 AST

图 2-3. Ruby 在解析 示例 2-1 代码后生成的 AST。

在我们详细讲解 Ruby 如何编译 puts 2+2 脚本之前,让我们先看一下 YARV 的一个非常重要的特性:它是一个 栈导向虚拟机。这意味着,当 YARV 执行你的代码时,它会维护一个值的栈——主要是 YARV 指令的参数和返回值。(我将在第三章中详细解释这个问题。)YARV 的大多数指令要么将值推入栈中,要么操作栈中的值,最终将结果值保留在栈中。

为了将 puts 2+2 的 AST 结构编译成 YARV 指令,Ruby 将从上到下递归遍历树,将每个 AST 节点转换为指令。图 2-4 展示了这个过程,从 NODE_SCOPE 开始。

Ruby 从 AST 的根开始编译过程

图 2-4. Ruby 从 AST 的根开始编译过程。

NODE_SCOPE 告诉 Ruby 编译器,它开始编译一个新的 作用域,或一段 Ruby 代码,在这个例子中是一个全新的程序。右侧的空框表示该作用域。(tableargs 值都为空,所以我们暂时忽略它们。)

接下来,Ruby 编译器会沿着 AST 树向下,遇到 NODE_FCALL,如图 2-5 所示。

为了编译一个函数调用,Ruby 首先创建一个指令将接收者推入栈中。

图 2-5. 为了编译一个函数调用,Ruby 首先创建一个指令将接收者推入栈中。

NODE_FCALL 表示一个函数调用——在这个例子中,就是调用 puts。(函数和方法调用在 Ruby 程序中非常重要,也非常常见。)Ruby 按照以下模式为 YARV 编译函数调用:

  • 推入接收者。

  • 推入参数。

  • 调用方法/函数。

在图 2-5 中,Ruby 编译器首先创建了一个名为 putself 的 YARV 指令,表示该函数调用使用当前 self 指针的值作为接收者。由于我在该简单脚本的顶层作用域中调用了 puts,也就是说在顶层部分,self 被设置为指向 top self 对象。(top self 对象是一个 Object 类的实例,它在 Ruby 启动时自动创建。top self 的一个作用是作为顶层作用域中类似这种函数调用的接收者。)

注意

在 Ruby 中,所有函数实际上都是方法。也就是说,函数总是与 Ruby 类关联;总会有一个接收者。然而,在 Ruby 内部,Ruby 的解析器和编译器区分函数和方法:方法调用有显式的接收者,而函数调用则假定接收者是当前的 self 值。

接下来,Ruby 需要创建指令将 puts 函数调用的参数推入栈中。但是,如何做到这一点呢?puts 的参数是 2+2,它是另一个方法调用的结果。尽管 2+2 是一个简单的表达式,puts 也可能是在处理一个非常复杂的 Ruby 表达式,涉及许多运算符、方法调用等。那么,Ruby 如何知道在这里创建哪些指令呢?

答案在 AST(抽象语法树)的结构中。通过递归地向下遍历树节点,Ruby 可以利用解析器之前的工作。在这种情况下,Ruby 现在只需一步步下到 NODE_CALL 节点,如图 2-6 所示。

接下来,Ruby 编写计算 2+2 的指令,作为  的参数。

图 2-6. 接下来,Ruby 编写计算 2+2 的指令,作为 puts 的参数。

这里 Ruby 将编译 + 方法调用,理论上这就是将 + 消息发送给 2 这个整数对象的过程。同样,按照接收者、参数、方法调用的模式,Ruby 按顺序执行以下操作:

  1. 创建一个 YARV 指令,将接收者(在此例中为对象2)推入栈中。

  2. 创建一个 YARV 指令,将参数或参数们推入栈中(在此例中是 2)。

  3. 创建一个方法调用 YARV 指令 send <callinfo!mid:+, argc:1, ARGS_SKIP>,这意味着“发送 + 消息”给接收者,也就是之前被推入 YARV 栈中的对象(在此例中为第一个 Fixnum 2 对象)。mid:+ 表示“方法 ID = +”,是我们想调用的方法的名称。argc:1 参数告诉 YARV 该方法调用有一个参数(第二个 Fixnum 2 对象)。ARGS_SKIP 表示参数是简单值(而非块或未命名参数的数组),允许 YARV 跳过一些原本需要做的工作。

当 Ruby 执行 send <callinfo!mid:+... 指令时,它会将 2+2 相加,从栈中获取这些参数,并将结果 4 留在栈顶作为一个新值。令人着迷的是,YARV 的栈导向特性也有助于 Ruby 更容易地编译 AST 节点,正如你在编译完 NODE_FCALL 时看到的那样,如图 2-7 所示。

现在,Ruby 可以假设 2+2 操作的返回值——也就是 4——将留在栈顶,正好作为 puts 函数调用的参数。Ruby 的栈导向虚拟机与它递归编译 AST 节点的方式相得益彰!正如你在图 2-7 右侧所看到的,Ruby 已经添加了 send <callinfo!mid:puts, argc:1 指令,该指令调用 puts 并表示 puts 有一个参数。

最终,Ruby 可以为调用 puts 写一条指令。

图 2-7. 最终,Ruby 可以为调用 puts 写一条指令。

结果表明,Ruby 在执行这些 YARV 指令之前,会进一步修改它们作为优化步骤的一部分。它的一项优化是用专用指令替换一些 YARV 指令,专用指令是代表常用操作的 YARV 指令,比如sizenotless thangreater than等。其中一条指令,opt_plus,用于将两个数字相加。在优化过程中,Ruby 会将 send <callinfo!mid:+... 替换为 opt_plus,如图 2-8 所示。

Ruby 用专用指令替换一些指令。

图 2-8. Ruby 用专用指令替换一些指令。

正如你在图 2-8 中看到的,Ruby 还将第二个 send 替换为 opt_send_simple,当所有参数都是简单值时,它运行得更快。

编译调用块

接下来,让我们编译我在示例 1-1")中给出的 10.times do 示例,位于第一章(参见示例 2-2)。

示例 2-2. 一个简单的调用块的脚本

10.times do |n|
  puts n
end

请注意,这个示例包含了传递给 times 方法的块参数。这很有趣,因为它将让我们有机会看看 Ruby 编译器如何处理块。图 2-9 再次显示了 10.times do 示例的 AST,使用实际的节点名称,而不是 Ripper 简化的输出。

调用 10.times 时传递块的 AST

图 2-9. 调用 10.times 时传递块的 AST

这看起来与 puts 2+2 非常不同,主要是因为右侧显示的内部块。(Ruby 对内部块的处理方式不同,稍后我们会看到。)

让我们分析一下 Ruby 如何编译位于左侧的图 2-9 所示的脚本的主要部分。如同之前一样,Ruby 从第一个 NODE_SCOPE 开始,并创建一个新的 YARV 指令片段,如图 2-10 所示。

每个 NODE_SCOPE 都被编译成一个新的 YARV 指令片段。

图 2-10. 每个 NODE_SCOPE 都被编译成一个新的 YARV 指令片段。

接下来,Ruby 继续遍历 AST 节点,直到 NODE_ITER,如图 2-11 所示。

Ruby 遍历 AST

图 2-11. Ruby 遍历 AST

此时,仍然没有生成代码,但请注意在图 2-9 中,两条箭头从 NODE_ITER 指向:一条指向 NODE_CALL,表示 10.times 调用,另一条指向内部块。Ruby 将首先继续沿着 AST 向下编译与 10.times 代码对应的节点。生成的 YARV 代码遵循我们在图 2-6 中看到的相同的接收者-参数-消息模式,并在图 2-12 中显示。

Ruby 编译 10.times 方法调用

图 2-12. Ruby 编译 10.times 方法调用

请注意,在图 2-12 中显示的新 YARV 指令,首先将接收者(整数对象 10)压入栈中,然后 Ruby 生成一条指令来执行 times 方法调用。但同样要注意的是,send 指令中的 block:block in <compiled> 参数。这表明该方法调用还包含一个块参数:我的 do |n| puts n end 块。在这个例子中,NODE_ITER 导致 Ruby 编译器包含了这个块参数,因为上面的 AST 显示了从 NODE_ITER 到第二个 NODE_SCOPE 的箭头。

Ruby 继续编译内部块,从右侧的第二个 NODE_SCOPE 开始,正如在图 2-9 中所示。图 2-13 显示了内部块对应的 AST 结构。

这看起来很简单——只有一个函数调用和一个单一的参数 n。但请注意 NODE_SCOPEtableargs 的值。这些值在父 NODE_SCOPE 中是空的,但在内部的 NODE_SCOPE 中被设置了。正如你所猜测的,这些值表明了块参数 n 的存在。

AST 分支,表示块的内容

图 2-13. AST 分支,表示块的内容

还要注意,Ruby 解析器创建了 NODE_DVAR 而不是我们在图 2-9 中看到的 NODE_LIT。这是因为 n 不仅仅是一个字面量字符串;它是一个从父作用域传递过来的块参数。

从相对较高的层次来看,图 2-14 展示了 Ruby 如何编译内部块。

Ruby 如何遍历 AST

让我们更仔细地看看 Ruby 是如何实际遍历 AST 结构的,将每个节点转换为 YARV 指令。实现 Ruby 编译器的 MRI C 源代码文件叫做 compile.c。为了了解 compile.c 中的代码是如何工作的,我们首先查找 iseq_compile_each 函数。示例 2-3 显示了该函数的开头。

示例 2-3. 这个 C 函数编译了 AST 中的每个节点。

/**
  compile each node

  self:  InstructionSequence
  node:  Ruby compiled node
  poped: This node will be poped
 */
static int
iseq_compile_each(rb_iseq_t *iseq, LINK_ANCHOR *ret, NODE * node,
                  int poped)
{

这个函数非常长,包含一个非常非常长的 switch 语句,运行了几千行!switch 语句根据当前 AST 节点的类型进行分支,并生成相应的 YARV 代码。示例 2-4 显示了 switch 语句的开头

示例 2-4. 这个 C switch 语句查看每个 AST 节点的类型。

 type = nd_type(node);
    --*snip*--
 switch (type) {

在此语句中,node 是传递给 iseq_compile_each 的参数,而 nd_type 是一个 C 宏,用于返回给定节点结构的类型。

现在我们将看看 Ruby 如何使用接收者-参数-方法调用模式将函数或方法调用节点编译成 YARV 指令。首先,在 compile.c 中搜索 示例 2-5 中显示的 C case 语句。

示例 2-5. 这个 switch 语句的情况编译了你 Ruby 代码中的方法调用。

case NODE_CALL:
case NODE_FCALL:
case NODE_VCALL:{                /* VCALL: variable or call */
  /*
    call:  obj.method(...)
    fcall: func(...)
    vcall: func
  */

NODE_CALL 代表真实的方法调用(例如 10.times),NODE_FCALL 是函数调用(例如 puts),而 NODE_VCALL 是变量或函数调用。跳过一些 C 代码的细节(包括用于实现 goto 语句的可选 SUPPORT_JOKE 代码),示例 2-6 展示了 Ruby 下一步如何编译这些 AST 节点。

示例 2-6. 这段 C 代码编译了方法调用的接收者值。

    /* receiver */
    if (type == NODE_CALL) {
     COMPILE(recv, "recv", node->nd_recv);
    }
    else if (type == NODE_FCALL || type == NODE_VCALL) {
     ADD_CALL_RECEIVER(recv, nd_line(node));
    }

在这里,Ruby 调用 COMPILEADD_CALL_RECEIVER,如下所示:

  • 在真实方法调用的情况下(例如 NODE_CALL),Ruby 调用 COMPILE 来递归调用 iseq_compile_each,继续处理与方法调用或消息的接收者对应的 AST 树中的下一个节点。这将创建 YARV 指令,用于评估用于指定目标对象的任何表达式。

  • 如果没有接收者(NODE_FCALLNODE_VCALL),Ruby 会调用 ADD_CALL_RECEIVER ,这会创建一个 putself YARV 指令。

接下来,正如在示例 2-7 中所示,Ruby 创建 YARV 指令将每个方法/函数调用的参数压入栈中。

示例 2-7. 这段 C 代码编译每个 Ruby 方法调用的参数。

    /* args */
    if (nd_type(node) != NODE_VCALL) {
     argc = setup_args(iseq, args, node->nd_args, &flag);
    }
    else {
     argc = INT2FIX(0);
    }

对于 NODE_CALLNODE_FCALL,Ruby 会调用 setup_args 函数 ,该函数将在需要时递归调用 iseq_compile_each 以编译每个方法/函数调用的参数。对于 NODE_VCALL,没有参数,因此 Ruby 只需将 argc 设置为 0

最后,Ruby 创建 YARV 指令来执行实际的方法或函数调用,如下所示:

ADD_SEND_R(ret, nd_line(node), ID2SYM(mid),
           argc, parent_block, LONG2FIX(flag));

这个 C 宏将创建新的 send YARV 指令,当 YARV 执行它时,将导致实际的方法调用发生。

Ruby 如何编译对块的调用

图 2-14. Ruby 如何编译对块的调用

你可以在顶部看到父级 NODE_SCOPE,以及来自图 2-12 的 YARV 代码。在下面,我列出了从内部块的 AST 编译的 YARV 代码。

关键点在于,Ruby 会将你程序中每个独立的作用域——例如方法、块、类或模块——编译成一段独立的 YARV 指令。

实验 2-1:显示 YARV 指令

查看 Ruby 如何编译代码的一个简单方法是使用 RubyVM::InstructionSequence 对象,它让你可以从 Ruby 程序中访问 Ruby 的 YARV 引擎!像 Ripper 工具一样,它的使用非常直接,正如你在示例 2-8 中看到的那样。

示例 2-8. 如何查看 puts 2+2 的 YARV 指令

code = <<END
puts 2+2
END
puts RubyVM::InstructionSequence.compile(code).disasm

挑战在于理解输出的实际含义。例如,示例 2-9 展示了 puts 2+2 的输出。

示例 2-9. puts 2+2 的 YARV 指令

    == disasm: <RubyVM::InstructionSequence:<compiled>@<compiled>>==========
 0000 trace            1                                               (   1)
    0002 putself
    0003 putobject        2
    0005 putobject        2
    0007 opt_plus         <callinfo!mid:+, argc:1, ARGS_SKIP>
    0009 opt_send_simple  <callinfo!mid:puts, argc:1, FCALL|ARGS_SKIP>
 0011 leave

如你在示例 2-9 中看到的,输出包含了从图 2-5 到图 2-8 的所有相同指令,并增加了两条新指令:trace leave trace 指令用于实现 set_trace_func 功能,这将在程序中执行每条 Ruby 语句时调用给定的函数。leave 功能类似于返回语句。左侧的行号显示了每条指令在编译器实际生成的字节码数组中的位置。

RubyVM::InstructionSequence 使得探索 Ruby 如何编译不同 Ruby 脚本变得容易。例如,示例 2-10 演示了如何编译我的 10.times do 示例。

示例 2-10. 显示调用块的 YARV 指令

code = <<END
10.times do |n|
  puts n
end
END
puts RubyVM::InstructionSequence.compile(code).disasm

我现在得到的输出如下所示,在示例 2-11 中显示。注意,send <callinfo!mid:times YARV 指令显示了 block:block in <compiled> ,这表示我正在向 10.times 方法调用传递一个块。

示例 2-11. 调用块和块本身的 YARV 指令

 == disasm: <RubyVM::InstructionSequence:<compiled>@<compiled>>==========
    == catch table
    | catch type: break  st: 0002 ed: 0006 sp: 0000 cont: 0006
    |------------------------------------------------------------------------
    0000 trace            1                                               (   1)
    0002 putobject        10
 0004 send             <callinfo!mid:times, argc:0, block:block in <compiled>>
    0006 leave
 == disasm: <RubyVM::InstructionSequence:block in <compiled>@<compiled>>=
    == catch table
    | catch type: redo   st: 0000 ed: 0011 sp: 0000 cont: 0000
    | catch type: next   st: 0000 ed: 0011 sp: 0000 cont: 0011
    |------------------------------------------------------------------------
    local table (size: 2, argc: 1 [opts: 0, rest: -1, post: 0, block: -1] s3)
    [ 2] n<Arg>
    0000 trace            256                                             (   1)
    0002 trace            1                                               (   2)
    0004 putself
    0005 getlocal_OP__WC__0 2
    0007 opt_send_simple  <callinfo!mid:puts, argc:1, FCALL|ARGS_SKIP>
    0009 trace            512                                             (   3)
    0011 leave                                                            (   2)

正如你所看到的,Ruby 将两个 YARV 指令片段分别显示。第一个对应全局作用域 ,第二个对应内部块作用域

本地表

在图 2-3 到图 2-14 之间,你可能已经注意到,AST 中的每个 NODE_SCOPE 元素包含了我标记为 tableargs 的信息。这些值存在于内部的 NODE_SCOPE 结构中,包含关于块参数 n 的信息(请参见图 2-9)。

Ruby 在解析过程中生成了关于这个块参数的信息。正如我在第一章中讨论的那样,Ruby 会使用语法规则解析块参数以及其他 Ruby 代码。实际上,我在图 1-30 中展示了解析块参数的具体规则:opt_block_param

然而,一旦 Ruby 的编译器运行时,关于块参数的信息会被从 AST 中复制到另一个数据结构中,这个结构被称为局部表格,并保存在新生成的 YARV 指令附近。你的 Ruby 程序中的每个 YARV 指令片段,每个作用域,都有它自己的局部表格。

图 2-15 展示了 Ruby 为示例 2-2 中的示例块代码生成的 YARV 指令和附带的局部表格。

带有局部表格的 YARV 指令片段

图 2-15. 一个带有局部表格的 YARV 指令片段

注意在图 2-15 的右侧,Ruby 已经将数字 2 与块参数 n 关联。如我们在第三章中将看到的那样,引用 n 的 YARV 指令将使用这个索引 2。getlocal 指令就是一个例子。<Arg> 表示该值是块的一个参数。

事实证明,Ruby 还会在这个表格中保存关于局部变量的信息,因此这个表格被称为局部表格。图 2-16 展示了 Ruby 在编译使用一个局部变量并接收两个参数的方法时,会生成的 YARV 指令和局部表格。

这个局部表格包含一个局部变量和两个参数。

图 2-16. 这个局部表格包含一个局部变量和两个参数。

在这里,你可以看到 Ruby 在局部表格中列出了所有三个值。正如我们在第三章中将看到的那样,Ruby 以相同的方式对待局部变量和方法参数。(请注意,局部变量 sum 没有 <Arg> 标签。)

可以把本地表格看作是帮助你理解 YARV 指令的钥匙,类似于地图上的图例。如同在图 2-16 中所示,本地变量没有标签,但 Ruby 使用以下标签来描述不同类型的方法和块参数:

<Arg> 一个标准的函数或块参数
<Rest> 一个无名参数数组,通过 splat (*) 运算符一起传递
<Post> 一个出现在 splat 数组之后的标准参数
<Block> 一个 Ruby proc 对象,通过 & 运算符传递
<Opt=i> 定义了默认值的参数。整数值 i 是一个索引,指向一个存储实际默认值的表格。这个表格与 YARV 代码片段一起存储,但不在本地表格中。

理解本地表格显示的信息可以帮助你理解 Ruby 复杂的参数语法如何工作,以及如何充分利用这门语言。

为了帮助你理解我的意思,让我们来看一下 Ruby 如何编译一个使用无名参数数组的函数调用,如示例 2-12 所示。

示例 2-12. 一个接受标准参数和无名参数数组的方法

def complex_formula(a, b, *args, c)
  a + b + args.size + c
end

这里 abc 是标准参数,args 是一个位于 bc 之间的其他参数数组。图 2-17 展示了本地表格如何保存所有这些信息。

如同在图 2-16 中所示,<Arg> 代表标准参数。但现在 Ruby 使用 <Rest> 来表示值 3 包含“其余”参数,并使用 <Post> 来表示值 2 包含出现在无名数组之后的参数,即最后一个参数。

Ruby 将有关特殊参数的信息保存在本地表格中。

图 2-17. Ruby 将有关特殊参数的信息保存在本地表格中。

编译可选参数

如你所知,你可以通过在参数列表中为某个参数指定默认值,使该参数变为可选的。之后,如果你在调用方法或块时没有提供该参数的值,Ruby 会使用默认值。示例 2-13 展示了一个简单的例子。

示例 2-13. 一个接受可选参数的方法

def add_two_optional(a, b = 5)
  sum = a+b
end

如果你为 b 提供一个值,方法将按照以下方式使用该值:

puts add_two_optional(2, 2)
 => 4

但如果你不提供,Ruby 会将默认值 5 赋给b

puts add_two_optional(2)
 => 7

在这种情况下,Ruby 需要做更多工作。默认值放在哪里?Ruby 编译器将其放在哪里?图 2-18 显示了 Ruby 如何在编译过程中生成一些额外的 YARV 指令来设置默认值。

Ruby 的编译器生成额外的代码来处理可选参数。

图 2-18. Ruby 的编译器生成额外的代码来处理可选参数。

Ruby 的编译器生成了加粗的 YARV 指令putobjectsetlocal,在你调用方法时将b的值设为 5。(正如我们在第三章中看到的那样,YARV 会在你没有为b提供值时调用这些指令,但如果你提供了值,它们则会被跳过。)你还可以看到,Ruby 在本地表中将可选参数b列出为b<Opt=0>。这里的0是一个索引,指向一个存储所有参数默认值的表。Ruby 将这些数据存储在接近 YARV 代码片段的位置,而不是在本地表中本身。

编译关键字参数

在 Ruby 2.0 中,我们可以为每个方法或块参数指定一个名称和默认值。这样写的参数被称为关键字参数。例如,示例 2-14 展示了使用 Ruby 2.0 新关键字参数语法声明的相同参数b

示例 2-14. 一个接受关键字参数的方法

def add_two_keyword(a, b: 5)
  sum = a+b
end

现在要为b提供一个值,我需要使用它的名称:

puts add_two_keyword(2, b: 2)
 => 4

或者,如果我根本不指定b,Ruby 将使用默认值:

puts add_two_keyword(2)
 => 7

Ruby 是如何编译关键字参数的?图 2-19 显示 Ruby 需要为方法的 YARV 代码片段添加相当多的额外代码。

Ruby 编译器生成更多指令来处理关键字参数。

图 2-19. Ruby 编译器生成更多指令来处理关键字参数。

Ruby 的编译器生成了所有加粗的 YARV 指令,共 13 条新指令,用于实现关键字参数b。第三章和第四章中,我会详细讲解 YARV 的工作原理以及这些指令的实际含义,但现在,我们可以大致猜测这里发生了什么:

  • 在本地表中,我们可以看到一个新的神秘值,显示为[ 3]?

  • 在图 2-19 的左侧,新的 YARV 指令调用了key?delete方法。

哪个 Ruby 类包含key?delete方法?是Hash。 图 2-19 显示了 Ruby 必须使用一个内部的、隐藏的哈希对象来实现关键字参数的证据。所有这些额外的 YARV 指令会自动在我的方法中添加一些逻辑,用于检查这个哈希中是否有参数b。如果 Ruby 在哈希中找到了b的值,它就使用它。如果没有,它就使用默认值 5。局部表中的神秘元素[3]?必须就是这个隐藏的哈希对象。

实验 2-2:显示局部表

除了 YARV 指令,RubyVM::InstructionSequence 还会显示与每个 YARV 片段或作用域相关的局部表。查找并理解你代码的局部表将帮助你理解相应的 YARV 指令的作用。在这个实验中,我们将查看RubyVM::InstructionSequence对象生成的输出中局部表的位置。

示例 2-15 重复了示例 2-10,来自实验 2-1:显示 YARV 指令。

示例 2-15. 显示调用块的 YARV 指令

code = <<END
10.times do |n|
  puts n
end
END

puts RubyVM::InstructionSequence.compile(code).disasm

而示例 2-16 重复了我们在实验 2-1:显示 YARV 指令中看到的输出。

示例 2-16. 除了 YARV 指令,RubyVM::InstructionSequence 显示局部表。

    == disasm: <RubyVM::InstructionSequence:<compiled>@<compiled>>==========
    == catch table
    | catch type: break  st: 0002 ed: 0006 sp: 0000 cont: 0006
    |------------------------------------------------------------------------
    0000 trace            1                                               (   1)
    0002 putobject        10
    0004 send             <callinfo!mid:times, argc:0, block:block in <compiled>>
    0006 leave
    == disasm: <RubyVM::InstructionSequence:block in <compiled>@<compiled>>=
    == catch table
    | catch type: redo   st: 0000 ed: 0011 sp: 0000 cont: 0000
    | catch type: next   st: 0000 ed: 0011 sp: 0000 cont: 0011
    |------------------------------------------------------------------------
 local table (size: 2, argc: 1 [opts: 0, rest: -1, post: 0, block: -1] s3)
 [ 2] n<Arg>
    0000 trace            256                                             (   1)
    0002 trace            1                                               (   2)
    0004 putself
    0005 getlocal_OP__WC__0 2
    0007 opt_send_simple  <callinfo!mid:puts, argc:1, FCALL|ARGS_SKIP>
    0009 trace            512                                             (   3)
    0011 leave                                                            (   2)

在内层作用域的 YARV 代码片段——即块——上方,我们可以看到其局部表的信息,位于 。这显示了表的总大小(size: 2)、参数计数(argc: 1)以及其他关于参数类型的信息(opts: 0, rest: -1, post: 0)。

第二行 显示了局部表的实际内容。在这个示例中,我们只有一个参数,n

示例 2-17 展示了如何以相同的方式使用RubyVM::InstructionSequence来编译我的未命名参数示例,参考示例 2-12。

示例 2-17. 该方法使用带星号操作符的未命名参数。

code = <<END
def complex_formula(a, b, *args, c)
  a + b + args.size + c
end
END

puts RubyVM::InstructionSequence.compile(code).disasm

而示例 2-18 展示了输出结果。

示例 2-18. 显示调用块的 YARV 指令输出

 == disasm: <RubyVM::InstructionSequence:<compiled>@<compiled>>==========
    0000 trace            1                                               (   1)
    0002 putspecialobject 1
    0004 putspecialobject 2
    0006 putobject        :complex_formula
    0008 putiseq          complex_formula
 0010 opt_send_simple  <callinfo!mid:core#define_method, argc:3, ARGS_SKIP>
    0012 leave
    == disasm: <RubyVM::InstructionSequence:complex_formula@<compiled>>=====
 local table (size: 5, argc: 2 [opts: 0, rest: 2, post: 1, block: -1] s0)
 [ 5] a<Arg>     [ 4] b<Arg>     [ 3] args<Rest> [ 2] c<Post>
    0000 trace            8                                               (   1)
    0002 trace            1                                               (   2)
    0004 getlocal_OP__WC__0 5
    0006 getlocal_OP__WC__0 4
    0008 opt_plus         <callinfo!mid:+, argc:1, ARGS_SKIP>
    0010 getlocal_OP__WC__0 3
    0012 opt_size         <callinfo!mid:size, argc:0, ARGS_SKIP>
    0014 opt_plus         <callinfo!mid:+, argc:1, ARGS_SKIP>
    0016 getlocal_OP__WC__0 2
    0018 opt_plus         <callinfo!mid:+, argc:1, ARGS_SKIP>
    0020 trace            16                                              (   3)
    0022 leave                                                            (   2)

顶层 YARV 作用域,位于附近,展示了 YARV 用来定义新方法的指令。请注意在处调用了core#define_method,这是一个 YARV 用于创建 Ruby 新方法的内部 C 函数。这对应于我脚本中调用def complex_formula的部分。(我将在第五章、第六章和第九章中更详细地讨论 Ruby 是如何实现方法的。)

请注意位于处的较低 YARV 片段的局部表。此行现在显示了更多关于未命名参数(rest: 2)和它们后面的最后一个标准参数(post: 1)的信息。最后,位于的这一行显示了我之前在图 2-17 中展示的局部表内容。

总结

在本章中,我们学习了 Ruby 是如何编译我们的代码的。你可能认为 Ruby 是一个动态脚本语言,但事实上,它使用的编译器和 C、Java 以及许多其他编程语言一样。明显的区别在于,Ruby 的编译器自动在后台运行;你不需要担心编译 Ruby 代码。

我们已经了解到,Ruby 的编译器通过遍历由分词和解析过程生成的抽象语法树(AST)来工作,并在此过程中生成一系列字节码指令。Ruby 将您的代码从 Ruby 语言转换为专门为 YARV 虚拟机量身定制的语言,它会将 Ruby 程序中的每个作用域或部分编译成一组或一段 YARV 指令。您程序中的每个块、方法、lambda 或其他作用域都有一组对应的字节码指令。

我们也已经了解了 Ruby 如何处理不同类型的参数。我们能够使用本地表作为一个关键或图例,帮助理解哪些 YARV 指令访问了哪些参数或局部变量。并且我们看到了 Ruby 的编译器如何生成额外的特殊 YARV 指令来处理可选参数和关键字参数。

在第三章中,我将开始解释 YARV 如何执行编译器生成的指令——也就是 YARV 如何执行你的 Ruby 程序。

第三章:Ruby 如何执行你的代码

没有标题的图片

YARV 不仅仅是一个栈机器——它是一个双栈机器!

现在 Ruby 已经对你的代码进行了标记化、解析和编译,终于准备好执行它了。但它究竟是如何执行的呢?我们已经看到 Ruby 编译器是如何创建 YARV(又一个 Ruby 虚拟机)指令的,但 YARV 是如何实际执行这些指令的呢?它是如何跟踪变量、返回值和参数的?它是如何实现 if 语句和其他控制结构的?

Koichi Sasada 和 Ruby 核心团队设计了 YARV,使其使用栈指针和程序计数器——也就是说,像计算机的实际微处理器一样工作。在本章中,我将探讨 YARV 指令的基础知识;具体来说,它们是如何从内部栈中弹出参数并将返回值推送到栈上的。我们还将看到 YARV 如何跟踪你的 Ruby 调用栈以及它自己的内部栈。我将解释 Ruby 如何访问局部变量,以及它如何通过动态访问找到调用栈更深层次的变量。最后,我们将了解 Ruby 如何实现特殊变量。在第四章中,我将继续讨论 YARV,分析它是如何实现控制结构和方法分发的。

路线图

  • YARV 的内部栈与你的 Ruby 栈

    • 逐步了解 Ruby 如何执行一个简单脚本

    • 执行对块的调用

    • 仔细查看 YARV 指令

  • 实验 3-1:基准测试 Ruby 2.0 和 Ruby 1.9 与 Ruby 1.8 的性能

    • Ruby 变量的局部和动态访问

    • 局部变量访问

    • 方法参数像局部变量一样处理

    • 动态变量访问

    • 在 C 中爬升环境指针阶梯

  • 实验 3-2:探索特殊变量

    • 特殊变量的权威列表
  • 总结

YARV 的内部栈和你的 Ruby 栈

正如我们稍后会看到的,YARV 在内部使用栈来追踪中间值、参数和返回值。YARV 是一个栈导向的虚拟机。

除了它自身的内部栈,YARV 还会追踪你的 Ruby 程序的调用栈,记录哪些方法调用了哪些其他方法、函数、块、lambda 等等。事实上,YARV 不仅仅是一个栈机——它是一个双栈机!它不仅需要追踪自己内部指令的参数和返回值,还要追踪你 Ruby 程序的参数和返回值。

图 3-1 展示了 YARV 的基本寄存器和内部栈。

YARV 的一些内部寄存器,包括程序计数器和栈指针

图 3-1. YARV 的一些内部寄存器,包括程序计数器和栈指针

YARV 的内部栈位于左侧。SP标签表示栈指针,即栈顶的位置。右侧是 YARV 正在执行的指令。PC程序计数器,即当前指令的位置。

你可以在图 3-1 的右侧看到 Ruby 从puts 2+2示例编译出的 YARV 指令。YARV 将SPPC寄存器存储在一个名为rb_control_frame_t的 C 结构体中,同时还包含一个type字段、Ruby 的self变量的当前值以及一些未在此展示的其他值。

与此同时,YARV 还维护着另一组rb_control_frame_t结构体栈,如图 3-2 所示。

YARV 通过一系列 rb_control_frame_t 结构体来追踪你的 Ruby 调用栈。

图 3-2. YARV 通过一系列rb_control_frame_t 结构体来追踪你的 Ruby 调用栈。

这第二个rb_control_frame_t结构体栈代表了 YARV 在执行你的 Ruby 程序时的路径以及当前的位置。换句话说,这就是你的 Ruby 调用栈——如果你运行puts caller,你会看到的内容。

CFP指针表示当前帧指针。你 Ruby 程序栈中的每一个栈帧依次包含不同的selfPCSP寄存器的值,如图 3-1 所示。每个rb_control_frame_t结构中的type字段表示在 Ruby 调用栈的这个级别上正在运行的代码类型。当 Ruby 调用方法、块或其他程序结构时,type可能被设置为METHODBLOCK或其他一些值。

分步解析 Ruby 如何执行一个简单的脚本

为了帮助你更好地理解,下面是一些示例。我将从第一章和第二章中的简单2+2示例开始,并在示例 3-1 中再次展示。

示例 3-1. 我们将执行的一个单行 Ruby 程序

puts 2+2

这个单行的 Ruby 脚本没有 Ruby 调用栈,所以我现在将重点介绍 YARV 的内部栈。图 3-3 展示了 YARV 如何执行该脚本,从第一条指令trace开始。

左侧是 YARV 的内部栈,右侧是我编译后的 puts 2+2 程序。

图 3-3. 左侧是 YARV 的内部栈,右侧是我编译后的puts 2+2程序。

如图 3-3 所示,YARV 将程序计数器(PC)设置为第一条指令,最初栈是空的。接下来,YARV 将执行trace指令,递增PC寄存器,如图 3-4 所示。

Ruby 执行第一条指令 trace。

图 3-4. Ruby 执行第一条指令trace

Ruby 使用trace指令来支持set_trace_func功能。如果你调用set_trace_func并提供一个函数,Ruby 将在每次执行一行 Ruby 代码时调用它。

接下来,YARV 执行putself,并将当前self的值推入栈中,如图 3-5 所示。

putself 将顶层  值推入栈中。

图 3-5. putselftop self 值推入栈中。

由于这个简单脚本不包含 Ruby 对象或类,self 指针被设置为默认的 top self 对象。这是 Ruby 在 YARV 启动时自动创建的 Object 类的一个实例。它作为方法调用的接收者,并且作为顶层作用域中的实例变量容器。top self 对象包含一个预定义的 to_s 方法,它返回字符串 main。你可以通过在控制台中运行以下命令来调用这个方法:

$ **ruby -e 'puts self'**
 => main

当 YARV 执行 opt_send_simple 指令时,它将使用栈中的 self 值:selfputs 方法的接收者,因为我没有为此方法调用指定接收者。

接下来,YARV 执行 putobject 2。它将数值 2 推入栈中,并再次增加 PC,如图 3-6 所示。

Ruby 将值 2 推入栈中,作为  方法的接收者。

图 3-6. Ruby 将值 2 推入栈中,作为 + 方法的接收者。

这是接收者(参数)操作模式的第一步,描述在 如何 Ruby 编译一个简单脚本 中。首先,Ruby 将接收者推入内部 YARV 栈中。在这个例子中,Fixnum 对象 2+ 消息/方法的接收者,它接受一个参数,也是 2。接下来,Ruby 推入参数 2,如图 3-7 所示。

Ruby 将另一个值 2 推入栈中,作为  方法的参数。

图 3-7. Ruby 将另一个值 2 推入栈中,作为 + 方法的参数。

最后,Ruby 执行 + 操作。在这种情况下,opt_plus 是一条优化指令,将会加法运算两个值:接收者和参数,如图 3-8 所示。

 指令计算 2 + 2 = 4。

图 3-8. opt_plus 指令计算 2 + 2 = 4。

如你在图 3-8 中看到的,opt_plus 指令将结果4保留在栈顶。现在,Ruby 完美地准备好执行 puts 函数调用:接收者 self 位于栈的最底层,单一参数 4 位于栈顶。(我将在第六章中描述方法查找的工作原理。)

接下来,图 3-9 展示了 Ruby 执行 puts 方法调用时发生的情况。如你所见,opt_send_simple 指令将返回值 nil 留在栈顶。最后,Ruby 执行最后一条指令 leave,这完成了我们简单的一行 Ruby 程序的执行。当然,当 Ruby 执行 puts 调用时,实际执行 puts 函数的 C 代码会在控制台输出中显示值 4

Ruby 在 top self 对象上调用  方法。

图 3-9. Ruby 在 top self 对象上调用 puts 方法。

执行对块的调用

接下来让我们看看 Ruby 调用栈是如何工作的。在示例 3-2 中,您将看到一个稍微复杂一点的示例,这是一个简单的 Ruby 脚本,调用块 10 次,并打印出一个字符串。

示例 3-2. 本示例程序调用块 10 次。

10.times do
  puts "The quick brown fox jumps over the lazy dog."
end

让我们跳过一些步骤,直接开始从 YARV 即将调用 times 方法的位置,如图 3-10 所示。

每个 Ruby 程序都从这两个控制帧开始。

图 3-10. 每个 Ruby 程序都从这两个控制帧开始。

图中左侧显示的是 Ruby 正在执行的 YARV 指令。右侧则展示了两个控制帧结构。

在栈底,您会看到一个类型设置为 TOP 的控制帧。Ruby 在启动新程序时总是首先创建此帧。在栈顶,至少最初,类型为 EVAL 的帧对应于 Ruby 脚本的顶层或主作用域。

接下来,Ruby 调用 Fixnum 对象 10 上的 times 消息——times 消息的接收者。当 Ruby 这么做时,它在控制帧栈中添加了一个新的层级,如图 3-11 所示。

Ruby 在调用 C 实现的内建函数时使用 CFUNC 帧。

图 3-11. Ruby 在调用 C 实现的内建函数时使用 CFUNC 框架。

这个新条目(位于图 3-11 的右侧)表示程序 Ruby 调用堆栈中的一个新级别,CFP 指针已上移,指向新的控制框架结构。此外,注意到由于 Integer#times 方法是 Ruby 内建的,因此没有针对它的 YARV 指令。相反,Ruby 会调用一些内部 C 代码,从堆栈中弹出参数 10,并调用提供的块 10 次。Ruby 给这个控制框架的类型是 CFUNC

最后,图 3-12 显示了如果我们在内部块暂停程序时,YARV 和控制框架堆栈的样子。

当我们在块内部暂停代码时的 CFP 堆栈

图 3-12. 当我们在 示例 3-2 的块内部暂停代码时的 CFP 堆栈

现在,控制框架堆栈右侧将有四个条目,如下所示:

  • Ruby 启动时总是存在的 TOPEVAL 框架

  • 调用 10.timesCFUNC 框架

  • 堆栈顶部的 BLOCK 框架,表示块内部运行的代码

仔细看看一个 YARV 指令

和处理其他大多数事物一样,Ruby 使用 C 代码实现所有 YARV 指令,如 putobjectsend,然后将其编译为机器语言,直接由硬件执行。然而,奇怪的是,您不会在 C 源文件中找到每个 YARV 指令的 C 源代码。相反,Ruby 核心团队将 YARV 指令的 C 代码放在一个名为 insns.def 的大文件中。示例 3-3 显示了 insns.def 中的一小段代码,Ruby 在其中实现了 putself YARV 指令。

示例 3-3. putself YARV 指令的定义

    /**
      @c put
      @e put self.
      @j スタックに self をプッシュする。
     */
    DEFINE_INSN
    putself
    ()
    ()
    (VALUE val)
    {
   val = GET_SELF();
    }

这看起来根本不像 C,实际上,大部分内容不是 C 代码。相反,您在这里看到的是一些 C 代码(val = GET_SELF()),出现在 下方,紧跟在 DEFINE_INSN 调用之后。

DEFINE_INSN 代表 定义指令,这并不难理解。事实上,在 Ruby 构建过程中,Ruby 会处理并将 insns.def 文件转换为真正的 C 代码,这类似于 Bison 将 parse.y 文件转换为 parse.c,如 图 3-13 所示。

Ruby 在构建过程中将 YARV 指令定义脚本 insns.def 编译成 C 代码。

图 3-13:Ruby 在构建过程中将 YARV 指令定义脚本 insns.def 编译成 C 代码。

Ruby 使用 Ruby 处理 insns.def 文件:构建过程首先编译一个称为 Miniruby 的较小版本,然后使用它运行一些 Ruby 代码,这些代码处理 insns.def 并将其转换为一个名为 vm.inc 的 C 源代码文件。稍后,Ruby 构建过程将 vm.inc 交给 C 编译器,C 编译器将生成的 C 代码包含在 Ruby 最终的编译版本中。

示例 3-4 展示了 Ruby 处理后,vm.incputself 的代码片段样子。

示例 3-4:putself 的定义在 Ruby 构建过程中被转换成这段 C 代码。

    INSN_ENTRY(putself){
    {
      VALUE val;
      DEBUG_ENTER_INSN("putself");
   ADD_PC(1+0);
      PREFETCH(GET_PC());
      #define CURRENT_INSN_putself 1
      #define INSN_IS_SC()     0
      #define INSN_LABEL(lab)  LABEL_putself_##lab
      #define LABEL_IS_SC(lab) LABEL_##lab##_##t
      COLLECT_USAGE_INSN(BIN(putself));
    {
    #line 282 "insns.def"
     val = GET_SELF();
    #line 408 "vm.inc"
      CHECK_VM_STACK_OVERFLOW(REG_CFP, 1);
   PUSH(val);
    #undef CURRENT_INSN_putself
    #undef INSN_IS_SC
    #undef INSN_LABEL
    #undef LABEL_IS_SC
      END_INSN(putself);}}}

单行代码 val = GET_SELF() 出现在列表的中间位置,如图所示 。在这行代码的上下,Ruby 调用了几个不同的 C 宏来执行各种操作,比如在 处将 1 加到程序计数器(PC)寄存器中,以及在 处将 val 值推送到 YARV 内部堆栈中。如果你查看 vm.inc,你会看到这段 C 代码会为每个 YARV 指令的定义重复多次。

vm.inc C 源代码文件,又被 vm_exec.c 文件包含,该文件包含了主要的 YARV 指令循环,逐条执行程序中的 YARV 指令,并调用每个指令对应的 C 代码。

实验 3-1:基准测试 Ruby 2.0 与 Ruby 1.9 和 Ruby 1.8 的性能

Ruby 核心团队在 Ruby 1.9 中引入了 YARV 虚拟机。更早版本的 Ruby 通过直接遍历 抽象语法树(AST) 的节点来执行程序。当时没有编译步骤:Ruby 只是将代码分词、解析,然后立即执行。

Ruby 1.8 运行得非常顺畅。事实上,多年来它一直是最常用的版本。那么,为什么 Ruby 核心团队要额外编写一个编译器和新的虚拟机呢?答案是:速度。使用 YARV 执行已编译的 Ruby 程序比直接遍历抽象语法树(AST)要快得多。

YARV 快多少?让我们来看看!在这个实验中,我们将通过执行示例 3-5 中显示的非常简单的 Ruby 脚本,测量 Ruby 2.0 和 1.9 相较于 Ruby 1.8 的执行速度。

示例 3-5。用于基准测试 Ruby 2.0 和 Ruby 1.9 相对于 Ruby 1.8 的简单测试脚本

i = 0
while i < ARGV[0].to_i
  i += 1
end

这个脚本从命令行通过ARGV数组接收一个计数值,然后在一个while循环中迭代,直到达到该值。这个 Ruby 脚本非常简单:通过测量在不同的ARGV[0]值下执行该脚本所需的时间,我们应该能很好地判断执行 YARV 指令是否比迭代 AST 节点更快。(没有涉及数据库调用或其他外部代码。)

我们可以使用 Unix 的time命令来测量 Ruby 执行一次迭代所需的时间:

$ **time ruby benchmark1.rb** 1
ruby benchmark1.rb 1  0.02s user 0.00s system 92% cpu 0.023 total

十次:

$ **time ruby benchmark1.rb 10**
ruby benchmark1.rb 10  0.02s user 0.00s system 94% cpu 0.027 total

依此类推。

图 3-14 显示了 Ruby 1.8.7、1.9.3 和 2.0 在对数坐标图上的测量时间。

Ruby 1.8.7 与 Ruby 1.9.3 和 Ruby 2.0 的性能对比;时间(秒)与迭代次数的对数坐标图

图 3-14。Ruby 1.8.7 与 Ruby 1.9.3 和 Ruby 2.0 的性能对比;时间(秒)与迭代次数的对数坐标图

从图表来看,你可以看到对于短生命周期的进程,比如迭代次数较少的循环(参见图 3-14 的左侧部分),Ruby 1.8.7 实际上比 Ruby 1.9.3 和 2.0 更快,因为不需要将 Ruby 代码编译成 YARV 指令。相反,在对代码进行词法分析和语法解析后,Ruby 1.8.7 立即执行它。图表左侧 Ruby 1.8.7 和 Ruby 1.9.3、2.0 之间的时间差,大约是 0.01 秒,告诉我们 Ruby 1.9.3 或 2.0 需要多长时间将脚本编译成 YARV 指令。你还可以看到,Ruby 2.0 实际上比 Ruby 1.9.3 在短循环中的执行速度稍慢。

然而,在约 11,000 次迭代后,Ruby 1.9.3 和 2.0 的速度更快。这个交叉点出现在通过执行 YARV 指令提供的额外速度开始发挥作用,并弥补了编译时所花费的额外时间。对于长时间运行的进程,例如包含大量迭代的循环(见 图 3-14), Ruby 1.9 和 2.0 的速度约为原来的 4.25 倍!此外,我们可以看到,Ruby 2.0 和 1.9.3 在许多迭代中执行 YARV 指令的速度完全相同。

在 图 3-14 中,这一速度提升在对数图表上看起来并不显著,但如果我们改用线性刻度重新绘制图表的右侧,就像在 图 3-15 中所示,效果就很明显了。

Ruby 1.8.7 与 Ruby 1.9.3 与 Ruby 2.0 性能对比;10 亿或 1 亿次迭代的时间(以秒为单位),使用线性刻度

图 3-15. Ruby 1.8.7 与 Ruby 1.9.3 与 Ruby 2.0 性能对比;10 亿或 1 亿次迭代的时间(以秒为单位),使用线性刻度

差异非常明显!使用 Ruby 1.9.3 或 Ruby 2.0 和 YARV 执行这个简单的 Ruby 脚本,比使用没有 YARV 的 Ruby 1.8.7 快大约 4.25 倍。

Ruby 变量的局部与动态访问

在上一节中,我们看到 Ruby 维护了 YARV 使用的内部栈以及你的 Ruby 程序调用栈。但在这两个代码示例中,显然有一样东西被遗漏了:变量。两个脚本都没有使用任何 Ruby 变量。一个更现实的示例程序会多次使用变量。那么 Ruby 如何在内部处理变量?它们存储在哪里?

Ruby 将你保存在变量中的所有值存储在 YARV 的栈上,连同 YARV 指令的参数和返回值。然而,访问这些变量并不简单。Ruby 在内部使用两种非常不同的方法来保存和检索你保存在变量中的值:局部访问动态访问

局部变量访问

每当你进行方法调用时,Ruby 会在 YARV 栈上为你调用的该方法中的任何局部变量预留空间。Ruby 通过查阅在编译步骤中为每个方法创建的 局部表 来知道你使用了多少变量,详见 局部表。

例如,假设我们编写了你在图 3-16 中看到的那个傻乎乎的 Ruby 函数。

使用局部变量的示例 Ruby 脚本

图 3-16. 使用局部变量的示例 Ruby 脚本

Ruby 代码位于图形的左侧;右侧是一个展示 YARV 栈和栈指针的示意图。你可以看到,Ruby 将变量保存在栈上,位于栈指针下方。(请注意,栈上为 str 值预留了一个空间,位于栈指针下方的三个槽,即 SP-3。)

Ruby 使用 svar/cref 来包含以下两者之一:要么是指向当前方法中特殊变量表的指针(例如,$! 表示 最后的异常消息$& 表示 最后的正则表达式匹配),要么是指向当前词法作用域的指针。词法作用域 表示你当前正在为哪个类或模块添加方法。(在实验 3-2:探索特殊变量中,我们将更详细地探索特殊变量,而在第六章中,我会进一步讨论词法作用域。)Ruby 使用第一个槽——special 变量——来跟踪与块相关的信息。(稍后我们将讨论动态变量访问时进一步讲解。)

当示例代码将一个值保存到 str 时,Ruby 只需要将该值写入栈上的相应空间,如图 3-17 所示。

Ruby 将局部变量保存在栈上,接近环境指针(EP)。

图 3-17. Ruby 将局部变量保存在栈上,接近环境指针(EP)。

为了在内部实现这一点,YARV 使用了一个类似于栈指针的指针,称为 EP环境指针。它指向当前方法的局部变量在栈上的位置。最初,EP 设置为 SP-1。随着 YARV 执行指令,SP 的值会变化,而 EP 的值通常保持不变。

图 3-18 展示了 Ruby 将我的 display_string 函数编译成的 YARV 指令。

display_string 方法编译成 YARV 指令

图 3-18. display_string 方法编译成 YARV 指令

Ruby 使用 setlocal YARV 指令来设置局部变量的值。然而,在图 3-18 中,我展示了一条名为 setlocal_OP__WC__0 的指令,而不是 setlocal

事实证明,从 2.0 版本开始,Ruby 使用一个优化过的指令,这个令人困惑的名字代替了简单的 setlocal。不同之处在于,Ruby 2.0 将指令的一个参数 0 包含在了指令名称本身中。

从内部来看,Ruby 2.0 将此称为 操作数 优化。(在优化后的指令名称中,OP 代表 操作数WC 代表 通配符。)换句话说,getlocal_OP__WC__0 等同于 getlocal *, 0,而 setlocal_OP__WC__0setlocal *, 0 相同。现在,该指令只需要一个参数,如 * 所示。这个技巧使得 Ruby 2.0 节省了一些时间,因为它不需要单独传递 0 作为参数。

但为了简化问题,我们暂时忽略操作数优化。图 3-19 重复了我的例子的 YARV 指令,但显示了 getlocalsetlocal,并且第二个操作数是正常列出的。

未进行操作数优化时显示字符串的编译版本

图 3-19. 未进行操作数优化时的 display_string 编译版本

这样更容易理解。正如你所看到的,首先,putstring 指令将 Local access 字符串保存在栈顶,并增加 SP 指针。然后,YARV 使用 setlocal 指令获取栈顶的值,并将其保存在栈上为 str 局部变量分配的空间中。图 3-19 左侧的两个虚线箭头显示了 setlocal 指令复制值的过程。这种操作叫做 局部变量访问

为了确定设置哪个变量,setlocal 使用 EP 指针和作为第一个参数提供的数字索引。在本例中,这将是 str 的地址 = EP-2。我们将在动态变量访问中讨论第二个参数 0 的含义。

接下来,对于 puts str 的调用,Ruby 使用 getlocal 指令,如图 3-20 所示。

使用 getlocal 获取局部变量的值

图 3-20. 使用 getlocal 获取局部变量的值

在这里,Ruby 将字符串值推送回栈顶,可以作为调用 puts 函数的参数使用。同样,getlocal 的第一个参数 2 表示访问哪个局部变量。Ruby 使用局部变量表来查找 2 对应的是变量 str

方法参数像局部变量一样处理

传递一个方法参数的方式与访问局部变量相同,如图 3-21 所示。

Ruby 像处理局部变量一样将方法参数保存在栈上。

图 3-21. Ruby 像处理局部变量一样将方法参数保存在栈上。

方法参数本质上与局部变量相同。两者之间的唯一区别是调用代码在方法调用之前就将参数推送到栈上。在这个示例中没有局部变量,但唯一的参数出现在栈上,像局部变量一样,如图 3-22 所示。

调用代码在方法调用前保存参数值。

图 3-22. 调用代码在方法调用前保存参数值。

动态变量访问

现在让我们看看动态变量访问是如何工作的,以及那个 special 值是什么。Ruby 使用动态访问时,当你使用在其他作用域中定义的变量时——例如,当你编写一个引用周围代码中值的块时。示例 3-6 展示了一个示例。

示例 3-6. 块中的代码访问周围方法中的 str

def display_string
  str = "Dynamic access."
  10.times do
    puts str
  end
end

这里,strdisplay_string 中的局部变量。如图 3-23 所示,Ruby 会使用 setlocal 指令保存 str,就像我们在图 3-18 中看到的一样。

Ruby 像往常一样将  局部变量的值保存在栈上。

图 3-23. Ruby 像往常一样将 str 局部变量的值保存在栈上。

接下来,Ruby 将调用 10.times 方法,并将一个块作为参数传入。让我们逐步了解调用带有块的方法的过程。图 3-24 展示了我们在 图 3-10、图 3-11 和 图 3-12 中看到的相同过程,不过它提供了更多关于 YARV 内部栈的细节。

当 Ruby 调用一个方法并传入块时,它会将指向新的 rb_block_t 结构体的指针保存在新栈帧中的特殊值。

图 3-24. 当 Ruby 调用一个方法并传入块时,它会将指向新的 rb_block_t 结构体的指针保存在新栈帧中的特殊值。

注意栈上的值 10:这是 times 方法的实际接收者。还要注意,Ruby 为实现 Integer#times 的 C 代码创建了一个新的栈帧,栈帧中的 svar/crefspecial 变量位于值 10 之上。因为我们传递了一个块作为方法调用的参数,Ruby 会在新栈帧中的 special 变量中保存指向该块的指针。YARV 栈上的每个方法调用帧都通过这个 special 变量来跟踪是否存在块参数。(我将在 第八章 中更详细地讨论块和 rb_block_t 结构。)

现在,Integer#times 方法会调用块中的代码 10 次。图 3-25 展示了 Ruby 执行块内代码时,YARV 栈的状态。

如果我们在块内部暂停执行,YARV 栈会是什么样子

图 3-25. 如果我们在块内部暂停执行,YARV 栈会是什么样子

就像我们在 Figure 3-17")到 Figure 3-22 中看到的那样,Ruby 将EP设置为指向每个栈帧中special值的位置。Figure 3-25 展示了两个EP值,一个是用于块的新栈帧,位于栈顶,另一个是位于原始方法栈帧中的EP,位于栈底。在 Figure 3-25 中,第二个指针被标记为Previous EP

现在,Ruby 执行块内部的puts str代码时会发生什么呢?Ruby 需要获取局部变量str的值,并将其作为参数传递给puts函数。但请注意,在 Figure 3-25 中,str 位于栈的下方。它不是块内部的局部变量,而是周围方法display_string中的变量。Ruby 如何在执行块内代码时从栈下方获取该值呢?

这就是动态变量访问的作用,也是为什么 Ruby 需要每个栈帧中的这些special值。Figure 3-26 展示了动态变量访问是如何工作的。

Ruby 使用动态变量访问从栈下方获取 str 的值

图 3-26. Ruby 使用动态变量访问从栈下方获取 str 的值

虚线箭头表示动态变量访问:getlocal YARV 指令将str的值从下方的栈帧(来自父级或外部 Ruby 作用域)复制到栈顶,块可以访问它。请注意,EP指针形成了一种阶梯,Ruby 可以沿着这条阶梯向上爬,以访问父作用域、祖父作用域等中的局部变量。

在 Figuere 3-26 中的getlocal 2, 1调用中,第二个参数1告诉 Ruby 从哪里找到变量。在这个例子中,Ruby 将沿着EP指针的阶梯向下移动一层,找到str。也就是说,1表示从块的作用域步进到周围方法的作用域。

示例 3-7 展示了另一个动态变量访问的例子。

示例 3-7. 在此示例中,Ruby 会通过动态变量访问步进两级栈来查找str

def display_string
  str = "Dynamic access."
  10.times do
    10.times do
      puts str
    end
  end
end

如果我有两个嵌套的代码块,如示例 3-7 所示,Ruby 会使用getlocal 2, 2而不是getlocal 2, 1

在 C 语言中爬升环境指针阶梯

让我们来看一下getlocal的实际 C 语言实现。与大多数 YARV 指令一样,Ruby 在insns.def代码文件中实现了getlocal,使用了示例 3-8 中的代码。

示例 3-8. getlocal YARV 指令的 C 语言实现

    /**
      @c variable
      @e Get local variable (pointed by `idx' and `level').
         'level' indicates the nesting depth from the current block.
      @j level, idx で指定されたローカル変数の値をスタックに置く。
         level はブロックのネストレベルで、何段上かを示す。
     */
    DEFINE_INSN
    getlocal
    (lindex_t idx, rb_num_t level)
    ()
    (VALUE val)
    {
        int i, lev = (int)level;
     VALUE *ep = GET_EP();

        for (i = 0; i < lev; i++) {
         ep = GET_PREV_EP(ep);
        }
     val = *(ep - idx);
    }

首先,GET_EP返回当前作用域的EP。(此宏在vm_insnhelper.h文件中定义,此外还有许多与 YARV 指令相关的其他宏。)接下来,Ruby 遍历EP指针,从当前作用域移动到父作用域,然后再从父作用域移动到祖父作用域,反复取消引用EP指针。Ruby 使用GET_PREV_EP宏在处(同样在vm_insnhelper.h中定义)从一个EP移动到另一个。level参数告诉 Ruby 需要迭代多少次,或者爬升多少级阶梯。

最后,Ruby 使用idx参数在处获取目标变量,该参数是目标变量的索引。因此,这行代码从目标变量获取值。

val = *(ep – idx);

这段代码意味着以下内容:

  • 从之前通过GET_PREV_EP迭代获取的目标作用域epEP地址开始。

  • 从这个地址减去idx。整数值idxgetlocal提供了你想要从局部表中加载的局部变量的索引。换句话说,它告诉getlocal目标变量在栈上有多深。

  • 从调整后的地址获取 YARV 栈中的值。

因此,在图 3-26 中对getlocal的调用中,YARV 会从 YARV 栈上一级的作用域获取EP,并减去索引值str(在此案例中是2),以获取指向str变量的指针。

getlocal 2, 1

实验 3-2:探索特殊变量

在 图 3-16 到 图 3-26 中,我向你展示了在栈的 EP-1 位置上一个名为 svar/cref 的值。这两个值是什么,Ruby 如何在栈的一个位置保存两个值?更重要的是,为什么 Ruby 要这样做?让我们一探究竟。

通常,栈中的 EP-1 插槽将包含 svar 值,这是指向该栈帧中定义的任何特殊变量的表的指针。在 Ruby 中,特殊变量 是指 Ruby 根据环境或最近的操作自动创建的值。例如,Ruby 将 $* 设置为 ARGV 数组,将 $! 设置为最后引发的异常。

所有特殊变量都以美元符号($)开头,这通常表示全局变量。这是否意味着特殊变量是全局变量?如果是的话,为什么 Ruby 要在栈上保存指向它们的指针?

为了回答这个问题,我们来创建一个简单的 Ruby 脚本,使用正则表达式匹配一个字符串。

/fox/.match("The quick brown fox jumped over the lazy dog.\n")
puts "Value of $& in the top level scope: #{$&}"

在这里,我使用正则表达式匹配字符串中的 fox,然后使用 $& 特殊变量打印匹配的字符串。以下是在控制台运行时得到的输出。

$ **ruby regex.rb**
Value of $& in the top level scope: fox

示例 3-9 显示了另一个示例,这次是搜索相同的字符串两次:第一次在顶层作用域中,第二次在方法调用内部。

示例 3-9. 从两个不同作用域引用 $&

    str = "The quick brown fox jumped over the lazy dog.\n"
 /fox/.match(str)

    def search(str)
   /dog/.match(str)
   puts "Value of $& inside method: #{$&}"
    end
    search(str)

 puts "Value of $& in the top level scope: #{$&}"

这是简单的 Ruby 代码,但仍然可能有些令人困惑。下面是它的工作原理:

  • 我们在顶层作用域中搜索字符串 fox,如图所示 。这与之匹配,并将 fox 保存到 $& 特殊变量中。

  • 我们调用 search 方法,并在 搜索 dog 字符串。然后我立即在方法内部使用同一个 $& 变量打印匹配结果,如图所示

  • 最后,我们返回到顶层作用域,并再次在 打印 $& 的值。

运行此测试将得到以下输出。

$ **ruby regex_method.rb**
Value of $& inside method: dog
Value of $& in the top level scope: fox

这是我们预期的情况,但请稍微考虑一下。显然,$& 变量不是全局的,因为它在 Ruby 脚本的不同地方具有不同的值。Ruby 在执行 search 方法时会保留顶层作用域中 $& 的值,这让我能够从原始搜索中打印出匹配的单词 fox。Ruby 通过在栈的每一层使用 svar 值保存一组单独的特殊变量来支持这种行为,正如在图 3-27 中所示。

每个栈帧都有自己的特殊变量集。

图 3-27. 每个栈帧都有自己的特殊变量集。

请注意,Ruby 将 fox 字符串保存在由 svar 指针引用的顶层作用域的表格中,将 dog 字符串保存在另一个表格中,该表格用于内方法作用域。Ruby 使用每个栈帧的 EP 指针找到合适的特殊变量表。

Ruby 将实际的全局变量(通过美元符号前缀定义的变量)保存在一个单独的全局哈希表中。无论你在哪里保存或检索一个普通全局变量的值,Ruby 都会访问相同的全局哈希表。

现在再做一次测试:如果我在一个块内执行搜索而不是在方法内呢?示例 3-10 显示了这个新搜索。

示例 3-10. 在块内显示 $& 的值

str = "The quick brown fox jumped over the lazy dog.\n"
/fox/.match(str)

2.times do
  /dog/.match(str)
  puts "Value of $& inside block: #{$&}"
end

puts "Value of $& in the top-level scope: #{$&}"

这是这次在控制台上得到的输出。

$ **ruby regex_block.rb**
Value of $& inside block: dog
Value of $& inside block: dog
Value of $& in the top-level scope: dog

请注意,现在 Ruby 已经用我在块内执行搜索时匹配的单词 dog 覆盖了顶层作用域中 $& 的值!这是有意为之:Ruby 认为顶层和内块作用域对于特殊变量来说是相同的。这类似于动态变量访问的工作方式;我们期望块内的变量与父作用域中的变量具有相同的值。

图 3-28 显示了 Ruby 如何实现这种行为。

Ruby 在块中使用 EP-1 栈位置来处理 cref,其他情况使用 svar。

图 3-28. Ruby 在块中使用 EP-1 栈位置来处理 cref,其他情况使用 svar

如你在 图 3-28 中看到的,Ruby 只有一个用于顶级作用域的特殊变量表。它通过之前的 EP 指针找到特殊变量,EP 指针指向顶级作用域。在块作用域内(因为不需要单独的特殊变量副本),Ruby 利用 EP-1 的空闲槽并将 cref 的值保存到其中。Ruby 使用 cref 值来追踪当前块属于哪个词法作用域。词法作用域是指程序语法结构中的一段代码,并被 Ruby 用来查找常量值。(有关词法作用域的更多信息,请参见 第六章)。具体而言,Ruby 在此处使用 cref 值来实现元编程 API 调用,如 evalinstance_evalcref 值指示给定块是否应该在与父作用域不同的词法作用域中执行。(参见 instance_eval 为新的词法作用域创建一个单例类)。

特殊变量的权威列表

查找 Ruby 所支持的所有特殊变量的准确列表的一个地方就是 C 源代码本身。例如,示例 3-11 是 Ruby C 源代码的一部分,它将你的 Ruby 程序进行标记化,摘自 parse.y 中的 parser_yylex 函数:

示例 3-11。查阅 parse.y 是找到 Ruby 多个特殊变量的确切列表的好方法。

 case '$':
    lex_state = EXPR_END;
    newtok();
    c = nextc();
 switch (c) {
   case '_':            /* $_: last read line string */
        c = nextc();
        if (parser_is_identchar()) {
            tokadd('$');
            tokadd('_');
            break;
        }
        pushback(c);
        c = '_';
        /* fall through */
   case '~':            /* $~: match-data */
      case '*':            /* $*: argv */
      case '$':            /* $$: pid */
      case '?':            /* $?: last status */
      case '!':            /* $!: error string */
      case '@':            /* $@: error position */
      case '/':            /* $/: input record separator */
      case '\\':           /* $\: output record separator */
      case ';':            /* $;: field separator */
      case ',':            /* $,: output field separator */
      case '.':            /* $.: last read line number */
      case '=':            /* $=: ignorecase */
      case ':':            /* $:: load path */
      case '<':            /* $<: reading filename */
      case '>':            /* $>: default output handle */
      case '\"':           /* $": already loaded files */
        tokadd('$');
        tokadd(c);
        tokfix();
        set_yylval_name(rb_intern(tok()));
        return tGVAR;

请注意在 Ruby 匹配了美元符号字符($)。这是一个大型 C switch 语句的一部分,它将你的 Ruby 代码进行标记化——我在 标记:构成 Ruby 语言的单词 中讨论过这个过程。接着是一个内部的 switch 语句,在 匹配接下来的字符。这些字符以及随后的每个 case 语句(在 和之后的 )都对应于 Ruby 的一个特殊变量。

在函数的稍下方,更多的 C 代码(参见 示例 3-12)解析了你在 Ruby 代码中编写的其他特殊变量标记,比如 $& 及相关的特殊变量。

示例 3-12. 这些 case 语句对应于 Ruby 中与正则表达式相关的特殊变量。

 case '&':                /* $&: last match */
    case '`':                /* $`: string before last match */
    case '\'':               /* $': string after last match */
    case '+':                /* $+: string matches last paren. */
      if (last_state == EXPR_FNAME) {
          tokadd('$');
          tokadd(c);
          goto gvar;
      }
      set_yylval_node(NEW_BACK_REF(c));
      return tBACK_REF;

中,你可以看到四个额外的 case 语句,分别对应于特殊变量 $&$`$/$+,这些变量都与正则表达式相关。

最后,在 示例 3-13 中,代码将 $1$2 等进行标记化,生成从上次正则表达式操作中返回的 nth 反向引用特殊变量。

示例 3-13. 这段 C 代码将 Ruby 的 nth 反向引用特殊变量:$1$2 等等。

 case '1': case '2': case '3':
    case '4': case '5': case '6':
    case '7': case '8': case '9':
      tokadd('$');
   do {
          tokadd(c);
          c = nextc();
      } while (c != -1 && ISDIGIT(c));
      pushback(c);
      if (last_state == EXPR_FNAME) goto gvar;
      tokfix();
      set_yylval_node(NEW_NTH_REF(atoi(tok()+1)));
      return tNTH_REF;

中的 case 语句匹配数字 1 到 9,而在 中的 C do...while 循环则会继续处理数字,直到读取完整的数字为止。这使得你能够创建具有多个数字的特殊变量,如 $12

总结

本章我们覆盖了很多内容。我们首先探讨了 Ruby 如何追踪两个栈:YARV 使用的内部栈和你的 Ruby 调用栈。接着,我们看到了 YARV 如何执行两个简单的 Ruby 程序:一个计算 2 + 2 = 4,另一个调用一个块 10 次。在 实验 3-1:基准测试 Ruby 2.0 和 Ruby 1.9 与 Ruby 1.8 中,我们了解到,在 Ruby 2.0 和 1.9 中执行 YARV 指令几乎比在 Ruby 1.8 中快四倍,而后者是直接从 AST 执行程序。

我们继续研究了 Ruby 如何使用两种方法在内部 YARV 栈上保存变量:局部变量和动态变量访问。我们还看到 Ruby 如何处理方法参数,方式与局部变量相同。在 实验 3-2:探索特殊变量 中,我们探讨了 Ruby 如何处理特殊变量。

当你运行任何 Ruby 程序时,实际上是在使用专门为执行 Ruby 程序设计的虚拟机。通过详细分析这个虚拟机的工作原理,我们对 Ruby 语言的运作有了更深入的理解,例如,当你调用方法或将值保存到局部变量时会发生什么。在 第四章 中,我们将继续探索这个虚拟机,研究控制结构的工作原理以及 YARV 的方法调度过程。

第四章 控制结构与方法调度

无标题的图片

YARV 使用它自己的一套控制结构,就像你在 Ruby 中使用的那些结构一样。

在第三章中,我解释了 YARV 如何在执行其指令集时使用栈,以及它如何本地或动态地访问变量。控制执行流程是任何编程语言的基本要求之一,Ruby 也有一套丰富的控制结构。那么 YARV 是如何实现控制结构的呢?

像 Ruby 一样,YARV 也有自己的控制结构,尽管它们位于更低的层次。YARV 使用两个低级指令 branchifbranchunless 来代替 ifunless 语句。YARV 使用一个叫做 jump 的低级函数来代替控制结构,如 while...enduntil...end 循环,jump 允许它改变程序计数器并在已编译的程序中跳转。通过将 branchifbranchunless 指令与 jump 指令结合,YARV 可以执行大多数 Ruby 的简单控制结构。

当你的代码调用方法时,YARV 使用 send 指令。这一过程被称为 方法调度。你可以把 send 看作 Ruby 的另一种控制结构——最复杂和最精密的控制结构。

在这一章中,我们将通过探索 YARV 如何控制程序的执行流程,进一步了解 YARV。我们还将研究方法调度过程,了解 Ruby 如何将方法分类为不同的类型,并分别调用每种方法类型。

路线图

  • Ruby 如何执行 if 语句

  • 从一个作用域跳转到另一个作用域

    • 捕获表

    • 捕获表的其他用途

  • 实验 4-1:测试 Ruby 如何内部实现 for 循环

  • send 指令:Ruby 最复杂的控制结构

    • 方法查找与方法调度

    • Ruby 方法的 11 种类型

  • 调用普通 Ruby 方法

    • 为普通 Ruby 方法准备参数
  • 调用内建 Ruby 方法

    • 调用 attr_reader 和 attr_writer

    • 方法调度优化 attr_reader 和 attr_writer

  • 实验 4-2: 探索 Ruby 如何实现关键字参数

  • 总结

Ruby 如何执行 if 语句

为了理解 YARV 如何控制执行流,我们来看一下 if...else 语句的工作原理。图 4-1 左侧展示了一个简单的 Ruby 脚本,其中使用了 ifelse。右侧则展示了对应的 YARV 编译指令片段。通过阅读 YARV 指令,你会发现 Ruby 实现 if...else 语句的模式如下:

  1. 评估条件

  2. 如果条件为假,跳转到假代码

  3. 真代码;跳过假代码

  4. 假代码

Ruby 如何编译 if...else 语句

图 4-1. Ruby 如何编译 if...else 语句

在接下来的页面中,图 4-2 中的流程图会让这个模式更容易理解。图中的 branchunless 指令是 Ruby 实现 if 语句的关键。它的工作原理如下:

  1. Ruby 使用 opt_lt(优化版小于)指令来评估 if 语句的条件 i < 10。这一评估将结果(真或假)留在栈上。

  2. branchunless 指令会在条件为假时跳转到 else 代码块,也就是说,除非条件为真,否则它会“分支”。Ruby 使用 branchunless 而非 branchif 来处理 if...else 条件,因为正向代码会编译在条件代码之后。因此,当条件为假时,YARV 需要进行跳转。

  3. 如果条件为真,Ruby 不会进行分支,而是继续执行正向的代码。执行完毕后,它通过 jump 指令跳转到 if...else 语句后的指令。

  4. 无论是否分支,Ruby 都会继续执行后续代码。

YARV 实现 unless 语句的方式与 if 类似,只是正向和反向代码片段的顺序相反。对于类似 while...enduntil...end 这样的循环控制结构,YARV 使用 branchif 指令,原理是一样的:计算循环条件,执行 branchif 进行跳转,然后使用 jump 指令来实现循环。

这个流程图展示了 Ruby 编译 if...else 语句的模式。

图 4-2. 这个流程图展示了 Ruby 编译 if...else 语句的模式。

从一个作用域跳转到另一个作用域

YARV 在实现一些控制结构时面临的挑战之一是,和动态变量访问一样,Ruby 可以从一个作用域跳到另一个作用域。例如,break 可以用于退出像示例 4-1 中的简单循环。

示例 4-1. 使用 break 退出一个简单的循环

i = 0
while i<10
  puts i
  i += 1
  break
end

它也可以用于退出一个块迭代,比如在示例 4-2 中的那个。

示例 4-2. 使用 break 退出一个块

10.times do |n|
  puts n
  break
end
puts "continue from here"

在第一个列表中,YARV 可以通过简单的 jump 指令退出 while 循环。但退出像第二个列表中的块就不那么简单了:在这种情况下,YARV 需要跳转到父作用域,并在调用 10.times 后继续执行。YARV 是如何知道跳转到哪里去的?它又是如何调整其内部栈和你的 Ruby 调用栈,以确保在父作用域中正确继续执行的呢?

为了实现 Ruby 调用栈中从一个地方跳转到另一个地方(也就是说,跳出当前作用域),Ruby 使用 throw YARV 指令。该指令类似于 Ruby 的 throw 关键字:它将执行路径抛回到更高的作用域。例如,图 4-3 展示了 Ruby 如何编译示例 4-2,其中块内包含一个 break 语句。左边是 Ruby 代码,右边是编译后的版本。

Ruby 如何编译在块中使用的 break 语句

图 4-3. Ruby 如何编译在块中使用的 break 语句

Catch 表

在图 4-3 的右上角,编译后的代码中的 throw 2 在 YARV 指令级别通过使用 catch 表(或可以附加到任何 YARV 代码片段的指针表)抛出一个异常。从概念上讲,一个 catch 表可能像图 4-4 一样。

每段 YARV 代码可以包含一个 catch 表。

图 4-4. 每段 YARV 代码可以包含一个 catch 表。

该捕获表只包含指向 pop 语句的一个指针,执行将在捕获到异常后从该语句继续。每当你在代码块中使用 break 语句时,Ruby 会将 throw 指令编译进代码块的代码,并在父作用域的捕获表中添加 BREAK 条目。对于一系列嵌套的代码块中的 break,Ruby 会将 BREAK 条目添加到更深层的 rb_control_frame_t 栈的捕获表中。

稍后,当 YARV 执行 throw 指令时,它会检查是否存在一个包含中断指针的捕获表,适用于当前的 YARV 指令序列,如 图 4-5 所示。

在执行  指令时,YARV 开始向下遍历 Ruby 调用栈。

图 4-5. 在执行 throw 指令时,YARV 开始向下遍历 Ruby 调用栈。

如果没有找到捕获表,Ruby 会开始通过 rb_control_frame_t 结构的栈向下遍历,寻找一个包含中断指针的捕获表,如 图 4-6 所示。

Ruby 会继续向下遍历调用栈,寻找带有中断指针的捕获表。

图 4-6. Ruby 会继续向下遍历调用栈,寻找带有中断指针的捕获表。

如你在 图 4-7 所见,Ruby 会继续遍历,直到找到带有中断指针的捕获表。

Ruby 会继续遍历,直到找到带有中断指针的捕获表或到达调用栈的末尾。

图 4-7. Ruby 会继续遍历,直到找到带有中断指针的捕获表或到达调用栈的末尾。

在这个简单的例子中,只有一层代码块嵌套,因此 Ruby 在一次遍历后就找到了捕获表和中断指针,如 图 4-8 所示。

Ruby 找到了带有中断指针的捕获表。

图 4-8. Ruby 找到了一个带有中断指针的捕获表。

一旦 Ruby 找到捕获表指针,它会重置 Ruby 调用栈(即 CFP 指针)和内部的 YARV 栈,以反映新的程序执行点。YARV 会从该位置继续执行你的代码——即根据需要重置内部的 PCSP 指针。

注意

Ruby 在内部使用类似于引发和捕获异常的过程来实现一个非常常用的控制结构:break关键字。换句话说,在其他更冗长的语言中是异常情况,在 Ruby 中却是一种常见的、每天都会发生的行为。Ruby 将一个令人困惑、不寻常的语法——引发/捕获异常——封装成一个简单的关键字break,使其易于理解和使用。(当然,由于块的工作方式,Ruby 需要使用异常。一方面,它们像是独立的函数或子程序,但另一方面,它们只是周围代码的一部分。)

捕获表的其他用途

return关键字是另一个普通的 Ruby 控制结构,也使用捕获表。每当你在块内部调用return时,Ruby 会引发一个内部异常,然后通过捕获表指针来捕获它,就像你调用break时一样。实际上,breakreturn使用相同的 YARV 指令实现,只有一个例外:对于return,Ruby 传递一个 1 给throw指令(例如,throw 1),而对于break,它传递一个 2(throw 2)。returnbreak关键字实际上是同一个硬币的两面。

除了break,Ruby 还使用捕获表来实现控制结构rescueensureretryredonext。例如,当你在 Ruby 代码中使用raise关键字明确引发异常时,Ruby 使用捕获表来实现rescue块,但使用一个rescue指针。捕获表只是一个可以被那些 YARV 指令序列捕获和处理的事件类型列表,就像你在 Ruby 代码中使用rescue块一样。

实验 4-1:测试 Ruby 如何在内部实现for循环

我一直知道 Ruby 的for循环控制结构本质上与Enumerable模块的each方法的块相同。也就是说,我知道这段代码:

for i in 0..5
   puts i
end

的工作方式类似于这段代码:

(0..5).each do |i|
  puts i
end

但我从未怀疑过 Ruby 在内部实际上是使用each来实现for循环的!换句话说,Ruby 没有for循环控制结构。相反,for关键字实际上只是调用一个范围的each的语法糖。

要证明这一点,只需检查 Ruby 在编译for循环时生成的 YARV 指令。在示例 4-3 中,让我们使用相同的RubyVM:: InstructionSequence.compile方法来显示 YARV 指令。

示例 4-3. 这段代码将展示 Ruby 如何编译一个for循环。

code = <<END
for i in 0..5
  puts i
end
END
puts RubyVM::InstructionSequence.compile(code).disasm

运行这段代码会得到示例 4-4 中显示的输出。

示例 4-4. 示例 4-3 生成的输出

== disasm: <RubyVM::InstructionSequence:<compiled>@<compiled>>==========
== catch table
| catch type: break  st: 0002 ed: 0006 sp: 0000 cont: 0006
|------------------------------------------------------------------------
local table (size: 2, argc: 0 [opts: 0, rest: -1, post: 0, block: -1] s1)
[ 2] i
0000 trace            1                                               (   1)
0002 putobject        0..5
0004 send             <callinfo!mid:each, argc:0, block:block in <compiled>>
0006 leave
== disasm: <RubyVM::InstructionSequence:block in <compiled>@<compiled>>=
== catch table
| catch type: redo   st: 0004 ed: 0015 sp: 0000 cont: 0004
| catch type: next   st: 0004 ed: 0015 sp: 0000 cont: 0015
|------------------------------------------------------------------------
local table (size: 2, argc: 1 [opts: 0, rest: -1, post: 0, block: -1] s3)
[ 2] ?<Arg>
0000 getlocal_OP__WC__0 2                                             (   3)
0002 setlocal_OP__WC__1 2                                             (   1)
0004 trace            256
0006 trace            1                                               (   2)
0008 putself
0009 getlocal_OP__WC__1 2
0011 opt_send_simple  <callinfo!mid:puts, argc:1, FCALL|ARGS_SKIP>
0013 trace            512                                             (   3)
0015 leave

图 4-9 显示了左侧的 Ruby 代码和右侧的 YARV 指令。(我已去除一些技术细节,例如 trace 语句,以简化内容。)

简化显示 YARV 指令

图 4-9. 示例 4-4 中简化显示的 YARV 指令

请注意,存在两个独立的 YARV 代码块:外部作用域对范围 0..5 调用 each,而内部块则执行 puts i 调用。内部块中的 getlocal 2, 0 指令加载了隐式的块参数值(在我的 Ruby 代码中是 i),紧随其后的 setlocal 指令将其保存到局部变量 i 中,该变量通过动态变量访问在父作用域中被使用。

实际上,Ruby 已自动执行了以下操作:

  • for i in 0..5 代码转换为 (0..5).each do

  • 创建了一个块参数来保存范围内的每个值

  • 每次循环时,都将块参数或迭代计数器复制回局部变量 i

发送指令:Ruby 最复杂的控制结构

我们已经看到 YARV 如何通过低级指令如 branchunlessjump 控制 Ruby 程序的执行流。然而,最常用和最重要的 YARV 指令是 send 指令。send 指令告诉 YARV 跳转到另一个方法并开始执行它。

方法查找和方法调度

send 如何工作?YARV 如何知道调用哪个方法,并且它如何实际调用方法?图 4-10 展示了这一过程的概述。

这看起来非常简单,但 Ruby 用来查找并调用目标方法的算法实际上非常复杂。首先,在方法查找过程中,Ruby 会搜索您的代码应该调用的实际方法。这涉及到循环遍历组成接收者对象的类和模块。

Ruby 使用方法查找来确定调用哪个方法以及调用哪个方法调度。

图 4-10. Ruby 使用方法查找来确定调用哪个方法以及调用哪个方法调度。

一旦 Ruby 找到代码中试图调用的方法,它就会使用方法调度来实际执行该方法调用。这涉及准备方法的参数,推送一个新的帧到 YARV 的内部栈中,并更改 YARV 的内部寄存器,从而开始执行目标方法。与方法查找类似,方法调度是一个复杂的过程,因为 Ruby 对方法进行分类的方式。

在本章的其余部分,我将讨论方法调度过程。我们将在 第六章 中进一步了解方法查找的工作原理,届时我们将深入学习 Ruby 如何实现对象、类和模块。

十一种 Ruby 方法类型

在内部,Ruby 将方法分为 11 种不同的类型!在方法调度过程中,Ruby 会确定你的代码试图调用的是哪种类型的方法。然后,它会根据方法类型的不同,以不同的方式调用每种类型的方法,如 图 4-11 所示。

大多数方法——包括你在程序中用 Ruby 代码编写的所有方法——都被 YARV 的内部源代码称为 ISEQ,或指令序列方法,因为 Ruby 会将你的代码编译成一系列的 YARV 字节码指令。但在内部,YARV 还使用其他 10 种方法类型。这些其他方法类型是必要的,因为 Ruby 需要以特殊方式调用某些方法,以加速方法调度,因为这些方法是通过 C 代码实现的,或出于各种内部技术原因。

在执行 send 时,YARV 根据目标方法的类型进行切换。

图 4-11。执行 send 时,YARV 根据目标方法的类型进行切换。

下面是所有 11 种方法类型的简要描述。我们将在接下来的章节中详细探讨其中一些。

  • ISEQ。你用 Ruby 代码编写的普通方法,这是最常见的类型。ISEQ 代表指令序列

  • CFUNC。使用直接包含在 Ruby 可执行文件中的 C 代码,这些是 Ruby 实现的方法,而非你自己实现的。CFUNC 代表C 函数

  • ATTRSET。这种类型的方法是通过 attr_writer 方法创建的。ATTRSET 代表属性集

  • IVAR。当你调用 attr_reader 时,Ruby 使用这种方法类型。IVAR 代表实例变量

  • BMETHOD。当你调用 define_method 并传入一个 proc 对象时,Ruby 使用这种方法类型。由于该方法在内部由 proc 表示,Ruby 需要以特殊的方式处理这种方法类型。

  • ZSUPER。Ruby 使用这种方法类型来设置方法的访问权限(如 public 或 private),当该方法实际上在某个超类中定义时。这种方法不常用。

  • UNDEF. Ruby 在内部使用这种方法类型,当它需要从类中移除一个方法时。另外,如果你使用 undef_method 删除一个方法,Ruby 会使用 UNDEF 方法类型创建一个同名的新方法。

  • NOTIMPLEMENTED. 和 UNDEF 一样,Ruby 使用这种方法类型标记某些方法为未实现。这在你在一个不支持特定操作系统调用的平台上运行 Ruby 时是必要的。

  • OPTIMIZED. Ruby 使用这种类型加速一些重要方法,如 Kernel#send 方法。

  • MISSING. 如果你通过 Kernel#method 从模块或类中请求方法对象,而该方法缺失,Ruby 会使用这种方法类型。

  • REFINED. Ruby 在其实现的精炼(refinements)中使用这种方法类型,这是 2.0 版本中引入的新特性。

现在让我们关注最重要和最常用的方法类型:ISEQ、CFUNC、ATTRSET 和 IVAR。

调用普通 Ruby 方法

在 Ruby 源代码中,大多数方法通过常量 VM_METHOD_TYPE_ISEQ 来标识。这意味着它们由一系列 YARV 指令组成。

你在代码中使用 def 关键字定义标准的 Ruby 方法,如下所示。

def display_message
  puts "The quick brown fox jumps over the lazy dog."
end
display_message

display_message 是一个标准方法,因为它是使用 def 关键字创建的,后面跟着普通的 Ruby 代码。图 4-12 展示了 Ruby 如何调用 display_message 方法。

一个普通方法由 YARV 指令组成.

图 4-12. 一个普通方法由 YARV 指令组成。

左侧是两段 YARV 代码:底部是调用代码,顶部是目标方法。右侧可以看到 Ruby 使用新的 rb_control_frame_t 结构创建了一个新的堆栈帧,并将其类型设置为 METHOD。

图 4-12 中的关键概念是,调用代码和目标方法都是由 YARV 指令组成的。当你调用一个标准方法时,YARV 会创建一个新的堆栈帧,然后开始执行目标方法中的指令。

准备普通 Ruby 方法的参数

当 Ruby 编译你的代码时,它会为每个方法创建一个局部变量和参数表。局部表中列出的每个参数都会标记为标准的(<Arg>)或是几种特殊类型之一,比如块、可选参数等。Ruby 通过这种方式记录每个方法参数的类型,以便在代码调用该方法时判断是否需要额外的工作。示例 4-5 展示了一个使用每种类型参数的 Ruby 方法。

示例 4-5. Ruby 的参数类型 (argument_types.rb)

def five_argument_types(a, b = 1, *args, c, &d)
  puts "Standard argument #{a.inspect}"
  puts "Optional argument #{b.inspect}"
  puts "Splat argument array #{args.inspect}"
  puts "Post argument #{c.inspect}"
  puts "Block argument #{d.inspect}"
end

five_argument_types(1, 2, 3, 4, 5, 6) do
  puts "block"
end

示例 4-6 显示了我们在调用示例方法时,使用数字 1 到 6 和一个块的结果。

示例 4-6。示例 4-5 生成的输出。

$ **ruby argument_types.rb**
Standard argument 1
Optional argument 2
Splat argument array [3, 4, 5]
Post argument 6
Block argument #<Proc:0x007ff4b2045ac0@argument_types.rb:9>

为了使这种行为成为可能,当你调用一个方法时,YARV 会对每种类型的参数进行一些额外处理:

  • 块参数。当你在参数列表中使用&运算符时,Ruby 需要将提供的块转换为一个 proc 对象。

  • 可选参数。当你使用具有默认值的可选参数时,Ruby 会在目标方法中添加额外的代码。这段代码将默认值设置为参数值。当你稍后调用一个带有可选参数的方法时,如果提供了值,YARV 会重置程序计数器(PC 寄存器),以跳过这段添加的代码。

  • Splat 参数数组。对于这些,YARV 会创建一个新的数组对象,并将提供的参数值收集到其中。(参见示例 4-6 中的数组[3, 4, 5]。)

  • 标准和后续参数。由于这些是简单的值,YARV 不需要做额外的工作。

然后是关键字参数。每当 Ruby 调用一个使用关键字参数的方法时,YARV 需要做更多的工作。(实验 4-2:探索 Ruby 如何实现关键字参数将更详细地探讨这一点。)

调用内置的 Ruby 方法

Ruby 语言中许多内置方法是 CFUNC 方法(在 Ruby 的 C 源代码中是VM_METHOD_TYPE_CFUNC)。Ruby 通过 C 代码而不是 Ruby 代码实现这些方法。例如,考虑执行对块的调用中的Integer#times方法。Integer类是 Ruby 的一部分,而times方法由C 代码在文件numeric.c中实现。

你每天使用的类有许多 CFUNC 方法的示例,如StringArrayObjectKernel等。例如,String#upcase方法由string.c中的 C 代码实现,而Struct#each方法由struct.c中的 C 代码实现。

当 Ruby 调用一个内置的 CFUNC 方法时,它不需要像处理普通 ISEQ 方法那样准备方法参数;它只需创建一个新的栈帧并调用目标方法,如图 4-13 所示。

Ruby 通过 C 代码在 Ruby 的 C 源文件中实现 CFUNC 方法。

图 4-13. Ruby 通过 C 代码在 Ruby 的 C 源文件中实现 CFUNC 方法。

正如我们在图 4-12 中看到的,调用 CFUNC 方法涉及创建一个新的栈帧。然而,这一次,Ruby 使用的是rb_control_frame_t结构,类型为 CFUNC。

调用attr_readerattr_writer

Ruby 使用两种特殊的方法类型,IVAR 和 ATTRSET,加速了在代码中访问和设置实例变量的过程。在我解释这些方法类型的含义以及方法分发如何与它们一起工作之前,请先看一下示例 4-7,它演示了如何检索和设置实例变量的值。

示例 4-7. 一个包含实例变量和访问器方法的 Ruby 类

    class InstanceVariableTest
   def var
        @var
      end
   def var=(val)
        @var = val
      end
    end

在这个列表中,类InstanceVariableTest包含一个实例变量@var,以及两个方法var var= 。由于我使用 Ruby 代码编写了这些方法,它们都会是标准的 Ruby 方法,并且类型设置为VM_METHOD_TYPE_ISEQ。如你所见,它们允许你获取或设置@var的值。

Ruby 实际上提供了创建这些方法的快捷方式:attr_readerattr_writer。以下代码展示了使用这些快捷方式编写相同类的更简洁方式。

class InstanceVariableTest
  attr_reader :var
  attr_writer :var
end

在这里,attr_reader自动定义了相同的var方法,而attr_writer自动定义了var=方法,二者都来自示例 4-7。

这里有一种更简单、更简洁的方式,通过使用attr_accessor定义相同的两个方法。

class InstanceVariableTest
  attr_accessor :var
end

如你所见,attr_accessor是同时调用attr_readerattr_writer以处理相同变量的简写方式。

方法分发优化了attr_readerattr_writer

由于 Ruby 开发者经常使用attr_readerattr_writer,YARV 使用两种特殊的方法类型,IVAR 和 ATTRSET,来加速方法分发并提高程序运行速度。

让我们从 ATTRSET 方法类型开始。每当你使用attr_writerattr_accessor定义方法时,Ruby 会在内部将生成的方法标记为VM_METHOD_TYPE_ATTRSET方法类型。当 Ruby 执行代码并调用方法时,它会使用 C 函数vm_setivar以快速优化的方式设置实例变量。图 4-14 展示了 YARV 如何调用生成的var=方法来设置var

VM_METHOD_TYPE_ATTRSET 方法直接调用 vm_setivar。

图 4-14. VM_METHOD_TYPE_ATTRSET方法直接调用vm_setivar

注意,这张图与图 4-13 非常相似。在这两种情况下,Ruby 在执行代码时都会调用一个内部的 C 函数。但请注意,在图 4-14 中,当执行 ATTRSET 方法时,Ruby 甚至不会创建新的栈帧。因为方法非常简短简单,所以不需要栈帧。而且,由于生成的var=方法永远不会抛出异常,Ruby 也不需要新的栈帧来显示错误信息。vm_setivar C 函数可以非常快速地设置值并返回。

IVAR 方法类型的工作原理类似。当你使用attr_readerattr_accessor定义方法时,Ruby 会在内部标记生成的方法为VM_METHOD_TYPE_IVAR方法类型。当执行 IVAR 方法时,Ruby 会调用一个名为vm_getivar的内部 C 函数,快速获取并返回实例变量的值,如图 4-15 所示。

VM_METHOD_TYPE_IVAR 方法直接调用 vm_getivar。

图 4-15. VM_METHOD_TYPE_IVAR方法直接调用vm_getivar

这里,左侧的opt_send_simple YARV 指令调用右侧的vm_getivar C 函数。如同在图 4-14 中所示,当调用vm_setivar时,Ruby 不需要创建新的栈帧或执行 YARV 指令。它只需立即返回var的值。

实验 4-2:探索 Ruby 如何实现关键字参数

从 Ruby 2.0 开始,你可以为方法参数指定标签。示例 4-8 展示了一个简单的例子。

示例 4-8. 使用关键字参数的简单示例

 def add_two(a: 2, b: 3)
      a+b
    end

 puts add_two(a: 1, b: 1)
     => 2

我们使用ab作为传递给add_two函数的关键字参数标签 。当我们调用函数时 ,得到结果 2。我在第二章中提到过,Ruby 使用哈希来实现关键字参数。我们可以通过示例 4-9 来证明这一点。

示例 4-9. 证明 Ruby 使用哈希来实现关键字参数

    class Hash
   def key?(val)
     puts "Looking for key #{val}"
        false
      end
    end

    def add_two(a: 2, b: 3)
      a+b
    end

    puts add_two (a: 1, b: 1)

我们重写了 Hash 类的 key? 方法 ,该方法会显示一条消息 ,然后返回 false。当我们运行 示例 4-9 时,输出结果如下。

Looking for key a
Looking for key b
5

如您所见,Ruby 调用了 Hash#key? 两次:第一次查找键 a,第二次查找键 b。由于某些原因,Ruby 创建了一个哈希,即使我们在代码中从未使用哈希。而且,Ruby 现在忽略了我们传递给 add_two 的值。我们得到的结果是 5 而不是 2。看起来 Ruby 使用了 ab 的默认值,而不是我们提供的值。为什么 Ruby 创建了一个哈希,它包含了什么?为什么在重写了 Hash#key? 方法后,Ruby 忽略了我的参数值?

为了了解 Ruby 如何实现关键字参数,并解释我们运行 示例 4-9 时看到的结果,我们可以检查 Ruby 编译器为 add_two 生成的 YARV 指令。运行 示例 4-10 将显示与 示例 4-9 对应的 YARV 指令。

示例 4-10. 显示 示例 4-9 代码的 YARV 指令

code = <<END
def add_two(a: 2, b: 3)
  a+b
end

puts add_two(a: 1, b: 1)
END

puts RubyVM::InstructionSequence.compile(code).disasm

图 4-16 显示了 示例 4-10 生成的输出的简化版本。

部分输出,由

图 4-16. 示例 4-10 生成的部分输出

在 图 4-16 的右侧,您可以看到 Ruby 首先将一个数组推送到栈中:[:a, 1, :b, 1]。接下来,它调用内部的 C 函数 hash_from_ary,我们可以推测它会将 [:a, 1, :b, 1] 数组转换为一个哈希。最后,Ruby 调用 add_two 方法进行加法运算,并调用 puts 方法来显示结果。

现在,让我们来看一下 add_two 方法本身的 YARV 指令,见于 图 4-17。

从 add_two 方法开头编译得到的 YARV 指令

图 4-17. 从 add_two 方法开头编译得到的 YARV 指令

这些 YARV 指令在做什么?Ruby 方法 add_two 并没有包含类似的代码!(add_two 只是将 ab 相加并返回它们的和。)

为了找出答案,让我们逐步分析 图 4-17。左侧是 Ruby 的 add_two 方法,右侧是 add_two 的 YARV 指令。在最右边,你可以看到 add_two 的局部变量表。注意这里列出了三个值:[ 2] ?[ 3] b[ 4] a。显然,ab 对应着 add_two 的两个参数,但 [ 2] ? 是什么意思呢?这似乎是某种神秘的值。

这个神秘的值就是我们在 图 4-16 中看到的哈希!为了实现关键字参数,Ruby 创建了这个第三个隐藏的参数,用于 add_two 方法。

图 4-17 中的 YARV 指令显示,getlocal 2, 0 紧接着 dup 将这个哈希放到栈上作为接收者。接下来,putobject :a 将符号 :a 放到栈上作为方法参数,opt_send_simple <callinfo!mid:key? 在接收者(即哈希)上调用 key? 方法。

这些 YARV 指令等价于以下这行 Ruby 代码。Ruby 正在查询隐藏的哈希对象,看它是否包含键 :a

hidden_hash.key?(:a)

从 图 4-17 继续阅读其余的 YARV 指令,我们看到如果哈希包含该键,Ruby 会调用 delete 方法,移除该键并返回对应的值。接着,setlocal 4, 0 会将这个值保存在 a 参数中。如果哈希不包含键 :a,Ruby 会调用 putobject 2 并使用 setlocal 4, 0 将默认值 2 保存到参数中。

总结一下,图 4-17 中显示的所有 YARV 指令实现了示例 4-11 中展示的 Ruby 代码片段。

示例 4-11。 在图 4-17 中显示的 YARV 指令与这段 Ruby 代码等价。

if hidden_hash.key?(:a)
  a = hidden_hash.delete(:a)
else
  a = 2
end

现在我们可以看到,Ruby 将关键字参数及其值存储在隐藏的哈希参数中。当方法开始时,它首先从哈希中加载每个参数的值,如果没有值,则使用默认值。图 4-14 中显示的 Ruby 代码所表示的行为解释了我们在运行示例 4-9 时看到的结果。记住我们将Hash#key?方法修改为始终返回false。如果hidden_hash.key?始终返回false,Ruby 将忽略每个参数的值,并使用默认值,即使提供了值。

关键字参数的最后一个细节是:每当你调用任何方法并使用关键字参数时,YARV 会检查你提供的关键字参数是否是目标方法所期望的。如果有意外的参数,Ruby 会抛出异常,如示例 4-12 所示。

示例 4-12。如果传递了一个意外的关键字参数,Ruby 会抛出异常。

def add_two(a: 2, b: 3)
  a+b
end

puts add_two(c: 9)
 => unknown keyword: c (ArgumentError)

因为add_two的参数列表中没有包含字母c,所以当我们尝试使用c调用该方法时,Ruby 会抛出一个异常。这个特殊的检查是在方法调度过程中发生的。

总结

本章开始时,我们探讨了 YARV 如何使用一系列低级控制结构控制 Ruby 程序的执行流程。通过展示 Ruby 编译器生成的 YARV 指令,我们看到了一些 YARV 的控制结构,并学习了它们的工作原理。在实验 4-1:测试 Ruby 如何在内部实现 for 循环中,我们发现 Ruby 通过each方法和块来实现for循环。

我们还了解到,Ruby 内部将方法分为 11 种类型。我们看到,当你使用def关键字编写方法时,Ruby 会创建一个标准的 ISEQ 方法,而且 Ruby 将自己内置的方法标记为 CFUNC 方法,因为它们是使用 C 代码实现的。我们了解了 ATTRSET 和 IVAR 方法类型,并看到 Ruby 如何在方法调度过程中根据目标方法的类型进行切换。

最后,在实验 4-2:探索 Ruby 如何实现关键字参数中,我们研究了 Ruby 如何实现关键字参数,并在过程中发现 Ruby 使用哈希来跟踪参数标签和默认值。

在第五章中,我们将转变方向,探讨对象和类的内容。我们将在第六章中再次回到 YARV 内部,研究方法查找过程的工作原理,并讨论词法作用域的概念。

第五章 对象与类

没有标题的图片

每个 Ruby 对象是类指针和实例变量数组的结合体。

我们早期就学到,Ruby 是一种面向对象的语言,源自像 Smalltalk 和 Simula 这样的语言。每个值都是一个对象,所有 Ruby 程序由一组对象和它们之间发送的消息组成。通常,我们通过了解如何使用对象以及它们能做什么来学习面向对象编程:如何将数据值和与这些值相关的行为组合在一起;如何使每个类具有单一责任或目的;以及不同的类如何通过封装或继承彼此关联。

但是,Ruby 对象是什么?一个对象包含什么信息?如果我们通过显微镜查看 Ruby 对象,我们会看到什么?内部有运动的部分吗?那么 Ruby 类呢?类究竟是什么?

我将在本章中通过探讨 Ruby 的内部工作原理来回答这些问题。通过查看 Ruby 如何实现对象和类,您将学习如何使用它们以及如何使用 Ruby 编写面向对象的程序。

路线图

  • Ruby 对象内部

    • 检查 klass 和 ivptr

    • 可视化一个类的两个实例

    • 通用对象

    • 简单的 Ruby 值根本不需要结构

    • 通用对象有实例变量吗?

    • 阅读 RBasic 和 RObject C 结构定义

    • Ruby 为通用对象保存实例变量的位置在哪里?

  • 实验 5-1:保存一个新的实例变量需要多长时间?

  • RClass 结构内部是什么?

    • 继承

    • 类实例变量与类变量

    • 获取和设置类变量

    • 常量

    • 实际的 RClass 结构

    • 读取 RClass C 结构定义

  • 实验 5-2:Ruby 将类方法保存在哪里?

  • 摘要

Ruby 对象内部

Ruby 将你的每个自定义对象保存在一个名为RObject的 C 结构中,在 Ruby 1.9 和 2.0 中,它的结构如图 5-1 所示。

没有说明的图片

如果我能切开一个 Ruby 对象,我会看到什么?

图形顶部是指向RObject结构的指针。(在内部,Ruby 始终使用VALUE指针来引用任何值。)在此指针下,RObject结构包含一个内部的RBasic结构和与自定义对象相关的特定信息。RBasic部分包含所有值都使用的信息:一组名为flags的布尔值,存储各种内部技术值,以及一个名为klass的类指针。类指针指示对象是哪个类的实例。在RObject部分,Ruby 保存了每个对象包含的实例变量数组,使用numiv表示实例变量计数,ivptr是指向值数组的指针。

如果我们用技术术语定义 Ruby 对象结构,我们可以说

每个 Ruby 对象都是类指针和实例变量数组的组合。

一开始,这个定义似乎并不特别有用,因为它并没有帮助我们理解对象的意义或用途,也没有说明如何在 Ruby 程序中使用它们。

RObject 结构

图 5-1. RObject结构

检查 klass 和 ivptr

为了理解 Ruby 如何在程序中使用RObject,我们将创建一个简单的 Ruby 类,并使用 IRB 检查该类的实例。例如,假设我有示例 5-1 所示的简单 Ruby 类。

示例 5-1. 一个简单的 Ruby 类

class Mathematician
  attr_accessor :first_name
  attr_accessor :last_name
end

Ruby 需要在RObject中保存类指针,因为每个对象都必须追踪用于创建它的类。当你创建一个类的实例时,Ruby 会在内部将该类的指针保存在RObject中,如示例 5-2 所示。

示例 5-2. 在 IRB 中创建一个对象实例

    $ **irb**
    > **euler = Mathematician.new**
  => #<Mathematician:0x007fbd738608c0>

通过在上显示类名#<Mathematician,Ruby 显示了euler对象的类指针值。随后的十六进制字符串实际上是该对象的VALUE指针。(这对于每个Mathematician实例都会有所不同。)

Ruby 还使用实例变量数组来跟踪你保存在对象中的值,正如 示例 5-3 中所示。

示例 5-3. 在 IRB 中检查实例变量

    > **euler.first_name = 'Leonhard'**
     => "Leonhard"
    > **euler.last_name  = 'Euler'**
     => "Euler"
    > **euler**
  => #<Mathematician:0x007fbd738608c0 @first_name="Leonhard", @last_name="Euler">

正如你所看到的,在 IRB 中,Ruby 还显示了 euler 的实例变量数组,见 。Ruby 需要在每个对象中保存这个值数组,因为每个对象实例可能对相同的实例变量有不同的值,正如 示例 5-4 中所示,见

示例 5-4. Mathematician 类的不同实例

    > **euclid = Mathematician.new**
    > **euclid.first_name = 'Euclid'**
    > **euclid**
  => #<Mathematician:0x007fabdb850690 @first_name="Euclid">

可视化同一类的两个实例

让我们更详细地了解一下 Ruby 的 C 结构。当你运行 图 5-2 中显示的 Ruby 代码时,Ruby 会创建一个 RClass 结构和两个 RObject 结构。

创建同一类的两个实例

图 5-2. 创建同一类的两个实例

我将在下一节讨论 Ruby 如何通过 RClass 结构实现类。现在,让我们来看一下 图 5-3,该图展示了 Ruby 如何将 Mathematician 信息保存在两个 RObject 结构中。

可视化同一类的两个实例

图 5-3. 可视化同一类的两个实例

正如你所看到的,每个 klass 值都指向 Mathematician RClass 结构,而每个 RObject 结构都有一个单独的实例变量数组。这两个数组都包含 VALUE 指针——这是 Ruby 用来引用 RObject 结构的相同指针。(注意,其中一个对象包含两个实例变量,而另一个仅包含一个。)

通用对象

现在你已经知道 Ruby 如何将自定义类(例如 Mathematician 类)保存在 RObject 结构中。但请记住,每个 Ruby 值——包括整数、字符串和符号等基本数据类型——都是一个对象。Ruby 源代码内部将这些内建类型称为“通用”类型。那 Ruby 是如何存储这些通用对象的呢?它们是否也使用 RObject 结构?

答案是否定的。在内部,Ruby 使用不同的 C 结构,而不是 RObject,来保存每种通用数据类型的值。例如,Ruby 将字符串值保存在 RString 结构中,将数组保存在 RArray 结构中,将正则表达式保存在 RRegexp 结构中,等等。Ruby 仅使用 RObject 来保存你创建的自定义对象类的实例以及 Ruby 内部创建的少数自定义对象类的实例。然而,所有这些不同的结构共享相同的 RBasic 信息,就像我们在 RObject 中看到的那样,如 图 5-4 所示。

由于 RBasic 结构包含类指针,因此每种通用数据类型也是一个对象。每个数据类型都是某个 Ruby 类的实例,正如 RBasic 中保存的类指针所指示的那样。

所有 Ruby 对象结构都使用 RBasic 结构。

图 5-4。所有 Ruby 对象结构都使用 RBasic 结构。

简单的 Ruby 值根本不需要结构

作为性能优化,Ruby 不使用任何结构来保存小整数、符号以及其他一些简单的值,而是将它们直接保存在 VALUE 指针中,如 图 5-5 所示。

Ruby 将整数保存在  指针中。

图 5-5。Ruby 将整数保存在 VALUE 指针中。

这些 VALUE 根本不是指针;它们是值本身。对于这些简单的数据类型,没有类指针。相反,Ruby 使用一系列位标志来记住类,这些位标志保存在 VALUE 的前几个位中。例如,所有小整数都设置了 FIXNUM_FLAG 位,如 图 5-6 所示。

FIXNUM_FLAG 表示这是  类的一个实例。

图 5-6。FIXNUM_FLAG 表示这是 Fixnum 类的一个实例。

每当设置 FIXNUM_FLAG 时,Ruby 就知道这个 VALUE 实际上是一个小整数,是 Fixnum 类的实例,而不是指向值结构的指针。(类似的位标志指示该 VALUE 是否是符号,并且像 niltruefalse 这样的值也有各自的标志。)

通过使用 IRB,很容易看出整数、字符串和其他通用值都是对象,正如 示例 5-5 中所看到的那样。

示例 5-5. 检查一些通用值的类

$ **irb**
> "string".class
 => String
> **1.class**
 => Fixnum
> **:symbol.class**
 => Symbol

在这里,我们看到 Ruby 通过调用每个对象的 class 方法,保存类指针或等效的位标志。反过来,class 方法返回类指针,或者至少返回每个 klass 指针所指向的类名。

通用对象有实例变量吗?

让我们回到 Ruby 对象的定义:

每个 Ruby 对象是类指针和实例变量数组的结合。

那么,通用对象的实例变量呢?整数、字符串和其他通用数据值是否有实例变量?这看起来很奇怪,但如果整数和字符串是对象,那么这一定是真的!如果这是真的,Ruby 将这些值保存在什么地方呢,如果它不使用 RObject 结构的话?

使用 instance_variables 方法,如 示例 5-6 中所示,你可以看到每个这些基本值也可以包含一个实例变量数组,尽管这看起来很奇怪。

示例 5-6. 在 Ruby 字符串对象中保存实例变量

$ **irb**
> **str = "some string value"**
 => "some string value"
> **str.instance_variables**
 => []
> **str.instance_variable_set("@val1", "value one")**
 => "value one"
> **str.instance_variables**
 => [:@val1]
> **str.instance_variable_set("@val2", "value two")**
 => "value two"
> **str.instance_variables**
 => [:@val1, :@val2]

重复此练习,使用符号、数组或任何 Ruby 值,你会发现每个 Ruby 值都是一个对象,并且每个对象都包含一个类指针和一个实例变量数组。

阅读 RBasic 和 RObject C 结构定义

示例 5-7 显示了 RBasicRObject C 结构的定义。(你可以在 include/ruby/ruby.h 头文件中找到这段代码。)

示例 5-7. RBasicRObject C 结构的定义

    struct RBasic {
   VALUE flags;
   VALUE klass;
    };

    #define ROBJECT_EMBED_LEN_MAX 3
    struct RObject {
   struct RBasic basic;
      union {
        struct {
       long numiv;
       VALUE *ivptr;
       struct st_table *iv_index_tbl;
     } heap;
     VALUE ary[ROBJECT_EMBED_LEN_MAX];
      } as;
    };

在顶部,你看到 RBasic 的定义。这个定义包含两个值:flags klass 。下面,你看到 RObject 的定义。注意,它包含了一个 RBasic 结构的副本,位于 。接下来,union 关键字包含一个名为 heap 的结构,位于 ,后面跟着一个名为 ary 的数组,位于

heap 结构位于 ,包含以下值:

  • 首先,值 numiv 位于 ,它跟踪此对象中包含的实例变量的数量。

  • 接下来,ivptr 位于 ,是指向包含此对象实例变量值的数组的指针。注意,这里并没有存储实例变量的名称或 ID;这里只存储了值。

  • iv_index_tbl 处指向一个哈希表,该哈希表将每个实例变量的名称或 ID 与其在 ivptr 数组中的位置进行映射。这个值实际上存储在该对象的类的 RClass 结构中;这个指针只是一个缓存或快捷方式,Ruby 用它来快速获取那个哈希表。(st_table 类型是指 Ruby 对哈希表的实现,我将在第七章中讨论。)

RObject 结构的最后一个成员,位于 处的 ary,由于顶部的 union 关键字,与所有先前的值共享相同的内存空间。通过这个 ary 值,Ruby 可以将所有实例变量直接保存在 RObject 结构中——如果它们足够容纳的话。这避免了调用 malloc 来分配额外的内存以存储实例变量值数组。(Ruby 还在 RStringRArrayRStructRBignum 结构中使用了类似的优化。)

Ruby 为通用对象保存实例变量的位置在哪里?

内部来说,Ruby 使用一种技巧来保存通用对象的实例变量——也就是对于不使用 RObject 结构的对象。当你在通用对象中保存实例变量时,Ruby 会将其保存在一个特殊的哈希表中,称为 generic_iv_tbl。这个哈希表维护了通用对象与指向包含各自实例变量的其他哈希表的指针之间的映射。图 5-7 展示了在示例 5-6 中如何应用于 str 字符串的例子。

generic_iv_tbl 存储通用对象的实例变量。

图 5-7. generic_iv_tbl 存储通用对象的实例变量。

实验 5-1:保存一个新实例变量需要多长时间?

为了了解 Ruby 如何在内部保存实例变量,让我们测量一下 Ruby 在一个对象中保存实例变量需要多长时间。为此,我将创建大量测试对象,如示例 5-8 所示。

示例 5-8. 使用 Class.new 创建测试对象

ITERATIONS = 100000
 GC.disable
 obj = ITERATIONS.times.map { Class.new.new }

在这里,我使用 Class.new 处为每个新对象创建一个独特的类,以确保它们都是独立的。我还在 处禁用了垃圾回收,以避免垃圾回收操作影响结果。然后,在 示例 5-9 中,我为每个对象添加实例变量。

示例 5-9. 向每个测试对象添加实例变量

Benchmark.bm do |bench|
  20.times do |count|
    bench.report("adding instance variable number #{count+1}") do
      ITERATIONS.times do |n|
        obj[n].instance_variable_set("@var#{count}", "value")
      end
    end
  end
end

示例 5-9 迭代了 20 次,每次将一个新的实例变量保存到每个对象中。图 5-8 显示了 Ruby 2.0 添加每个变量所需的时间:左侧的第一条柱状图是保存所有对象中第一个实例变量所需的时间,每个后续的柱状图则表示在每个对象中保存一个额外实例变量所需的时间。

添加一个新的实例变量所需时间(以秒为单位 x 100,000)与实例变量数量的关系

图 5-8. 添加一个新的实例变量所需时间(以秒为单位 x 100,000)与实例变量数量的关系

图 5-8 展示了一个奇怪的模式。有时 Ruby 添加一个新的实例变量所需的时间更长。这是怎么回事呢?

这种行为的原因与 Ruby 存储实例变量的 ivptr 数组有关,如 图 5-9 所示。

一个对象中保存的两个实例变量

图 5-9. 一个对象中保存的两个实例变量

在 Ruby 1.8 中,这个数组是一个哈希表,包含了变量名(哈希键)和对应的值,该哈希表会自动扩展以容纳任意数量的元素。

Ruby 1.9 和 2.0 通过将值保存在一个简单的数组中稍微加快了速度。实例变量名则保存在对象的类中,因为它们对类的所有实例来说都是相同的。因此,Ruby 1.9 和 2.0 需要预先分配一个大数组来处理任意数量的实例变量,或者在保存更多变量时反复增大该数组的大小。

事实上,正如你在图 5-8 中看到的,Ruby 1.9 和 2.0 会反复增加数组的大小。例如,假设你在一个给定的对象中有七个实例变量,如图 5-10 所示。

一个对象中的七个实例变量

图 5-10. 一个对象中的七个实例变量

当你添加第八个变量时——在图 5-8 中的第 8 条——Ruby 1.9 和 2.0 会将数组大小增加三,以预期你很快会添加更多变量,如图 5-11 所示。

添加第八个值分配了额外的空间。

图 5-11. 添加第八个值分配了额外的空间。

分配更多的内存需要额外的时间,这就是为什么第 8 条比其他条更高的原因。现在,如果你再添加两个实例变量,Ruby 1.9 和 2.0 就不需要重新分配该数组的内存了,因为空间已经预留好了。这也解释了第 9 条和第 10 条时间更短的原因。

RClass 结构体中有什么?

每个对象通过保存指向 RClass 结构的指针来记住自己的类。每个 RClass 结构包含什么信息?如果我们能看到 Ruby 类的内部结构,会看到什么呢?让我们构建一个关于 RClass 中必须存在的信息模型。这个模型将为我们提供 Ruby 类的技术定义,基于我们所知道的类的功能。

没有标题的图片

两个对象,一个类

每个 Ruby 开发者都知道如何编写一个类:你输入class关键字,指定新类的名称,然后编写类的方法。示例 5-10 展示了一个常见的示例。

示例 5-10. 我们在示例 5-1 中看到的相同的简单 Ruby 类

class Mathematician
  attr_accessor :first_name
  attr_accessor :last_name
end

attr_accessor是定义属性的 getter 和 setter 方法的简写。(attr_accessor定义的方法还会检查nil值)。示例 5-11 展示了定义相同 Mathematician 类的更冗长的方式。

示例 5-11. 没有使用attr_accessor编写的相同类

class Mathematician
  def first_name
    @first_name
  end
  def first_name=(value)
    @first_name = value
  end
  def last_name
    @last_name
  end
  def last_name=(value)
    @last_name = value
  end
end

看起来这个类——以及每个 Ruby 类——只是方法定义的集合。你可以通过向类中添加方法来为对象分配行为,当你在对象上调用方法时,Ruby 会在对象的类中查找该方法。这导致了我们对 Ruby 类的第一个定义:

Ruby 类是方法定义的集合。

因此,MathematicianRClass 结构体必须保存类中定义的所有方法的列表,见 图 5-12。

请注意,在 示例 5-11 中,我还创建了两个实例变量:@first_name@last_name。我们之前看到 Ruby 如何在每个 RObject 结构中存储这些值,但你可能已经注意到,RObject 中存储的仅仅是这些变量的,而不是它们的名字。(Ruby 1.8 会在 RObject 中存储名字。)Ruby 必须将属性名称存储在 RClass 中,这也是有道理的,因为这些名称对于每个 Mathematician 实例来说都是相同的。

Ruby 类包含方法表。

图 5-12. Ruby 类包含方法表。

让我们重新绘制 RClass,这次加入属性名称表,见 图 5-13。

Ruby 类还包含属性名称表。

图 5-13. Ruby 类还包含属性名称表。

现在我们对 Ruby 类的定义如下:

Ruby 类是方法定义的集合和属性名称的表。

在本章开始时,我提到过 Ruby 中的每个值都是对象。这对类也许同样适用。让我们通过 IRB 来证明这一点。

> **p Mathematician.class**
 => Class

如你所见,Ruby 类都是 Class 类的实例;因此,类也是对象。现在我们再次更新我们对 Ruby 类的定义:

Ruby 类是一个 Ruby 对象,它还包含方法定义和属性名称。

因为 Ruby 类是对象,我们知道 RClass 结构体必须包含一个类指针和一个实例变量数组,这些值是我们知道每个 Ruby 对象都包含的,见 图 5-14。

Ruby 类还包含类指针和实例变量。

图 5-14. Ruby 类还包含类指针和实例变量。

正如你所看到的,我已经添加了指向 Class 类的指针,理论上这是每个 Ruby 类对象的类。然而,在实验 5-2:Ruby 如何保存类方法?中,我将展示这个图表实际上并不准确——klass 实际上指向的是别的东西!我还添加了实例变量的表格。

注意

这些是类级别的实例变量。不要将它们与对象级别实例变量的属性名称表混淆。

这越来越难以控制了!RClass 结构似乎比 RObject 结构复杂得多。但别担心——我们正在接近准确的 RClass 结构。接下来,我们需要考虑 Ruby 类中包含的另外两种重要信息。

继承

继承是面向对象编程的一个基本特性。Ruby 通过允许我们在创建类时可选地指定一个父类来实现单继承。如果我们没有指定父类,Ruby 会将 Object 类作为父类。例如,我们可以像这样使用一个父类重写 Mathematician 类:

class Mathematician < Person
--*snip*--

现在每个 Mathematician 的实例都将包含 Person 实例拥有的相同方法。在这个例子中,我们可能想把 first_namelast_name 访问器方法移到 Person 中。我们也可以把 @first_name@last_name 属性移到 Person 类中。每个 Mathematician 的实例将包含这些方法和属性,即使我们已经把它们移到了 Person 类。

Mathematician 类必须包含对 Person 类(它的父类)的引用,以便 Ruby 能够找到父类中定义的任何方法或属性。

让我们再次更新定义,假设 Ruby 使用类似 klass 的另一个指针来跟踪父类:

Ruby 类是一个 Ruby 对象,它还包含方法定义、属性名称和一个指向父类的指针。

然后我们重新绘制 RClass 结构,以包括新的父类指针,如图 5-15 所示。

Ruby 类还包含一个父类指针。

图 5-15. Ruby 类还包含一个父类指针。

此时,理解 klass 指针和 super 指针之间的区别至关重要。klass 指针表示 Ruby 类对象是哪个类的实例。这个指针总是指向 Class 类:

> **p Mathematician.class**
 => Class

Ruby 使用 klass 指针来查找 Mathematician 类对象的方法,例如每个 Ruby 类都实现的 new 方法。然而,super 指针记录的是类的父类:

> **p Mathematician.superclass**
 => Person

Ruby 使用super指针帮助查找每个Mathematician实例中包含的方法,比如first_name=last_name。正如我们接下来将看到的,Ruby 在获取或设置类变量时也会使用super指针。

类实例变量与类变量

Ruby 语法中一个让人困惑的概念是类变量。你可能会认为这些只是类的实例变量(来自图 5-14 的类级别实例变量),但类实例变量和类变量是明显不同的。

要创建一个类实例变量,你只需使用@符号创建一个实例变量,但它是在类的上下文中,而非对象中。例如,示例 5-12 展示了我们如何使用Mathematician的实例变量来表示该类对应的数学分支。我们在 创建了@type实例变量。

示例 5-12. 创建类级别实例变量

    class Mathematician
   @type = "General"
      def self.type
        @type
      end
    end

    puts Mathematician.type
     => General

相比之下,要创建一个类变量,你需要使用@@符号。示例 5-13 展示了相同的示例,其中创建了类变量@@type

示例 5-13. 创建类变量

    class Mathematician
   @@type = "General"
      def self.type
        @@type
      end
    end

    puts Mathematician.type
     => General

这有什么不同吗?当你创建一个类变量时,Ruby 会为你创建一个单一的值,供该类及其任何子类使用。另一方面,使用类实例变量会导致 Ruby 为每个类或子类创建一个单独的值。

让我们回顾一下示例 5-14,看看 Ruby 如何不同地处理这两种类型的变量。首先,我在Mathematician类中定义了一个类实例变量@type,并将其值设置为字符串General。接着,我创建了一个名为Statistician的类,它是Mathematician的子类,并将@type的值改为字符串Statistics

示例 5-14. 每个类和子类都有自己的实例变量。

    class Mathematician
      @type = "General"
      def self.type
        @type
      end
    end

    class Statistician < Mathematician
      @type = "Statistics"
    end

    puts Statistician.type
  => Statistics
    puts Mathematician.type
  => General

注意到Statistician中的@type值(在 )与Mathematician中的@type值(在 )不同。每个类都有自己独立的@type副本。

然而,如果我改为使用类变量,Ruby 会在MathematicianStatistician之间共享该值,正如在示例 5-15 中演示的那样。

示例 5-15. Ruby 在类和所有子类之间共享类变量。

    class Mathematician
      @@type = "General"
      def self.type
        @@type
      end
    end

    class Statistician < Mathematician
      @@type = "Statistics"
    end

    puts Statistician.type
  => Statistics
    puts Mathematician.type
  => Statistics

在这里,Ruby 显示了 Statistician 中的 @@type 与在 Mathematician 中的相同值,分别位于

然而,内部实现上,Ruby 实际上将类变量和类实例变量保存在 RClass 结构中的同一表中。图 5-16 显示了如果你创建了 Mathematician 类的 @type@@type 值,它们是如何被保存的。额外的 @ 符号允许 Ruby 区分这两种变量类型。

Ruby 将类变量和类实例变量保存在同一个表中。

图 5-16. Ruby 将类变量和类实例变量保存在同一个表中。

获取和设置类变量

这是真的:Ruby 将类变量和类实例变量保存在同一个表中。然而,Ruby 获取或设置这两种类型的变量的方式却是完全不同的。

当你获取或设置类实例变量时,Ruby 会在对应目标类的 RClass 结构中查找该变量,然后保存或获取该值。图 5-17 显示了 Ruby 如何从 示例 5-14 中保存类实例变量。

Ruby 将类实例变量保存在目标类的 RClass 结构中。

图 5-17. Ruby 将类实例变量保存在目标类的 RClass 结构中。

在图的顶部,你可以看到一行代码,它将类实例变量保存在 Mathematician 中。下面是另一行类似的代码,它将一个值保存在 Statistician 中。在这两种情况下,Ruby 都会将类实例变量保存在当前类的 RClass 结构中。

Ruby 对类变量使用了更复杂的算法。为了实现我们在 示例 5-15 中看到的行为,Ruby 需要搜索所有父类,以检查它们是否定义了相同的类变量。图 5-18 显示了一个示例。

在保存之前,Ruby 会检查类变量是否存在于目标类或其任何父类中。

图 5-18。 在保存之前,Ruby 会检查类变量是否存在于目标类或其任何父类中。

当你保存一个类变量时,Ruby 会在目标类及其所有父类中查找现有的变量。它将使用在最高父类中找到的副本。在 图 5-18 中,你可以看到 Ruby 在保存 Statistician 类中的 @@type 类变量时会检查 StatisticianMathematician 类。因为我已经在 Mathematician 类中保存了相同的类变量(示例 5-15),所以 Ruby 会使用它并用新值覆盖它,如 图 5-19 所示。

Ruby 使用在最高父类中找到的类变量副本。

图 5-19。 Ruby 使用在最高父类中找到的类变量副本。

常量

我们还有 Ruby 类的一个特性要介绍:常量。正如你所知道的,Ruby 允许你在类内部定义常量值,像这样:

class Mathematician < Person
  AREA_OF_EXPERTISE = "Mathematics"
  --*snip*--

常量值必须以大写字母开头,它们在当前类的作用域内有效。(有趣的是,Ruby 允许你更改常量值,但当你这么做时会显示一个警告。)让我们在 RClass 结构中添加一个常量表,因为 Ruby 必须将这些值保存在每个类中,如 图 5-20 所示。

现在我们可以写出 Ruby 类的完整技术定义:

Ruby 类是一个 Ruby 对象,它还包含方法定义、属性名称、父类指针和常量表。

当然,这不像我们为 Ruby 对象定义的简洁,但每个 Ruby 类包含的信息远多于每个 Ruby 对象。显然,Ruby 类对语言来说是至关重要的。

Ruby 类还包含一个常量表。

图 5-20。 Ruby 类还包含一个常量表。

实际的 RClass 结构

在为 RClass 构建了一个概念模型之后,让我们看看 Ruby 实际上用来表示类的结构,如 图 5-21 所示。

如你所见,Ruby 使用两个独立的结构来表示每个类:RClassrb_classext_struct。但是这两个结构作为一个大的结构共同作用,因为每个 RClass 都包含一个指向对应 rb_classext_struct 的指针(ptr)。你可能猜测 Ruby 核心团队决定使用两种不同的结构,是因为有很多不同的值需要保存,但实际上他们可能创建了 rb_classext_struct 来保存他们不希望在公共 Ruby C 扩展 API 中暴露的内部值。

RObject 一样,RClass 也有一个 VALUE 指针(如 图 5-21 中所示左侧)。Ruby 总是通过这些 VALUE 指针访问类。图的右侧显示了字段的技术名称:

  • flagsklass 是每个 Ruby 值都包含的相同 RBasic 值。

  • m_tbl 是方法表,一个哈希表,其键是每个方法的名称或 ID,值是指向每个方法定义的指针,包括已编译的 YARV 指令。

  • iv_index_tbl 是属性名称表,一个哈希表,将每个实例变量名称映射到每个 RObject 实例变量数组中该属性值的索引。

  • super 是指向该类的父类的 RClass 结构体的指针。

    Ruby 实际上是如何表示一个类的

    图 5-21. Ruby 实际上是如何表示一个类的

  • iv_tbl 包含类级别的实例变量和类变量,包括它们的名称和值。

  • const_tbl 是一个哈希表,包含在此类作用域中定义的所有常量(名称和值)。可以看到 Ruby 以相同的方式实现了 iv_tblconst_tbl:类级别的实例变量和常量几乎是一样的。

  • Ruby 使用 origin 来实现 Module#prepend 特性。我将在 第六章中讨论 prepend 的作用以及 Ruby 是如何实现它的。

  • Ruby 使用 refined_class 指针来实现新的实验性 refinements 特性,稍后我会在 第九章中进一步讨论。

  • 最后,Ruby 内部使用 allocator 来分配该类新实例的内存。

阅读 RClass C 结构体定义

现在快速查看实际的 RClass 结构体定义,如 示例 5-16 所示。

示例 5-16. RClass C 结构体的定义

typedef struct rb_classext_struct rb_classext_t;
struct RClass {
    struct RBasic basic;
    rb_classext_t *ptr;
    struct st_table *m_tbl;
    struct st_table *iv_index_tbl;
};

就像我们在 示例 5-7 中看到的 RObject 定义一样,这个结构定义——包括 图 5-21 中显示的所有值——可以在 include/ruby/ruby.h 文件中找到。

另一方面,rb_classext_struct 结构定义可以在 internal.h C 头文件中找到,如 示例 5-17 所示。

示例 5-17. rb_classext_struct C 结构的定义

struct rb_classext_struct {
    VALUE super;
    struct st_table *iv_tbl;
    struct st_table *const_tbl;
    VALUE origin;
    VALUE refined_class;
    rb_alloc_func_t allocator;
};

再次,你可以看到来自 图 5-21 的值。注意,st_table C 类型在 示例 5-16 和 示例 5-17 中出现了四次;这就是 Ruby 的哈希表数据结构。内部上,Ruby 使用哈希表保存每个类的大部分信息:属性名称表、方法表、类级实例变量表和常量表。

实验 5-2:Ruby 如何保存类方法?

我们已经看到每个 RClass 结构如何保存某个类中定义的所有方法。在这个例子中,Ruby 使用方法表将 first_name 方法的信息存储在 MathematicianRClass 结构中:

class Mathematician
  def first_name
    @first_name
  end
end

那么类方法呢?在 Ruby 中,通常使用直接将方法保存在类中,语法如 示例 5-18 所示。

示例 5-18. 使用 def self 定义类方法

class Mathematician
  def self.class_method
    puts "This is a class method."
  end
end

或者,你可以使用 示例 5-19 中显示的语法。

示例 5-19. 使用 class << self 定义类方法

class Mathematician
  class << self
    def class_method
      puts "This is a class method."
    end
  end
end

它们是保存在与每个类的普通方法一起的 RClass 结构中吗,可能使用一个标志来指示它们是类方法而不是普通方法?还是它们保存在其他地方?让我们来看看!

很容易看出类方法不是保存在哪里。它们显然没有与普通方法一起保存在 RClass 方法表中,因为 Mathematician 的实例无法调用它们,正如下例所示:

> **obj = Mathematician.new**
> **obj.class_method**
 => undefined method `class_method' for
#< Mathematician:0x007fdd8384d1c8 (NoMethodError)

现在,记住 Mathematician 也是一个 Ruby 对象,请回顾以下定义:

一个 Ruby 类是一个 Ruby 对象,它还包含方法定义、属性名称、父类指针和常量表。

我们假设 Ruby 应该像保存任何对象的方法一样保存 Mathematician 的方法:保存在该对象类的方法表中。换句话说,Ruby 应该通过 klass 指针获取 Mathematician 的类,并将方法保存在该 RClass 结构体的方法表中,如图 5-22 所示。

Ruby 是否应将类方法保存在类的方法表中?

图 5-22. Ruby 是否应将类方法保存在类的方法表中?

但是 Ruby 实际上并没有这样做,你可以通过创建另一个类并尝试调用新方法来发现这一点:

> **class AnotherClass; end**
> **AnotherClass.class_method**
 => undefined method `class_method' for AnotherClass:Class (NoMethodError)

如果 Ruby 将类方法添加到 Class 类的方法表中,那么你应用程序中的所有类都会有这个方法。显然,这不是我们编写类方法时的意图,幸运的是,Ruby 并没有这样实现类方法。

那么,类方法去哪了呢?提示是使用方法 ObjectSpace.count_objects,如示例 5-20 中所示:

示例 5-20. 使用 `ObjectSpace.count_objects` 与 `:T_CLASS`

 $ **irb**
![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/rb-mscp/img/httpatomoreillycomsourcenostarchimages1853843.png.jpg) > **ObjectSpace.count_objects[:T_CLASS]**
![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/rb-mscp/img/httpatomoreillycomsourcenostarchimages1853845.png.jpg)  => 859
    > class Mathematician; end
     => nil
    > **ObjectSpace.count_objects[:T_CLASS]**
![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/rb-mscp/img/httpatomoreillycomsourcenostarchimages1853847.png.jpg)  => 861

`ObjectSpace.count_objects` 在 ![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/rb-mscp/img/httpatomoreillycomsourcenostarchimages1853843.png.jpg) 返回给定类型的对象数量。在这个测试中,我传递了 `:T_CLASS` 符号来获取在我的 IRB 会话中存在的类对象数量。在我创建 `Mathematician` 之前,存在 859 个类,在 ![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/rb-mscp/img/httpatomoreillycomsourcenostarchimages1853845.png.jpg) 处显示。声明 `Mathematician` 后,存在 861 个类,在 ![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/rb-mscp/img/httpatomoreillycomsourcenostarchimages1853847.png.jpg) 处显示——多了两个。这很奇怪,我只声明了一个新类,但 Ruby 实际上创建了两个!第二个类是干什么的?它在哪里?

事实证明,每当你创建一个新类时,Ruby 内部会创建两个类!第一个类就是你创建的新类:Ruby 会创建一个新的 `RClass` 结构体来表示你的类,如上所述。但在内部,Ruby 还会创建第二个隐藏的类,称为 *元类*。为什么?为了保存你以后可能为新类创建的任何类方法。实际上,Ruby 会将元类设置为你新类的类:它将你新 `RClass` 结构体的 `klass` 指针指向元类。

没有编写 C 代码,无法轻松查看元类或 `klass` 指针的值,但你可以像这样将元类作为 Ruby 对象获取:

class Mathematician
end

p Mathematician
 => Mathematician

p Mathematician.singleton_class
 => #<Class:Mathematician>

第一个打印语句显示对象的类,而第二个显示对象的元类。奇怪的 `#<Class:Mathematician>` 语法表示第二个类是 `Mathematician` 的元类。这是 Ruby 在我声明 `Mathematician` 类时自动为我创建的第二个 `RClass` 结构。而这个第二个 `RClass` 结构是 Ruby 保存我的类方法的地方,如图 5-23 所示。

![一个对象,它的类和元类](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/rb-mscp/img/httpatomoreillycomsourcenostarchimages1854085.png.jpg)

图 5-23。一个对象,它的类和元类

如果我们现在显示元类的方法,我们将看到所有 `Class` 类的方法,以及 `Mathematician` 的新类方法:

p obj.singleton_class.methods
 => [ ... :class_method, ...  ]

总结

在本章中,我们已经看到 Ruby 如何在内部表示对象和类:Ruby 使用 `RObject` 结构来表示你在代码中定义的任何自定义类的实例,以及 Ruby 自身预定义的一些类的实例。`RObject` 结构非常简单,仅包含指向对象类的指针和一个实例变量值的表格,并且还包含变量的计数。其结构的简单性使我们能够给出 Ruby 对象的非常简单的定义:

每个 Ruby 对象都是类指针和实例变量数组的组合。

这个定义非常强大且有用,因为 Ruby 中的一切都是对象:无论你在 Ruby 程序中使用什么值,记住它都是一个对象,因此它将具有类指针和实例变量。

我们还看到 Ruby 使用特殊的 C 结构来表示许多常用的内置 Ruby 类的实例,这些类被称为“通用”对象。例如,Ruby 使用 `RString` 结构来表示 `String` 类的实例,`RArray` 用于表示 `Array` 类的实例,或者 `RRegexp` 用于表示 `Regexp` 类的实例。虽然这些结构不同,但 Ruby 同样为每个这些通用对象保存类指针和实例变量数组。最后,我们看到 Ruby 会将一些简单的值(如小整数和符号)保存,而完全不使用 C 结构。Ruby 将这些值直接保存在 `VALUE` 指针中,而该指针通常指向保存值的结构。

虽然 Ruby 对象很简单,但我们在本章中学到,Ruby 类并没有那么简单。`RClass` 结构与 `rb_classext_struct` 结构一起工作,保存了大量的信息。学习这一点迫使我们为 Ruby 类写出一个更复杂的定义:

Ruby 类是一个 Ruby 对象,它还包含方法定义、属性名称、父类指针和常量表。

通过查看 `RClass` 和 `rb_classext_struct`,我们发现 Ruby 类也是 Ruby 对象,因此它们也包含实例变量和类指针。我们研究了类的实例变量与类变量之间的区别,并了解到 Ruby 将这两种变量类型保存在同一个哈希表中。我们还发现类包含一系列哈希表,用于存储其方法、对象级实例变量的名称以及类中定义的常量。最后,我们看到了每个 Ruby 类如何通过 `super` 指针记录其父类。

第六章。方法查找与常量查找

没有标题的图片

在 Ruby 内部,模块是类。

正如我们在第五章中看到的,类在 Ruby 中扮演着重要角色,保存方法定义和常量值等内容。我们还学习了 Ruby 是如何通过每个RClass结构中的super指针实现继承的。

实际上,随着程序的增长,你可以想象它是按类和父类组织的,形成一种巨大的树形结构。底部是Object类(或者,实际上是内部的BasicObject类)。这个类是 Ruby 的默认父类,你所有的类都会出现在树的某个更高的地方,向不同的方向分支。在本章中,我们将学习 Ruby 是如何利用这个父类树来查找方法的。当你编写调用方法的代码时,Ruby 会以非常精确的方式在这棵树中查找。我们将通过一个具体的例子来演示方法查找过程。

在本章后面,我们将学习另一种方式来可视化你的 Ruby 代码。每次你创建一个新的类或模块时,Ruby 会向一个不同的树中添加一个新的作用域,这棵树是基于你程序的语法结构的。这棵树的主干是顶级作用域,也就是你开始编写 Ruby 代码文件的地方。随着你定义越来越多的嵌套模块和类,这棵树也会不断变得更高。我们将学习如何通过这个语法或命名空间树,Ruby 能够找到常量定义,就像父类树帮助 Ruby 找到方法一样。

但在我们深入方法和常量查找之前,让我们先来看一下 Ruby 模块。什么是模块?它们与类有何不同?当你将模块包含进类时会发生什么?

路线图

  • Ruby 是如何实现模块的

    • 模块是类

    • 将模块包含进类中

  • Ruby 的方法查找算法

    • 方法查找示例

    • 方法查找算法实践

    • Ruby 中的多重继承

    • 全局方法缓存

    • 内联方法缓存

    • 清除 Ruby 的方法缓存

    • 将两个模块包含进一个类中

    • 将一个模块包含到另一个模块中

    • Module#prepend 示例

    • 如何 Ruby 实现 Module#prepend

  • 实验 6-1:在包含模块后修改模块

    • 类看到后来添加到模块中的方法

    • 类看不见后加入的子模块

    • 包含的类与原始模块共享方法表

    • 深入了解 Ruby 如何复制模块

  • 常量查找

    • 在超类中查找常量

    • Ruby 如何在父命名空间中查找常量?

  • Ruby 中的词法作用域

    • 为新类或模块创建常量

    • 使用词法作用域在父命名空间中查找常量

    • Ruby 的常量查找算法

  • 实验 6-2:Ruby 首先找到哪个常量?

    • Ruby 的实际常量查找算法
  • 总结

Ruby 如何实现模块

如你所知,模块在 Ruby 中与类非常相似。你可以像创建类一样创建模块——通过输入 module 关键字,然后定义一系列方法。但尽管模块和类相似,它们在 Ruby 中的处理方式有三点重要的不同:

  • Ruby 不允许你直接从模块创建对象。实际上,这意味着你不能在模块上调用 new 方法,因为 newClass 的方法,而不是 Module 的方法。

  • Ruby 不允许为模块指定一个超类。

  • 此外,你可以使用 include 关键字将模块包含到类中。

那么模块到底是什么?Ruby 是如何在内部表示它们的?它使用RModule结构吗?将模块“包含”到类中意味着什么?

模块即类

事实证明,在内部 Ruby 将模块实现为类。当你创建一个模块时,Ruby 会为该模块创建一个新的RClass/rb_classext_struct结构对,就像它为一个新类创建一样。例如,假设我们像这样定义一个新的模块。

module Professor
end

在内部,Ruby 会创建一个类,而不是一个模块!图 6-1 展示了 Ruby 如何在内部表示模块。

Ruby 类结构中用于模块的部分

图 6-1. Ruby 类结构中用于模块的部分

在这个图中,我再次展示了 Ruby 的RClass结构。然而,我从图表中移除了一些值,因为模块并不使用它们。最重要的是,我移除了iv_index_tbl,因为你不能为模块创建对象实例——换句话说,你不能在模块上调用new方法。这意味着没有对象级别的属性需要跟踪。我还移除了refined_classallocator值,因为模块也不使用它们。我保留了super指针,因为模块确实有内部的超类,尽管你不能自行指定它们。

Ruby 模块的技术定义(暂时忽略origin值)可能如下所示:

Ruby 模块是一个 Ruby 对象,它还包含方法定义、一个超类指针和一个常量表。

将模块包含到类中

模块背后的真正魔力发生在你将模块包含到类中时,如示例 6-1 所示。

示例 6-1. 将模块包含到类中

module Professor
end

class Mathematician < Person
  include Professor
end

当我们运行示例 6-1 时,Ruby 为Professor模块创建了一个RClass结构的副本,并将其作为Mathematician的新的超类。Ruby 的 C 源代码将这个模块的副本称为包含类。新副本的Professor的超类被设置为Mathematician原始超类,这样可以保持超类或祖先链。图 6-2 总结了这一有些令人困惑的情况。

将模块包含到类中

图 6-2. 将模块包含到类中

你可以看到图 6-2 左上角的Mathematician类。在它下面的左侧,你可以看到它的父类链:Mathematician的父类是PersonPerson的父类是Another Superclass,依此类推。每个RClass结构中的super指针(实际上是每个rb_classext_struct结构中的super指针)都指向下一个父类。

现在来看图 6-2 右侧的Professor模块。当我们将此模块包含到Mathematician类中时,Ruby 会将Mathematiciansuper指针指向Professor的一个副本,而该副本的super指针又指向Person,即Mathematician的原始父类。

注意

Ruby 实现extend的方式与此完全相同,不同之处在于被包含的类成为目标类的类或 metaclass 的父类。因此,extend允许你向类添加类方法。

Ruby 的方法查找算法

每当你调用一个方法,或者用面向对象编程术语来说,“给接收者发送一条消息”时,Ruby 需要确定哪个类实现了该方法。有时这很明显:接收者的类可能实现了目标方法。然而,这种情况并不常见。可能是你的系统中其他模块或类实现了该方法。Ruby 使用一个非常精确的算法,在你的程序中的模块和类之间按照特定的顺序进行搜索,以找到目标方法。理解这个过程对每个 Ruby 开发者来说都是至关重要的,所以让我们仔细研究一下。

图 6-3 中的流程图为你提供了 Ruby 方法查找算法的图形表示。

Ruby 的方法查找算法

图 6-3. Ruby 的方法查找算法

这个算法非常简单,不是吗?正如你所看到的,Ruby 只是沿着super指针一直查找,直到找到包含目标方法的类或模块。你可能会想象 Ruby 需要通过一些特殊的逻辑来区分模块和类——例如,它需要处理有多个包含模块的情况。但是不,实际上它只是对super指针链表进行简单的循环。

方法查找示例

稍后我们将详细演示这个算法,以确保我们完全理解它。但首先,让我们设置一个包含类、父类和模块的示例。这将帮助我们了解类和模块在 Ruby 中如何协同工作。

示例 6-2 展示了带有访问器方法 first_namelast_nameMathematician 类。

示例 6-2。一个简单的 Ruby 类,重复自示例 5-1

class Mathematician
  attr_accessor :first_name
  attr_accessor :last_name
end

现在让我们介绍一个父类。在示例 6-3 中, 在 我们将 Person 设置为 Mathematician 的父类。

示例 6-3。PersonMathematician 的父类。

    class Person
    end

 class Mathematician < Person
      attr_accessor :first_name
      attr_accessor :last_name
    end

我们将姓名属性移到 Person 父类中,因为不仅仅是数学家有名字。我们最终得到了示例 6-4 中显示的代码。

示例 6-4。现在姓名属性在 Person 父类中。

class Person
  attr_accessor :first_name
  attr_accessor :last_name
end

class Mathematician < Person
end

最后,我们将在 Mathematician 类中包含 Professor 模块,如 所示。示例 6-5 展示了完整的示例。

示例 6-5。现在我们有一个包含模块并且有父类的类。

    class Person
      attr_accessor :first_name
      attr_accessor :last_name
    end

    module Professor
      def lectures; end
    end

    class Mathematician < Person
   include Professor
    end

方法查找算法的实际应用

现在我们已经设置好示例,准备查看 Ruby 如何找到我们调用的方法。每当你在程序中调用任何方法时,Ruby 会按照我们接下来将看到的相同流程来查找方法。

让我们开始吧,先调用一个方法。使用以下代码,我们创建一个新的数学家对象并设置它的名字:

ramanujan = Mathematician.new
ramanujan.first_name = "Srinivasa"

为了执行这段代码,Ruby 需要找到 first_name= 方法。这个方法在哪里呢?Ruby 到底是如何找到它的?

首先,Ruby 通过 klass 指针从 ramanujan 对象中获取类,如图 6-4 所示。

Ruby 首先在对象的类中查找 first_name= 方法。

图 6-4。Ruby 首先在对象的类中查找 first_name= 方法。

接下来,Ruby 检查 Mathematician 是否通过查看其方法表直接实现了 first_name=,如图 6-5 所示。

Ruby 首先在类的方法表中查找 first_name=。

图 6-5。Ruby 首先在类的方法表中查找 first_name=

因为我们已将所有方法移到 Person 超类中,所以 first_name= 方法不再存在。Ruby 继续执行算法,并通过 super 指针获取 Mathematician 的超类,如图 6-6 所示。

Mathematician 的超类是  模块的副本。

图 6-6. Mathematician 的超类是 Professor 模块的副本。

请记住,这不是 Person 类;它是 包含的 类,即 Professor 模块的副本。由于它是副本,Ruby 会在 Professor 的方法表中查找。回想一下示例 6-5,Professor 只包含单一方法 lectures。Ruby 不会找到 first_name= 方法。

注意

请注意,由于 Ruby 将模块插入到原始超类之上,包含的模块中的方法会覆盖超类中的方法。在这种情况下,如果 Professor 也有一个 first_name= 方法,Ruby 将调用它,而不是 Person 中的方法。

由于 Ruby 没有在 Professor 中找到 first_name=,它继续遍历 super 指针,但这一次它使用的是 Professor 中的 super 指针,如图 6-7 所示。

Person 类是包含的  模块副本的超类。

图 6-7. Person 类是包含的 Professor 模块副本的超类。

请注意,Professor 模块的超类——更准确地说,包含的 Professor 模块的副本的超类——是 Person 类。这是 Mathematician 原始的超类。最后,Ruby 在 Person 的方法表中找到了 first_name= 方法。因为它已经确定了哪个类实现了 first_name=,所以 Ruby 可以使用我们在第四章中学到的方法调度过程来调用这个方法。

Ruby 中的多重继承

这里最有趣的是,Ruby 在内部使用类继承来实现模块的包含。实际上,包含一个模块和指定一个超类之间没有区别。这两种操作都会将新的方法引入目标类,并且都在内部使用类的 super 指针。将多个模块包含到 Ruby 类中,相当于指定多个超类。

尽管如此,Ruby 仍通过强制执行一个单一的祖先列表来保持简单。虽然包含多个模块会在内部创建多个超类,但 Ruby 会将它们维护在一个单一的列表中。结果是?作为 Ruby 开发者,你既可以享受多重继承的好处(从任意数量的模块中为类添加新行为),又能保持单继承模型的简单性。

Ruby 也从这种简化中受益!通过强制使用单一的超类祖先列表,其方法查找算法非常简单。每当你在对象上调用方法时,Ruby 只需要遍历超类链表,直到找到包含目标方法的类或模块。

全局方法缓存

根据继承链中超类的数量,方法查找可能会很耗时。为了解决这个问题,Ruby 会缓存查找结果以供后续使用。它记录了哪个类或模块实现了你代码中调用的方法,存在两个缓存中:一个是全局方法缓存,一个是内联方法缓存。

让我们先了解一下全局方法缓存。Ruby 使用 全局方法缓存 保存接收者类和实现者类之间的映射,如表格 6-1 所示。

表格 6-1. 全局方法缓存可能包含的示例

klass defined_class
Fixnum#times Integer#times
Object#puts BasicObject#puts
等等... 等等...

表格 6-1 中的左栏 klass 显示了接收者类;这是你在其上调用方法的对象所属的类。右栏 defined_class 记录了方法查找的结果。它是实现方法的类,或者说是实现 Ruby 查找方法的类。

以表格 6-1 的第一行为例;它显示了 Fixnum#timesInteger#times。在全局方法缓存中,这意味着 Ruby 的方法查找算法开始在 Fixnum 类中查找 times 方法,但实际上它是在 Integer 类中找到了。类似地,表格 6-1 的第二行表示 Ruby 开始在 Object 类中查找 puts 方法,但实际上找到了 BasicObject 类中的实现。

全局方法缓存允许 Ruby 在下次你的代码调用全局缓存中第一列列出的某个方法时跳过方法查找过程。你的代码第一次调用 Fixnum#times 后,Ruby 知道它可以执行 Integer#times 方法,无论你在程序中的哪个位置调用 times

内联方法缓存

Ruby 使用另一种类型的缓存,称为内联方法缓存,进一步加速方法查找。内联缓存将信息保存在 Ruby 执行的编译后 YARV 指令旁边(见图 6-8)。

左侧的 YARV 指令应调用右侧的实现。

图 6-8. 左侧的 YARV 指令应调用右侧的Integer#times实现。

在图形的左侧,我们看到与代码10.times do... end对应的编译后的 YARV 指令。首先,putobject 10Fixnum对象10推入 YARV 的内部栈中。这是times方法调用的接收者。接下来,send调用times方法,方法名位于尖括号之间的文本中。

图形右侧的矩形表示 Ruby 通过其方法查找算法找到的Integer#times方法(在Fixnum类及其超类中查找times方法后)。Ruby 的内联缓存使得它能够将times方法调用与Integer#times实现之间的映射直接保存在 YARV 指令中。图 6-9 展示了内联缓存的样子。

内联缓存将方法查找的结果保存在需要调用该方法的指令旁边。

图 6-9. 内联缓存将方法查找的结果保存在需要调用该方法的send指令旁边。

如果 Ruby 再次执行这一行代码,它将立即执行Integer#times,而不需要调用方法查找算法。

清除 Ruby 的方法缓存

由于 Ruby 是一种动态语言,你可以随时定义新的方法。为了让你能够这样做,Ruby 必须清除全局和内联方法缓存,因为方法查找的结果可能会发生变化。例如,如果我们向FixnumInteger类中添加新的times方法定义,Ruby 需要调用新的times方法,而不是它之前使用的Integer#times方法。

实际上,每当你创建或删除(未定义)方法、将模块包含到类中或执行类似的操作时,Ruby 会清除全局和内联方法缓存,迫使重新调用方法查找代码。当你使用修饰符或进行其他类型的元编程时,Ruby 也会清除缓存。事实上,在 Ruby 中清除缓存是一个相当频繁的操作。全局和内联方法缓存可能只能在短时间内保持有效。

将两个模块包含到一个类中

虽然 Ruby 的方法查找算法可能很简单,但它用于包含模块的代码并不简单。如上所述,当你将一个模块包含到一个类中时,Ruby 会将该模块的副本插入到该类的祖先链中。这意味着如果你依次包含两个模块,第二个模块会首先出现在祖先链中,并且会被 Ruby 的方法查找逻辑首先找到。

例如,假设我们将两个模块包含到Mathematician中,如示例 6-6 所示。

示例 6-6。将两个模块包含到一个类中

class Mathematician < Person
  include Professor
  include Employee
end

现在Mathematician对象拥有来自Professor模块、Employee模块和Person类的方法。但 Ruby 会首先找到哪些方法,哪些方法会被覆盖呢?

图 6-10 和图 6-11 展示了优先级的顺序。由于我们首先包含了Professor模块,Ruby 首先将对应的Professor模块的包含类作为父类插入。

在示例中,我们首先包含了模块。

图 6-10。在示例 6-6 中,我们首先包含了Professor模块。

现在,当我们包含Employee模块时,Employee模块对应的包含类会被插入到Professor模块对应的包含类之上,如图 6-11 所示。

在示例中,我们在包含模块之后,第二次包含了模块。

图 6-11。在示例 6-6 中,我们在包含Professor模块之后,第二次包含了Employee模块。

由于Employee在父类链中位于Professor之上,如图 6-11 左侧所示,Employee的方法会覆盖Professor的方法,而Professor的方法又会覆盖Person的方法,即实际的父类方法。

将一个模块包含到另一个模块中

模块不允许你指定父类。例如,我们不能写出如下代码:

module Professor < Employee
end

但是我们可以将一个模块包含到另一个模块中,正如在示例 6-7 中所示。

示例 6-7。一个模块包含另一个模块

module Professor
  include Employee
end

如果我们将 Professor(一个包含其他模块的模块)包含进 Mathematician,Ruby 会先找到哪些方法呢?如 图 6-12 所示,当我们将 Employee 包含进 Professor 时,Ruby 会创建一个 Employee 的副本,并将其设置为 Professor 的父类。

当你将一个模块包含进另一个模块时,Ruby 会将其设置为目标模块的父类。

图 6-12. 当你将一个模块包含进另一个模块时,Ruby 会将其设置为目标模块的父类。

模块在你的代码中不能有父类,但它们在 Ruby 内部可以有父类,因为 Ruby 内部用类来表示模块!

最后,当我们将 Professor 包含进 Mathematician 时,Ruby 会遍历这两个模块,并将它们都插入为 Mathematician 的父类,如 图 6-13 所示。

同时将两个模块包含到一个类中

图 6-13. 同时将两个模块包含到一个类中

现在,Ruby 会首先查找 Professor 中的方法,其次查找 Employee 中的方法。

Module#prepend 示例

在 图 6-2 中,我们看到 Ruby 如何将一个模块包含进一个类。具体来说,我们看到 Ruby 如何将模块的 RClass 结构副本插入到目标类的父类链中,位于类和它的父类之间。

从版本 2.0 开始,Ruby 现在允许你将一个模块“前置”到类中。我们将使用 Mathematician 类来进行说明,如 示例 6-8 所示。

示例 6-8. 一个带有 name 属性的简单 Ruby 类

    class Mathematician
   attr_accessor :name
    end

    poincaré = Mathematician.new
    poincaré.name = "Henri Poincaré"
 p poincaré.name
     => "Henri Poincaré"

首先,我们定义一个仅有 name 属性的 Mathematician 类,如 所示。然后,我们创建一个 Mathematician 类的实例,设置其名称,并显示它,如 所示。

现在假设我们通过再次将 Professor 模块包含进 Mathematician 类,使所有数学家都成为教授,如 示例 6-9 所示的

示例 6-9. 将 Professor 模块包含进 Mathematician

    module Professor
    end

    class Mathematician
      attr_accessor :name
   include Professor
    end

图 6-14 显示了 MathematicianProfessor 的父类链。

Professor 是 Mathematician 的父类。

图 6-14. ProfessorMathematician 的父类。

如果我们决定在每个数学家的名字前显示 Prof. 头衔,我们可以像在 示例 6-10 中所示那样,仅在 Mathematician 类中添加该行为。

示例 6-10. 一种丑陋的方式在每个数学家的名字前显示 Prof. 头衔

    module Professor
    end

    class Mathematician
      attr_writer :name
      include Professor
   def name
        "Prof. #{@name}"
      end
    end

但是这是一个非常糟糕的解决方案:Mathematician 类必须承担显示教授头衔的工作,见 。如果其他类也包含 Professor 呢?它们不也应该显示 Prof. 头衔吗?如果 Mathematician 中包含了显示 Prof. 的代码,那么任何其他包含 Professor 的类都将缺少这段代码。

将显示头衔的代码包含在 Professor 模块中更为合理,正如在 示例 6-11 中所示。这样,任何包含 Professor 的类都能够显示 Prof. 头衔以及其类名。

示例 6-11. 我们如何让 Ruby 调用模块的 name 方法?

    module Professor
   def name
        "Prof. #{super}"
      end
    end

    class Mathematician
      attr_accessor :name
   include Professor
    end

    poincaré = Mathematician.new
    poincaré.name = "Henri Poincaré"
 p poincaré.name
     => "Henri Poincaré"

处,我们在 Professor 中定义了一个 name 方法,该方法会在实际姓名之前显示 Prof. 头衔(假设 name 已在父类中定义)。在 处,我们将 Professor 包含到 Mathematician 中。最后,在 处,我们调用 name 方法,但得到的是 Henri Poincaré 的名字,没有 Prof. 头衔。出了什么问题?

如 图 6-14 所示,问题在于 ProfessorMathematician 的父类,而不是反过来。这意味着当我在 示例 6-11 中调用 poincaré.name 时,Ruby 会从 Mathematician 中找到 name 方法,而不是从 Professor 中找到。图 6-15 直观地展示了当我调用 poincaré.name 时 Ruby 方法查找算法的执行过程。

Ruby 在找到来自 Professor 的 name 方法之前会先调用 attr_accessor 方法。

图 6-15. Ruby 在找到来自 Professorname 方法之前会先调用 attr_accessor 方法。

当我们在示例 6-11 中调用name时,Ruby 会在父类链中从顶部开始向下查找它看到的第一个name方法。如图 6-15 所示,第一个name方法是Mathematician中的简单attr_accessor方法。

然而,如果我们使用prepend而不是include,我们就能得到预期的行为,如示例 6-12 所示。

示例 6-12. 使用prepend时,Ruby 首先找到模块的name方法。

    module Professor
      def name
        "Prof. #{super}"
      end
    end

    class Mathematician
      attr_accessor :name
   prepend Professor
    end

    poincaré = Mathematician.new
    poincaré.name = "Henri Poincaré"
 p poincaré.name
     => "Prof. Henri Poincaré"

这里唯一的区别是使用了prepend,如图 6-16 所示。

Ruby 如何实现 Module#prepend

当你将一个模块预置到一个类时,Ruby 会将其放在类之前,形成父类链,正如图 6-16 所示。

使用 prepend 时,Ruby 将模块放在目标类之前,形成父类链。

图 6-16. 使用prepend时,Ruby 将模块放在目标类之前,形成父类链。

但是这里有些奇怪的地方。当我们在一个 mathematician 对象上调用name时,Ruby 是如何找到模块的方法的呢?也就是说,在示例 6-12 中,我们是在Mathematician类上调用name,而不是在Professor模块上调用。Ruby 应该会找到简单的attr_accessor方法,而不是模块中的版本,但事实并非如此。Ruby 是从父类链中向上查找模块的方法吗?如果是这样,Ruby 是如何做到的呢,特别是当super指针指向下方时?

其中的秘密在于,Ruby 内部使用了一个技巧,使得它看起来MathematicianProfessor的父类,虽然实际上并不是,如图 6-17 所示。将一个模块预置(prepend)到类中,就像是将模块包含(include)到类中一样。Mathematician处于父类链的顶部,往下走时,我们会发现 Ruby 仍然将Professor类设置为Mathematician的父类。

但是在图 6-17 中,Professor 下方我们看到了一些新的内容,即 Mathematician原始类。这是 Ruby 为使 prepend 生效而创建的 Mathematician 的新副本。

当你使用 prepend 插入模块时,Ruby 会创建目标类的副本(内部称为 原始类),并将其设置为被插入模块的父类。Ruby 使用我们在图 6-1 和图 6-2 中看到的 origin 指针来跟踪这个类的新副本。此外,Ruby 会将原始类中的所有方法移动到原始类中,这意味着这些方法现在可以被具有相同名称的插入模块中的方法重写。在图 6-17 中,你可以看到 Ruby 已将 attr_accessor 方法从 Mathematician 移动到原始类。

Ruby 创建目标类的副本,并将其设置为被插入模块的父类。

图 6-17. Ruby 创建目标类的副本,并将其设置为被插入模块的父类。

实验 6-1: 在包含模块后修改模块

根据 Xavier Noria 的建议,本实验将探讨当你修改已被包含到类中的模块时会发生什么。我们将使用相同的 Mathematician 类和 Professor 模块,但方法有所不同,如在示例 6-13 中所示。

示例 6-13. 另一个将模块包含进类的例子

    module Professor
      def lectures; end
    end

    class Mathematician
   attr_accessor :first_name
      attr_accessor :last_name
   include Professor
    end

这次,Mathematician 类包含了用于 @first_name@last_name 的访问器方法,如下图所示!,我们再次包含了 Professor 模块,如下图所示!。如果我们检查数学家对象的方法,如示例 6-14 所示,我们应该看到属性方法,例如 first_name= 和来自 Professorlectures 方法。

示例 6-14. 检查数学家对象的方法

fermat = Mathematician.new
fermat.first_name = 'Pierre'
fermat.last_name = 'de Fermat'

p fermat.methods.sort
 => [ ... :first_name, :first_name=, ... :last_name, :last_name=, :lectures ... ]

毫不奇怪,我们看到所有方法。

类查看后添加到模块的方法

现在,让我们在将Professor模块包含到Mathematician类之后,再添加一些新方法。Ruby 是否知道这些新方法也应该添加到Mathematician类呢?让我们通过在示例 6-15 运行后,接着运行示例 6-14 来找出答案。

示例 6-15. 将新方法添加到Professor模块,并在其被包含到Mathematician后进行修改。

    module Professor
      def primary_classroom; end
    end

    p fermat.methods.sort
  => [ ... :first_name, :first_name=, ... :last_name, :last_name=, :lectures,
    ... :primary_classroom, ... ]

如你所见,在 我们得到了所有的方法,包括新添加的primary_classroom方法,它是在将Professor包含进Mathematician后添加的。这里也没有惊讶,Ruby 总是领先一步。

类看不到稍后包含的子模块

现在进行另一个测试。如果我们重新打开Professor模块,并使用示例 6-16 再将另一个模块包含到其中,会发生什么呢?

示例 6-16. 将新模块包含进Professor模块,且该模块已被包含进Mathematician

module Employee
  def hire_date; end
end

module Professor
  include Employee
end

这变得有些混乱,因此让我们回顾一下在示例 6-13 和示例 6-16 中所做的操作:

  • 在示例 6-13 中,我们将Professor模块包含进了Mathematician类。

  • 然后,在示例 6-16 中,我们将Employee模块包含进了Professor模块。因此,Employee模块的方法现在应该在mathematician对象上可用。

让我们看看 Ruby 是否按照预期工作:

p fermat.methods.sort
 => [ ... :first_name, :first_name=, ... :last_name, :last_name=, :lectures ... ]

没有成功!hire_date方法在fermat对象中不可用。将模块包含到已包含模块的类中并不会影响该类。

正如我们已经了解了 Ruby 如何实现模块,这个事实应该不难理解。将Employee模块包含进Professor模块并不会改变 Ruby 在我们将其包含到Mathematician模块时所创建的Professor模块副本,正如在图 6-18 中所示。

Employee 模块被包含到原始的 Professor 模块中,而不是 Mathematician 使用的包含副本。

图 6-18. Employee 模块被包含到原始的 Professor 模块中,而不是 Mathematician 使用的包含副本。

包含的类与原始模块共享方法表

那么,我们在 示例 6-15 中添加的 primary_classroom 方法呢?即使我们在将 Professor 包含到 Mathematician 后才添加了 primary_classroom 方法,Ruby 是如何将该方法包含到 Mathematician 中的呢?图 6-18 显示 Ruby 在我们向 Professor 添加新方法之前,已经创建了 Professor 模块的副本。但那 fermat 对象是如何获得这个新方法的呢?

事实证明,当你包含一个模块时,Ruby 复制的是 RClass 结构,而不是底层的方法表,如 图 6-19 所示。

Ruby 不会在包含模块时复制方法表。

图 6-19. Ruby 不会在包含模块时复制方法表。

Ruby 不会复制 Professor 的方法表。相反,它只是将 Professor 的新副本中的 m_tbl(即“包含类”)设置为指向相同的方法表。这意味着,通过重新打开模块并添加新方法来修改方法表,将会改变该模块以及任何已经包含该模块的类。

深入了解 Ruby 如何复制模块

通过直接查看 Ruby 的 C 源代码,你将精确理解 Ruby 在包含模块时如何复制它们,以及为什么 Ruby 会按你在这个实验中看到的行为表现。你可以在 class.c 文件中找到 Ruby 用于复制模块的 C 函数。示例 6-17 展示了 rb_include_class_new 函数的一部分。

示例 6-17. rb_include_class_new C 函数的一部分,来自 class.c

    VALUE
 rb_include_class_new(VALUE module, VALUE super)
    {
     VALUE klass = class_alloc(T_ICLASS, rb_cClass);
        --*snip*--
     RCLASS_IV_TBL(klass) = RCLASS_IV_TBL(module);
        RCLASS_CONST_TBL(klass) = RCLASS_CONST_TBL(module);
     RCLASS_M_TBL(klass) = RCLASS_M_TBL(RCLASS_ORIGIN(module));
     RCLASS_SUPER(klass) = super;
        --*snip*--
        return (VALUE)klass;
    }

中,Ruby 传入了 module(要复制的目标模块)和 super(用于新副本的超类)。通过指定特定的超类,Ruby 将新副本插入到超类链中的特定位置。如果你在 class.c 中搜索 rb_include_class_new,你会发现 Ruby 是通过另一个 C 函数 include_modules_at 来调用它的,该函数处理了 Ruby 用来包含模块的复杂内部逻辑。

Ruby 调用class_alloc创建一个新的RClass结构,并将其引用保存在klass中。请注意,class_alloc的第一个参数是值T_ICLASS,用于标识新类为一个包含类。在处理包含类时,Ruby 在其 C 源代码中始终使用T_ICLASS

Ruby 通过三个操作RClass的 C 宏,从原始模块的RClass结构复制一系列指针到新副本中。

  • RCLASS_IV_TBL 获取或设置指向实例变量表的指针。

  • RCLASS_CONST_TBL 获取或设置指向常量变量表的指针。

  • RCLASS_M_TBL 获取或设置指向方法表的指针。

例如,RCLASS_IV_TBL(klass) = RCLASS_IV_TBL(module)klass(新副本)中的实例变量表指针设置为module(要复制的目标模块)中的实例变量指针。现在klassmodule使用相同的实例变量。同样,klassmodule共享常量和方法表。因为它们共享相同的方法表,向module添加新方法也会将其添加到klass。这解释了我们在实验 6-1:包含模块后修改模块中看到的行为:向模块添加方法也会将其添加到包含该模块的每个类中。

还要注意,在 Ruby 使用RCLASS_ORIGIN(module),而不是module。通常RCLASS_ORIGIN(module)module相同;但是,如果您之前在module中使用了prepend,那么RCLASS_ORIGIN(module)会返回module的原始类。回想一下,当您调用Module#prepend时,Ruby 会复制(原始类)目标模块并将复制插入到超类链中。通过使用RCLASS_ORIGIN(module),Ruby 获取原始模块的方法表,即使您使用不同的模块进行了预先处理。

最后,在 Ruby 将klass的超类指针设置为指定的超类并返回它。

常量查找

我们已经了解了 Ruby 的方法查找算法以及它如何通过超类链搜索找到正确的方法调用。现在我们将把注意力转向一个相关的过程:Ruby 的常量查找算法,或者说 Ruby 用来在代码中查找你引用的常量值的过程。

显然,方法查找是语言的核心,但为什么要研究常量查找?作为 Ruby 开发人员,我们在代码中并不经常使用常量——当然不像我们经常使用类、模块、变量和块那样。

一个原因是常量像模块和类一样,是 Ruby 内部工作方式和我们使用 Ruby 的方式的核心。每当你定义一个模块或类时,你也定义了一个常量。而每当你引用或使用一个模块或类时,Ruby 必须查找对应的常量。

第二个原因与 Ruby 查找你在代码中引用的常量的方式有关。你可能知道,Ruby 会查找在超类中定义的常量,但它也会查找在程序的周围命名空间或语法作用域中的常量。研究 Ruby 如何处理语法作用域,能帮助我们发现一些关于 Ruby 内部工作方式的重要信息。

让我们先回顾一下常量在 Ruby 中是如何工作的。

在超类中查找常量

Ruby 查找你引用的常量定义的一个方式是通过使用超类链,正如它查找方法定义时的方式一样。示例 6-18 展示了一个类如何在其超类中查找常量的例子。

示例 6-18。Ruby 查找你在超类中定义的常量。

    class MyClass
   SOME_CONSTANT = "Some value..."
    end

 class Subclass < MyClass
      p SOME_CONSTANT
    end

在示例 6-18 中,我们定义了一个只有一个常量 SOME_CONSTANTMyClass,位置在 。然后我们创建了 Subclass,并将 MyClass 设为超类,位置在 。当我们打印 SOME_CONSTANT 的值时,Ruby 使用了与查找方法相同的算法,正如图 6-20 所示。

Ruby 使用超类链查找常量,就像查找方法一样。

图 6-20。Ruby 使用超类链来查找常量,就像查找方法一样。

在这里,右侧显示了来自示例 6-18 的代码,左侧显示了我们创建的两个类对应的 RClass 结构。在图的左上角,你可以看到 MyClass,它在常量表中包含了 SOME_CONSTANT 的值。下面是 Subclass。当我们在 Subclass 中引用 SOME_CONSTANT 时,Ruby 使用 super 指针查找 MyClass 及其 SOME_CONSTANT 的值。

Ruby 如何在父命名空间中查找常量?

示例 6-19 展示了另一种定义常量的方法。

示例 6-19。使用在周围命名空间中定义的常量

 module Namespace
   SOME_CONSTANT = "Some value..."
   class Subclass
     p SOME_CONSTANT
      end
    end

使用地道的 Ruby 风格,我们在图片中创建了一个名为Namespace的模块。然后,在该模块内,我们在图片中声明了相同的SOME_CONSTANT值。接下来,我们在图片中在Namespace内声明了Subclass,并且我们能够像在示例 6-18 中一样引用并打印SOME_CONSTANT的值。

但当我们在图片中显示SOME_CONSTANT时,Ruby 是如何在示例 6-19 中找到它的呢?图 6-21 展示了这个问题。

Ruby 如何在周围命名空间中找到常量?

图 6-21. Ruby 如何在周围命名空间中找到常量?

在该图的左侧有两个RClass结构,一个用于Namespace模块,另一个用于Subclass。注意,Namespace并不是Subclass的超类;Subclass中的super指针指向的是Object类,即 Ruby 的默认超类。那么,Ruby 是如何在我们引用Subclass中的SOME_CONSTANT时找到它的呢?某种程度上,Ruby 允许你沿着“命名空间链”向上查找常量。这种行为被称为使用词法作用域查找常量。

Ruby 中的词法作用域

词法作用域指的是程序中语法结构内的一部分代码区域,而不是在超类层次结构或其他某些方案内。例如,假设我们使用class关键字定义MyClass,如示例 6-20 所示。

示例 6-20. 使用class关键字定义类

class MyClass
  SOME_CONSTANT = "Some value..."
end

这段代码告诉 Ruby 创建RClass结构的新副本,但它也定义了程序中的一个新作用域或语法区域。这个区域是classend关键字之间的部分,如图 6-22 所示。

关键字创建类并引入新的词法作用域。

图 6-22. class关键字创建一个类并引入新的词法作用域。

将你的 Ruby 程序视为一系列作用域,每个你创建的模块或类都有一个作用域,另外还有一个默认的顶级词法作用域。为了追踪这个新作用域在程序词法结构中的位置,Ruby 会将几个指针附加到 YARV 指令片段上,这些指针对应于它在这个新作用域内编译的代码,如 图 6-23 所示。

对于每一段编译后的代码,Ruby 使用指针来追踪父级词法作用域和当前的类或模块。

图 6-23. 对于每一段编译后的代码,Ruby 使用指针来追踪父级词法作用域和当前的类或模块。

此图展示了附加在 Ruby 代码右侧的词法作用域信息。这里有两个重要的值:

  • 首先,nd_next 指针被设置为父级或周围的词法作用域——在这种情况下是默认或顶级作用域。

  • 接下来,nd_clss 指针表示与该作用域对应的 Ruby 类或模块。在这个例子中,因为我们刚刚使用 class 关键字定义了 MyClass,Ruby 会将 nd_clss 指针指向与 MyClass 对应的 RClass 结构。

为新类或模块创建常量

每当你创建一个类或模块时,Ruby 会自动创建一个相应的常量,并将其保存在类或模块的父级词法作用域中。

让我们回到 示例 6-19 中的“命名空间”示例。图 6-24 显示了当你在 Namespace 内部创建 MyClass 时 Ruby 内部的操作。

当你声明一个新类时,Ruby 会创建一个新的 RClass 结构,并定义一个新的常量,其值为新类的名称。

图 6-24. 当你声明一个新类时,Ruby 会创建一个新的 RClass 结构,并定义一个新的常量,其值为新类的名称。

图中的虚线箭头显示了 Ruby 在你创建一个新的类或模块时所采取的操作:

  • 首先,Ruby 为新的模块或类创建一个新的 RClass 结构,如下图所示。

  • 然后,Ruby 使用新的模块或类名称创建一个新的常量,并将其保存在对应于父词法作用域的类中。Ruby 将新常量的值设置为指向新的 RClass 结构的引用或指针。在 图 6-24 中,你可以看到 MyClass 常量出现在 Namespace 模块的常量表中。

新的类也获得了它自己的新的词法作用域,如 图 6-25 中所示。

新类也会获得它自己的词法作用域,图中显示为第二个阴影矩形。

图 6-25. 新的类也会获得它自己的词法作用域,图中显示为第二个阴影矩形。

该图显示了新作用域的一个新的阴影矩形。其 nd_clss 指针被设置为指向 MyClass 的新的 RClass 结构,而 nd_next 指针被设置为对应于 Namespace 模块的父作用域。

在父命名空间中使用词法作用域查找常量

在 示例 6-21") 中,让我们回到 示例 6-19 的例子,该示例打印 SOME_CONSTANT 的值。

示例 6-21. 在父级词法作用域中查找常量(重复自 示例 6-19)

    module Namespace
      SOME_CONSTANT = "Some value..."
      class Subclass
     p SOME_CONSTANT
      end
    end

在 图 6-20 中,我们看到了 Ruby 如何通过 super 指针遍历以查找来自父类的常量。但是在 图 6-21 中,我们看到 Ruby 无法使用 super 指针查找 SOME_CONSTANT,因为 Namespace 不是 MyClass 的父类。相反,如 图 6-26 所示,Ruby 可以使用 nd_next 指针向上遍历程序的词法作用域,以查找常量值。

Ruby 可以通过  和  指针在父级词法作用域中找到 SOME_CONSTANT。

图 6-26. Ruby 可以通过 nd_nextnd_clss 指针在父词法作用域中找到 SOME_CONSTANT

通过跟随这个图中的箭头,你可以看到在示例 6-21 中,p SOME_CONSTANT 命令如何工作:

  • 首先,Ruby 在当前作用域的类 MyClass 中查找 SOME_CONSTANT 的值。在图 6-26 中,当前作用域包含 p SOME_CONSTANT 代码。你可以看到 Ruby 如何使用 nd_clss 指针在右侧找到当前作用域的类。在这里,MyClass 的常量表中没有任何内容。

  • 接下来,Ruby 使用 nd_next 指针找到父词法作用域,向上移动图 6-26。

  • Ruby 重复这一过程,使用 nd_clss 指针在当前作用域的类中进行查找。这一次,当前作用域的类是 Namespace 模块,位于图 6-26 的右上角。现在,Ruby 在 Namespace 的常量表中找到了 SOME_CONSTANT

Ruby 的常量查找算法

图 6-27 中的流程图总结了 Ruby 在查找常量时如何遍历词法作用域链。

Ruby 常量查找算法的一部分

图 6-27. Ruby 常量查找算法的一部分

注意,这个图与图 6-3 非常相似。Ruby 在查找常量时,会遍历每个词法作用域中的 nd_next 指针所形成的链表,就像它在查找方法时遍历 super 指针一样。Ruby 使用超类来查找方法,使用父词法作用域来查找常量。

然而,这只是 Ruby 常量查找算法的一部分。正如我们在图 6-20 中看到的,Ruby 还会通过超类查找常量。

实验 6-2:Ruby 首先会找到哪个常量?

我们刚刚了解到,Ruby 会遍历一个链接列表的词法作用域,以查找常量值。然而,我们在 图 6-20 中看到,Ruby 还会使用超类链查找常量。让我们使用 示例 6-22 来更详细地了解这一点。

示例 6-22. Ruby 是先查找词法作用域链吗?还是先查找超类链?(find-constant.rb)

    class Superclass
   FIND_ME = "Found in Superclass"
    end

    module ParentLexicalScope
   FIND_ME = "Found in ParentLexicalScope"

      module ChildLexicalScope

        class Subclass < Superclass
          p FIND_ME
        end

      end
    end

请注意,我已经将常量 FIND_ME 定义了两次——一次在 ,另一次在 。Ruby 会首先找到哪个常量呢?是会首先遍历词法作用域链,并在 处找到常量吗?还是会遍历超类链,并在 处找到常量值?

让我们来看看!当我们运行 示例 6-22 时,我们得到以下结果:

$ **ruby find-constant.rb**
"Found in ParentLexicalScope"

你可以看到,Ruby 首先会查看词法作用域链。

现在,让我们注释掉 示例 6-22 中的第二个定义,并再次尝试实验:

    module ParentLexicalScope
   #FIND_ME = "Found in ParentLexicalScope"

当我们运行修改后的 示例 6-22 时,我们得到以下结果:

$ **ruby find-constant.rb**
"Found in Superclass"

因为现在只有一个 FIND_ME 的定义,Ruby 通过遍历超类链来找到它。

Ruby 的实际常量查找算法

不幸的是,事情并没有那么简单;Ruby 在处理常量时还有一些其他的怪癖。图 6-28 是一个简化的流程图,展示了 Ruby 的整个常量查找算法。

Ruby 常量查找算法的高级总结

图 6-28. Ruby 常量查找算法的高级总结

在开头,你可以看到 Ruby 首先通过迭代词法作用域链进行查找,就像我们在示例 6-22 中看到的那样。Ruby 总是会查找在父词法作用域中定义的常量,包括类和模块。然而,当 Ruby 迭代作用域链时,它会检查你是否使用了 autoload 关键字,这会指示 Ruby 如果某个常量未定义,则打开并读取一个新的代码文件。(Rails 框架使用 autoload,允许你加载模型、控制器和其他 Rails 对象,而无需显式使用 require。)

如果 Ruby 遍历整个词法作用域链,仍未找到给定的常量或相应的 autoload 关键字,它会继续迭代超类链,就像我们在示例 6-18 中看到的那样。这样可以让你加载在超类中定义的常量。Ruby 再次尊重任何可能存在于超类中的 autoload 关键字,并在必要时加载额外的文件。

最后,如果一切都失败了,且常量仍未找到,Ruby 会调用你模块中的 const_missing 方法(如果你提供了该方法)。

概要

在本章中,我们学习了两种完全不同的方式来看待你的 Ruby 程序。一方面,你可以按类和超类组织代码,另一方面,你可以按词法作用域组织代码。我们看到 Ruby 如何在内部使用不同的 C 指针集合,在执行程序时跟踪这两棵树。RClass 结构中的 super 指针形成了超类树,而词法作用域结构中的 nd_next 指针形成了命名空间或词法作用域树。

我们学习了两种使用这些树的重要算法:Ruby 如何查找方法和常量。Ruby 使用类树来查找你的代码(以及 Ruby 自身的内部代码)调用的方法。类似地,Ruby 使用词法作用域树和超类层次结构来查找你的代码引用的常量。理解方法和常量查找算法至关重要。它们使你能够设计程序并组织代码,使用这两棵树以适应你要解决的问题。

初看起来,这两种组织方式似乎完全是正交的,但实际上,它们通过 Ruby 类的行为紧密相关。当你创建一个类或模块时,你将它们同时添加到超类和词法作用域层次结构中,当你引用类或超类时,你指示 Ruby 使用词法作用域树查找特定的常量。

第七章. 哈希表:Ruby 内部的工作马

没有标题的图片

Ruby 将大量内部数据存储在哈希表中。

实验 5-1:保存一个新的实例变量需要多长时间? 向我们展示了在 Ruby 1.9 和 2.0 中,RObject 结构体的 ivptr 成员指向一个简单的实例变量值数组。我们了解到,添加新值通常非常快速,但 Ruby 在保存每第三个或第四个实例变量时会稍微变慢,因为它必须分配一个更大的数组。

从 Ruby 的 C 源代码库的更广泛角度来看,我们发现这种技术并不常见。相反,Ruby 经常使用一种称为 哈希表 的数据结构。与我们在 实验 5-1:保存一个新的实例变量需要多长时间? 中看到的简单数组不同,哈希表可以自动扩展以容纳更多的值;哈希表的客户端无需担心可用空间或为其分配更多内存。

除此之外,Ruby 使用哈希表来保存你在 Ruby 脚本中创建的哈希对象中的数据。Ruby 还将大量内部数据保存在哈希表中。每当你创建一个方法或常量时,Ruby 会将一个新值插入哈希表中,Ruby 还将我们在 实验 3-2:探索特殊变量 中看到的许多特殊变量保存在哈希表中。此外,Ruby 将泛型对象(如整数或符号)的实例变量保存在哈希表中。因此,哈希表是 Ruby 内部的工作马。

在本章中,我将首先解释哈希表是如何工作的:当你用一个键保存一个新值时,表内会发生什么,以及当你稍后使用相同的键检索该值时会发生什么。我还将解释哈希表是如何自动扩展以容纳更多的值的。最后,我们将探讨哈希函数在 Ruby 中是如何工作的。

路线图

  • Ruby 中的哈希表

    • 在哈希表中保存一个值

    • 从哈希表中检索值

  • 实验 7-1:从不同大小的哈希中检索值

  • 哈希表如何扩展以容纳更多值

    • 哈希冲突

    • 重新哈希条目

    • Ruby 如何在哈希表中重新哈希条目?

  • 实验 7-2:在不同大小的哈希中插入一个新元素

    • 魔法数字 57 和 67 从哪里来?
  • Ruby 如何实现哈希函数

  • 实验 7-3:在哈希中使用对象作为键

    • Ruby 2.0 中的哈希优化
  • 总结

Ruby 中的哈希表

哈希表是计算机科学中常用的、广为人知的、历史悠久的概念。它们根据从每个值计算得到的整数值——即哈希——将值组织成不同的组或。当你需要查找某个值时,可以通过重新计算它的哈希值来确定它所在的桶,从而加速搜索过程。

没有标题的图片

每次你编写方法时,Ruby 都会在哈希表中创建一个条目。

在哈希表中保存一个值

图 7-1 展示了一个单一的哈希对象及其哈希表。

一个包含空哈希表的 Ruby 哈希对象

图 7-1:一个包含空哈希表的 Ruby 哈希对象

左边是RHash(即Ruby 哈希)结构。右边是该哈希使用的哈希表,表示为st_table结构。这个 C 结构包含了哈希表的基本信息,包括表中保存的条目数量、桶的数量以及指向桶的指针。每个RHash结构包含一个指向相应st_table结构的指针。右下角的空桶是因为 Ruby 1.8 和 1.9 最初为一个新的空哈希创建了 11 个桶。(Ruby 2.0 略有不同;请参见 Ruby 2.0 中的哈希优化。)

理解哈希表如何工作最好的方式是通过一个例子。假设我向一个名为my_hash的哈希中添加一个新的键/值对:

my_hash[:key] = "value"

在执行这行代码时,Ruby 会创建一个新的结构,叫做 st_table_entry,并将其保存到 my_hash 的哈希表中,如 图 7-2 所示。

一个包含单个值的 Ruby 哈希对象

图 7-2. 一个包含单个值的 Ruby 哈希对象

在这里,你可以看到 Ruby 将新的键/值对保存在了第三个桶,也就是第 2 号桶下。Ruby 通过获取给定的键——在这个例子中是符号 :key——并将其传递给一个内部哈希函数,返回一个伪随机整数来完成这一步:

some_value = internal_hash_function(:key)

接下来,Ruby 会取哈希值——在这个例子中是 some_value——并通过桶的数量计算余数,即除以桶的数量后的余数。

some_value % 11 = 2

注意

在 图 7-2 中,我假设 :key 的实际哈希值除以 11 后的余数是 2。稍后在本章中,我将更详细地探讨 Ruby 实际使用的哈希函数。

现在让我们为哈希表添加第二个元素:

my_hash[:key2] = "value2"

这次让我们假设 :key2 的哈希值除以 11 得到的余数是 5。

internal_hash_function(:key2) % 11 = 5

图 7-3 显示了 Ruby 将第二个 st_table_entry 结构放在了第 5 个桶,也就是第六个桶下。

一个包含两个值的 Ruby 哈希对象

图 7-3. 一个包含两个值的 Ruby 哈希对象

从哈希表中检索值

使用哈希表的好处在于,当你要求 Ruby 根据给定的键来检索值时,这种优势会变得非常明显。例如:

p my_hash[:key]
 => "value"

如果 Ruby 将所有键和值保存在一个数组或链表中,它就必须遍历数组或链表中的所有元素来查找 :key。根据元素的数量,这可能会花费很长时间。但使用哈希表时,Ruby 可以通过重新计算该键的哈希值,直接跳到它需要查找的键。

为了重新计算某个特定键的哈希值,Ruby 只需再次调用哈希函数:

some_value = internal_hash_function(:key)

然后,它重新将哈希值除以桶的数量来得到余数,或者说是模值。

some_value % 11 = 2

到此为止,Ruby 知道要在第 2 个桶中查找键为 :key 的条目。Ruby 之后可以通过重复相同的哈希计算来找到 :key2 的值。

internal_hash_function(:key2) % 11 = 5

注意

Ruby 用来实现哈希表的 C 库是由加利福尼亚大学伯克利分校的彼得·摩尔(Peter Moore)在 1980 年代编写的。后来,这个库经过了 Ruby 核心团队的修改。你可以在 C 代码文件 st.c include/ruby/st.h 中找到摩尔的哈希表代码。该代码中所有的函数和结构体名称都遵循 st_ 的命名规范。表示每个 Ruby Hash 对象的 RHash 结构体的定义位于* include/ruby/ruby.h 文件中。除了 RHash,该文件还包含 Ruby 源代码中使用的所有其他主要对象结构体:RStringRArrayRValue 等。

实验 7-1:从不同大小的哈希表中检索值

这个实验将创建大小差异巨大的哈希表,从 1 个到 100 万个元素不等,然后测量从这些哈希表中查找并返回一个值所需的时间。示例 7-1 展示了实验代码。

示例 7-1。测量从大小差异巨大的哈希表中检索一个元素所需的时间

    require 'benchmark'

 21.times do |exponent|

      target_key = nil

   size = 2**exponent
      hash = {}
   (1..size).each do |n|
        index = rand
     target_key = index if n > size/2 && target_key.nil?
     hash[index] = rand
      end

      GC.disable

      Benchmark.bm do |bench|
        bench.report("retrieving an element
                      from a hash with #{size} elements 10000 times") do
          10000.times do
         val = hash[target_key]
          end
        end
      end

      GC.enable
    end

,外层循环遍历二的幂次,计算不同的 size 值,如 所示。这些大小从 1 到大约 100 万不等。接下来,内层循环在 插入指定数量的元素到一个新的空哈希表中,位置在

在禁用垃圾回收以避免影响结果后,实验 7-1:从不同大小的哈希表中检索值 使用基准库来测量从每个哈希表中检索一个值 10,000 次所需的时间,如下所示 。代码行 保存一个随机的键值,稍后将在 处作为 target_key 使用。

图 7-4 中的结果显示,Ruby 能以与从小哈希表中返回值相同的速度,从包含超过 100 万个元素的哈希表中找到并返回一个值。

Ruby 2.0 中检索 10,000 个值所需的时间(毫秒)与哈希大小的关系

图 7-4。Ruby 2.0 中检索 10,000 个值所需的时间(毫秒)与哈希大小的关系

显然,Ruby 的哈希函数非常快速,一旦 Ruby 确定了包含目标键的桶,它就可以非常迅速地找到对应的值并返回。这里值得注意的是,图表基本上是平坦的。

哈希表如何扩展以容纳更多值

如果有数百万个 st_table_entry 结构,为什么将它们分布到 11 个桶中能帮助 Ruby 快速查找?因为即使哈希函数很快,且 Ruby 将值均匀地分配到哈希表的 11 个桶中,如果总共有 100 万个元素,Ruby 仍然需要在每个桶中搜索近 100,000 个元素来找到目标键。

这里肯定发生了其他的事情。似乎随着元素的不断增加,Ruby 必须为哈希表添加更多的桶。让我们再次看看 Ruby 内部哈希表代码是如何工作的。继续看 图 7-1 到 图 7-3 的示例,假设我不断向哈希表中添加更多的元素。

my_hash[:key3] = "value3"
my_hash[:key4] = "value4"
my_hash[:key5] = "value5"
my_hash[:key6] = "value6"

随着我们添加更多元素,Ruby 会继续创建更多的 st_table_entry 结构并将它们添加到不同的桶中。

哈希碰撞

最终,可能会有两个或多个元素被保存到同一个桶中。当发生这种情况时,我们就遇到了 哈希碰撞。这意味着 Ruby 不再能够仅凭哈希函数唯一地识别和检索一个键。

图 7-5 显示了 Ruby 用来跟踪每个桶中条目的链表。每个 st_table_entry 结构包含指向同一桶中下一个条目的指针。随着你向哈希表中添加更多条目,链表会变得越来越长。

包含 44 个值的哈希表

图 7-5. 包含 44 个值的哈希表

为了检索一个值,Ruby 需要遍历链表并将每个键与目标进行比较。只要单个桶中的条目数量不增长到太大,这并不是一个严重的问题。对于整数或符号(通常用作哈希键),这只是一个简单的数值比较。然而,如果你使用更复杂的数据类型,如自定义对象,Ruby 会对键调用 eql? 方法,以检查链表中的每个键是否是目标。正如你可能猜到的那样,eql? 如果两个值相等,则返回 true,如果不相等,则返回 false

重新哈希条目

为了防止这些链表失控地增长,Ruby 会衡量每个桶的 密度,即每个桶的平均条目数。在 图 7-5 中,你可以看到每个桶的平均条目数大约是 4。 这意味着哈希值取模 11 开始为不同的键和哈希值返回重复的值;因此,发生了哈希碰撞。

一旦密度超过 5(这是 Ruby C 源代码中的常量),Ruby 就会分配更多的桶,然后重新哈希,或者重新分配现有条目到新的桶集中。如果我们继续添加更多的键值对,例如,Ruby 最终会丢弃 11 个桶的数组,并分配一个包含 19 个桶的数组,如图 7-6 所示。

包含 65 个值的哈希表

图 7-6:包含 65 个值的哈希表

在这个图中,桶的密度已降到大约 3。

通过监控桶的密度,Ruby 确保链表保持简短,并且检索哈希元素始终是快速的。在计算出哈希值后,Ruby 只需要遍历一两个元素就能找到目标键。

Ruby 是如何重新哈希哈希表中的条目?

你可以在st.c源文件中找到rehash函数(这段代码会循环遍历st_table_entry结构并重新计算每个条目的桶位置)。为了简化,示例 7-2 展示了来自 Ruby 1.8.7 的rehash版本。虽然 Ruby 1.9 和 2.0 的工作方式大致相同,但它们的 C 语言重哈希代码稍微复杂一些。

示例 7-2:Ruby 1.8.7 中重新哈希哈希表的 C 代码

    static void
    rehash(table)
        register st_table *table;
    {
        register st_table_entry *ptr, *next, **new_bins;
        int i, old_num_bins = table->num_bins, new_num_bins;
        unsigned int hash_val;
     new_num_bins = new_size(old_num_bins+1);
        new_bins = (st_table_entry**)Calloc(new_num_bins,
                                            sizeof(st_table_entry*));
     for(i = 0; i < old_num_bins; i++) {
            ptr = table->bins[i];
            while (ptr != 0) {
                next = ptr->next;
             hash_val = ptr->hash % new_num_bins;
             ptr->next = new_bins[hash_val];
                new_bins[hash_val] = ptr;
                ptr = next;
            }
        }
     free(table->bins);
        table->num_bins = new_num_bins;
        table->bins = new_bins;
    }

在这个示例中,new_size方法调用在处返回新的桶数。一旦 Ruby 得到了新的桶数,它会分配新的桶,然后从开始,遍历所有现有的st_table_entry结构(哈希中的所有键值对)。对于每个st_table_entry,Ruby 使用处相同的取模公式重新计算桶的位置:hash_val = ptr->hash % new_num_bins。然后,Ruby 将每个条目保存到该新桶的链表中,位置在。最后,Ruby 更新st_table结构,并在处释放旧的桶。

实验 7-2:向不同大小的哈希表中插入一个新元素

测试这个重新哈希(或重新分配)条目是否真的发生的一种方法是,测量 Ruby 将一个新元素保存到不同大小的现有哈希表中所需的时间。当我们向同一个哈希表中添加更多元素时,我们最终应该能够看到 Ruby 花费额外时间重新哈希这些元素的证据。

这个实验的代码展示在示例 7-3 中。

示例 7-3:向不同大小的哈希表中添加一个元素

    require 'benchmark'

 100.times do |size|

      hashes = []
   10000.times do
        hash = {}
        (1..size).each do
          hash[rand] = rand
        end
        hashes << hash
      end

      GC.disable

      Benchmark.bm do |bench|
        bench.report("adding element number #{size+1}") do
          10000.times do |n|
         hashes[n][size] = rand
          end
        end
      end

      GC.enable
    end

外部循环遍历从 0 到 100 的哈希表大小,而在 内部循环创建给定大小的 10,000 个哈希表。在禁用垃圾回收后,实验使用基准库测量 Ruby 插入单个新值所需的时间,在 将新值插入所有 10,000 个给定大小的哈希表中。

结果令人惊讶!图 7-7 显示了 Ruby 1.8 的结果。

添加 10,000 对键值对所需的时间与哈希表大小的关系(Ruby 1.8)

图 7-7. 添加 10,000 对键值对所需的时间与哈希表大小的关系(Ruby 1.8)

从左到右解释这些数据值,我们可以看到以下内容:

  • 向空哈希表插入第一个元素大约需要 7 毫秒(共插入 10,000 次)。

  • 随着哈希表大小从 2 增加到 3,再到大约 60 或 65,插入新元素所需的时间缓慢增加。

  • 在包含 64、65 或 66 个元素的哈希表中,每插入一个新的键值对大约需要 11 到 12 毫秒(共插入 10,000 次)。

  • 一个巨大的尖峰!插入第 67 个键值对所需的时间是之前的两倍多:大约 26 毫秒,而不是 10,000 个哈希表的 11 毫秒!

  • 在插入第 67 个元素后,插入其他元素所需的时间降至约 10 毫秒或 11 毫秒,然后从此处开始缓慢增加。

这里发生了什么?嗯,Ruby 花费额外时间来插入第 67 个键值对,将桶数组从 11 个桶重新分配到 19 个桶,然后将 st_table_entry 结构重新分配到新的桶数组中。

图 7-8 显示了 Ruby 2.0 的相同图表。这次的桶密度阈值有所不同。Ruby 2.0 在插入第 57 个元素时就开始重新分配元素到桶中,而不是像之前在第 67 次插入时才进行这项操作。随后,Ruby 2.0 在插入第 97 个元素后再次进行重新分配。

添加 10,000 对键值对所需的时间与哈希表大小的关系(Ruby 2.0)

图 7-8. 添加 10,000 对键值对所需的时间与哈希表大小的关系(Ruby 2.0)

在图中,第一次和第七次插入时出现的两个较小的尖峰非常引人注目。尽管它们不像第 57 和第 97 个元素处的尖峰那样明显,但这些较小的尖峰仍然是可以察觉的。事实证明,Ruby 2.0 还包含另一个优化,使得在包含少于 7 个元素的小哈希表中,哈希访问速度更快。我将在 Ruby 2.0 中的哈希优化中进一步讨论这个问题。

魔术数字 57 和 67 从哪里来?

要了解这些魔法数字(57、67 等)是从哪里来的,查看你所使用的 Ruby 版本的st.c代码文件顶部。你应该能找到像示例 7-4 中所示的素数列表。

示例 7-4. Ruby 使用基于素数的算法来确定每个哈希表中所需的桶数。

    /*
    Table of prime numbers 2^n+a, 2<=n<=30.
    */
    static const unsigned int primes[] = {
   8 + 3,
   16 + 3,
   32 + 5,
      64 + 3,
      128 + 3,
      256 + 27,
      512 + 9,
    --*snip*--

这个 C 数组列出了接近 2 的幂的素数。Peter Moore 的哈希表代码使用这个表来决定在哈希表中使用多少个桶。例如,上面列表中的第一个素数是 11,在 ,这就是为什么 Ruby 哈希表一开始有 11 个桶。后来,随着元素数量的增加,桶的数量增加到 19,在 ,接着是 37,在 ,依此类推。

Ruby 总是将哈希表桶的数量设置为素数,以便更有可能使哈希值在桶之间均匀分布。在数学上,素数在这里的作用是因为它们与哈希值共享的因子较少,如果一个较差的哈希函数返回的哈希值不完全随机的话。记住,Ruby 在计算应该将值放入哪个桶时,会将哈希值除以桶的数量。如果哈希值和桶的数量有共同因子,甚至更糟的是,如果哈希值是桶数量的倍数,那么桶号(模数)可能总是相同的。这将导致表中的条目在桶之间分布不均。

st.c 文件的其他地方,你应该能看到这个 C 常量:

#define ST_DEFAULT_MAX_DENSITY 5

这个常量定义了允许的最大密度,或者说每个桶中的平均元素数量。

最后,你应该能看到决定何时执行桶重新分配的代码,方法是查找在st.c中使用常量ST_DEFAULT_MAX_DENSITY的地方。对于 Ruby 1.8,你将看到这段代码:

if (table->num_entries/(table->num_bins) > ST_DEFAULT_MAX_DENSITY) {
  rehash(table);

Ruby 1.8 在 num_entries/11 大于 5 时(即当它等于 66)会将桶从 11 重新哈希到 19。由于这个检查是在添加新元素之前进行的,所以当你添加第 67 个元素时条件成立,因为此时 num_entries 会是 66。

对于 Ruby 1.9 和 Ruby 2.0,你将看到以下代码:

if ((table)->num_entries >
    ST_DEFAULT_MAX_DENSITY * (table)->num_bins) {
  rehash(table);

你可以看到,Ruby 2.0 在 num_entries 大于 5*11 时首次重新哈希,或者当你插入第 57 个元素时。

Ruby 如何实现哈希函数

现在,仔细看看 Ruby 用于将键和值分配到哈希表中的桶的实际哈希函数。这个函数是哈希对象实现的核心——如果它运作良好,Ruby 哈希非常快速,但一个糟糕的哈希函数会导致严重的性能问题。此外,Ruby 内部使用哈希表来存储其自身的信息,除了你保存在哈希对象中的数据值之外。显然,拥有一个好的哈希函数是非常重要的!

无标题图片

哈希函数允许 Ruby 找到包含给定键和值的桶。

让我们回顾一下 Ruby 如何使用哈希值。记住,当你在哈希中保存一个新元素——即一个新的键/值对——Ruby 会将该元素分配到哈希对象使用的内部哈希表中的一个桶(bin)里,如 图 7-9 所示。

Ruby 根据桶的数量计算键的哈希值的余数。

bin_index = internal_hash_function(key) % bin_count

使用我们之前使用的相同示例值,这个公式变成了:

2 = hash(:key) % 11

一个包含单个值的 Ruby 哈希对象(重复自)

图 7-9. 一个包含单个值的 Ruby 哈希对象(重复自 图 7-2)

这个公式之所以有效,是因为 Ruby 的哈希值对于任何给定的输入数据基本上是随机整数。为了更好地理解 Ruby 的哈希函数如何工作,可以调用 hash 方法,如 示例 7-5 所示。

示例 7-5. 显示不同 Ruby 对象的哈希值

$ **irb**
> **"abc".hash**
 => 3277525029751053763
> **"abd".hash**
 => 234577060685640459
> **1.hash**
 => -3466223919964109258
> **2.hash**
 => -2297524640777648528

在这里,即使是相似的值,它们的哈希值也有很大的不同。如果我们再次调用hash,对于相同的输入数据,我们总是会得到相同的整数值。

> **"abc".hash**
 => 3277525029751053763
> **"abd".hash**
 => 234577060685640459

下面是 Ruby 哈希函数如何实际运作的说明,适用于大多数 Ruby 对象:

  • 当你调用hash时,Ruby 会在 Object 类中找到默认的实现。如果需要,你可以重写它。

  • Object 类的 hash 方法实现所使用的 C 代码获取目标对象的 C 指针值——即该对象 RValue 结构的实际内存地址。这本质上是该对象的唯一 ID。

  • Ruby 将指针值传递给一个复杂的 C 函数(哈希函数),该函数会打乱值中的位,从而以可重复的方式生成一个伪随机整数。

对于字符串和数组,Ruby 实际上会遍历字符串中的所有字符或数组中的所有元素,并计算一个累积的哈希值。这保证了字符串或数组的任何实例的哈希值总是相同的,并且如果字符串或数组中的任何值发生变化,哈希值也会随之变化。整数和符号是另一种特殊情况。Ruby 直接将它们的值传递给哈希函数。

为了从值中计算哈希,Ruby 1.9 和 2.0 使用一种叫做 MurmurHash 的哈希函数,它是由 Austin Appleby 在 2008 年发明的。Murmur 这个名字来自于该算法中使用的机器语言操作:multiplyrotate。(要了解 Murmur 算法是如何工作的,可以阅读其 C 语言代码,该代码在 st.c Ruby 源代码文件中。或者阅读 Austin 关于 Murmur 的网页:sites.google.com/site/murmurhash/。)

Ruby 1.9 和 2.0 使用一个随机种子值初始化 MurmurHash,该值在每次重新启动 Ruby 时都会重新初始化。这意味着如果你停止并重新启动 Ruby,你将会得到相同输入数据的不同哈希值。这也意味着如果你自己尝试,你将会得到与上面不同的值,但在同一个 Ruby 进程中,哈希值始终是相同的。

实验 7-3:在哈希中使用对象作为键

因为哈希值是伪随机数,一旦 Ruby 将其除以桶的数量,例如 11,剩余的值(模值)就是 0 到 10 之间的随机数。这意味着当 st_table_entry 结构保存在哈希表中时,它们会均匀地分布在可用的桶中,从而确保 Ruby 可以快速查找任何给定的键。每个桶中的条目数始终很少。

但是,如果 Ruby 的哈希函数不返回随机整数,而是对于每个输入数据值返回相同的整数,会发生什么呢?

在这种情况下,每次你向哈希中添加键/值时,它总是会被分配到同一个桶。Ruby 最终会将所有条目都放在该一个桶下的一个长列表中,而其他任何桶中都没有条目,正如在图 7-10 中所示。

使用非常差的哈希函数创建的哈希表

图 7-10。使用非常差的哈希函数创建的哈希表

如果你尝试从这个哈希中检索一个值,Ruby 就必须一个一个地查找这个长列表中的每个元素,以找到请求的键。在这种情况下,从哈希中加载一个值将会非常非常慢。

为了证明这一点,并且说明 Ruby 的哈希函数有多重要,我们将使用哈希函数较差的对象作为哈希中的键。我们将在这里重复实验 7-1:从大小差异的哈希中检索值,但这次使用我定义的类的实例作为键值,而不是随机数。示例 7-6 展示了实验 7-1:从大小差异的哈希中检索值的代码,并且做了两处更新。

示例 7-6. 测量从大小差异极大的哈希中检索元素所需的时间。这与示例 7-1 相同,只不过这次使用的是KeyObject实例作为键。

    require 'benchmark'

 class KeyObject
      def eql?(other)
        super
      end
    end

    21.times do |exponent|

      target_key = nil

      size = 2**exponent
      hash = {}
      (1..size).each do |n|
     index = KeyObject.new
        target_key = index if n > size/2 && target_key.nil?
        hash[index] = rand
      end

      GC.disable

      Benchmark.bm do |bench|
        bench.report("retrieving an element
                       from a hash with #{size} elements 10000 times") do
          10000.times do
            val = hash[target_key]
          end
        end
      end

      GC.enable

    end

处,我们定义了一个名为KeyObject的空类。注意,我实现了eql?方法;这使得 Ruby 在检索值时能够正确地搜索目标键。然而,在这个示例中,KeyObject中没有任何有趣的数据,因此我只是调用了super并使用了Object类中eql?方法的默认实现。

然后,在 处,我们使用KeyObject的新实例作为哈希值的键。图 7-11 展示了这个测试的结果。

使用对象作为键时,检索 10,000 个值的时间与哈希大小的关系(Ruby 2.0)

图 7-11. 使用对象作为键时,检索 10,000 个值的时间与哈希大小的关系(Ruby 2.0)

如你所见,结果与图 7-4 非常相似。图表几乎是平的。无论哈希中有 100 万个元素还是只有 1 个元素,检索一个值所需的时间差不多。毫无意外;使用对象作为键并没有让 Ruby 变慢。

现在让我们改变KeyObject类并再试一次。示例 7-7 展示了相同的代码,并在 处添加了一个新的哈希函数。

示例 7-7. KeyObject现在有一个非常差的哈希函数。

    require 'benchmark'

    class KeyObject
      def hash
     4
      end
      def eql?(other)
        super
      end
    end

    21.times do |exponent|

      target_key = nil

      size = 2**exponent
      hash = {}
      (1..size).each do |n|
        index = KeyObject.new
        target_key = index if n > size/2 && target_key.nil?
        hash[index] = rand
      end

      GC.disable

      Benchmark.bm do |bench|
        bench.report("retrieving an element
                      from a hash with #{size} elements 10000 times") do
          10000.times do
            val = hash[target_key]
          end
        end
      end

      GC.enable
    end

我故意编写了一个非常差的哈希函数。这个哈希函数不像返回伪随机整数,而是始终返回整数 4,正如在示例 7-7 中所示,不管你在哪个KeyObject对象实例上调用它。现在,Ruby 在计算哈希值时总是会得到 4。它将必须将所有哈希元素都分配到内部哈希表中的 4 号桶里,正如在图 7-10 所示。

让我们来尝试一下,看看会发生什么!图 7-12 显示了运行示例 7-7 代码的结果。

使用较差哈希函数(Ruby 2.0)时,检索 10,000 个值所需的时间与哈希大小的关系

图 7-12. 使用较差哈希函数(Ruby 2.0)时,检索 10,000 个值所需的时间与哈希大小的关系

图 7-12 与 图 7-11 有很大的不同!注意图表的比例。y 轴显示的是毫秒,x 轴则是哈希中元素的数量,以对数尺度显示。但这次请注意,y 轴上有数千毫秒——也就是说,实际的秒数!

使用一个或少数几个元素,我们可以非常快速地检索到 10,000 个值——如此之快,时间短到在图表上无法显示。事实上,这大约需要 1.5 毫秒。然而,当元素数量超过 100,尤其是超过 1,000 时,加载 10,000 个值所需的时间会随着哈希大小的增加而线性增长。对于一个包含大约 10,000 个元素的哈希表,加载这 10,000 个值需要超过 1.6 秒。如果我们继续用更大的哈希表进行测试,加载这些值可能需要几分钟甚至几小时。

这里发生的情况是,所有哈希元素都被保存到同一个桶中,这迫使 Ruby 一个一个地查找键。

Ruby 2.0 中的哈希优化

从 2.0 版本开始,Ruby 引入了一种新的优化,使得哈希表的运作更快。对于包含 6 个或更少元素的哈希表,Ruby 现在完全避免计算哈希值,而是将哈希数据直接保存为数组。这些被称为 紧凑哈希表。图 7-13 显示了一个紧凑的哈希表。

在内部,Ruby 2.0 将包含 6 个或更少元素的小哈希表保存为数组。

图 7-13。内部,Ruby 2.0 将包含 6 个或更少元素的小哈希表保存为数组。

Ruby 2.0 不使用 st_table_entry 结构来处理小哈希表,也不创建桶数组。相反,它创建一个数组,并将键/值对直接保存到该数组中。这个数组足够大,可以容纳 6 个键/值对;一旦插入第 7 个键和值,Ruby 就会丢弃该数组,创建桶数组,并像往常一样通过计算哈希值将所有 7 个元素移动到 st_table_entry 结构中。这解释了我们在插入第 7 个元素时在图 7-8 中看到的小幅波动。real_entries 保存数组中保存的值的数量,范围从 0 到 6。

在一个紧凑的哈希表中,只有 6 个或更少的元素;因此,Ruby 遍历键值寻找目标值的速度比计算哈希值并使用桶数组要快。图 7-14 显示了 Ruby 2.0 如何从紧凑的哈希表中获取一个元素。

要查找给定键 target 的值,Ruby 会遍历数组并在每个键值上调用 eql? 方法(如果值是对象)。对于简单的值,如整数或符号,Ruby 直接使用数值比较。Ruby 2.0 对于紧凑哈希表从不调用哈希函数。

对于小哈希表,Ruby 2.0 通过遍历数组来查找给定的键。

图 7-14。对于小哈希表,Ruby 2.0 通过遍历数组来查找给定的键。

总结

理解哈希表是理解 Ruby 内部工作原理的关键,因为哈希表的速度和灵活性使得 Ruby 能够以多种方式使用它们。

在本章开始时,我们学习了哈希表如何能够快速返回值,无论表中有多少元素。接下来,我们学习了 Ruby 如何在你添加更多元素时自动增大哈希表的大小。哈希表的用户无需担心表的速度或大小。哈希表始终会保持快速,并会根据需要自动扩展。

最后,我们看了 Ruby 哈希函数的重要性。哈希表的算法依赖于底层的哈希函数。使用有效的哈希函数时,值会在哈希表的桶中均匀分布,碰撞较少,从而使得数据能够快速保存和检索。然而,如果使用一个差的哈希函数,值将会被保存到同一个桶中,导致性能下降。

第八章。Ruby 如何借鉴 Lisp 中的一个几十年历史的想法

没有标题的图片

块是 Ruby 对闭包的实现。

块是 Ruby 中最常用且强大的特性之一,因为它们允许你将代码片段传递给 Enumerable 方法,例如 eachdetectinject。通过使用 yield 关键字,你还可以编写自定义迭代器或函数,以调用其他原因的块。包含块的 Ruby 代码通常比等效的老旧语言(如 C)代码更简洁、更优雅、更具表现力。

但不要急于得出结论,认为块是一个新概念!事实上,块在 Ruby 中并不新鲜。块背后的计算机科学概念,称为闭包,最早由 Peter J. Landin 在 1964 年发明,距离 Lisp 的原始版本由 John McCarthy 在 1958 年创建才几年。闭包后来被 Lisp 采用,或者更准确地说,被 Lisp 的一个方言——Scheme 采用,Scheme 是由 Gerald Sussman 和 Guy Steele 在 1975 年发明的。Sussman 和 Steele 在 Scheme 中使用闭包的做法首次将这一概念带给了许多程序员。

那么在这个语境中,闭包一词到底是什么意思呢?换句话说,Ruby 块到底是什么?它们只是出现在 doend 关键字之间的 Ruby 代码片段吗?在本章中,我将回顾 Ruby 如何在内部实现块,并展示它们如何符合 Sussman 和 Steele 在 1975 年提出的闭包定义。我还将展示块、lambda 和 proc 如何作为不同的方式来理解闭包。

路线图

  • 块:Ruby 中的闭包

    • 逐步了解 Ruby 如何调用块

    • 借鉴 1975 年的一个想法

    • rb_block_t 和 rb_control_frame_t 结构

  • 实验 8-1:哪种更快:while 循环还是将块传递给 each?

  • Lambdas 和 Procs:将函数视为一等公民

    • 栈与堆内存

    • 深入了解 Ruby 如何保存字符串值

    • Ruby 如何创建 Lambda

    • Ruby 如何调用 Lambda

    • Proc 对象

  • 实验 8-2: 调用 lambda 后改变局部变量

    • 在同一作用域内多次调用 lambda
  • 总结

块:Ruby 中的闭包

内部地,Ruby 使用一个名为 rb_block_t 的 C 结构来表示每个块,参见图 8-1。通过了解 Ruby 在 rb_block_t 中存储的内容,我们可以准确地弄清楚一个块是什么。

rb_block_t C 结构内部是什么?

图 8-1. rb_block_t C 结构内部是什么?

正如我们在第五章中对 RClass 结构的分析一样,我们也可以基于块在 Ruby 中的功能推测 rb_block_t 结构的内容。我们将从块的最明显特征开始。我们知道每个块必须包含一段 Ruby 代码,或者在内部是编译后的 YARV 字节码指令。例如,假设我们调用一个方法并传递一个块作为参数,见示例 8-1。

示例 8-1. 表面上,一个块只是 Ruby 代码的一小段。

10.times do
  str = "The quick brown fox jumps over the lazy dog."
  puts str
end

当执行 10.times 调用时,Ruby 需要知道要迭代的代码是什么。因此,rb_block_t 结构必须包含指向该代码的指针,如图 8-2 所示。

rb_block_t 结构包含指向 YARV 指令片段的指针。

图 8-2. rb_block_t 结构包含指向 YARV 指令片段的指针。

iseq 是指向块中 Ruby 代码的 YARV 指令的指针。

另一个显而易见但常被忽视的块行为是,块可以访问周围或父级 Ruby 作用域中的变量,正如在示例 8-2 中所示。

示例 8-2. 块内的代码访问来自周围代码的 str 变量。

 str = "The quick brown fox"
 10.times do
   str2 = "jumps over the lazy dog."
   puts "#{str} #{str2}"
    end

这里,puts 函数调用在 中同时引用了块内的 str2 变量以及在周围代码中定义的 str 变量,位置见 。显然,块可以访问它们周围代码中的值。这种能力正是块有用之处之一。

块在某种程度上具有双重特性。一方面,它们表现得像独立的方法:你可以像调用任何方法一样调用它们并传递参数。另一方面,它们是周围函数或方法的一部分。

步骤解析 Ruby 如何调用块

这是如何在内部工作的?Ruby 是将块实现为独立的方法,还是作为周围方法的一部分?让我们通过 示例 8-2 步骤查看,当你调用块时,Ruby 内部发生了什么。

当 Ruby 执行 示例 8-2 中的第一行代码时,在 处,str = "The quick brown fox",YARV 将局部变量 str 存储在其内部栈上。YARV 使用 EP(环境指针)追踪 str 的位置,EP 位于当前的 rb_control_frame_t 结构中,如 图 8-3 所示。^([1])

Ruby 将局部变量 str 保存到栈上。

图 8-3. Ruby 将局部变量 str 保存到栈上。

接下来,Ruby 在 示例 8-2 中到达 10.times do 调用,在 处。执行实际迭代之前——也就是在调用 times 方法之前——Ruby 创建并初始化一个新的 rb_block_t 结构来表示块。Ruby 现在需要创建这个块结构,因为块实际上只是 times 方法的另一个参数。图 8-4 显示了这个新的 rb_block_t 结构。

在创建新的块结构时,Ruby 将 EP 的当前值复制到新的块中。换句话说,Ruby 将当前栈帧的位置保存在新的块中。

Ruby 在调用方法并将块传递给它之前创建了一个新的 rb_block_t 结构。

图 8-4. Ruby 在调用方法并将块传递给它之前创建了一个新的 rb_block_t 结构。

接下来,Ruby 在 10 对象(Fixnum 类的一个实例)上调用 times 方法。在执行此操作时,YARV 在其内部堆栈上创建了一个新的帧。现在我们有两个堆栈帧:上方是 Fixnum.times 方法的新堆栈帧,下面是用于顶层函数的原始堆栈帧(见 图 8-5)。

Ruby 在执行 10.times 调用时创建了一个新的堆栈帧。

图 8-5. Ruby 在执行 10.times 调用时创建了一个新的堆栈帧。

Ruby 使用自己的 C 代码在内部实现 times 方法。尽管这是一个内置方法,但 Ruby 的实现方式与你可能实现的方法类似。Ruby 开始迭代数字 0、1、2 等,直到 9,然后它调用 yield,每次为这些整数调用一次块。最后,实现 yield 的代码在内部每次通过循环时都会调用块,为块中的代码在堆栈顶部推送第三个帧。 图 8-6 展示了这个第三个堆栈帧。

Ruby 在  方法调用块时创建了第三个堆栈帧。

图 8-6. Ruby 在 10.times 方法调用块时创建了第三个堆栈帧。

图的左侧现在有三个堆栈帧:

  • 在顶部是为块创建的新堆栈帧,包含在示例 8-2 中定义的 str2 变量,位于

  • 中间是用于实现 Fixnum#times 方法的内部 C 代码的堆栈帧。

  • 最底部是原始函数的堆栈帧,包含在示例 8-2 中定义的 str 变量,位于

在创建新的栈帧时,Ruby 内部的 yield 代码将 EP 从块中复制到新的栈帧中。现在,块内的代码可以通过 rb_control_frame_t 结构直接访问它的局部变量,并通过 EP 指针间接访问父作用域中的变量,利用动态变量访问。具体来说,这使得 示例 8-2 中的 puts 语句能够访问父作用域中的 str2 变量。

借用 1975 年的一个想法

到目前为止,我们已经看到 Ruby 的 rb_block_t 结构包含两个重要的值:

  • 指向 YARV 代码指令片段的指针——iseq 指针

  • 指向 YARV 内部栈位置的指针,即在创建块时栈顶所在的位置——EP 指针

图 8-7 显示了 rb_block_t 结构中的这两个值。

到目前为止,我们已经看到 Ruby 块包含指向 YARV 指令片段的指针和 YARV 栈上的一个位置。

图 8-7。到目前为止,我们已经看到 Ruby 块包含指向 YARV 指令片段的指针和 YARV 栈上的一个位置。

我们还看到,当 Ruby 块访问周围代码中的值时,Ruby 使用 EP。一开始,这看起来像是一个非常技术性的、不重要的细节。这显然是我们期望 Ruby 块表现出的行为,而 EP 似乎只是 Ruby 内部实现块的一个小细节,一个不那么引人注目的部分。或者说它不是吗?

没有标题的图片

上图中的 IBM 704 是第一台在 1960 年代初运行 Lisp 的计算机。(来源:NASA)

EP 实际上是 Ruby 内部实现中一个非常重要的部分。它是 Ruby 实现 闭包 的基础,闭包是一个计算机科学概念,在 Ruby 创立之前的 Lisp 中就已被引入。以下是 Sussman 和 Steele 在 1975 年对 闭包 一词的定义:

为了解决这个问题,我们引入了闭包([11, 14])的概念,它是一个数据结构,包含一个 lambda 表达式和一个在应用该 lambda 表达式时使用的环境。^([2])

他们定义闭包为以下内容的组合:

  • 一个“lambda 表达式”——即一个接受一组参数的函数

  • 在调用该 lambda 或函数时使用的环境

让我们再看一下内部的 rb_block_t 结构体,为了方便起见,重复在图 8-8 中展示。

块是一个函数与调用该函数时使用的环境的结合。

图 8-8. 块是一个函数与调用该函数时使用的环境的结合。

该结构体符合 Sussman 和 Steele 对闭包的定义:

  • iseq 是指向一个 lambda 表达式的指针——即一个函数或代码片段。

  • EP 是指向调用该 lambda 或函数时使用的环境的指针——即指向周围堆栈帧的指针。

按照这个思路,我们可以看到块(blocks)是 Ruby 实现闭包的方式。具有讽刺意味的是,块——正是使 Ruby 既优雅又现代的特性之一——基于的是至少在 Ruby 诞生前 20 年就已经完成的研究和工作!

rb_block_trb_control_frame_t 结构体

在 Ruby 1.9 及以后的版本中,你可以在 vm_core.h 文件中找到 rb_block_t 结构体的定义,如 示例 8-3 所示。

示例 8-3. rb_block_t 结构体定义,来自 vm_core.h

    typedef struct rb_block_struct {
     VALUE self;
     VALUE klass;
     VALUE *ep;
     rb_iseq_t *iseq;
     VALUE proc;
    } rb_block_t;

你可以看到上面描述的 iseq ep 值,以及一些其他值:

  • self :当块首次被引用时,self 指针指向的值也是闭包环境的一个重要部分。Ruby 在与块外部相同的对象上下文中执行块代码。

  • klass :与 self 一起,Ruby 还使用这个指针来追踪当前对象的类。

  • proc :Ruby 在从块创建 proc 对象时会使用这个值。正如我们将在下一节中看到的,procs 和块是密切相关的。

vm_core.hrb_block_t 的定义上方,我们可以看到 rb_control_frame_t 结构体的定义,如 示例 8-4 所示。

示例 8-4. rb_control_frame_t 结构体定义,来自 vm_core.h

    typedef struct rb_control_frame_struct {
        VALUE *pc;                  /* cfp[0] */
        VALUE *sp;                  /* cfp[1] */
        rb_iseq_t *iseq;            /* cfp[2] */
        VALUE flag;                 /* cfp[3] */
     VALUE self;                 /* cfp[4] / block[0] */
        VALUE klass;                /* cfp[5] / block[1] */
        VALUE *ep;                  /* cfp[6] / block[2] */
        rb_iseq_t *block_iseq;      /* cfp[7] / block[3] */
     VALUE proc;                 /* cfp[8] / block[4] */
        const rb_method_entry_t *me;/* cfp[9] */

    #if VM_DEBUG_BP_CHECK
        VALUE *bp_check;            /* cfp[10] */
    #endif
    } rb_control_frame_t;

请注意,这个 C 结构体也包含了与 rb_block_t 结构体相同的值:从selfproc,正如下图所示!。这两个结构体共享相同的值是 Ruby 为加速内部操作而采用的一项有趣但令人困惑的优化措施。每当你第一次通过将代码块传递到方法调用中时,Ruby 需要创建一个新的 rb_block_t 结构体,并将当前 rb_control_frame_t 结构体中的值(如 EP)复制到其中。然而,由于这两个结构体在相同的顺序中包含相同的值(rb_block_trb_control_frame_t 的子集),Ruby 可以避免创建新的 rb_block_t 结构体,而是将新的代码块指针设置为 rb_control_frame_t 结构体中的公共部分。换句话说,Ruby 不是分配新的内存来存放新的 rb_block_t 结构体,而是简单地传递指向 rb_control_frame_t 结构体中间部分的指针。通过这种方式,Ruby 避免了不必要的 malloc 调用,加快了创建代码块的过程。

实验 8-1: 哪个更快:while 循环还是将代码块传递给 each?

包含代码块的 Ruby 代码通常比较老语言(如 C)中的等效代码更优雅、简洁。例如,在 C 中,我们会写出 示例 8-5 中展示的简单 while 循环来将数字 1 到 10 相加。

示例 8-5. 使用 while 循环在 C 中求 1 到 10 的和

#include <stdio.h>
main()
{
  int i, sum;
  i = 1;
  sum = 0;
  while (i <= 10) {
    sum = sum + i;
    i++;
  }
  printf("Sum: %d\n", sum);
}

示例 8-6 展示了 Ruby 中相同的 while 循环。

示例 8-6. 使用 while 循环在 Ruby 中求 1 到 10 的和

sum = 0
i = 1
while i <= 10
  sum += i
  i += 1
end
puts "Sum: #{sum}"

然而,大多数 Ruby 开发者会像在 示例 8-7 中所示的那样,使用带有代码块的区间对象来编写这段代码。

示例 8-7. 使用区间对象和代码块在 Ruby 中求 1 到 10 的和

sum = 0
(1..10).each do |i|
  sum += i
end
puts "Sum: #{sum}"

抛开美学问题,使用代码块会有什么性能惩罚吗?Ruby 是否会为了创建新的 rb_block_t 结构体、复制 EP 值并创建新的栈帧而显著变慢?

嗯,我不会对 C 代码进行基准测试,因为显然它会比使用 Ruby 的任何选项都要快。相反,让我们测量一下 Ruby 使用简单的 while 循环来将整数 1 到 10 加起来得到 55 所需的时间,正如 示例 8-8 所示。

示例 8-8. 基准测试 while 循环 (while.rb)

require 'benchmark'
ITERATIONS = 1000000
Benchmark.bm do |bench|
  bench.report("iterating from 1 to 10, one million times") do
    ITERATIONS.times do
      sum = 0
      i = 1
      while i <= 10
        sum += i
        i += 1
      end
    end
  end
end

在这里,我使用基准测试库来测量运行while循环一百万次所需的时间。诚然,我使用了一个块来控制这百万次迭代(ITERATIONS.times do),但在下一个测试中,我也会使用相同的块。使用 Ruby 2.0 在我的笔记本电脑上,我能在不到半秒的时间内完成这段代码:

$ **ruby while.rb**
      user     system      total        real
      iterating from 1 to 10, one million times  0.440000   0.000000
                                                 0.440000 (  0.445757)

现在让我们测量运行示例 8-9 的代码所需的时间,该代码使用each和一个块。

示例 8-9. 基准测试调用一个块 (each.rb)

require 'benchmark'
ITERATIONS = 1000000
Benchmark.bm do |bench|
  bench.report("iterating from 1 to 10, one million times") do
    ITERATIONS.times do
      sum = 0
      (1..10).each do |i|
        sum += i
      end
    end
  end
end

这次运行一百万次循环所需的时间略长,约为 0.75 秒:

$ **ruby each.rb**
      user     system      total        real
      iterating from 1 to 10, one million times  0.760000   0.000000
                                                 0.760000 (  0.765740)

与简单的while循环迭代 10 次相比,Ruby 调用块的时间多了约 71%(参见图 8-9)。

Ruby 2.0 调用块的时间比简单的 while 循环多 71%。图表显示了一百万次迭代所需的时间(以秒为单位)。

图 8-9. Ruby 2.0 调用块的时间比简单的while循环多 71%。图表显示了一百万次迭代所需的时间(以秒为单位)。

使用each会更慢,因为在内部,Range#each方法每次循环时都必须调用或传递控制给块。这涉及相当大量的工作。为了将控制传递给一个块,Ruby 首先必须为该块创建一个新的rb_block_t结构,并将新的块的EP设置为引用的环境,然后将该块传递给each的调用。然后,在每次循环时,Ruby 必须在 YARV 的内部栈上创建一个新的栈帧,调用块的代码,最后将块的EP从块复制到新的栈帧。运行一个简单的while循环更快,因为 Ruby 只需要在每次循环时重置PC(程序计数器)。它从不调用方法,也不会创建新的栈帧或新的rb_block_t结构。

增加 71% 的时间看起来像是一个巨大的性能惩罚,根据你的工作和这个 while 循环的上下文,它可能重要也可能不重要。如果这个循环是一个时间敏感的关键操作,并且最终用户正在等待,如果循环内部没有其他昂贵的操作,那么使用传统的 C 风格 while 循环来编写迭代可能是值得的。然而,大多数 Ruby 应用程序,尤其是 Ruby on Rails 网站的性能,通常受到数据库查询、网络连接和其他因素的限制,而不是 Ruby 执行速度的限制。Ruby 的执行速度很少会对应用程序的整体性能产生直接的影响。(当然,如果你使用的是一个大型框架,比如 Ruby on Rails,那么你的 Ruby 代码只是一个非常庞大系统中的一小部分。我想 Rails 在处理一个简单的 HTTP 请求时,会多次使用块和迭代器,除了你自己写的 Ruby 代码之外。)

Lambdas 和 Procs:将函数视为一等公民

现在来看一种更复杂的方式,将“quick brown fox”字符串打印到控制台。示例 8-10 展示了使用 lambda 的一个例子。

示例 8-10. 使用 Ruby 中的 lambda

 def message_function
   str = "The quick brown fox"
   lambda do |animal|
     puts "#{str} jumps over the lazy #{animal}."
      end
    end
 function_value = message_function
 function_value.call('dog')

让我们仔细地走过这段代码。首先,在 我们定义了一个名为 message_function 的方法。在 message_function 内部,在 我们创建了一个名为 str 的局部变量。接下来,在 我们调用了 lambda,并传递了一个代码块。在这个代码块内,在 我们再次打印了“quick brown fox”字符串。然而,message_function 不会立即显示这个字符串,因为它并没有实际调用位于 的代码块。相反,lambda 返回我们传给它的代码块作为数据值,而这个数据值又会被 message_function 返回。

这是一个“将函数视为一等公民”的例子,用计算机科学中常用的表达方式来说。块从 message_function 返回后,我们将其保存在局部变量 function_value 中,如 ,然后显式地使用 call 方法调用它,如 。通过 lambda 关键字—或者等效的 proc 关键字—Ruby 允许你以这种方式将一个块转换为数据值。

Ruby 是否使用 rb_lambda_t C 结构?如果是,它会包含什么?

图 8-10. Ruby 是否使用 rb_lambda_t C 结构?如果是,它会包含什么?

我有很多关于 示例 8-10 的问题。当我们调用 lambda 时会发生什么?Ruby 如何将块转换为数据值,将块视为一等公民意味着什么?message_function 是直接返回一个 rb_block_t 结构,还是返回一个 rb_lambda_t 结构?那么 rb_lambda_t 会包含哪些信息呢?(参见 图 8-10)?

栈与堆内存

在我们能够回答这些问题之前,我们需要更仔细地了解 Ruby 如何保存数据。在内部,Ruby 将数据保存在两个地方:上或中。

我们之前见过 。这是 Ruby 保存局部变量、返回值和每个方法参数的地方。栈上的值仅在该方法运行时有效。当方法返回时,YARV 会删除其栈帧和其中的所有值。

Ruby 使用 来保存你可能需要较长时间的数据,甚至在某个方法返回后依然有效。堆中的每个值都会在有引用指向它时保持有效。一旦一个值不再被程序中的任何变量或对象引用,Ruby 的垃圾回收系统会删除它,释放其内存供其他用途。

这种方案并非 Ruby 独有。事实上,许多其他编程语言也使用这种方式,包括 Lisp 和 C。而且请记住,Ruby 本身就是一个 C 程序。YARV 的栈设计基于 C 程序使用栈的方式,Ruby 的堆使用的是底层 C 堆的实现。

栈和堆在另一个重要方面有所不同。Ruby 只在栈上保存数据的引用——即 VALUE 指针。对于简单的整数值、符号和常量(例如 niltruefalse),引用就是实际的值。然而,对于所有其他数据类型,VALUE 是指向包含实际数据的 C 结构的指针,例如 RObject。如果栈上只保存 VALUE 引用,那么 Ruby 将数据结构保存在什么地方?保存在堆中。让我们通过一个例子更好地理解这一点。

更深入地了解 Ruby 如何保存字符串值

让我们详细看一下 Ruby 如何处理字符串值 str,来自 示例 8-10。首先,假设 YARV 有一个外部作用域的栈帧,但尚未调用 message_function。图 8-11 显示了这个初始栈帧。

为了执行代码,Ruby 从初始栈帧开始。

图 8-11。为了执行示例 8-11 中的代码,Ruby 从初始栈帧开始。

在这张图中,你可以看到左侧是 YARV 的内部栈,右侧是 rb_control_frame_t 结构。现在假设 Ruby 执行了在示例 8-10 中显示的 message_function 函数调用。接下来的图 8-12 展示了接下来的情况。

Ruby 在调用 message_function 时创建了第二个栈帧。

图 8-12。Ruby 在调用 message_function 时创建了第二个栈帧。

Ruby 将 str 局部变量保存在 message_function 使用的新栈帧中。让我们仔细看看这个 str 变量,以及 Ruby 如何将“quick brown fox”字符串存储到其中。Ruby 将每个对象保存在一个叫做 RObject 的 C 结构中,将每个数组保存在一个叫做 RArray 的结构中,将每个字符串保存在一个叫做 RString 的结构中,依此类推。图 8-13 展示了将“quick brown fox”字符串保存在 RString 中的情况。

Ruby 使用 RString C 结构来保存字符串值。

图 8-13。Ruby 使用 RString C 结构来保存字符串值。

实际的字符串结构如图右侧所示,字符串的引用或指针如图左侧所示。当 Ruby 将字符串值(或任何对象)保存到 YARV 栈时,实际上只将字符串的引用放入栈中。实际的字符串结构则保存于堆中,如下一页中的图 8-14 所示。

一旦堆中不再有任何指针引用某个特定对象或值,Ruby 会在下一次垃圾回收系统运行时释放该对象或值。为了演示这一点,假设我的示例代码根本没有调用 lambda,而是在保存 str 变量后立即返回 nil,如示例 8-11 所示。

示例 8-11。此代码没有调用 lambda

def message_function
  str = "The quick brown fox"
  nil
end

栈上的 str 值是保存于堆中的 RString 结构的引用。

图 8-14。栈上的 str 值是保存于堆中的 RString 结构的引用。

一旦 message_function 调用完成,YARV 会简单地将 str 值从栈中弹出(以及栈中保存的任何其他临时值),并返回到原始栈帧,如 图 8-15 所示。

现在不再引用  结构体。

图 8-15。现在不再引用 RString 结构体。

如图所示,不再引用包含“快速棕色狐狸”字符串的 RString 结构体。Ruby 的垃圾回收系统旨在识别堆中没有任何引用的值,就像这里的“快速棕色狐狸”字符串。识别后,GC 系统将释放这些孤立的值,将内存返回到堆中。

Ruby 如何创建 Lambda

现在我们对堆以及 Ruby 如何使用堆有了一些了解,准备进一步学习关于 lambdas 的知识。之前我提到过“将函数作为一等公民”的说法,这意味着 Ruby 允许你将函数或代码当作数据值来处理,将它们保存在变量中,作为参数传递等等。Ruby 使用块来实现这个概念。

lambda(或 proc)关键字将块转换为数据值。但请记住,块是 Ruby 对闭包的实现。这意味着新的数据值必须以某种方式包含块的代码和引用的环境。

为了说明我的意思,我们返回到 示例 8-10,并在 示例 8-12") 中重复,重点看它如何使用 lambda

示例 8-12。使用 lambda 在 Ruby 中(从 示例 8-10 重复)

    def message_function
   str = "The quick brown fox"
   lambda do |animal|
     puts "#{str} jumps over the lazy #{animal}."
      end
    end
    function_value = message_function
 function_value.call('dog')

请注意,在 处,当我们调用 lambda(块)时,块内的 puts 语句位于 处,可以访问在 处的 message_function 内定义的 str 字符串变量。这是怎么回事呢?我们刚刚看到,当 message_function 返回时,strRString 结构体的引用被从栈中弹出!显然,在调用 lambda 后,str 的值仍然存在,以便块在稍后访问它。

当你调用 lambda 时,Ruby 将当前 YARV 堆栈帧的全部内容复制到堆中,其中包含 RString 结构。例如,图 8-16 显示了 message_function 开始时 YARV 堆栈的状态,位于 示例 8-12 的 处。(为了简化起见,这里没有显示 RString 结构,但请记住,RString 结构也会被保存在堆中。)

Ruby 在调用 message_function 时创建了第二个堆栈帧。

图 8-16. Ruby 在调用 message_function 时创建了第二个堆栈帧。

接下来,示例 8-12 在 处调用 lambda。图 8-17 显示了调用 lambda 时 Ruby 会发生什么。

虚线下方的水平堆栈图标显示,Ruby 为 message_function 创建了堆栈帧的新副本并保存在堆中。现在,str RString 结构有了第二个引用,这意味着当 message_function 返回时,Ruby 不会释放该结构。

事实上,除了堆栈帧的副本外,Ruby 还在堆中创建了另外两个新对象:

  • 一个内部环境对象,通过图中左下角的 rb_env_t C 结构表示。它本质上是堆中堆栈副本的封装器。正如我们在 第九章 中看到的,你可以通过 Binding 类间接访问该环境对象。

  • 一个 Ruby proc 对象,通过 rb_proc_t 结构表示。这是 lambda 关键字的实际返回值;它是 message_function 函数的返回值。

请注意,新的 proc 对象,即 rb_proc_t 结构,包含一个 rb_block_t 结构,其中包括 iseqEP 指针。可以将 proc 看作是一个封装块的 Ruby 对象。与普通块类似,这些指针跟踪块的代码和其闭包的引用环境。Ruby 会设置块中的 EP 指针,指向堆中堆栈帧的副本。

此外,请注意 proc 对象包含一个内部值is_lambda。对于这个例子,is_lambda被设置为true,因为我们使用lambda关键字创建了 proc。如果我使用proc关键字或者仅仅通过调用Proc.new来创建 proc,那么is_lambda会被设置为false。Ruby 通过这个标志来区分 proc 和 lambda 之间的细微行为差异,但最好将 proc 和 lambda 视为本质相同。

当你调用 lambda 时,Ruby 会将当前的堆栈帧复制到堆中。

图 8-17。当你调用lambda时,Ruby 会将当前的堆栈帧复制到堆中。

Ruby 如何调用 lambda

让我们回到示例 8-13 中的 lambda 示例。

示例 8-13。在 Ruby 中使用lambda(从示例 8-10 再次重复)

    def message_function
      str = "The quick brown fox"
      lambda do |animal|
        puts "#{str} jumps over the lazy #{animal}."
      end
    end
 function_value = message_function
 function_value.call('dog')

message_function处返回时会发生什么?因为 lambda 或 proc 对象是其返回值,lambda 的引用会保存在外部作用域的堆栈帧中的function_value局部变量中。这防止了 Ruby 释放 proc、内部环境对象以及str变量,并且现在堆中有指针指向这些值(参见图 8-18)。

一旦 message_function 返回,外围代码会持有 proc 对象的引用。

图 8-18。一旦message_function返回,外围代码会持有 proc 对象的引用。

当 Ruby 在处执行 proc 对象的call方法时,它也会执行其块。图 8-19 展示了当你在 lambda 或 proc 上使用call方法时,Ruby 会发生什么。

和任何块一样,当 Ruby 调用 proc 对象中的块时,它会创建一个新的栈帧,并将 EP 设置为块的引用环境。然而,这个环境是之前已经复制到堆中的栈帧的副本;新的栈帧包含一个指向堆的 EP。这个 EP 使得块内对 puts 的调用可以访问在 message_function 中定义的 str 值。图 8-19 展示了作为 proc 参数的 animal,它像其他方法或块的参数一样,保存在新的栈帧中。

调用 proc 对象像往常一样创建一个新的栈帧,并将 EP 设置为指向堆的引用环境。

图 8-19。调用 proc 对象像往常一样创建一个新的栈帧,并将 EP 设置为指向堆的引用环境。

Proc 对象

我们已经看到 Ruby 其实并没有一个叫做 rb_lambda_t 的结构。换句话说,图 8-20 中展示的那个结构实际上并不存在。

Ruby 实际上并没有使用一个叫做 rb_lambda_t 的结构。

图 8-20。Ruby 实际上并没有使用一个叫做 rb_lambda_t 的结构。

相反,在这个例子中,Ruby 的 lambda 关键字创建了一个 proc 对象——实际上,这是我们传递给 lambdaproc 关键字的块的封装器。Ruby 使用 rb_proc_t C 结构来表示 procs,正如你在图 8-21 中看到的那样。

Ruby 的 proc 是闭包;它们包含指向函数和引用环境的指针。

图 8-21。Ruby 的 proc 是闭包;它们包含指向函数和引用环境的指针。

这是一个闭包:它包含一个函数以及该函数所引用或创建的环境。这个环境是保存在堆中的栈帧的持久副本。

一个 proc 是一个 Ruby 对象。它包含与其他对象相同的信息,包括 RBasic 结构。为了保存与对象相关的信息,Ruby 使用一个叫做 RTypedData 的结构,配合 rb_proc_t 来表示 proc 对象的实例。图 8-22 展示了这些结构如何协同工作。

你可以将RTypedData看作是 Ruby 的 C 代码用来围绕 C 数据结构创建 Ruby 对象包装器的一种技巧。在这个案例中,Ruby 使用RTypedData来创建Proc Ruby 类的一个实例,该类表示rb_proc_t结构体的单个副本。RTypedData结构体包含与所有 Ruby 对象相同的RBasic信息:

  • flags。Ruby 需要跟踪的某些内部技术信息

  • klass。指向该对象所属的 Ruby 类的指针;在此示例中是Proc

Ruby 将与 proc 对象相关的信息保存在 RTypedData 结构中。

图 8-22。Ruby 将与 proc 对象相关的信息保存在RTypedData结构中。

图 8-23 再次查看 Ruby 如何表示 proc 对象。proc 对象位于右侧,紧邻一个RString结构体。

请注意,Ruby 处理字符串值和 proc 的方式相似。与字符串一样,procs 可以被保存在变量中或作为函数调用的参数传递。每当你引用一个 proc 或将其保存在变量中时,Ruby 使用指向该 proc 的VALUE指针。

比较 Ruby 字符串与 proc

图 8-23。比较 Ruby 字符串与 proc

实验 8-2:调用 lambda 后更改局部变量

示例 8-10 到示例 8-13 展示了如何调用lambda将当前的栈帧复制到堆上。现在来看一个略有不同的示例。示例 8-14 基本相同,唯一不同的是在这一行,lambda调用后更改了str

示例 8-14。lambda会将str的哪个版本复制到堆上?(modify_after_lambda.rb)

    def message_function
      str = "The quick brown fox"
   func = lambda do |animal|
        puts "#{str} jumps over the lazy #{animal}."
      end
   str = "The sly brown fox"
      func
    end
    function_value = message_function
 function_value.call('dog')

因为我们在调用lambda时,str的值尚未更改为The sly brown fox,所以 Ruby 应该已经将栈帧复制到了堆中,包括str的原始值。这意味着当我们在调用 lambda 时,应该看到原始的“quick brown fox”字符串。然而,运行代码时,我们得到以下结果:

$ **ruby modify_after_lambda.rb**
The sly brown fox jumps over the lazy dog.

发生了什么?Ruby 以某种方式将str的新值The sly brown fox复制到了堆上,以便我们在调用 lambda 时能够访问它,调用时位于

为了弄清楚 Ruby 是如何做到这一点的,让我们更仔细地看看当你调用lambda时发生了什么。图 8-24 展示了 Ruby 如何将栈帧复制到堆中,包括来自示例 8-14 的str值。

当你调用 lambda 时,Ruby 将栈帧复制到堆中。

图 8-24。当你调用lambda时,Ruby 将栈帧复制到堆中。

一旦创建了此副本,示例 8-14 中的代码在图示str更改为“sly fox”字符串:

str = "The sly brown fox"

由于 Ruby 在调用lambda时复制了栈帧,我们应该修改原始的str副本,而不是新的 lambda 副本(参见图 8-25)。

Ruby 在创建堆副本后是否继续使用原始栈帧?

图 8-25。Ruby 在创建堆副本后是否继续使用原始栈帧?

字符串的新堆副本应保持未修改,稍后调用 lambda 时应该返回原始的“quick fox”字符串,而不是修改后的“sly fox”字符串。Ruby 是如何允许我们在lambda创建新持久副本后修改栈的呢?

结果表明,一旦 Ruby 创建了栈的新的堆副本(新的rb_env_t结构或内部环境对象),它会重置rb_control_frame_t结构中的EP,使其指向该副本。图 8-26 展示了 Ruby 在创建堆栈帧的持久副本后如何重置EP

Ruby 在创建堆栈帧的持久副本后重置 EP。

图 8-26。Ruby 在创建堆中的栈帧持久副本后,重置了EP

这里的区别在于 EP 现在指向堆。当我们在示例 8-14 中调用 str = "The sly brown fox" 时,Ruby 将使用新的 EP 访问堆中的值,而不是栈上的原始值。请注意,The sly brown fox 出现在堆中的图 8-26 底部。

在同一作用域内多次调用 lambda。

lambda 关键字的另一个有趣行为是,Ruby 避免多次复制栈帧,正如你在示例 8-15 中看到的那样。

示例 8-15。在同一作用域内调用 lambda 两次

i = 0
increment_function = lambda do
  puts "Incrementing from #{i} to #{i+1}"
  i += 1
end
decrement_function = lambda do
  i -= 1
  puts "Decrementing from #{i+1} to #{i}"
end

这段代码期望两个 lambda 函数在主作用域中操作局部变量 i

但是,如果 Ruby 为每次调用 lambda 创建了栈帧的单独副本,那么每个函数都会操作 i 的单独副本。请看以下示例 8-16 中的例子。

示例 8-16。调用在示例 8-15 中创建的 lambda。

increment_function.call
decrement_function.call
increment_function.call
increment_function.call
decrement_function.call

如果 Ruby 为每个 lambda 函数使用了 i 的单独副本,那么前面的示例会生成示例 8-17 中显示的输出。

示例 8-17。如果每次调用 lambda 创建了自己的栈帧副本,我们期望的输出

Incrementing from 0 to 1
Decrementing from 0 to -1
Incrementing from 1 to 2
Incrementing from 2 to 3
Decrementing from -1 to -2

但实际上,我们看到的是在示例 8-18 中显示的输出。

示例 8-18。由于 lambda 函数共享相同堆上的栈副本,运行示例 8-16 会生成以下输出。

Incrementing from 0 to 1
Decrementing from 1 to 0
Incrementing from 0 to 1
Incrementing from 1 to 2
Decrementing from 2 to 1

通常这正是你期望的:你传递给 lambda 的每个块都访问父作用域中的相同变量。Ruby 通过检查 EP 是否已经指向堆来实现这一点。如果是,就像在示例 8-15 中第二次调用 lambda 时,Ruby 不会创建第二个副本;它只会在第二个 rb_proc_t 结构中重用相同的 rb_env_t 结构。最终,两个 lambda 使用相同的堆副本栈。

总结

在第三章中,我们看到每次调用块时,YARV 都会创建一个新的栈帧,正如调用方法时的行为一样。乍一看,Ruby 块似乎是一种特殊的可以调用并传递参数的方法。然而,正如我们在本章中所看到的,块的含义远不止于此。

仔细查看 rb_block_t 结构体时,我们看到块是如何在 Ruby 中实现计算机科学中的闭包概念的。块是一个函数与调用该函数时所需的环境的结合体。我们了解到,块在 Ruby 中具有一种奇特的双重身份:它们类似于方法,但又成为调用它们的方法的一部分。Ruby 语法允许这种双重角色的方式非常简洁,这是这门语言最美丽和优雅的特点之一。

后来我们看到,Ruby 允许你使用 lambda 关键字将函数或代码作为一等公民,这将一个块转化为可以传递、保存和重用的数据值。在回顾栈内存和堆内存的区别之后,我们探讨了 Ruby 如何实现 lambdas 和 procs,并且发现当你调用 lambdaproc 时,Ruby 会将栈帧复制到堆上,并在调用 lambda 的块时重用它。最后,我们看到 proc 对象如何将代码表示为 Ruby 中的数据对象。


^([1]) 如果外部代码位于函数或方法内部,则 EP 会指向栈帧,如图所示。但如果外部代码位于 Ruby 程序的顶层作用域中,那么 Ruby 会使用动态访问将变量保存在 TOPLEVEL_BINDING 环境中。不管怎样,EP 始终指示 str 变量的位置。

^([2]) Gerald J. Sussman 和 Guy L. Steele, Jr., “Scheme: An Interpreter for Extended Lambda Calculus” (MIT 人工智能实验室,AI 备忘录 No. 349,1975 年 12 月)。

第九章. 元编程

没有标题的图片

一旦你了解 Ruby 如何在内部实现元编程,它就变得更容易理解了。

Ruby 开发者面临的最令人困惑和令人畏惧的主题之一是元编程。如同前缀 meta 所示,元编程字面意思是以不同或更高的抽象层次进行编程。Ruby 提供了许多不同的方式来实现这一点,使得程序能够动态地检查和改变自己。在 Ruby 中,程序可以改变自己!

Ruby 的一些元编程特性允许程序查询关于自身的信息——例如,关于方法、实例变量和超类的信息。其他元编程特性则允许你以一种替代的、更灵活的方式执行正常的任务,例如定义方法或常量。最后,像 eval 这样的函数允许你的程序从头开始编写新的 Ruby 代码,在运行时调用解析器和编译器。

在本章中,我们将重点关注元编程的两个重要方面。首先,我们将探讨如何改变标准的方法定义过程,这是元编程最常见和最实用的用途。我们将学习 Ruby 通常如何将方法分配给类,以及这与词法作用域的关系。接着,我们将看看如何使用元类和单例类以替代方式定义方法。我们还将学习 Ruby 如何实现新的实验性特性——修饰符功能,允许你定义方法并在稍后激活它们(如果你愿意的话)。

路线图

  • 定义方法的替代方式

    • Ruby 的正常方法定义过程

    • 使用对象前缀定义类方法

    • 使用新的词法作用域定义类方法

    • 使用单例类定义方法

    • 在词法作用域中使用单例类定义方法

    • 创建修饰符

    • 使用修饰符

  • 实验 9-1: 我是谁?self 在词法作用域中的变化

    • 顶层作用域中的 self

    • 类作用域中的 self

    • 元类作用域中的 self

    • 类方法中的 self

  • 元编程与闭包:eval、instance_eval 和 binding

    • 写代码的代码

    • 图 9-16

    • instance_eval 示例

    • Ruby 闭包的另一个重要部分

    • instance_eval 将 self 改为接收者

    • instance_eval 为新的词法作用域创建单例类

    • Ruby 如何跟踪块的词法作用域

  • 实验 9-2:使用闭包定义方法

    • 使用 define_method

    • 作为闭包的方式的函数

  • 总结

在本章的下半部分,我们将看到如何使用eval方法编写写代码的代码:这是元编程的最纯粹形式。我们还将看到元编程与闭包是如何相关的。像块、lambda 和 proc 一样,eval及其相关的元编程方法在调用时会创建一个闭包。事实上,我们将学习如何使用在第八章中开发的相同思维模型来理解 Ruby 的许多元编程特性。

定义方法的替代方法

通常,我们使用def关键字在 Ruby 中定义方法。def后面是新方法的名称,接着是方法体。然而,通过使用 Ruby 的一些元编程特性,我们可以以不同的方式定义方法。我们可以创建类方法而非普通方法;我们可以为单个对象实例创建方法;正如我们在实验 9-2:使用闭包定义方法中看到的,我们还可以创建能够访问周围环境的闭包方法。

接下来,我们将探讨在使用元编程定义方法时,Ruby 内部发生了什么。在每种情况下,研究 Ruby 内部的操作将使 Ruby 的元编程语法更容易理解。但在我们研究元编程之前,让我们先了解 Ruby 如何正常地定义方法。这些知识将为我们学习替代方法定义方式奠定基础。

Ruby 的普通方法定义过程

示例 9-1 展示了一个非常简单的 Ruby 类,包含一个方法。

示例 9-1。使用 def 关键字向类添加方法

class Quote
  def display
    puts "The quick brown fox jumped over the lazy dog."
  end
end

Ruby 如何执行这个小程序?它是如何知道将 display 方法分配给 Quote 类的?

当 Ruby 执行 class 关键字时,它为新的 Quote 类创建了一个新的词法作用域(见图 9-1)。Ruby 在词法作用域中设置 nd_clss 指针,使其指向新的 Quote 类的 RClass 结构。由于这是一个新类,RClass 结构最初有一个空的方法表,如图的右侧所示。

接下来,Ruby 执行 def 关键字,它用于定义 display 方法。但 Ruby 如何创建普通方法?当你调用 def 时,内部发生了什么?

当你定义一个类时,Ruby 会创建一个新的词法作用域。

图 9-1。Ruby 在你定义类时会创建一个新的词法作用域。

默认情况下,当你使用 def 时,只提供新方法的名称。(我们将在下一节看到,你还可以指定对象前缀与新方法名称一起使用。)只提供新方法名称的 def 指示 Ruby 使用当前的词法作用域来查找目标类,如图 9-2 所示。

默认情况下,Ruby 使用当前的词法作用域来查找新方法的目标类。

图 9-2。默认情况下,Ruby 使用当前的词法作用域来查找新方法的目标类。

当 Ruby 初次编译示例 9-1 时,它为 display 方法创建了一个单独的 YARV 代码片段。稍后,当执行 def 关键字时,Ruby 将此代码分配给目标类 Quote,并将给定的方法名称保存到方法表中(见图 9-3)。

Ruby 将新方法添加到目标类的方法表中。

图 9-3. Ruby 将新方法添加到目标类的方法表中。

当我们执行这个方法时,Ruby 会根据 Ruby 的方法查找算法查找该方法。因为display现在出现在Quote的函数表中,Ruby 能够找到并执行该方法。

总结一下,使用def关键字在你的程序中定义新方法时,Ruby 遵循以下三步过程:

  1. 它将每个方法的主体编译成一个独立的 YARV 指令片段。(当 Ruby 解析并编译你的程序时,就会发生这种情况。)

  2. 它使用当前的词法作用域来获取指向类或模块的指针。(当 Ruby 在执行你的程序时遇到def关键字时,就会发生这种情况。)

  3. 它将新方法的名称——实际上是映射到名称的整数 ID 值——保存在该类的方法表中。

使用对象前缀定义类方法

现在我们理解了 Ruby 方法定义过程的正常工作方式,让我们通过元编程来学习一些替代的定义方法。如同我们在图 9-2 中所看到的,Ruby 通常将新方法分配给与当前词法作用域对应的类。然而,有时你会决定将方法添加到另一个类中——例如,在定义类方法时。(记住,Ruby 会将类方法保存在类的元类中。)示例 9-2 展示了如何创建类方法的一个例子。

示例 9-2. 使用def self添加类方法

    class Quote
   def self.display
        puts "The quick brown fox jumped over the lazy dog."
      end
    end

中,我们使用def来定义新方法,但这一次我们使用self前缀。这个前缀告诉 Ruby 将方法添加到你在前缀中指定的对象的类中,而不是使用当前的词法作用域。图 9-4 展示了 Ruby 如何在内部实现这一点。

这种行为与标准的方法定义过程非常不同!当你为def提供对象前缀时,Ruby 使用以下算法来决定将新方法放置到哪里:

  1. Ruby 评估前缀表达式。在 示例 9-2 中,我们使用了 self 关键字。当 Ruby 在 class Quote 的作用域内执行代码时,self 被设置为 Quote 类。(我们本可以在这里提供任何 Ruby 表达式,而不仅仅是 self。)在 图 9-4 中,从 selfRClass 结构的箭头表示 self 的值是 Quote

    提供对象前缀给 def 会指示 Ruby 将新方法添加到对象的类中。

    图 9-4。提供对象前缀给 def 会指示 Ruby 将新方法添加到对象的类中。

  2. Ruby 查找该对象的类。在 示例 9-2 中,由于 self 本身是一个类(Quote),对象的类实际上是 Quote 的元类。图 9-4 通过箭头从 QuoteRClass 结构向右延伸来表示这一点。

  3. Ruby 将新方法保存到该类的函数表中。在这种情况下,Ruby 将 display 方法放置在 Quote 的元类中,使得 display 成为一个新的类方法。

注意

如果你调用 Quote.class,Ruby 会返回 Class。所有类实际上都是 Class 类的实例。元类是一个内部概念,通常对你的 Ruby 程序隐藏。要查看 Quote 的元类,你可以调用 Quote.singleton_class,它将返回 #<Class:Quote>

使用新的词法作用域定义类方法

示例 9-3 展示了一种不同的方法,将 display 分配为 Quote 的类方法。

示例 9-3。使用 class << self 定义类方法

 class Quote
   class << self
        def display
          puts "The quick brown fox jumped over the lazy dog."
        end
      end
    end

处,class << self 声明了一个新的词法作用域,就像在 处的 class Quote 一样。在 Ruby 的正常方法定义过程 中,我们看到在由 class Quote 创建的作用域中使用 def 会将新方法分配给 Quote。但是,在 class << self 创建的作用域中,Ruby 将方法分配给哪个类呢?答案是 self 的类。因为在 处,self 被设置为 Quote,所以 self 的类就是 Quote 的元类。

图 9-5 显示了 class << self 如何为 Quote 的 metaclass 创建一个新的词法作用域。

Ruby 使用 class << self 为类的 metaclass 创建了一个新的词法作用域。

图 9-5. 使用 class << self 时,Ruby 为类的 metaclass 创建了一个新的词法作用域。

在这个图中,Ruby 的 class << 元编程语法的作用如下:

  1. Ruby 首先求值出 class << 后面的表达式。在示例 9-3 中,这个表达式是 self,它的值是 Quote 类,就像在示例 9-2 中使用对象前缀语法时一样。图中从 selfRClass 结构的长箭头指示了 self 的值是 Quote 类。

  2. Ruby 会找到表达式求值后的对象所属的类。在示例 9-3 中,这将是 Quote 类,或者是 Quote 的 metaclass,图中从 QuoteQuote 的 metaclass 向下延伸的箭头指示了这一点。

  3. Ruby 为这个类创建了一个新的词法作用域。在这个示例中,词法作用域使用的是 Quote 的 metaclass,如图中从 nd_clss 向右延伸到新作用域的箭头所示。

现在我们可以使用新的词法作用域来像往常一样使用 def 定义一系列的类方法。在示例 9-3 中,Ruby 会将 display 方法直接分配给 Quote 的 metaclass。这是定义 Quote 类方法的一种不同方式。你可能会觉得 class << selfdef self 更让人困惑,但它是通过在内部的 metaclass 词法作用域内声明所有类方法来创建一系列类方法的便捷方式。

使用单例类定义方法

我们已经看到,元编程允许你通过向类的类或 metaclass 添加方法来声明类方法。Ruby 还允许你向单个对象实例添加方法,如在示例 9-4 中所示。

示例 9-4. 向单个对象实例添加方法

 class Quote
    end

 some_quote = Quote.new
 def some_quote.display
      puts "The quick brown fox jumped over the lazy dog."
    end

处,我们声明了 Quote 类;然后在 处,我们创建了 Quote 的一个实例:some_quote。然而,在 处,我们为 some_quote 实例创建了一个新方法,而不是为 Quote 类创建方法。因此,只有 some_quote 会有 display 方法;Quote 的其他实例将没有这个方法。

Ruby 内部通过一个名为 单例类 的隐藏类来实现这种行为,它类似于单个对象的元类。以下是它们的区别:

  • Singleton class(单例类)是 Ruby 内部创建的一个特殊隐藏类,用于存放仅为特定对象定义的方法。

  • Metaclass(元类)是一个单例类,当该对象本身是一个类时就会出现这种情况。

所有元类都是单例类,但并不是所有单例类都是元类。Ruby 会为你创建的每个类自动创建一个元类,并将类方法存储在元类中,供你稍后声明。另一方面,Ruby 仅在你为单个对象定义方法时创建单例类,如 示例 9-4 所示。当你使用 instance_eval 或相关方法时,Ruby 也会创建单例类。

注意

大多数 Ruby 开发者将 singleton class metaclass 这两个术语互换使用,当你调用 singleton_class 方法时,Ruby 会返回一个单例类或元类。然而,Ruby 的 C 源代码在内部确实区分了单例类和元类。

图 9-6 展示了 Ruby 在执行 示例 9-4 时如何创建单例类。Ruby 评估作为 def 前缀提供的表达式:some_quote。因为 some_quote 是一个对象实例,Ruby 为 some_quote 创建了一个新的单例类,然后将新方法分配给这个单例类。使用带有对象前缀的 def 关键字会指示 Ruby 使用元类(如果前缀是类)或创建单例类(如果前缀是其他对象)。

为  提供对象前缀会指示 Ruby 将新方法添加到对象的单例类中。

图 9-6. 为 def 提供对象前缀会指示 Ruby 将新方法添加到对象的单例类中。

在词法作用域中使用单例类定义方法

您还可以使用 class << 语法为单个对象实例声明新的词法作用域,以便添加方法,如 示例 9-5 所示。

示例 9-5. 使用 class << 添加单例方法

    class Quote
    end

    some_quote = Quote.new
 class << some_quote
      def display
        puts "The quick brown fox jumped over the lazy dog."
      end
    end

这段代码与 示例 9-4 中的代码的区别出现在 处,当我们使用 class << 语法与表达式 some_quote 结合时,some_quote 会被求值为一个单个对象实例。如 图 9-7 所示,class << some_quote 指示 Ruby 创建一个新的单例类以及一个新的词法作用域。

在 图 9-7 的左侧,您可以看到来自 示例 9-5 的部分代码。Ruby 首先计算表达式 some_quote,并发现它是一个对象,而不是类。图 9-7 使用指向 some_quoteRObject 结构的长箭头来表示这一点。由于它不是一个类,Ruby 会为 some_quote 创建一个新的单例类,并且还会创建一个新的词法作用域。接下来,它将新作用域的类设置为新的单例类。如果 some_quote 的单例类已经存在,Ruby 会重用它。

Ruby 为 some_quote 创建了一个新的单例类和词法作用域。

图 9-7. Ruby 为 some_quote 创建了一个新的单例类和词法作用域。

创建 Refinements

Ruby 2.0 的 refinements 特性使我们能够在以后定义方法并将其添加到类中。如果我们想查看如何实现这一点,我们将使用与 示例 9-1 中相同的 Quote 类和 display 方法,方便起见,下面再次列出。

class Quote
  def display
    puts "The quick brown fox jumped over the lazy dog."
  end
end

现在假设在我们的 Ruby 应用程序的其他地方,我们希望覆盖或更改 display 方法的行为,而不需要在整个 Quote 类中进行更改。Ruby 提供了一种优雅的方式来实现这一点,如 示例 9-6 所示。

示例 9-6. 在模块内部优化类

module AllCaps
  refine Quote do
    def display
      puts "THE QUICK BROWN FOX JUMPED OVER THE LAZY DOG."
    end
  end
end

refine Quote do中,我们使用 refine 方法并传递 Quote 类作为参数。这为 Quote 定义了新的行为,稍后我们可以激活它。图 9-8 展示了我们调用 refine 时内部发生的事情。

Ruby 在你调用 refine 时创建一个特殊的模块,并更新目标类方法的类型。

图 9-8. Ruby 在你调用 refine 时创建一个特殊的模块,并更新目标类方法的类型。

从左上角开始,逐步解析 图 9-8,我们可以看到以下内容:

  • refine方法创建了一个新的词法作用域(阴影矩形区域)。

  • Ruby 创建一个新的“refinement”模块,并使用它作为这个新作用域的类。

  • Ruby 将指向Quote类的指针保存在新 refinement 模块中的refined_class

当你在 refine 块中定义新的方法时,Ruby 会将它们保存在 refinement 模块中。但它也会跟随 refined_class 指针,并更新目标类中的相同方法,使其使用方法类型 VM_METHOD_TYPE_REFINED

使用 Refinements

你可以决定在程序的特定部分使用 using 方法激活这些“refined”方法,如 示例 9-7 所示。

示例 9-7. 激活一个 refined 方法

 Quote.new.display
     => The quick brown...

 using AllCaps

 Quote.new.display
     => THE QUICK BROWN...

当我们第一次在 调用 display 时,Ruby 使用原始方法。然后,在 处我们通过using激活 refinement,这导致 Ruby 在我们再次调用 display 时,使用更新后的方法,如 所示。

using方法将指定模块的 refinement 关联到当前的词法作用域。当我写这段话时,当前版本的 Ruby 2.0 只允许在顶级作用域中使用 refinement,如这个例子所示;using是顶级main对象的方法。(未来版本可能允许在程序的任何词法作用域中使用 refinement。)图 9-9 展示了 Ruby 如何在内部将 refinement 与顶级词法作用域关联。

方法将模块的 refinements 与顶级词法作用域关联。

图 9-9. using方法将模块的 refinements 与顶级词法作用域关联。

注意,每个词法作用域中都包含一个 nd_refinements 指针,它跟踪该作用域中活跃的 refinements。using 方法会设置 nd_refinements,否则它的值为 nil

最后,图 9-10 展示了当我调用方法时,Ruby 的方法分发算法如何找到更新后的方法。

当你调用方法时,Ruby 使用一个复杂的方法分发过程。该算法的一部分查找 VM_METHOD_TYPE_REFINED 方法。当它遇到已修改的方法时,Ruby 会在当前词法作用域中查找任何活跃的 refinements。如果它找到了活跃的 refinement,Ruby 会调用已修改的方法;否则,它会调用原始方法。

当原始方法标记为 VM_METHOD_TYPE_REFINED 时,Ruby 会在 refine 块中查找方法。

图 9-10。当原始方法标记为VM_METHOD_TYPE_REFINED时,Ruby 会在 refine 块中查找方法。

实验 9-1:我是谁?self 如何随词法作用域变化

我们已经见过多种在 Ruby 中定义方法的方式。我们使用常规的 def 关键字创建了方法。然后,我们看到了如何在元类和单例类上创建方法,以及如何使用 refinements。

尽管每种技术将方法添加到不同的类中,但每种方法也遵循一个简单的规则:Ruby 会根据当前的词法作用域将新方法添加到相应的类中。(然而,当你使用前缀时,def 关键字会将方法分配给另一个类。)在 refinements 中,当前作用域的类实际上是创建用来存放已修改方法的特殊模块。事实上,这正是词法作用域在 Ruby 中扮演的重要角色之一:它标识了我们当前正在向哪个类或模块添加方法。

我们还知道,self 关键字返回当前对象——当前由 Ruby 执行的方法的接收者。回想一下,YARV 会在你的 Ruby 调用栈的每个级别中保存 self 的当前值,这些值保存在 rb_control_frame_t 结构中。那么,这个对象是否与当前词法作用域的类相同?

顶级作用域中的 self

让我们看看在运行一个简单程序时,self 的值如何变化,这个程序从 示例 9-8 开始。

示例 9-8:只有一个词法作用域的简单 Ruby 程序

p self
 => main
p Module.nesting
 => []

为了简单起见,我已经在控制台中展示了输出。你可以看到,Ruby 在开始执行你的代码之前会创建一个 top self 对象。这个对象作为顶级作用域中方法调用的接收者。Ruby 使用字符串 main 来表示这个对象。

Module.nesting 调用返回一个数组,显示词法作用域栈——即,代码中到该点为止“嵌套”的模块。这个数组将包含词法作用域栈中每个词法作用域的元素。因为我们处于脚本的顶层,Ruby 返回一个空数组。

图 9-11 显示了该简单程序的词法作用域栈和 self 的值。

在顶层,Ruby 将 self 设置为 main 对象,并在词法作用域栈中有一个条目。

图 9-11。在顶层,Ruby 将 self 设置为 main 对象,并在词法作用域栈中有一个条目。

在此图的右侧,您可以看到 main 对象:当前 self 的值。左侧是词法作用域栈,其中仅包含一个顶层作用域的条目。Ruby 将顶层作用域的类设置为 main 对象的类,也就是 Object 类。

注意

回想一下,当你使用 def 关键字声明一个新方法时,Ruby 会将方法添加到当前词法作用域的类中。我们刚刚看到,顶层词法作用域的类是 Object。因此,我们可以得出结论,当你在脚本的顶层定义方法时,方法会被添加到 Object 类中,因为 Object 是每个其他类的超类。你可以从任何地方调用在顶层定义的方法。

类作用域中的 self

现在,让我们定义一个新类,看看 self 和词法作用域栈的变化,正如 示例 9-9 中所示。

示例 9-9。声明一个新类会更改 self 并在词法作用域栈中创建一个新条目。

    p self
    p Module.nesting

    class Quote
      p self
    => Quote
      p Module.nesting
    => [Quote]
    end

打印语句的输出显示在线。我们可以在 中看到,Ruby 已将 self 更改为 Quote——新的类——并且我们可以在 中看到,词法作用域栈中新增了一个层级。图 9-12 显示了一个总结。

现在,self 在当前词法作用域中与类相同。

图 9-12。现在,self 在当前词法作用域中与类相同。

在这张图的左侧,我们可以看到词法作用域栈。最顶层的作用域位于左上方,下面是由class关键字创建的新词法作用域。同时,在图的右侧,我们可以看到调用classself的值是如何变化的。在最顶层,self被设置为main对象,但当我们调用class时,Ruby 将self改为新的类。

元类作用域中的 self

让我们使用class << self语法来创建一个新的元类作用域。示例 9-10 展示了包含更多代码行的相同程序。

示例 9-10. 声明元类作用域

    p self
    p Module.nesting

    class Quote
      p self
      p Module.nesting

      class << self
        p self
      => #<Class:Quote>
        p Module.nesting
      => [#<Class:Quote>, Quote]
      end
    end

我们看到 Ruby 再次改变了self的值。语法#<Class:Quote>表示self被设置为Quote的元类。在 我们看到 Ruby 还为词法作用域栈添加了另一个级别。图 9-13 展示了栈中的下一个级别。

为元类创建了一个新的词法作用域。

图 9-13. 为元类创建了一个新的词法作用域。

在左侧,我们可以看到 Ruby 在执行class << self时创建了一个新的作用域。图的右侧显示了新作用域中self的值,即Quote的元类。

类方法中的 self

现在再进行一次测试。假设我们向Quote类添加一个类方法,然后按示例 9-11 所示调用它。(输出位于底部,因为p语句直到我们调用class_method时才会执行。)

示例 9-11. 声明和调用类方法

    p self
    p Module.nesting

    class Quote
      p self
      p Module.nesting

      class << self
        p self
        p Module.nesting

        def class_method
          p self
          p Module.nesting
        end
      end
    end

    Quote.class_method
  => Quote
  => [#<Class:Quote>, Quote]

我们看到当我们调用class_method时,Ruby 将self重新设置为Quote类。这是有道理的:当我们在接收者上调用方法时,Ruby 总是将self设置为接收者。因为在这个例子中我们调用了一个类方法,所以 Ruby 将接收者设置为该类。

我们看到 Ruby 没有改变词法作用域栈。它仍然设置为[#<Class:Quote>, Quote],正如图 9-14 中所示。

当你调用方法时,Ruby 会改变 self,但不会创建新的作用域。

图 9-14. 当你调用方法时,Ruby 会改变self,但不会创建新的作用域。

请注意,词法作用域没有改变,但self已经变成了Quote,即方法调用的接收者。

你可以使用这些通用规则来跟踪self和词法作用域:

  • 在类或模块作用域内部,self始终会被设置为该类或模块。Ruby 在你使用classmodule关键字时会创建一个新的词法作用域,并将该作用域的类设置为新的类或模块。

  • 在一个方法内部(包括类方法),Ruby 会将self设置为该方法调用的接收者。

元编程和闭包:eval,instance_eval 和 binding

在第八章中,我们学到了块是 Ruby 实现闭包的方式,并且我们看到了块如何将函数与函数引用所在的环境结合起来。在 Ruby 中,元编程和闭包是紧密相关的。许多 Ruby 的元编程构造也充当闭包,使其内部的代码可以访问引用的环境。我们将学习三个重要的元编程特性,以及每个特性如何通过充当闭包来像块一样访问引用的环境。

写代码的代码

在 Ruby 中,eval方法是元编程的最纯粹形式:你将一个字符串传递给eval,Ruby 会立即解析、编译并执行这段代码,正如示例 9-12 所示。

示例 9-12. 使用eval解析和编译代码

str = "puts"
str += " 2"
str += " +"
str += " 2"
eval(str)

我们动态构建字符串puts 2+2并将其传递给eval。然后 Ruby 会评估这个字符串。也就是说,它会使用与首次处理 Ruby 主脚本时相同的 Bison 语法规则和解析引擎来对其进行标记、解析和编译。完成这个过程后,Ruby 会生成一组新的 YARV 字节码指令,并执行新的代码。

但关于eval有一个非常重要的细节,在示例 9-12 中并不明显。具体来说,Ruby 会在你调用eval的相同上下文中评估新的代码字符串。要理解我的意思,请看看示例 9-13。

示例 9-13. 这里并不明显,但eval也通过闭包访问周围的作用域。

    a = 2
    b = 3
    str = "puts"
    str += " a"
    str += " +"
    str += " b"
 eval(str)

你可能会期望运行此代码的结果是 5,但请注意 示例 9-12 和 示例 9-13 之间的区别。示例 9-13 涉及周围作用域中的局部变量 ab,Ruby 可以访问它们的值。图 9-15 展示了 YARV 内部栈在调用 eval 之前的样子,如 所示。

Ruby 像往常一样将局部变量 a、b 和 str 保存在 YARV 的内部栈中。

图 9-15. Ruby 像往常一样将局部变量 abstr 保存在 YARV 的内部栈中。

正如预期的那样,我们看到 Ruby 将 abstr 的值保存在左侧的栈中。在右侧,我们有 rb_control_frame_t 结构体,它代表了该脚本的外部(或主)作用域。

图 9-16 展示了当我们调用 eval 方法时发生的情况。

调用 eval 并访问父作用域的值

图 9-16. 调用 eval 并访问父作用域的值

调用 eval 会对我们传递的文本执行解析和编译。当编译器完成后,Ruby 会为执行新编译的代码创建一个新的栈帧(rb_control_frame_t)(如上所示)。然而请注意,Ruby 将这个新栈帧中的 EP 设置为指向包含 ab 变量的较低栈帧。这个指针允许传递给 eval 的代码访问这些值。

Ruby 在这里使用的 EP 应该看起来很熟悉。除了动态解析和编译代码,eval 的工作方式与我们将一个块传递给某个函数时相同,就像在 示例 9-14 中那样。

示例 9-14. 块内部的代码可以访问周围作用域的变量。

a = 2
b = 3
10.times do
  puts a+b
end

换句话说,eval 方法创建了一个闭包:它是函数与该函数所在环境的结合体。在这种情况下,函数是新编译的代码,而环境是我们调用 eval 的地方。

使用 binding 调用 eval

eval 方法可以接受第二个参数:一个 绑定。绑定是没有函数的闭包——也就是说,它只是一个引用环境。可以将绑定看作是指向 YARV 栈帧的指针。将绑定值传递给 Ruby 表示你不希望使用当前上下文作为闭包的环境,而是希望使用其他引用环境。示例 9-15 展示了一个例子。

示例 9-15. 使用 binding 访问来自其他环境的变量

    def get_binding
      a = 2
      b = 3
   binding
    end
 eval("puts a+b", get_binding)

get_binding 函数包含了局部变量 ab,但它也在 返回一个绑定。在代码的底部,我们希望 Ruby 动态编译并执行代码字符串,并打印出结果。通过将 get_binding 返回的绑定传递给 eval,我们告诉 Ruby 在 get_binding 函数的上下文中评估 puts a+b。如果我们在没有绑定的情况下调用 eval,它将创建新的空的局部变量 ab

Ruby 会在堆中创建当前环境的持久副本,因为你可能会在当前栈帧已经被弹出之后很久才调用 eval。即使在这个例子中 get_binding 已经返回,Ruby 仍然可以在执行由 eval 解析和编译的代码时访问 ab 的值,正如在 中所示。

图 9-17 展示了调用 binding 时发生的内部操作。

调用 binding 会将当前栈帧的副本保存到堆中。

图 9-17. 调用 binding 会将当前栈帧的副本保存到堆中。

这个图形类似于当你调用 lambda 时 Ruby 的操作(参见 图 8-18),不同之处在于 Ruby 创建的是一个 rb_binding_t C 结构,而不是 rb_proc_t 结构。这个绑定结构仅仅是对内部环境结构的一个包装——栈帧在堆中的副本。绑定结构还包含了你调用 binding 的文件名和行号。

与 proc 对象一样,Ruby 使用 RTypedData 结构将 Ruby 对象包装在 rb_binding_t C 结构中(参见 图 9-18)。

绑定对象允许你创建一个闭包,然后将其环境作为数据值获取并处理。然而,由绑定创建的闭包不包含任何代码;它没有功能。你可以将绑定对象视为一种间接方式,用于访问、保存和传递 Ruby 内部的 rb_env_t 结构。

Ruby 使用 RTypedData 将 Ruby 对象包装在 rb_binding_t 结构中。

图 9-18。Ruby 使用 RTypedData 将 Ruby 对象包装在 rb_binding_t 结构中。

instance_eval 示例

现在来看一个 eval 方法的变体:instance_eval 在 示例 9-16 中展示了它的实际应用。

示例 9-16。instance_eval 内部的代码可以访问 obj 的实例变量。

 class Quote
      def initialize
     @str = "The quick brown fox"
      end
    end
    str2 = "jumps over the lazy dog."
 obj = Quote.new
 obj.instance_eval do
   puts "#{@str} #{str2}"
    end

下面是发生的事情:

  • 我们创建了一个 Ruby 类 Quote,它在 initialize 中将字符串的前半部分保存在实例变量中,见

  • 我们创建了一个 Quote 类的实例,然后在 调用 instance_eval,并传递一个块。instance_eval 方法与 eval 类似,不同之处在于它在接收者的上下文中评估给定的字符串,或者说我们调用它的对象。如这里所示,如果不想动态解析和编译代码,我们可以将一个块传递给 instance_eval,而不是传递一个字符串。

  • 我们传递给 instance_eval 的块打印出 中的字符串,访问了 obj 的实例变量中的字符串前半部分,以及从周围的作用域或环境中获取的后半部分。

这怎么可能起作用呢?似乎传递给 instance_eval 的块有两个环境:quote 实例和周围的代码环境。换句话说,@str 变量来自一个地方,而 str2 变量来自另一个地方。

Ruby 闭包的另一个重要部分

这个例子突出了 Ruby 中闭包环境的另一个重要部分:当前的 self 值。回想一下,Ruby 调用栈中每个栈帧或级别的 rb_control_frame_t 结构包含一个 self 指针,以及 PCSPEP 指针及其他值(见 图 9-19)。

 结构

图 9-19。rb_control_frame_t 结构

self指针记录了你在 Ruby 项目中self的当前值;它指示哪个对象是当前执行方法的拥有者。你 Ruby 调用栈中的每一层都可能包含不同的self值。

回想一下,每当你创建一个闭包时,Ruby 会在rb_block_t结构中设置EP(环境指针),指向引用环境,使得块内的代码可以访问外部变量。实际上,Ruby 还会将self的值复制到rb_block_t中。这意味着当前对象也是 Ruby 闭包的一部分。图 9-20 展示了在 Ruby 中闭包包含的内容。

在 Ruby 中,闭包环境包含了栈帧和来自引用代码的当前对象。

图 9-20. 在 Ruby 中,闭包环境包含了栈帧和来自引用代码的当前对象。

因为rb_block_t结构包含了来自引用环境的self值,所以块中的代码可以访问在创建或引用闭包时处于活动状态的对象的值和方法。这个功能对于块来说可能看起来是显而易见的:在你调用一个块之前和之后,当前对象并没有改变。然而,如果你使用 lambda、proc 或 binding,Ruby 会记住你创建它时的当前对象是什么。而正如我们稍后会看到的,使用instance_eval时,Ruby 有时会在你创建闭包时改变self,使你的代码可以访问不同对象的值和方法。

instance_eval 将 self 改变为接收者

当你在示例 9-16 中的调用instance_eval时,Ruby 同时创建了一个闭包和一个新的词法作用域。例如,正如你在图 9-21 中看到的,instance_eval中的代码所创建的新栈帧为EPself都使用了新的值。

在图的左侧,我们看到执行instance_eval时创建了一个闭包。这个结果应该不令人惊讶。将一个块传递给instance_eval时,在示例 9-16 中的会在栈上创建一个新的层次,并将EP设置为引用环境,从而使块内的代码可以访问变量str2obj

执行  时创建的栈帧具有新的  值。

图 9-21. 执行 instance_eval 时创建的栈帧具有新的 self 值。

然而,如你在图示右侧所见,instance_eval 还会改变新闭包中 self 的值。当 instance_eval 块内的代码运行时,self 指向 instance_eval 的接收者,或者称为 obj,如示例 9-16 所示。这使得 instance_eval 内的代码能够访问接收者内部的值。在示例 9-16 中,位于 的代码可以访问 obj 内的 @str 和外部代码中的 str2

instance_eval 为新的词法作用域创建单例类

instance_eval 方法还会创建一个新的单例类,并将其作为新词法作用域的类,如图 9-22 所示。

instance_eval 为新的单例类创建词法作用域。

图 9-22. instance_eval 为新的单例类创建词法作用域。

在执行 instance_eval 时,Ruby 创建了一个新的词法作用域,如 instance_eval 块内阴影矩形所示。如果我们将一个字符串传递给 instance_eval,Ruby 将解析并编译这个字符串,然后以相同的方式创建新的词法作用域。

随着新的词法作用域的创建,Ruby 为接收者 obj 创建了一个单例类。这个单例类允许你为接收对象定义新方法(参见图 9-22):instance_eval 块中的 def new_method 调用将 new_method 添加到 obj 的单例类中。作为一个单例类,obj 将拥有这个新方法,但程序中的其他对象或类将无法访问它。(元编程方法 class_evalmodule_eval 也以类似的方式工作,同样创建新的词法作用域;然而,它们只是使用目标类或模块作为新的作用域,并不会创建元类或单例类。)

Ruby 如何追踪块的词法作用域

让我们更深入地看看 Ruby 如何在内部表示词法作用域。图 9-23 展示了 Ruby 为 Quote 类创建的词法作用域。

Ruby 的 C 源代码在内部使用一个独立的结构体来跟踪词法作用域。

图 9-23. Ruby 的 C 源代码在内部使用一个独立的结构体cref来跟踪词法作用域。

你可以看到display方法的代码片段被表示为左侧矩形框中的内容,位于class Quote声明内。在矩形框的右侧,你可以看到一个指向标记为cref的结构的小箭头,它表示实际的词法作用域。这个结构包含指向Quote类(nd_clss)和父词法作用域(nd_next)的指针。

如图所示,Ruby 的 C 源代码在内部通过这些cref结构来表示词法作用域。左侧的小箭头显示,程序中的每段代码都通过指针引用一个cref结构,这个指针跟踪着该段代码属于哪个词法作用域。

注意关于图 9-23 的一个重要细节:class Quote声明中的代码片段和词法作用域都指向同一个RClass结构体。代码、词法作用域和类之间存在一一对应的关系。每次 Ruby 执行class Quote声明中的代码时,它使用的是Quote类的相同RClass结构体。这种行为看起来显而易见;类声明中的代码总是引用相同的类。

然而,对于代码块来说,事情就不那么简单了。通过使用诸如instance_eval之类的元编程方法,你可以为同一段代码(例如一个代码块)指定不同的词法作用域,每次执行时都可以使用不同的作用域。图 9-24 展示了这个问题。

代码块的代码无法引用单一的词法作用域,因为作用域的类依赖于的值。

图 9-24. 代码块的代码无法引用单一的词法作用域,因为作用域的类依赖于obj的值。

我们在上一节中了解到,Ruby 为通过instance_eval创建的词法作用域创建了一个单例类。然而,这段代码可能会为不同的obj值多次执行。实际上,你的程序可能会在不同的线程中同时执行这段代码。这就要求 Ruby 不能像处理类定义那样为代码块保持对单一cref结构的指针。这个代码块的作用域在不同时间会引用不同的类。

Ruby 通过将块使用的词法作用域的指针保存在不同的位置来解决这个问题:作为 YARV 内部堆栈上的一个条目(参见 图 9-25)。

Ruby 使用堆栈上的 svar/cref 条目来跟踪块的词法作用域,而不是使用块的代码片段。

图 9-25. Ruby 使用堆栈上的 svar/cref 条目来跟踪块的词法作用域,而不是使用块的代码片段。

在图的左侧,你可以看到调用 instance_eval 和块内的代码片段。在图的中央是表示词法作用域的 cref 结构。在图的右侧,你可以看到 YARV 在堆栈的第二个条目中保存了指向词法作用域的指针,标记为 svar/cref

从 第三章 回顾一下,YARV 的内部堆栈的第二个条目包含两种值之一:svarcref。正如我们在 实验 3-2:探索特殊变量 中看到的那样,svar 保存指向特殊变量表的指针,例如执行方法时上一个正则表达式匹配的结果。而在执行块时,YARV 则在这里保存 cref 值。通常,这个值并不重要,因为块通常使用周围代码的词法作用域。但是,在执行 instance_eval 和一些其他元编程特性时,比如 module_evalinstance_exec,Ruby 会将 cref 设置为当前的词法作用域。

实验 9-2:使用闭包定义方法

Ruby 中另一个常见的元编程模式是使用 define_method 动态地在类中定义方法。例如,示例 9-17 展示了一个简单的 Ruby 类,当你调用 display 时,它会打印出一个字符串。

示例 9-17. 一个从实例变量显示字符串的 Ruby 类

class Quote
  def initialize
    @str = "The quick brown fox jumps over the lazy dog"
  end
  def display
    puts @str
  end
end
Quote.new.display
 => The quick brown fox jumps over the lazy dog

这段代码类似于 示例 9-1 中的代码,只不过我们使用实例变量 @str 来保存字符串值。

使用 define_method

我们本可以使用元编程以更冗长但动态的方式定义 display,就像在 示例 9-18 中所示。

示例 9-18. 使用 define_method 创建一个方法

    class Quote
      def initialize
        @str = "The quick brown fox jumps over the lazy dog"
      end
   define_method :display do
        puts @str
      end
    end

我们在 处调用define_method,而不是正常的def关键字。由于新方法的名称作为参数:display传递,我们可以从一些数据值动态构建方法名称,或者遍历一个方法名称数组,为每个名称调用define_method

defdefine_method之间有另一个微妙的区别。对于define_method,我们通过块提供方法体;也就是说,我们在 处使用do关键字。这个语法差异看似微不足道,但请记住,块实际上是闭包。添加do关键字引入了一个闭包,这意味着新方法内部的代码可以访问外部的环境。而def关键字则无法做到这一点。

在调用define_method时,示例 9-18 中没有本地变量,但假设我们在应用程序的其他地方确实有一些值,并希望在新方法中使用这些值。通过使用闭包,Ruby 会在堆上创建周围环境的内部副本,新方法可以访问这个副本。

方法作为闭包

现在进行另一个测试。示例 9-19 将字符串的前一半存储在实例变量中。稍后,我们将为Quote类编写一个新方法来访问这个变量。

示例 9-19. 现在 @str 只有字符串的前一半。

class Quote
  def initialize
    @str = "The quick brown fox"
  end
end

示例 9-20 展示了我们如何使用闭包来访问实例变量和周围的环境。

示例 9-20. 使用闭包与define_method

    def create_method_using_a_closure
      str2 = "jumps over the lazy dog."
   Quote.send(:define_method, :display) do
        puts "#{@str} #{str2}"
      end
    end

由于define_methodModule类的私有方法,我们需要使用令人困惑的send语法在 处。早些时候,在 处,在示例 9-18 中,我们能够直接调用define_method,因为我们在类的作用域内使用了它。而在应用程序的其他地方,我们不能直接这么做。通过使用sendcreate_method_using_a_closure方法可以调用一个它通常无法访问的私有方法。

更重要的是,请注意即使create_method_using_a_closure返回后,str2变量仍然保留在堆中供新方法使用:

create_method_using_a_closure
Quote.new.display
 => The quick brown fox jumps over the lazy dog.

在内部,Ruby 将其视为对lambda的调用。也就是说,这段代码的功能就像我在示例 9-21 中编写的代码一样。

示例 9-21. 将一个 proc 传递给 define_method

    class Quote
      def initialize
        @str = "The quick brown fox"
      end
    end
    def create_method_using_a_closure
      str2 = "jumps over the lazy dog."
      lambda do
        puts "#{@str} #{str2}"
      end
    end
 Quote.send(:define_method, :display, create_method_using_a_closure)
 Quote.new.display

示例 9-21 将创建闭包和定义方法的代码分开。因为在 我们向 define_method 传递了三个参数,Ruby 期望第三个参数是一个 proc 对象。虽然这种写法更加冗长,但由于调用 lambda 明确表示 Ruby 会创建一个闭包,因此它显得稍微不那么令人困惑。

最后,当我们在 调用 new 方法时,Ruby 会将 self 指针从闭包重置为接收者对象,类似于 instance_eval 的工作方式。这使得 new 方法能够像预期的那样访问 @str

总结

在本章中,我们看到闭包的概念——这一概念在 Ruby 中块、lambda 和 proc 的工作原理中至关重要——同样适用于 evalinstance_evaldefine_method 等方法。相同的底层概念解释了这些不同的 Ruby 方法是如何工作的。同样,词法作用域的概念是 Ruby 中所有创建方法并将其赋值给类的方式的基础。理解词法作用域的概念应该使你更容易理解 Ruby 中 def 关键字和 class << 语法的不同用法。

虽然元编程刚开始看起来可能很复杂,但了解 Ruby 的内部工作原理可以帮助我们理解 Ruby 的元编程特性到底做了什么。最初看似一组庞大且不相关的方法,在一个混乱的 API 中,最终会发现它们是由一些重要的思想联系起来的。研究 Ruby 的内部机制使我们能够看到这些概念,并理解它们的含义。

第十章. JRuby:JVM 上的 Ruby

无标题图片

JRuby 是在 Java 平台上实现的 Ruby。

在 第一章 到 第九章 中,我们学习了 Ruby 的标准版本是如何在内部工作的。由于 Ruby 是用 C 语言编写的,它的标准实现通常被称为 CRuby。它也常被称为 Matz 的 Ruby 解释器(MRI),这是因为 Yukihiro Matsumoto 在 1990 年代初期创建了这个语言。

在本章中,我们将看到一种名为 JRuby 的 Ruby 替代实现。JRuby 是用 Java 实现的 Ruby,而不是用 C 语言。使用 Java 使得 Ruby 应用程序能够像任何其他 Java 程序一样运行,利用 Java 虚拟机(JVM)。它还使得你的 Ruby 代码能够与成千上万的用 Java 和其他在 JVM 上运行的语言编写的库进行互操作。得益于 JVM 的先进垃圾回收(GC)算法、即时编译器(JIT)和许多其他技术创新,使用 JVM 意味着你的 Ruby 代码通常运行得更快,更可靠。

在本章的前半部分,我们将对比标准 Ruby——即 MRI——与 JRuby。你将了解当使用 JRuby 运行 Ruby 程序时会发生什么,以及 JRuby 是如何解析和编译你的 Ruby 代码的。在后半部分,我们将看到 JRuby 和 MRI 是如何使用 String 类保存你的字符串数据的。

路线图

  • 使用 MRI 和 JRuby 运行程序

    • 如何 JRuby 解析和编译你的代码

    • 如何 JRuby 执行你的代码

    • 使用 Java 类实现 Ruby 类

  • 实验 10-1:监控 JRuby 的即时编译器

    • 实验代码

    • 使用 -J-XX:+PrintCompilation 选项

    • JIT 是否加速你的 JRuby 程序?

  • JRuby 和 MRI 中的字符串

    • JRuby 和 MRI 如何保存字符串数据

    • 写时复制

  • 实验 10-2:测量写时复制性能

    • 创建唯一的、非共享的字符串

    • 实验代码

    • 可视化写时复制

    • 修改共享字符串较慢

  • 总结

使用 MRI 和 JRuby 运行程序

使用标准 Ruby 运行 Ruby 程序的常见方法是输入ruby,然后跟上 Ruby 脚本的名称,如图 10-1 所示。

使用标准 Ruby 在命令行运行脚本

图 10-1. 使用标准 Ruby 在命令行运行脚本

如左侧矩形框所示,在终端提示符下输入ruby会启动一个二进制可执行文件,这是 Ruby 构建过程中将 Ruby 的 C 源代码编译后的产物。右侧则显示了ruby命令的命令行参数,即包含 Ruby 代码的文本文件。

要使用 JRuby 运行 Ruby 脚本,通常在终端提示符下输入jruby。 (根据你安装 JRuby 的方式,标准的ruby命令可能已重新映射为启动 JRuby。)图 10-2 展示了这个命令的高层工作原理。

 命令实际上映射到一个 shell 脚本

图 10-2. jruby 命令实际上映射到一个 shell 脚本。

ruby命令不同,jruby命令并不映射到二进制可执行文件,而是指向一个执行java命令的 shell 脚本。图 10-3 展示了 JRuby 启动 Java 的命令简化版。

JRuby 启动 JVM 的命令简化版

图 10-3. JRuby 启动 JVM 的命令简化版

在图 10-3 中注意到,JRuby 使用被称为Java 虚拟机 (JVM) 的二进制可执行文件来执行 Ruby 脚本。与标准 Ruby 可执行文件一样,JVM 是用 C 编写并编译成二进制可执行文件。JVM 运行 Java 应用程序,而 MRI 运行 Ruby 应用程序。

还要注意,在 Figure 10-3 的中间,java程序的一个参数,-Xbootclasspath,指定了一个额外的 Java 编译代码库或集合,用于新程序:jruby.jar。JRuby Java 应用程序包含在jruby.jar中。最后,在右侧,你再次看到包含你的 Ruby 代码的文本文件。

简而言之,当标准 Ruby 和 JRuby 启动你的 Ruby 程序时,会发生以下情况:

  • 当你使用 MRI 运行 Ruby 脚本时,你启动了一个二进制可执行文件,最初用 C 语言编写,直接编译和执行你的 Ruby 脚本。这是 Ruby 的标准版本。

  • 当你使用 JRuby 运行 Ruby 脚本时,你启动了一个二进制可执行文件,即 JVM,它执行 JRuby Java 应用程序。这个 Java 应用程序依次解析、编译和执行你的 Ruby 脚本,同时在 JVM 内部运行。

JRuby 如何解析和编译你的代码

一旦你启动了 JRuby,它需要解析和编译你的代码。为此,它使用一个解析器生成器,就像 MRI 一样。Figure 10-4 展示了 JRuby 解析和编译过程的高级概述。

JRuby 使用名为 Jay 的解析器生成器。

Figure 10-4. JRuby 使用名为 Jay 的解析器生成器。

正如 MRI 使用 Bison 一样,JRuby 在构建过程中使用名为Jay的解析器生成器来创建解析你的 Ruby 代码的代码。Jay 与 Bison 非常相似,只是它是用 Java 而不是 C 编写的。在运行时,JRuby 使用生成的解析器对你的 Ruby 代码进行标记化和解析。与 MRI 一样,这个过程产生一个抽象语法树(AST)。

一旦 JRuby 解析了你的代码并生成了 AST,它会对你的代码进行编译。但是,与 MRI 生成 YARV 指令不同,JRuby 生成一系列指令,即Java 字节码指令,供 JVM 执行。Figure 10-5 and JRuby (right)")展示了 MRI 和 JRuby 处理 Ruby 代码的高级比较。

图的左侧显示了使用 MRI 执行时你的 Ruby 代码的变化。MRI 将你的代码转换为标记,然后转换为 AST 节点,最后转换为 YARV 指令。Interpret箭头表示 MRI 可执行文件读取 YARV 指令并解释或执行它们。(你不需要编写 C 或机器语言代码;这项工作已经为你完成。)

MRI(左)和 JRuby(右)中你的 Ruby 代码的不同形式

Figure 10-5. MRI(左)和 JRuby(右)中你的 Ruby 代码的不同形式

图右侧的高层次概述展示了 JRuby 如何在内部处理您的 Ruby 代码。大矩形中的各个框显示了 JRuby 执行代码时,您的代码所经历的不同形式。您可以看到,像 MRI 一样,JRuby 首先将您的代码转换为词法单元,随后转换为 AST 节点。但随后 MRI 和 JRuby 分道扬镳:JRuby 将 AST 节点编译为 Java 字节码指令,JVM 可以执行这些指令。此外,JVM 还可以使用 JIT 编译器将 Java 字节码转换为机器语言,这样可以进一步加速程序,因为执行机器语言比执行 Java 字节码要快。(我们将在实验 10-1:监控 JRuby 的即时编译器中更详细地了解 JIT 编译器。)

JRuby 如何执行您的代码

我们已经看到 JRuby 以几乎与 MRI 相同的方式对代码进行词法分析和解析。就像 MRI Ruby 1.9 和 2.0 将您的代码编译为 YARV 指令一样,JRuby 将其编译为 Java 字节码指令。

但相似性到此为止:MRI 和 JRuby 使用两种完全不同的虚拟机来执行您的代码。标准 Ruby 使用 YARV,而 JRuby 使用 JVM 来执行您的程序。

使用 Java 构建 Ruby 解释器的核心目的,就是能够通过 JVM 执行 Ruby 程序。使用 JVM 的能力非常重要,原因有二:

  • 环境性。JVM 使您能够在服务器、应用程序以及之前无法运行 Ruby 的 IT 组织中使用 Ruby。

  • 技术性。JVM 是经过近 20 年的强烈研究和开发的产物,包含了许多难题的复杂解决方案,比如垃圾回收和多线程。Ruby 在 JVM 上通常能更快、更可靠地运行。

为了更好地理解这一过程,让我们看看 JRuby 如何执行简单的 Ruby 脚本 simple.rb,如示例 10-1")所示。

示例 10-1。 一行 Ruby 程序 (simple.rb)

puts 2+2

首先,JRuby 对这段 Ruby 代码进行词法分析并解析为 AST 节点结构。接下来,它遍历这些 AST 节点并将 Ruby 转换为 Java 字节码。如示例 10-2 所示,使用 --bytecode 选项可以查看这些字节码。

示例 10-2。 JRuby 的 --bytecode 选项显示您的 Ruby 代码被编译成的 Java 字节码。

$ **jruby --bytecode simple.rb**

由于该命令的输出较为复杂,我在这里不做深入探讨,但图 10-6 总结了 JRuby 如何编译并执行该脚本。

在此图的左侧,你可以看到代码 puts 2+2。指向下方的大箭头表示 JRuby 将这段代码转换为一系列 Java 字节码指令,这些指令实现了一个名为 simple 的 Java 类(与脚本的文件名相同)。class simple extends AbstractScript 的表示法是 Java 代码;它在这里声明了一个新的 Java 类 simple,该类以 AbstractScript 作为父类。

simple 类是我们 Ruby 代码的 Java 版本,它执行 2 + 2 并打印总和。simple Java 类用 Java 完成了相同的操作。在 simple 内部,JRuby 创建了一个名为 __file__ 的 Java 方法,执行 2+2 的代码,正如图中底部的内部 __file__ 矩形所示。方法矩形 <init>simple 类的构造函数。

JRuby 将你的 Ruby 代码转换为 Java 类。

图 10-6. JRuby 将你的 Ruby 代码转换为 Java 类。

在图 10-6 的右侧,你可以看到 JRuby 的 Ruby 类库的一个小部分。这些是 Ruby 的内建类,比如 FixnumStringArray。MRI 使用 C 实现这些类。当你的代码调用这些类中的某个方法时,方法调度过程会使用 CFUNC 方法类型。然而,JRuby 使用 Java 代码实现所有内建的 Ruby 类。在图 10-6 的右侧,你可以看到我们的代码调用的两个内建 Ruby 方法。

  • 首先,你的代码执行 2 + 2,使用 Ruby Fixnum 类的 + 方法。JRuby 使用名为 RubyFixnum 的 Java 类实现 Ruby Fixnum 类。在这个例子中,你的代码调用了 RubyFixnum 类中的 op_plus Java 方法。

  • 为了打印总和,代码调用了内建 Ruby IO 类的puts方法(实际上是通过Kernel模块)。JRuby 以类似的方式实现这一点,使用一个名为 RubyIO 的 Java 类。

使用 Java 类实现 Ruby 类

正如你所知道的,标准 Ruby 是使用 C 内部实现的,C 不支持面向对象编程的概念。C 代码无法像 Ruby 代码那样使用对象、类、方法或继承。

然而,JRuby 是用 Java 实现的,Java 是一种面向对象的编程语言。虽然 Java 不如 Ruby 本身那样灵活和强大,但它确实支持编写类、创建类的实例对象,并通过继承将一个类与另一个类关联起来,这意味着 JRuby 对 Ruby 的实现也是面向对象的。

JRuby 使用 Java 对象实现 Ruby 对象。为了更好地理解这意味着什么,请参见图 10-7,它将 Ruby 代码与 MRI C 结构进行了比较。

MRI 使用 C 结构实现对象和类。

图 10-7。MRI 使用 C 结构实现对象和类。

Ruby 在内部为每个类创建一个RClass C 结构,为每个对象创建一个RObject结构。Ruby 通过RObject结构中的klass指针追踪每个对象的类。图 10-7 展示了一个RClass表示Mathematician类,一个RObject表示pythagoras,即Mathematician的一个实例。

图 10-8 显示 JRuby 中的情况非常相似,至少在第一眼看上去是这样。

在内部,JRuby 使用 RubyObject Java 类表示对象,使用 RubyClass Java 类表示类。

图 10-8。 在内部,JRuby 使用RubyObject Java 类表示对象,使用RubyClass Java 类表示类。

在图的左侧,我们看到相同的 Ruby 代码。右侧是两个 Java 对象,一个是RubyObject Java 类的实例,另一个是RubyClass Java 类的实例。JRuby 对 Ruby 对象和类的实现与 MRI 非常相似,但 JRuby 使用 Java 对象而不是 C 结构。JRuby 使用RubyObjectRubyClass这两个名称,因为这些 Java 对象代表了你的 Ruby 对象和类。

但是,当我们更仔细地观察时,事情并不像看起来那么简单。由于RubyObject是一个 Java 类,JRuby 可以利用继承来简化其内部实现。事实上,RubyObject的父类是RubyBasicObject。这反映了 Ruby 类之间的关系,正如我们通过调用ancestors方法查看Object时所看到的那样。

p Object.ancestors
 => [Object, Kernel, BasicObject]

调用ancestors返回一个包含接收者所有父类链中类和模块的数组。这里,我们可以看到Object的父类是Kernel模块,而它的父类是BasicObject。JRuby 对其内部 Java 类层次结构使用相同的模式,如图 10-9 所示。

RubyBasicObject 是 RubyObject Java 类的父类。

图 10-9。RubyBasicObjectRubyObject Java 类的父类。

除了 Kernel 模块,我们可以看到 JRuby 的内部 Java 类层次结构反映了它实现的 Ruby 类层次结构。这种相似性得益于 Java 的面向对象设计。

现在来看第二个示例。让我们再次使用 ancestors 来展示 Class Ruby 类的超类。

p Class.ancestors
 => [Class, Module, Object, Kernel, BasicObject]

在这里,我们看到 Class 的超类是 Module,它的超类是 Object,依此类推。正如我们所预期的那样,JRuby 的 Java 代码在内部使用了相同的设计(参见 图 10-10)。

JRuby 的 RubyClass 内部 Java 类层次结构

图 10-10. JRuby 的 RubyClass 内部 Java 类层次结构

实验 10-1:监控 JRuby 的即时编译器

我之前提到过,JRuby 可以通过使用 JIT 编译器加速你的 Ruby 代码。JRuby 总是将你的 Ruby 程序转换为 Java 字节码指令,JVM 可以将其编译为机器语言,直接由计算机的微处理器执行。在这个实验中,我们将看到何时发生这种情况,并测量它加速了多少代码。

实验代码

示例 10-3") 展示了一个 Ruby 程序,它打印出 1 到 100 之间的 10 个随机数。

示例 10-3. 测试 JRuby JIT 行为的示例程序 (jit.rb)

 array = (1..100).to_a
 10.times do
   sample = array.sample
      puts sample
    end

我们创建了一个包含 100 个元素的数组:从 1 到 100。然后,在 我们对以下代码块迭代 10 次。在这个块内部,我们在 使用 sample 方法随机选择数组中的一个值并打印。当我们运行这段代码时,得到的输出如 示例 10-4 所示。

示例 10-4. 来自 示例 10-3") 的输出

$ **jruby jit.rb**
87
88
69
5
38
--*snip*--

现在,让我们移除 puts 语句并增加迭代次数。(移除输出将使实验更易管理。)示例 10-5 展示了更新后的程序。

示例 10-5. 我们移除了 puts 并将迭代次数增加到 1,000。

    array = (1..100).to_a
    1000.times do
   sample = array.sample
    end

使用 -J-XX:+PrintCompilation 选项

当然,如果我们现在运行程序,我们不会看到任何输出,因为我们已经去除了puts。让我们再次运行程序——这次使用一个调试标志(如示例 10-6 所示)来显示 JVM 的 JIT 编译器正在做什么。

示例 10-6. -J-XX:+PrintCompilation选项生成的输出

$ **jruby -J-XX:+PrintCompilation jit.rb**
    101   1       java.lang.String::hashCode (64 bytes)
    144   2       java.util.Properties$LineReader::readLine (452 bytes)
    173   3       sun.nio.cs.UTF_8$Decoder::decodeArrayLoop (553 bytes)
    200   4       java.lang.String::charAt (33 bytes)
--*snip*--

在这里,我们为 JRuby 使用了-J选项,并将XX:+PrintCompilation选项传递给底层 JVM 应用程序。PrintCompilation使 JVM 显示你在示例 10-6 中看到的信息。java.lang.String::hashCode这一行意味着 JVM 将String Java 类的hashCode方法编译成了机器语言。其他值显示了关于 JIT 过程的技术信息(101是时间戳,1是编译 ID,64 bytes是已编译字节码片段的大小)。

这个实验的目标是验证假设,即示例 10-5 应该会在 JVM 的 JIT 编译器将其转换为机器语言后运行得更快。注意,示例 10-5 在循环中只有一行 Ruby 代码,在处调用了array.sample。因此,一旦 JIT 将 JRuby 实现的Array#sample编译成机器语言,我们应该会看到我们的 Ruby 程序明显更快地完成,因为Array#sample被调用了很多次。

由于示例 10-6 中的输出非常长且复杂,我们将使用grep来搜索输出中org.jruby.RubyArray的出现情况。

$ **jruby -J-XX:+PrintCompilation jit.rb | grep org.jruby.RubyArray**

结果没有输出。在PrintCompilation输出中没有任何一行与名称org.jruby.RubyArray匹配,这意味着 JIT 编译器没有将Array#sample方法转换为机器语言。它之所以没有进行转换,是因为 JVM 仅在程序执行多次的 Java 字节码指令上运行 JIT 编译器——这些字节码指令被称为热点。JVM 会花更多时间编译热点,因为它们被调用的次数非常多。为了验证这一点,我们可以将迭代次数增加到 100,000 并重复测试,正如在示例 10-7 中所示。

示例 10-7. 增加迭代次数应该触发 JIT 编译器将Array#sample转换为机器语言。

array = (1..100).to_a
100000.times do
  sample = array.sample
end

当我们再次用grep重复相同的jruby命令时,看到的输出与示例 10-8 相同。

示例 10-8. 运行示例 10-7 并将-J-XX:+PrintCompilation通过grep输出后的结果

 $ **jruby -J-XX:+PrintCompilation jit.rb | grep org.jruby.RubyArray**
       1809 165       org.jruby.RubyArray::safeArrayRef (11 bytes)
       1810 166  !    org.jruby.RubyArray::safeArrayRef (12 bytes)
       1811 167       org.jruby.RubyArray::eltOk (16 bytes)
       1927 203       org.jruby.RubyArray$INVOKER$i$0$2$sample::call (36 bytes)
    1928 204  !    org.jruby.RubyArray::sample (834 bytes)
       1930 205       org.jruby.RubyArray::randomReal (10 bytes)

由于我们在!使用了grep org.jruby.RubyArray,所以我们只看到与文本org.jruby.RubyArray匹配的 Java 类名。在!我们可以看到 JIT 编译器编译了Array#sample方法,因为我们看到了文本org.jruby.RubyArray::sample

JIT 是否加速了你的 JRuby 程序?

现在来看看 JIT 是否加速了。根据命令行参数ARGV[0],我将其保存在iterations中,见!,示例 10-9 测量调用Array#sample特定次数所需的时间。

示例 10-9. 用于基准测试 JIT 性能的示例代码

    require 'benchmark'

 iterations = ARGV[0].to_i

    Benchmark.bm do |bench|
      array = (1..100).to_a
      bench.report("#{iterations} iterations") do
        iterations.times do
          sample = array.sample
        end
      end
    end

通过运行如下的列表,我们可以测量执行循环 100 次所需的时间,例如。

$ **jruby jit.rb 100**

图 10-11 显示了使用 JRuby 和 MRI 在 100 到 1 亿次迭代中的结果。

JRuby 与 MRI 性能对比图。时间以秒为单位,横轴为迭代次数(使用 JRuby 1.7.5 和 Java 1.6;MRI Ruby 2.0)。

图 10-11. JRuby 与 MRI 性能对比图。时间以秒为单位,横轴为迭代次数(使用 JRuby 1.7.5 和 Java 1.6;MRI Ruby 2.0)。

MRI 的图表大致是一条向右上方延伸的直线。这意味着 Ruby 2.0 执行Array#sample方法的时间几乎是固定的。另一方面,JRuby 的结果则更为复杂。你可以看到,在不到 100,000 次迭代时,JRuby 执行示例 10-9 所需的时间较长。(该图表使用对数刻度,因此左侧的绝对时间差异较小。)然而,一旦迭代次数达到约 100 万,JRuby 的速度显著加快,开始用更少的时间执行Array#sample

最终,经过许多迭代,JRuby 的速度快于 MRI。但这里重要的不是 JRuby 可能更快,而是它的性能特性会有所变化。你的代码运行得越久,JVM 优化它的时间就越长,速度也会越快。

JRuby 和 MRI 中的字符串

我们已经学习了 JRuby 如何执行字节码指令,在你的代码与通过 Java 实现的 Ruby 对象库之间传递控制。现在我们将更深入地了解这个库,特别是 JRuby 如何实现 String 类。JRuby 和 MRI 是如何实现字符串的?它们将你在 Ruby 代码中使用的字符串数据保存在何处,它们的实现方式有何异同?我们通过查看 MRI 如何实现字符串来开始解答这些问题。

JRuby 和 MRI 如何保存字符串数据

这段代码将毕达哥拉斯的名言保存到一个局部变量中。那么,这个字符串去了哪里?

str = "Geometry is knowledge of the eternally existent."

回顾第五章,MRI 使用不同的 C 结构来实现内建类,如 RRegExpRArrayRHash,以及用于保存字符串的 RString。图 10-12 展示了 MRI 如何在内部表示 Geometry... 字符串。

RString C 结构的一部分

图 10-12。RString C 结构的一部分

请注意,MRI 将实际的字符串数据保存在一个单独的缓冲区或内存区域中,如右侧所示。RString 结构本身包含指向该缓冲区的指针 ptr。还要注意,RString 包含另外两个整数值:len,即字符串的长度(此示例为 48),和 capa,即数据缓冲区的容量(同样为 48)。数据缓冲区的大小可以大于字符串的长度,在这种情况下,capa 会大于 len。(如果你执行了减少字符串长度的代码,则会出现这种情况。)

现在让我们考虑 JRuby。图 10-13 展示了 JRuby 如何在内部表示这个字符串。JRuby 使用 Java 类 RubyString 来表示 Ruby 代码中的字符串,这与我们之前看到的 RubyObjectRubyClass 命名模式一致。RubyString 使用另一个类来跟踪实际的字符串数据:ByteList。这个低级别的代码追踪一个独立的数据缓冲区(称为 bytes),类似于 MRI 中 RString 结构的实现方式。ByteList 还在 realSize 实例变量中存储字符串的长度。

JRuby 为每个字符串使用两个 Java 对象和一个数据缓冲区。

图 10-13。JRuby 为每个字符串使用两个 Java 对象和一个数据缓冲区。

写时复制

在内部,JRuby 和 MRI 都使用一种名为写时复制(copy-on-write)的优化技术来处理字符串和其他数据。这个技巧允许两个相同的字符串值共享同一个数据缓冲区,从而节省内存和时间,因为 Ruby 避免了不必要地对相同的字符串数据进行单独复制。

例如,假设我们使用dup方法复制一个字符串。

str = "Geometry is knowledge of the eternally existent."
str2 = str.dup

JRuby 是否必须将Geometry is...文本从一个字符串对象复制到另一个字符串对象?不需要。图 10-14 展示了 JRuby 如何在两个不同的字符串对象之间共享字符串数据。

两个 JRuby 字符串对象可以共享相同的数据缓冲区。

图 10-14. 两个 JRuby 字符串对象可以共享相同的数据缓冲区。

当我们调用dup时,JRuby 会创建新的RubyStringByteList Java 对象,但它不会复制实际的字符串数据。相反,它会让第二个ByteList对象指向与原始字符串相同的数据缓冲区。现在我们有两组 Java 对象,但只有一个底层的字符串值,如图右侧所示。由于字符串可以包含成千上万的字节,这种优化通常能节省大量内存。

MRI 使用相同的技巧,尽管方式稍微复杂一些。图 10-15 展示了标准 Ruby 是如何共享字符串的。

MRI 通过创建第三个 RString 结构来共享字符串。

图 10-15. MRI 通过创建第三个RString结构来共享字符串。

像 JRuby 一样,MRI 共享底层的字符串数据。然而,当你在标准的 MRI Ruby 中复制一个字符串时,它会创建一个第三个RString结构,然后将原始的RString和新的RString都设置为通过shared指针指向它。

无论哪种情况,我们都会遇到一个问题。如果我们更改其中一个字符串变量会怎样?例如,假设我们将其中一个字符串转换为大写,如下所示:

    str = "Geometry is knowledge of the eternally existent."
 str2 = str.dup
 str2.upcase!

图 10-14中,无论是在 JRuby 还是 MRI 中,我们都有两个共享的字符串,但是在图 10-15中,我使用upcase!方法更改了第二个字符串。现在这两个字符串不同了,这意味着 Ruby 显然不能继续共享底层的字符串缓冲区,否则upcase!方法会更改两个字符串。我们可以通过显示字符串的值来看到这两个字符串现在已经不同。

p str
 => "Geometry is knowledge of the eternally existent."
p str2
 => "GEOMETRY IS KNOWLEDGE OF THE ETERNALLY EXISTENT."

到某个时刻,Ruby 必须将这两个字符串分开,创建一个新的数据缓冲区。这就是写时复制(copy-on-write)这个短语的含义:无论是在 MRI 还是 JRuby 中,当你对其中一个字符串进行写操作时,它们会创建一个新的字符串数据缓冲区副本。

实验 10-2:测量写时复制性能

在这个实验中,我们将收集证据,证明当我们向共享字符串写入时,确实会发生额外的复制操作。首先,我们将创建一个简单的非共享字符串并写入它。然后,我们将创建两个共享字符串,并向其中一个写入。如果确实发生写时复制,那么向共享字符串写入应该会稍微长一点,因为 Ruby 必须在写入之前创建字符串的新副本。

创建一个唯一的、非共享的字符串

让我们重新创建我们的示例字符串str。最初,Ruby 不可能与其他任何内容共享 str,因为只有一个字符串。我们将使用 str 作为基准性能测量。

str = "Geometry is knowledge of the eternally existent."

但事实证明,Ruby 会立即共享 str!为了理解为什么,我们将检查 MRI 用于执行此代码的 YARV 指令,如 示例 10-10 所示。

示例 10-10. 当你使用字面量字符串常量时,MRI Ruby 在内部使用 dup YARV 指令。

    code = <<END
    str = "Geometry is knowledge of the eternally existent."
    END

    puts RubyVM::InstructionSequence.compile(code).disasm
    == disasm: <RubyVM::InstructionSequence:<compiled>@<compiled>>==========
    local table (size: 2, argc: 0 [opts: 0, rest: -1, post: 0, block: -1] s1)
 [ 2] str
    0000 trace            1                                               (   1)
 0002 putstring        "Geometry is knowledge of the eternally existent."
 0004 dup
 0005 setlocal_OP__WC__0 2
    0007 leave

仔细阅读上面的 YARV 指令,我们可以看到在 Ruby 使用 putstring 将字符串放到栈上。这个 YARV 指令内部将字符串参数复制到栈中,已经创建了一个共享副本。在 Ruby 使用 dup 创建了另一个共享副本的字符串,用作 setlocal 的参数。最后,在 setlocal_OP__WC__0 2 将这个字符串保存到 str 变量中,在本地表中显示为 [2]

图 10-16 总结了这个过程。

执行 putstring 和 dup 创建共享字符串

图 10-16. 执行 putstringdup 创建共享字符串。

左侧是 YARV 指令 putstringdupsetlocal。右侧是这些指令创建的 RString 结构,以及底层共享的字符串数据。正如我刚才提到的,putstring 实际上会将字符串常量从图表中遗漏的第三个 RString 中复制,这意味着字符串实际上被共享了第三次。

因为 Ruby 最初会共享从常量值创建的字符串,所以我们需要通过以下方式将两个字符串连接在一起来创建字符串:

str = "This string is not shared" + " and so can be modified faster."

这个拼接的结果将是一个新的、唯一的字符串。Ruby 不会将它的字符串数据与任何其他字符串对象共享。

实验代码

让我们做一些测量。示例 10-11 展示了这个实验的代码。

示例 10-11. 测量写时复制的延迟

    require 'benchmark'

    ITERATIONS = 1000000

    Benchmark.bm do |bench|
      bench.report("test") do
        ITERATIONS.times do
       str = "This string is not shared" + " and so can be modified faster."
       str2 = "But this string is shared" + " so Ruby will need to copy it
                  before writing to it."
       str3 = str2.dup
       str3[3] = 'x'
        end
      end
    end

在运行此测试之前,让我们先看一下这段代码。在 处,我们通过连接两个字符串创建一个独立的、未共享的字符串,这就是str。接下来,在 处,我们创建第二个独立字符串 str2。但在 处,我们使用dup来创建这个字符串的副本str3,现在 str2str3 共享相同的值。

可视化写时复制

的示例 10-11 中,我们使用代码 str3[3] = 'x' 修改 str3 的第四个字符。但在这里,Ruby 无法仅修改 str3 中的字符而不同时修改 str2,如图 10-17 所示。

Ruby 无法修改 str3 而不修改 str2。

图 10-17. Ruby 无法修改 str3 而不修改 str2

Ruby 必须首先为 str3 创建一个单独的副本,如图 10-18 所示。

Ruby 在写入之前,将字符串复制到 str3 的新缓冲区。

图 10-18. Ruby 在写入之前,将字符串复制到 str3 的新缓冲区。

现在,Ruby 可以在不影响 str2 的情况下将数据写入 str3 的新缓冲区。

修改共享字符串更慢

当我们执行示例 10-11 时,benchmark库会测量运行内部代码块一百万次所需的时间。这个代码块创建了strstr2str3,然后修改了str3。在我的笔记本电脑上,benchmark测得的时间大约是 1.87 秒。

接下来,让我们将str3[3] = 'x' 处改为修改str

#str3[3] = 'x'str[3] = 'x'

现在我们正在修改未共享的独立字符串,而不是共享字符串。再次运行测试,得到的结果大约是 1.69 秒,约比benchmark报告的共享字符串时间少了 9.5%。如预期,修改独立字符串所需的时间略少于修改共享字符串的时间。

图表图 10-19 展示了我对 MRI 和 JRuby 的 10 次不同观察结果的累积平均值。在图表的左侧是 MRI 的平均测量值。最左边的条形图表示修改共享字符串str3所需的时间,右侧的 MRI 条形图显示了修改唯一字符串str所花费的时间。右侧的两根条形图展示了 JRuby 的相同模式,但条形图的高度差异要小得多。显然,JVM 可以比 MRI 更快地创建字符串的新副本。

但还有更多:注意到总体上 JRuby 运行实验代码的时间减少了 60%。也就是说,它比 MRI 快了 2.5 倍!正如在实验 10-1:监控 JRuby 的即时编译器中所看到的,我们一定是在看到 JVM 的优化,如 JIT 编译,使得 JRuby 相比 MRI 更快。

MRI 和 JRuby 在写时复制(秒)上显示了延迟。

图 10-19. MRI 和 JRuby 在写时复制(秒)上显示了延迟。

总结

在本章中,我们了解了 JRuby,它是用 Java 编写的 Ruby 版本。我们看到了jruby命令如何启动 JVM,并将jruby.jar作为参数传递。我们探讨了 JRuby 如何解析和编译我们的代码,并在实验 10-1:监控 JRuby 的即时编译器中学到,JVM 如何编译热点,或是频繁执行的 Java 字节码片段,转化为机器语言。来自实验 10-1:监控 JRuby 的即时编译器的结果显示,编译热点显著提升了性能,使得 JRuby 在某些情况下比 MRI 运行得更快。

本章的后半部分,我们学习了 MRI 和 JRuby 如何在内部表示我们的字符串数据。我们发现,两个版本的 Ruby 都使用写时复制优化,当可能时,多个字符串对象之间共享字符串数据。最后,在实验 10-2:测量写时复制性能中,我们证明了写时复制在 JRuby 和 MRI 中都确实发生了。

JRuby 是 Ruby 的一个非常强大且巧妙的实现:通过在 Java 平台上运行 Ruby 代码,你可以从在 JVM 上投入的多年研究、开发、调优和测试中受益。JVM 是当今最流行、最成熟且最强大的软件平台之一。它不仅被 Java 和 JRuby 使用,还被许多其他编程语言使用,例如 Clojure、Scala 和 Jython,等等。通过使用这个共享平台,JRuby 可以利用 Java 平台的速度、稳定性和多样性——而且这一切是免费的!

JRuby 是一项开创性的技术,每个 Ruby 开发者都应该熟悉它。

第十一章。Rubinius:用 Ruby 实现的 Ruby

无标题图像

Rubinius 使用 Ruby 实现 Ruby。

像 JRuby 一样,Rubinius 是 Ruby 的一个替代实现。Rubinius 的许多内部源代码是用 Ruby 本身编写的,而不是仅使用 C 或 Java 编写。Rubinius 实现了内置类,如ArrayStringInteger,正如你使用 Ruby 代码实现它们一样!

这一设计为你提供了一个独特的机会,了解 Ruby 的内部工作原理。如果你不确定某个特定的 Ruby 特性或方法是如何工作的,你可以阅读 Rubinius 中的 Ruby 代码来了解,而无需特别掌握 C 或 Java 编程知识。

Rubinius 还包括一个用 C++编写的复杂虚拟机。这个虚拟机会执行你的 Ruby 程序,像 JRuby 一样,支持 JIT 和真正的并发,并使用复杂的垃圾回收算法。

本章开始时提供了 Rubinius 的高级概述,并举例说明如何使用回溯输出深入探讨 Rubinius 的源代码。接下来,我们将了解 Rubinius 和 MRI 如何实现Array类,包括 Ruby 如何将数据保存到数组中,以及从数组中删除元素时发生的事情。

路线图

  • Rubinius 内核和虚拟机

    • 词法分析与解析

    • 使用 Ruby 编译 Ruby

    • Rubinius 字节码指令

    • Ruby 与 C++的协作

    • 使用 C++对象实现 Ruby 对象

  • 实验 11-1:比较 MRI 和 Rubinius 中的回溯

    • Rubinius 中的回溯
  • Rubinius 和 MRI 中的数组

    • MRI 中的数组

    • RArray C 结构定义

    • Rubinius 中的数组

  • 实验 11-2:探索 Rubinius 中 Array#shift 的实现

    • 阅读 Array#shift

    • 修改 Array#shift

  • 总结

Rubinius 内核和虚拟机

要使用 Rubinius 运行 Ruby 程序(参见图 11-1),通常使用ruby命令(与 MRI 相同)或rbx,因为ruby命令实际上是 Rubinius 中可执行文件rbx的符号链接。

Rubinius 由 C++虚拟机和 Ruby 内核组成。

图 11-1. Rubinius 由 C++虚拟机和 Ruby 内核组成。

与 MRI 一样,你通过一个可执行文件来启动 Rubinius,该文件读取并执行命令行中指定的 Ruby 程序。但 Rubinius 的可执行文件与标准的 Ruby 可执行文件完全不同。正如前面的图所示,Rubinius 由两个主要部分组成:

  • Rubinius 内核。这是 Rubinius 中用 Ruby 编写的部分。它实现了大部分语言特性,包括许多内建核心类的定义,如StringArray。Rubinius 内核被编译成字节码指令并安装到你的计算机上。

  • Rubinius 虚拟机。Rubinius 虚拟机是用 C++编写的。它执行来自 Rubinius 内核的字节码指令,并执行一系列其他低级任务,如垃圾回收。Rubinius 可执行文件包含了此虚拟机的已编译机器语言版本。

图 11-2 更详细地展示了 Rubinius 的虚拟机和内核。Rubinius 内核包含一组 Ruby 类,如StringArrayObject,以及执行各种任务的其他 Ruby 类,如编译或加载代码。图中左侧的 Rubinius 虚拟机是你从命令行启动的rbx可执行文件。C++虚拟机包含执行垃圾回收、即时编译(以及许多其他任务)的代码,以及用于内建类(如StringArray)的附加代码。事实上,正如箭头所示,每个内建于 Rubinius 中的 Ruby 类都由 C++和 Ruby 代码共同工作。Rubinius 使用 Ruby 定义某些方法,使用 C++定义其他方法。

Rubinius 内部的更近视图

图 11-2. Rubinius 内部的更近视图

为什么使用两种语言实现 Ruby?因为 C++加速了 Rubinius 程序,并允许它们以低级别直接与操作系统进行交互。使用 C++而不是 C 还使得 Rubinius 能够在内部使用优雅的面向对象设计。而使用 Ruby 来实现内建类和其他功能使得 Ruby 开发者能够轻松阅读和理解 Rubinius 源代码的大部分内容。

分词和解析

Rubinius 处理你的 Ruby 程序的方式与 MRI 非常相似,如图 11-3 所示。

Rubinius 如何处理你的代码

图 11-3. Rubinius 如何处理你的代码

Rubinius 在构建过程中使用 Bison 生成一个 LALR 解析器,和 MRI 一样。当你运行程序时,解析器将代码转换为一个标记流、一个抽象语法树(AST)结构,然后是一个高层次虚拟机指令序列,称为 Rubinius 指令。图 11-4 比较了代码在 MRI 和 Rubinius 内部的形式。

最初,Rubinius 和 MRI 的工作方式相似,但与 MRI 直接解释代码不同,Rubinius 使用名为低级虚拟机(LLVM)的编译器框架,将代码再次编译成更低级的指令。LLVM 反过来可能使用 JIT 编译器将这些指令编译成机器语言。

MRI 和 Rubinius 如何在内部转换你的代码

图 11-4. MRI 和 Rubinius 如何在内部转换你的代码

使用 Ruby 编译 Ruby

Rubinius 最吸引人的特点之一是它如何通过 Ruby 和 C++ 的结合实现 Ruby 编译器。当你使用 Rubinius 运行程序时,你的代码将同时由 C++ 和 Ruby 代码处理,如图 11-5 所示。

Rubinius 如何编译你的代码的高级概述

图 11-5. Rubinius 如何编译你的代码的高级概述

在图的左上角,Rubinius 和 MRI 一样,使用 C 代码通过一系列语法规则解析 Ruby 代码。在右侧,Rubinius 开始使用 Ruby 代码处理你的 Ruby 程序,将 AST 中的每个节点表示为 Ruby 类的实例。在编译过程中,每个 Ruby AST 节点都知道如何为程序的相应部分生成 Rubinius 指令。最后,在左下角,LLVM 框架进一步将 Rubinius 指令编译成 LLVM 指令,并最终编译成机器语言。

Rubinius 字节码指令

为了理解 Rubinius 指令,让我们使用 Rubinius 运行一个简短的程序(参见示例 11-1"))。

示例 11-1. 使用 Rubinius 计算 2 + 2 = 4 (simple.rb)

$ **cat simple.rb**
puts 2+2
$ **rbx simple.rb**
4

当我们使用 rbx compile 命令和 -B 选项重新运行 simple.rb 时,Rubinius 显示了它的编译器生成的字节码指令,如示例 11-2 所示。

示例 11-2. 使用 rbx compile 命令和 -B 选项显示 Rubinius 字节码指令

    $ **rbx compile simple.rb -B**
    ============= :__script__ ==============
    Arguments:   0 required, 0 post, 0 total
    Arity:       0
    Locals:      0
    Stack size:  3
    Literals:    2: :+, :puts
    Lines to IP: 1: 0..12

    0000:  push_self
    0001:  meta_push_2
    0002:  meta_push_2
 0003:  send_stack                 :+, 1
    0006:  allow_private
 0007:  send_stack                 :puts, 1
    0010:  pop
    0011:  push_true
    0012:  ret
    ----------------------------------------

这些指令与 MRI 的 YARV 指令有些相似。每条指令通常会将一个值推送到内部栈上,对栈上的值进行操作,或者执行一个方法,比如 +puts

图 11-6 显示了 Ruby 代码和对应的 Rubinius 指令,分别用于 simple.rbKernel 模块的一部分。

Rubinius 中的 puts 方法是通过 Ruby 代码实现的。

图 11-6. Rubinius 中的 puts 方法是通过 Ruby 代码实现的。

你可以在图中的顶部看到 Ruby 代码:左侧是 puts 2+2 代码,右侧是 Rubinius 对 puts 方法的定义。Rubinius 在 Ruby 中实现了内置 Ruby 类,比如 Kernel 模块;因此,当我们调用 puts 方法时,Rubinius 会将控制权传递给 Rubinius 内核中包含的 Ruby 代码中的 Kernel#puts 方法。

图的下半部分显示了 Ruby 代码编译后的 Rubinius 指令。左侧是 puts 2+2 的指令,右侧是 Kernel#puts 方法的编译版本。Rubinius 以相同的方式编译其内置 Ruby 代码和你的 Ruby 代码(只是 Rubinius 在构建过程中编译内置 Ruby 代码)。

Ruby 和 C++ 协同工作

为了处理某些底层技术细节并提高速度,Rubinius 在其虚拟机中使用 C++ 代码来帮助实现内置的类和模块。也就是说,它同时使用 Ruby 和 C++ 来实现语言的核心类。

为了理解这个过程,让我们在 Rubinius 中执行这个简短的 Ruby 脚本(见示例 11-3)。

示例 11-3. 调用 String#[] 方法

str = "The quick brown fox..."
puts str[4]
 => q

这个简单的程序打印了样本字符串中的第五个字符(索引为 4 的字母 q)。由于 String#[] 方法是内置 Ruby 类的一部分,Rubinius 使用 Ruby 代码来实现它,如图 11-7 所示。

Rubinius 通过结合 Ruby 和 C++ 代码实现内置类。

图 11-7. Rubinius 使用 Ruby 和 C++ 代码的组合来实现内置类。

图左侧是打印字母 q 的 Ruby 脚本。右侧是 Rubinius 用于实现 String#[] 方法的 Ruby 代码,取自名为 string.rb 的 Rubinius 源代码文件(该文件以 String 类命名)。(我们将在实验 11-1:比较 MRI 和 Rubinius 的回溯中学习如何找到 Rubinius 源代码文件。)

注意,String#[] 的开始部分是方法调用 Rubinius.primitive。这表明 Rubinius 实际上使用 C++ 代码来实现这个方法;Rubinius.primitive 是一个指令,告诉 Rubinius 编译器生成对相应 C++ 代码的调用。实际实现 String#[] 的代码是一个名为 String::aref 的 C++ 方法,位于 图 11-7 的右下方。

使用 C++ 对象实现 Ruby 对象

Ruby 使用面向对象的 C++ 使其虚拟机能够通过相应的 C++ 对象在内部表示每个 Ruby 对象(见 图 11-8)。

Rubinius 使用 C++ 对象,就像 MRI 使用 RClassRObject C 结构体一样。当你定义一个类时,Rubinius 会创建一个 Class C++ 类的实例。当你创建一个 Ruby 对象时,Rubinius 会创建一个 Object C++ 类的实例。pythagoras 对象中的 klass_ 指针表示它是 Mathematician 类的实例,就像 MRI 中 RObject C 结构体中的 klass 指针一样。

Rubinius 使用 C++ 对象表示类和对象。

图 11-8. Rubinius 使用 C++ 对象表示类和对象。

实验 11-1:比较 MRI 和 Rubinius 的回溯

回想一下,当异常发生时,Ruby 会显示回溯以帮助你找到问题。示例 11-4 展示了一个简单的例子。

示例 11-4. 一个抛出异常的 Ruby 脚本

10.times do |n|
  puts n
  raise "Stop Here"
end

我们调用 raise 来告诉 Ruby 在显示参数 n 的值后第一次执行块时停止。示例 11-5 展示了运行 示例 11-4 时的输出,使用的是 MRI。

示例 11-5. MRI 如何显示异常的回溯

$ **ruby iterate.rb**
0
iterate.rb:3:in 'block in <main>': Stop Here (RuntimeError)
    from iterate.rb:1:in 'times'
    from iterate.rb:1:in '<main>'

在开发 Ruby 程序时,你可能多次看到类似的输出。然而,有一个微妙的细节值得仔细观察。 图 11-9 展示了 MRI 回溯输出的示意图。

MRI 显示内置 CFUNC 方法的调用位置,而非定义位置。

图 11-9. MRI 显示内置 CFUNC 方法的调用位置,而非定义位置。

注意,包含 raise 调用的 iterate.rb 第 3 行位于调用栈的顶部。在调用栈底部,MRI 显示了 iterate.rb:1,即脚本的起始位置。

还要注意,MRI 的回溯包含了一个断开的链接:iterate.rb 中没有定义 times 方法。相反,MRI 引用了 调用 times 方法的那行代码:10.times do。实际的 times 方法是用 C 代码在 MRI 内部实现的——一个 CFUNC 方法。MRI 在回溯中显示对 CFUNC 方法的调用位置,而不是这些方法的实际 C 实现位置。

Rubinius 中的回溯

与 MRI 不同,Rubinius 使用 Ruby 而非 C 实现内置方法。该实现使得 Rubinius 在回溯中包含了内置方法的准确源文件和行号信息。为了演示这一点,我们再次使用 Rubinius 运行 示例 11-4。 示例 11-6 显示了结果。

示例 11-6. Rubinius 如何显示异常的回溯

$ **rbx iterate.rb**
0
An exception occurred running iterate.rb
    Stop Here (RuntimeError)

Backtrace:
          { } in Object#__script__ at iterate.rb:3
             Integer(Fixnum)#times at kernel/common/integer.rb:83
                 Object#__script__ at iterate.rb:1
  Rubinius::CodeLoader#load_script at kernel/delta/codeloader.rb:68
  Rubinius::CodeLoader.load_script at kernel/delta/codeloader.rb:119
           Rubinius::Loader#script at kernel/loader.rb:645
             Rubinius::Loader#main at kernel/loader.rb:844

Rubinius 显示了更多信息!为了更好地理解这个输出,参见 图 11-10 和 图 11-11。

在 图 11-10 的左侧是 Rubinius 在运行 iterate.rb 时显示的回溯信息的简化版。Rubinius 显示了与 iterate.rb 对应的两行回溯信息,正如 MRI 所做的那样。但 Rubinius 还在 Ruby 调用栈中包含了新的条目,这些条目对应于 Rubinius 内核中的 Ruby 源代码文件。我们可以猜测,loader.rbcodeloader.rb 文件包含了加载和执行我们脚本的代码。

像 MRI 一样,Rubinius 在回溯中包含程序信息。

图 11-10. 与 MRI 相似,Rubinius 在回溯中包含了你的程序信息。

但是调用栈中最有趣的条目是 kernel/common/integer.rb:83。这个条目告诉我们 Integer#times 方法在 Rubinius 内核中的实现位置,如 图 11-11 所示。

Rubinius 在回溯中包含其内核信息。

图 11-11. Rubinius 在回溯中包含其内核信息。

图中左侧的回溯信息与 图 11-10 中的信息相同。箭头指向 Ruby 调用栈的第二级,指向调用 puts n 块的代码——Integer#times 方法中的 yield 指令。

使用 Rubinius 时,iterate.rb 成为更大 Ruby 程序的一部分:Rubinius 内核。当我们调用 10.times 时,Rubinius 调用右侧显示的 Ruby 代码,然后使用第 83 行的 yield 关键字执行我们的块。

注意

路径 kernel/common/integer.rb 指的是 Rubinius 源代码树中的一个位置。如果你是通过二进制安装程序安装 Rubinius,你需要从 rubini.us/ 或 GitHub 下载源代码,以便查看。

Rubinius 通过从 0 开始计数到指定的整数(减去 1),在每次循环中调用块来实现 Integer#times。让我们仔细看看 Integer#times,如 示例 11-7 所示。

示例 11-7. 来自 kernel/common/integer.rb 的 Integer#times 的 Rubinius 实现

 def times
   return to_enum(:times) unless block_given?

   i = 0
   while i < self
     yield i
        i += 1
      end
   self
    end

times 方法的定义从 开始。在 处,如果没有提供块,Rubinius 会返回 to_enum 的结果,如下所示。(to_enum 方法返回一个新的枚举器对象,允许你在以后需要时执行枚举。)

p 10.times
 => #<Enumerable::Enumerator:0x120 @generator=nil @args=[] @lookahead=[]     @object=10 @iter=:times>

如果你提供一个块,Rubinius 会继续执行方法的其余部分。 在时,Rubinius 创建了一个计数器i并将其初始化为 0。接下来,它在使用一个 while 循环来执行迭代。请注意,while 循环条件i < self引用了self的值。在Integer#times内部,self设置为当前的整数对象,在我们的脚本中为 10。 在时,Rubinius 将控制权传递给(调用)给定的块,传递当前的i值。这将调用我们的puts n块。最后,在时,Rubinius 返回self,这意味着10.times的返回值将是 10。

Rubinius 和 MRI 中的数组

数组在 Ruby 中如此普遍,以至于我们容易理所当然地认为它们是理所当然的。但它们在 Ruby 内部是如何工作的呢?Ruby 是如何保存你放入数组中的对象的,内部又是如何表示数组对象的呢?在接下来的章节中,我们将探讨 Rubinius 和 MRI 用于存储数组中值的内部数据结构。

MRI 中的数组

假设你将斐波那契数列中的前六个数字放入一个数组中。

fibonacci_sequence = [1, 1, 2, 3, 5, 8]

如图 11-12 所示,MRI 为数组创建了一个 C 结构,但将其元素保存在其他地方。

MRI 使用 RArray C 结构来表示数组。

图 11-12。MRI 使用RArray C 结构来表示数组。

MRI 使用一个RArray结构来表示你创建的每个数组。像RStringRObject和其他 C 结构一样,RArray使用内部的RBasic结构来保存klass指针和其他技术信息。(在这个例子中,klass指针指向Array类的RClass结构。)

RBasic下方是一些特定于数组的附加值——ptrlencapa

  • ptr是指向 Ruby 单独分配的内存段的指针,用于存储数组元素。斐波那契数列出现在此内存段中,如图 11-12 右侧所示。

  • len是数组的长度——即保存在独立内存段中的值的数量。

  • capa跟踪内存段的容量。这个数字通常比len大。MRI 避免在每次改变数组大小时不断调整内存段的大小;相反,当你添加数组元素时,它偶尔会增加独立内存段的大小,每次分配比新元素所需的更多内存。

单独内存段中的每个值实际上是一个指向 Ruby 对象的 VALUE 指针。在这种情况下,斐波那契数列将直接保存在 VALUE 指针中,因为它们是简单的整数。

RArray C 结构体定义

示例 11-8 展示了来自 MRI C 源代码的 RArray 定义。

示例 11-8. RArray 的定义(来自 include/ruby/ruby.h)

    #define RARRAY_EMBED_LEN_MAX 3struct RArray {
      struct RBasic basic;
   union {
        struct {
       long len;
          union {
         long capa;
         VALUE shared;
          } aux;
       VALUE *ptr;
        } heap;
     VALUE ary[RARRAY_EMBED_LEN_MAX];
      } as;
    };

这个定义展示了 图 11-12 中缺失的一些值。首先,在 ,请注意,MRI 使用 C union 关键字声明 RArray 的两个备选定义。第一个,是一个内部的 struct,在 定义了 len,在 定义了 capa,在 定义了 shared,在 定义了 ptr。与字符串类似,MRI 使用写时复制优化来处理数组,使得两个或更多数组可以共享相同的底层数据。对于共享数据的数组,位于 shared 值指向另一个包含共享数据的 RArray

联合体的后半部分在 定义了 ary,这是 RArray 中一个 VALUE 指针的 C 数组。这是一个优化,允许 MRI 将包含三项或更少元素的数组数据直接保存在 RArray 结构中,从而避免了为其分配单独的内存段。MRI 以类似的方式优化了其他四个 C 结构体:RStringRObjectRStruct(由 Struct 类使用)和 RBignum(由 Bignum 类使用)。

Rubinius 内部的数组

现在让我们看看 Rubinius 如何在内部保存相同的斐波那契数组。我们之前学到,Rubinius 使用相应的 C++ 对象来表示每个 Ruby 对象。这种表示方式同样适用于数组。例如,图 11-13 展示了 Rubinius 用于表示 fibonacci_sequence 的 C++ 对象。

Rubinius 使用 C++ 对象来表示数组。

图 11-13. Rubinius 使用 C++ 对象来表示数组。

四个组合块代表了 Array C++ 类的一个实例。每当你创建一个数组时,Rubinius 会创建一个 C++ 数组对象。从左到右,字段如下:

  • ObjectHeader 包含 Rubinius 在每个对象内部跟踪的技术信息,包括类指针和实例变量数组。ObjectHeader 对应于 MRI 中的 RBasic C 结构,并且是 Rubinius 虚拟机内 Array C++ 类的 C++ 超类之一。

  • total_ 是数组的长度,对于 fibonacci_sequence 来说,长度为 6。

  • tuple_ 是指向另一个 C++ 类实例的指针,该类名为 Tuple,其中包含数组数据。

  • start_ 表示元组对象内部数组数据的起始位置。(元组可能包含比数组所需更多的数据。)最初,Rubinius 将其设置为 0。

Rubinius 并不将数组数据保存在 C++ 数组对象中。它将数据保存在元组对象中,如 图 11-14 所示。

Rubinius 将数组数据保存在元组对象中。

图 11-14. Rubinius 将数组数据保存在元组对象中。

每个元组都包含与数组相同的对象头信息。Rubinius 会在每个 C++ 对象中保存此头信息。在对象头之后,元组对象包含一个名为 full_size_ 的值,用于跟踪该元组对象的字节大小。紧接着这个值,Rubinius 将实际的数据值保存在一个名为 fields 的 C++ 数组中。这些数据值就是我们的六个斐波那契数,如 图 11-14 右侧所示。

注意

数组数据值保存在元组 C++ 对象中。如果我们创建了一个更大的数组,Rubinius 将使用更大的元组对象。如果我们更改数组的大小,Rubinius 会分配一个适当大小的新元组,或者正如我们将在 实验 11-2:探索 Rubinius 实现的 Array#shift 中看到的那样,它可以优化某些数组方法,以避免分配新的对象并加速程序。

实验 11-2:探索 Rubinius 实现的 Array#shift

我们已经看到 Rubinius 使用 C++ 对象表示数组,但请记住,Rubinius 结合了 Ruby 和 C++ 代码来实现 Array 类中的方法。在这个实验中,我们将通过观察 Rubinius 如何实现 Array#shift 方法来更深入地了解数组的工作原理。

但首先,快速回顾一下 Array#shift 的作用。正如你所知,调用 shift 会从数组的开头移除一个元素,并且将剩余的元素向左移动,如 示例 11-9 所示。

示例 11-9. Array#shift 从数组中移除第一个元素,并将剩余的元素向左移动。

    fibonacci_sequence = [1, 1, 2, 3, 5, 8]
    p fibonacci_sequence.shift
  => 1
    p fibonacci_sequence
  => [1, 2, 3, 5, 8]

Array#shift 返回 fibonacci_sequence 的第一个元素。从 的输出中可以看到,Array#shift 还会移除数组中的第一个元素,并将剩余的五个元素向左移动。但 Ruby 是如何在内部实现 Array#shift 的呢?它是否真的将剩余的数组元素向左复制,还是将它们复制到一个新数组中?

阅读 Array#shift

首先,让我们找出 Array#shift 方法在 Rubinius 中的位置。因为我们没有像实验 11-1:比较 MRI 和 Rubinius 中的回溯那样可以参考的回溯信息,我们可以使用 source_location 来询问 Rubinius 方法的位置。

p Array.instance_method(:shift).source_location
 => ["kernel/common/array.rb", 848]

这个输出告诉我们,Rubinius 在源代码树中的 *kernel/common/array.rb* 文件的第 848 行定义了 Array#shift 方法。示例 11-10 显示了 Array#shift 在 Rubinius 中的实现。

示例 11-10. Array#shift 在 Rubinius 内核中的实现

 def shift(n=undefined)
      Rubinius.check_frozen

   if undefined.equal?(n)
        return nil if @total == 0
     obj = @tuple.at @start
        @tuple.put @start, nil
        @start += 1
        @total -= 1

        obj
   else
        n = Rubinius::Type.coerce_to(n, Fixnum, :to_int)
        raise ArgumentError, "negative array size" if n < 0

        Array.new slice!(0, n)
      end
    end

shift 方法接受一个可选参数 n。如果 shift 方法在没有参数 n 的情况下被调用,如在示例 11-9 中所示,它将移除第一个元素并将剩余元素向左移动一个位置。如果你向 shift 提供参数 n,它将移除 n 个元素,并将剩余元素向左移动 n 个位置。在 中,Rubinius 会检查是否提供了参数 n。如果指定了 n,它将跳转到 并使用 Array#slice! 来移除前 n 个元素并返回它们。

修改 Array#shift

现在,让我们看看当你不提供任何参数调用 shift 时会发生什么。Rubinius 如何将数组向左移动一个元素呢?不幸的是,在 调用的 Tuple#at 方法是由 Rubinius 虚拟机中的 C++ 代码实现的。(你在 Ruby 的 *kernel/common/tuple.rb* 文件中找不到 at 的定义。)这意味着我们无法用 Ruby 阅读整个算法。

然而,我们可以向 Rubinius 中添加 Ruby 代码,当我们调用shift时显示有关数组数据的信息。由于 Rubinius 内核是用 Ruby 编写的,我们可以像修改任何其他 Ruby 程序一样修改它!首先,我们将向Array#shift添加几行代码,如示例 11-11 所示。

示例 11-11. 向 Rubinius 内核添加调试代码

    if undefined.equal?(n)
      return nil if @total == 0

   fibonacci_array = (self == [1, 1, 2, 3, 5, 8])
   puts "Start: #{@start} Total: #@total} Tuple: #{@tuple.inspect}" if  fibonacci_array

      obj = @tuple.at @start
      @tuple.put @start, nil
      @start += 1
      @total -= 1

   puts "Start: #{@start} Total: #{@total} Tuple: #{@tuple.inspect}" if  fibonacci_array

      obj
    end

,我们检查这个数组是否是我们的斐波那契数组。Rubinius 使用这种方法来处理系统中的每个数组,但我们只想显示有关我们数组的信息。然后,在,我们显示@start@total@tuple的值。底层,@tuple是一个 C++对象,但在 Rubinius 中它也充当 Ruby 对象,允许我们调用其inspect方法。在,我们显示了被Array#shift代码更改后的相同值。

现在,我们需要重新构建 Rubinius 以包括我们的代码更改。示例 11-12 展示了通过rake install命令生成的输出。(在 Rubinius 源代码树的根目录下运行此命令。)

示例 11-12. 重新构建 Rubinius

    $ **rake install**

    --*snip*--

    RBC kernel/common/hash.rb
    RBC kernel/common/hash19.rb
    RBC kernel/common/hash_hamt.rb
 RBC kernel/common/array.rb
    RBC kernel/common/array19.rb
    RBC kernel/common/kernel.rb

    --*snip*--

Rubinius 构建过程重新编译了array.rb源代码文件,位于,以及其他许多内核文件。(RBC指的是 Rubinius 编译器。)

注意

不要在生产环境中尝试使用这种代码更改。

现在,使用我们修改过的 Rubinius 版本重新运行示例 11-9。 示例 11-13 显示了与我们原始代码交替的输出。

示例 11-13. 使用我们修改过的Array#shift

    fibonacci_sequence = [1, 1, 2, 3, 5, 8]
    p fibonacci_sequence.shift
 Start: 0 Total: 6 Tuple: #<Rubinius::Tuple: 1, 1, 2, 3, 5, 8>
 Start: 1 Total: 5 Tuple: #<Rubinius::Tuple: nil, 1, 2, 3, 5, 8> => 1
    p fibonacci_sequence
     => [1, 2, 3, 5, 8]

,我们在Array#shift中的新 Ruby 代码展示了fibonacci_sequence的内部内容:@start@total@tuple实例变量。比较,我们可以看到Array#shift的内部工作原理。Rubinius 并没有分配一个新的数组对象,而是重用了底层的元组对象。Rubinius 做了以下操作:

  • @total从 6 改为 5,因为数组的长度减少了 1

  • @start从 0 改为 1,这使得它能够继续使用相同的@tuple值;现在数组内容从@tuple中的第二个值(索引 1)开始,而不是第一个值(索引 0)

  • @tuple 中的第一个值从 1 改为 nil,因为数组不再使用这个第一个值

创建新对象并分配新内存可能需要很长时间,因为 Rubinius 可能需要向操作系统请求内存。它对元组对象中底层数据的重用,而不是复制或为新数组分配内存,使得 Rubinius 的运行速度更快。

图 11-15 和 图 11-16 总结了 Array#shift 的工作原理。图 11-15 显示了调用 Array#shift 前的数组:@start 指向元组中的第一个值,@length 为 6。

调用 Array#shift 前保存斐波那契数的元组

图 11-15. 调用 Array#shift 前保存斐波那契数的元组

图 11-16 显示了调用 Array#shift 后的元组;Rubinius 仅仅更改了 @start@length 的值,并将元组中的第一个值设为 nil

调用 Array#shift 后相同的元组

图 11-16. 调用 Array#shift 后相同的元组

如你所猜测,MRI 通过跟踪数组数据在原始数组中的起始位置来对 Array#shift 进行类似的优化。然而,它使用的 C 代码更复杂且更难理解。Rubinius 内核为我们提供了更清晰的算法视图。

总结

我们在本章中了解到,Rubinius 使用一个用 C++ 实现的虚拟机来运行你的 Ruby 代码。像 YARV 一样,Rubinius 虚拟机是专门设计来运行 Ruby 程序的,它使用一个编译器将 Ruby 程序转换为字节码。我们看到,这些 Rubinius 指令类似于 YARV 指令;它们以类似的方式操作栈值。

但与其他 Ruby 实现不同,Rubinius 的独特之处在于其 Ruby 语言内核。Rubinius 内核使用 Ruby 代码实现了许多内置的 Ruby 类,例如 Array。这一创新设计提供了一个探索 Ruby 内部机制的窗口——你可以通过使用 Rubinius 学习 Ruby 如何在内部工作,而无需了解 C 或 Java。你可以通过阅读 Rubinius 内核中的 Ruby 源代码,了解 Ruby 如何实现字符串、数组或其他类。Rubinius 不仅仅是一个 Ruby 实现;它是 Ruby 社区一个宝贵的学习资源。

第十二章。MRI、JRuby 和 Rubinius 中的垃圾回收

没有标题的图片

垃圾回收器是 Ruby 对象的诞生地,也是它们的死亡地。

垃圾回收(GC)是 Ruby 等高级语言用来为你管理内存的过程。你的 Ruby 对象在使用时存活在哪里?Ruby 如何清理你程序不再使用的对象?Ruby 的 GC 系统解决了这些问题。

垃圾回收并不是 Ruby 所独有的。垃圾回收的第一个实现出现在 Lisp 编程语言中,由约翰·麦卡锡(John McCarthy)在大约 1960 年发明。与 Ruby 一样,Lisp 也通过垃圾回收自动为你管理内存。自其发明以来,垃圾回收一直是计算机科学研究的课题,且已成为许多计算机语言的重要特性,包括 Java、C#,当然还有 Ruby。

计算机科学家发明了许多不同的算法来执行垃圾回收。事实证明,MRI 使用的是约翰·麦卡锡(John McCarthy)50 多年前发明的相同垃圾回收算法:标记-扫描垃圾回收。而 JRuby 和 Rubinius 则使用另一种算法,它是在 1963 年发明的:复制垃圾回收。它们还采用了另一种创新方法,称为代际垃圾回收,并且可以在应用程序继续运行的同时,通过并发垃圾回收在单独的线程中执行 GC 任务。在本章中,我们将介绍这些复杂的垃圾回收算法背后的基本思想。MRI、JRuby 和 Rubinius 的垃圾回收器使用这些算法的更复杂版本,但相同的基本原则依然适用。

路线图

  • 垃圾回收器解决的三个问题

  • MRI 中的垃圾回收:标记与扫描

    • 空闲列表

    • MRI 使用多个空闲列表

    • 标记

    • MRI 如何标记活跃对象?

    • 扫描

    • 懒惰扫描

    • RVALUE 结构

    • 标记与扫描的缺点

  • 实验 12-1:观察 MRI 垃圾回收的实际应用

    • 查看 MRI 执行懒惰扫描

    • 看到 MRI 执行完整的回收过程

    • 解读 GC 配置文件报告

  • JRuby 和 Rubinius 中的垃圾回收

  • 复制垃圾回收

    • Bump 分配

    • 半空间算法

    • 伊甸堆

  • 代际垃圾回收

    • 弱代际假设

    • 为年轻对象使用半空间算法

    • 提升对象

    • 成熟对象的垃圾回收

    • 代际间的引用

  • 并发垃圾回收

    • 在对象图变化时标记

    • 三色标记

    • JVM 中的三个垃圾回收器

  • 实验 12-2:在 JRuby 中使用详细 GC 模式

    • 触发主要回收过程
  • 进一步阅读

  • 总结

垃圾回收器解决了三个问题

尽管名称为“垃圾回收”,但它不仅仅是清理垃圾对象的过程。垃圾回收器实际上解决了三个问题:

  • 它们分配内存供新对象使用。

  • 它们识别程序不再使用的对象。

  • 它们回收来自未使用对象的内存。

Ruby 的 GC 系统也不例外。当你创建一个新的 Ruby 对象时,垃圾回收器会为该对象分配内存。稍后,Ruby 的垃圾回收器会判断你的程序何时不再使用该对象,从而可以重用该内存来创建新的 Ruby 对象。分配内存和回收内存是同一枚硬币的两面;因此,Ruby 的垃圾回收器执行这两项任务是合乎逻辑的。

MRI 中的垃圾回收:标记和清扫

学习垃圾回收的一个好地方是 MRI 相对简单的 GC 算法,它类似于约翰·麦卡锡在 1960 年通过其开创性工作 Lisp 所使用的算法。一旦我们理解了该算法的工作原理,我们将进一步探讨 JRuby 和 Rubinius 中更复杂的垃圾回收机制,并研究 MRI 是如何采纳它们的一些技术的。

MRI 的标记-清除算法会为你的程序分配新的对象内存,直到可用内存(或)耗尽,此时 MRI 会暂停程序并标记你代码中仍然持有引用的对象作为活跃对象。然后 Ruby 会清除剩余的对象,这些对象称为垃圾对象,并允许其内存被重新利用。一旦这个过程完成,Ruby 允许你的程序继续执行。

空闲列表

标准 MRI Ruby 使用麦卡锡最初的内存分配解决方案,称为空闲列表。图 12-1 展示了空闲列表的概念图。

MRI 内部空闲列表的概念图

图 12-1。MRI 内部空闲列表的概念图

图中的每一个白色方块代表一个可用于创建新对象的小块内存。可以把这个图看作是一个未使用的 Ruby 对象链表。当你创建一个新的 Ruby 对象时,MRI 从列表的头部取出一个空闲内存块,并用它来创建一个新的 Ruby 对象,如图 12-2 所示。

Ruby 从空闲列表中取出了第一个内存块,并用它创建了一个新的 Ruby 对象。

图 12-2。Ruby 从空闲列表中取出了第一个内存块,并用它创建了一个新的 Ruby 对象。

该图中的灰色框表示一个已分配的、活跃的对象。其余的白色框仍然是可用的。在内部,所有 Ruby 对象都由一个 C 结构体RVALUE表示。MRI 在RVALUE内部使用一个 C 联合体来包含我们目前在 MRI 中看到的所有 C 结构体,例如RArrayRStringRRegexp等。换句话说,每个方块都可以是任何类型的 Ruby 对象,或是一个自定义 Ruby 类的实例(通过RObject)。每个对象的内容,如字符串中的字符,通常存储在一个单独的内存位置。

随着你的程序开始分配更多新对象,MRI 从空闲列表中取出更多新的RVALUE结构,未使用的值列表逐渐缩小,如图 12-3 所示。

当你的程序创建更多对象时,MRI 开始使用空闲列表。

图 12-3. 当你的程序创建更多对象时,MRI 开始使用空闲列表。

MRI 的多空闲列表使用

当 MRI 开始执行一个 Ruby 脚本时,它会为空闲列表分配内存。它将初始空闲列表的长度设置为约 10,000 个RVALUE结构体,这意味着 MRI 可以在不分配更多内存的情况下创建 10,000 个 Ruby 对象。当需要更多对象时,MRI 会分配更多内存,将更多空的 RVALUE 结构体放到空闲列表中。

Ruby 不是创建一个包含 10,000 个元素的单一长链表,而是将分配的内存划分为多个子区块,在 MRI 源代码中称为,每个堆约 16k 大小。然后,它为每个堆创建一个空闲列表,最初创建 24 个列表,每个列表包含 407 个对象,剩余的内存则用于其他内部数据结构。

由于存在多个空闲列表,MRI 会不断从一个空闲列表中返回RVALUE结构体,直到该列表为空,然后跳到另一个空闲列表,从第二个列表中返回更多结构体。通过这种方式,MRI 会遍历所有可用的空闲列表,直到它们全部为空。

标记

当你的程序运行时,它会创建新对象,最终 MRI 会用完空闲列表上所有剩余的对象。此时,GC 系统会停止你的程序,识别出你的代码不再使用的对象,并回收它们的内存,以便分配给新对象。如果没有找到未使用的对象,Ruby 会向操作系统请求更多内存;如果没有可用内存,Ruby 会抛出内存不足的异常并停止。

你的程序分配但不再使用的对象被称为垃圾对象。为了识别垃圾对象,MRI 会遍历你对象的 C 结构体中的指针,跟随引用从一个对象到另一个对象,找到所有活跃对象(参见图 12-4)。如果 MRI 没有找到对某个对象的引用,它就知道你的代码不再使用该对象。

Ruby 从左边的根对象开始,沿着指针或引用从一个对象到另一个对象。

图 12-4. Ruby 从左边的根对象开始,沿着指针或引用从一个对象到另一个对象。

左侧的灰色框是根对象,这是你创建的全局变量或 Ruby 知道你的应用程序必须使用的内部对象。通常,在任何给定时刻都会有许多根对象。箭头表示从该根对象到其他对象的引用,这些对象可能包含对其他对象的引用。这种对象及其引用的网络被称为对象图。MRI 在遍历对象图时标记每个找到的 Ruby 对象,在标记过程中暂停你的程序,以确保不会创建新的对象引用。

一旦标记过程完成,堆中包含一系列对象,既有标记的也有未标记的,如图 12-5 所示。标记的对象是活跃的,意味着你的代码正在使用它们。未标记的对象是垃圾,意味着 Ruby 可以释放或回收它们的内存。由于你的代码仍在使用这些标记的对象,因此它们的内存必须被保留。

MRI 已标记五个活跃对象(灰色),并在堆中剩余五个垃圾对象(白色)。

图 12-5. MRI 已标记五个活跃对象(灰色),并在堆中剩余五个垃圾对象(白色)。

MRI 如何标记活跃对象?

MRI 使用一种称为位图标记的技术来保存标记和未标记对象的信息。位图标记是指将活跃对象的标记作为一系列位保存在一种名为空闲位图的数据结构中(参见图 12-6)。MRI 使用一个单独的内存结构来存储空闲位图,而不是将标记保存在对象附近。

MRI 将 GC 标记标志保存在一个名为空闲位图的单独数据结构中。

图 12-6. MRI 将 GC 标记标志保存在一个名为空闲位图的单独数据结构中。

使用一个单独的内存结构来存储标记位的原因与一种称为写时复制的 Unix 内存优化技术有关(参见写时复制)。类似于 Ruby 如何在包含相同字母的不同字符串之间共享内存,写时复制允许 Unix 进程共享包含相同值的内存。通过将标记位单独保存,MRI 最大化了内存中相同值的共享数量。(在 Ruby 1.9 及更早版本中,标记位保存在每个RVALUE结构内部,这导致垃圾回收器在标记活跃对象时修改几乎所有 Ruby 的共享内存,从而使写时复制优化失效。)

扫描

在识别到垃圾对象后,接下来是回收它们。Ruby 的 GC 算法将未标记的对象重新放回空闲链表,如图 12-7 所示。

在清扫过程中,MRI 将未使用的 RVALUE 结构重新放回空闲链表。

图 12-7. 在清扫过程中,MRI 将未使用的RVALUE结构重新放回空闲链表。

将未使用的对象移回空闲链表的过程称为清扫这些对象。通常,这个过程运行得非常快,因为 MRI 并不会真正复制对象;它只是调整每个RVALUE中的指针,以创建空闲链表(图 12-7 中的实线箭头所示)。

懒惰清扫

从版本 1.9.3 开始,MRI 引入了一种名为懒惰清扫的优化。懒惰清扫算法减少了程序在垃圾回收器停顿时的时间。(记住,在正常的标记清扫过程中,MRI 会停止执行你的代码。)

懒惰清扫仅将足够的垃圾对象回收到空闲链表中,以便创建一些新的 Ruby 对象,并让程序继续运行,从而减少了清扫所需的时间。Ruby 只会将 MRI 的一个内部堆结构中找到的所有垃圾RVALUE对象清扫回该堆的空闲链表。如果当前堆中没有找到垃圾对象,Ruby 会尝试对下一个堆进行懒惰清扫,并继续遍历剩余的堆。(我们将在实验 12-1:观察 MRI 垃圾回收的实际操作中看到这个算法的运作。)

懒惰清扫可以减少程序在等待垃圾回收时的暂停时间;然而,它并不会减少垃圾回收工作的总体量。懒惰清扫将相同的清扫工作量分摊到多个 GC 暂停中。

RVALUE 结构

你可以在 MRI 的gc.c源代码文件中找到RVALUE C 结构的定义,该文件包含了 MRI 垃圾回收器的实现。示例 12-1 展示了RVALUE定义的一部分。

示例 12-1. RVALUE定义的一部分 来自 gc.c

    typedef struct RVALUE {
   union {
     struct {
          VALUE flags;        /* always 0 for freed obj */
          struct RVALUE *next;
        } free;
     struct RBasic  basic;
        struct RObject object;
        struct RClass  klass;
        struct RFloat  flonum;
        struct RString string;
        struct RArray  array;
        struct RRegexp regexp;

    --*snip*--

        } as;
    #ifdef GC_DEBUG    const char *file;
        int   line;
    #endif} RVALUE;

请注意在 处,RVALUE 使用联合体来内部存储多种不同类型的值。第一个可能的值是 free 结构,定义在 处,表示仍在空闲列表中的 RVALUE。MRI 在联合体中包括所有可能类型的 Ruby 对象,起始位置在 处:RObjectRString 等等。

标记清除的缺点

标记清除的主要缺点是,它要求程序暂停并等待标记和清除过程的完成。然而,从 1.9.3 版本开始,MRI 的懒清除技术在一定程度上缩短了 GC 暂停的时间。

另一个缺点是,执行标记清除垃圾回收所需的时间与堆的总大小成正比。在标记阶段,Ruby 需要访问程序中的每一个活动对象。在清除阶段,Ruby 需要遍历堆中所有未使用的垃圾对象。随着程序创建的对象数量和堆的总大小的增加,这两个任务都会变得更加耗时。

标记清除的最后一个问题是,所有空闲列表中的元素——所有未使用的、可供程序使用的对象——必须具有相同的大小。MRI 在你分配新对象时并不知道它是字符串、数组还是一个简单的数字。这就是为什么 MRI 在空闲列表中使用的 RVALUE 结构必须能够包含任何可能类型的 Ruby 对象。

实验 12-1:观察 MRI 垃圾回收的实际运行

你已经了解了 MRI 垃圾回收算法的理论原理。现在,让我们换个角度,看看 MRI 如何执行实际的垃圾回收。在示例 12-2 中,创建了 10 个 Ruby 对象。

示例 12-2. 使用 Object.new 创建 10 个 Ruby 对象

10.times do
  obj = Object.new
end

如果 MRI 确实从空闲列表中分配未使用的空间给新对象,那么在我们运行 示例 12-2 时,Ruby 应该从空闲列表中移除 10 个 RVALUE 结构,并将它们分配给这 10 个新对象。为了查看这一过程的实际效果,我们使用 ObjectSpace#count_objects 方法,如 示例 12-3 所示。

示例 12-3. 使用 ObjectSpace#count_objects 显示 MRI 堆的信息

    def display_count
   data = ObjectSpace.count_objects
   puts "Total: #{data[:TOTAL]} Free: #{data[:FREE]} Object: #{data[:T_OBJECT]}"end

    10.times do
      obj = Object.new
   display_count
    end

现在我们每次在循环中调用 display_countdisplay_count 使用 ObjectSpace#count_objects 来显示关于总对象数量、空闲对象数量以及每次循环中活动的 RObject 结构的相关信息。

运行 示例 12-3 会产生 示例 12-4 中显示的结果。

示例 12-4. 示例 12-3 产生的输出

Total: 17491 Free: 171 Object: 85
Total: 17491 Free: 139 Object: 86
Total: 17491 Free: 132 Object: 87
Total: 17491 Free: 125 Object: 88
Total: 17491 Free: 118 Object: 89
Total: 17491 Free: 111 Object: 90
Total: 17491 Free: 104 Object: 91
Total: 17491 Free: 97 Object: 92
Total: 17491 Free: 90 Object: 93
Total: 17491 Free: 83 Object: 94

Total: 字段显示 MRI 返回的 ObjectSpace.count_objects[:TOTAL] 的值。该值(17491)表示当前 Ruby 中所有活动对象的总数。它包括我们创建的对象;Ruby 在解析、编译和执行程序时内部创建的对象;以及空闲列表中的对象。这个数字在我们创建新对象时不会改变,因为它已经包括了整个空闲列表中的对象。

Free: 字段显示 ObjectSpace.count_objects[:FREE] 返回的空闲列表长度值。请注意,每次循环时,该值都会减少约 7。我们每次迭代只创建一个对象,但 Ruby 在执行 display_count 方法中的代码时,每次循环都会创建 6 个其他对象。

Object: 字段显示当前在 Ruby 中活动的 RObject 结构的数量。请注意,每次循环时,这个值都会增加 1,即使我们没有保持对新对象的活动引用。也就是说,我们没有将 Object.new 返回的值保存到任何地方。RObject 的计数包括活动对象和垃圾对象。

查看 MRI 执行懒散清理

现在,如果我们将迭代次数从 10 增加到 30,并重新运行 示例 12-3,我们将在 示例 12-5 中看到以下输出。

示例 12-5. 运行 示例 12-3,将迭代次数从 10 改为 30

    Total: 17493 Free: 166 Object: 85
    Total: 17493 Free: 134 Object: 86
    Total: 17493 Free: 127 Object: 87
    Total: 17493 Free: 120 Object: 88

    --*snip*--

    Total: 17493 Free: 29 Object: 101
    Total: 17493 Free: 22 Object: 102
    Total: 17493 Free: 15 Object: 103
 Total: 17493 Free: 8 Object: 104
 Total: 17493 Free: 246 Object: 104
    Total: 17493 Free: 239 Object: 105
    Total: 17493 Free: 232 Object: 106
    Total: 17493 Free: 225 Object: 107

这时,空闲列表计数下降至 8,在 处。然后在 处,空闲计数增加到 246,但对象计数保持在 104。 这看起来像是一次完整的垃圾回收。但是实际上并不是!如果 Ruby 已经回收了所有可用的垃圾对象,当空闲计数增加时,RObject 的计数应该会减少,因为我们的所有对象会立即成为垃圾。这里到底发生了什么?

这是一种懒惰清理。Ruby 首先标记所有活动对象,间接识别出垃圾对象。然而,Ruby 并没有将所有垃圾对象移动到空闲列表,而是只清理了其中一部分:它从某个内部堆结构中找到了垃圾对象。空闲计数增加了,但 RObject 的计数保持不变,因为 MRI 重用了一个先前迭代中创建的 RObject 结构来创建新对象。

查看 MRI 执行完整的垃圾回收

我们可以通过手动触发完整垃圾回收来观察其效果,方法是使用 GC.start(见 示例 12-6)。

示例 12-6. 触发完整的垃圾回收

    def display_count
      data = ObjectSpace.count_objects
      puts "Total: #{data[:TOTAL]} Free: #{data[:FREE]} Object: #{data[:T_OBJECT]}"end

    30.times do
      obj = Object.new
      display_count
    end

 GC.start
 display_count

在这里,我们再次迭代 30 次,创建新对象并调用 display_count。然后,在 处,我们调用 GC.start,这会触发 MRI 执行完整的垃圾回收。最后,在 处,我们再次调用 display_count 来显示相同的技术信息。示例 12-7 显示了新的输出。

示例 12-7. 示例 12-6 生成的输出

    --*snip*--

    Total: 17491 Free: 26 Object: 101
    Total: 17491 Free: 19 Object: 102
    Total: 17491 Free: 12 Object: 103
 Total: 17491 Free: 251 Object: 103
    Total: 17491 Free: 244 Object: 104
    Total: 17491 Free: 237 Object: 105
    Total: 17491 Free: 230 Object: 106
    Total: 17491 Free: 223 Object: 107
    Total: 17491 Free: 216 Object: 108
    Total: 17491 Free: 209 Object: 109
    Total: 17491 Free: 202 Object: 110
    Total: 17491 Free: 195 Object: 111
    Total: 17491 Free: 188 Object: 112
    Total: 17491 Free: 181 Object: 113
 Total: 17491 Free: 9527 Object: 43

示例 12-7 的大部分内容与 示例 12-5 的输出相似。总数保持不变,而空闲计数逐渐减少。在 处,我们看到懒惰清理再次发生,空闲计数增加至 251。然而,在 处,我们看到一个显著变化。对象的总数保持在 17491,但空闲计数跃升至 9527,且对象数剧烈减少至 43!

从这个观察中,我们可以得出以下结论:

  • 处,free 计数显著增加,因为 Ruby 通过一次大规模操作将所有垃圾对象移动到了空闲列表中。这些垃圾对象包括我们代码在之前迭代中创建的对象,以及 Ruby 在解析和编译阶段内部创建的对象。

  • RObject的数量减少到 43,因为之前迭代中创建的所有对象都是垃圾(因为我们没有将它们保存在任何地方)。这个 43 的数量仅包括 Ruby 内部创建的对象,而不包括我们代码创建的对象。如果我们将新对象保存到某个地方,RObject的数量将保持不变。(我们接下来会尝试这样做。)

解读 GC 性能分析报告

在这个实验中,我们只从空闲列表中分配了少量对象。当然,您的 Ruby 程序通常会创建更多的对象,远超 30 个。当我们创建数千个甚至数百万个对象时,MRI 的垃圾回收器会如何表现?如何得知在复杂的 Ruby 应用程序中垃圾回收器占用了多少时间?

答案是使用GC::Profiler类。如果启用它,MRI 的内部 GC 代码将收集关于每次 GC 运行的统计数据。示例 12-8 展示了如何使用GC::Profiler

示例 12-8. 使用GC::Profiler显示 GC 使用情况(gc-profile.rb)

 GC::Profiler.enable

    10000000.times do
      obj = Object.new
    end

 GC::Profiler.report

我们首先在 处通过调用GC::Profiler.enable启用性能分析器。以下代码创建了 1000 万个 Ruby 对象。在 处,我们通过调用GC::Profiler.report显示 GC 性能分析报告。示例 12-9 显示了在示例 12-8 中生成的报告。

示例 12-9. 从示例 12-8 生成的 GC 性能分析报告的一部分

$ **ruby gc-profile.rb**
GC 1046 invokes.
Invoke Time(sec)       Use Size(byte)     Total Size(byte)         Total Object      GC Time(ms)
           0.036               690920               700040                17501         0.694000
           0.039               695200               700040                17501         0.433999
           0.041               695200               700040                17501         0.585000
           0.046               695200               700040                17501         0.577000
           0.049               695200               700040                17501         0.466000
           0.051               695200               700040                17501         0.516999
           0.054               695200               700040                17501         0.419000
           0.056               695200               700040                17501         0.535000
           0.059               695200               700040                17501         0.410000
           0.062               695200               700040                17501         0.426999
--*snip*--

为了节省空间,我已从报告中移除了第一列——一个简单的计数器。以下是其他列的含义:

  • 调用时间 显示垃圾回收发生的时间,单位是 Ruby 脚本开始运行后的秒数。

  • 使用的大小 显示每次垃圾回收完成后,所有存活的 Ruby 对象占用的堆内存大小。

  • 总大小 显示回收后堆的总大小——换句话说,就是存活对象占用的内存加上空闲列表的大小。

  • 总对象 显示 Ruby 对象的总数,包括存活对象和空闲列表中的对象。

  • 最后,GC 时间 显示每次垃圾回收所用的时间。

在此实验中,注意到除了 调用时间 之外,没有任何值发生变化。活跃 Ruby 对象所使用的内存量、堆的总大小和对象的总数量都保持不变。这是因为我们没有将新 Ruby 对象保存到任何地方。它们立即变成垃圾。GC 时间值有所波动,但大致保持不变。垃圾回收器清扫所有新对象并将其移回空闲列表所需的时间保持大致相同,因为每次回收器清扫的对象数量大致相同。

然而,如果我们将所有新对象保存在一个数组中,它们将保持活跃并且不会变成垃圾。示例 12-10") 展示了将每个对象保存到一个单一的大数组中的代码。

示例 12-10. 在数组中保存 1000 万个 Ruby 对象 (gc-profile-array.rb)

    GC::Profiler.enable

 arr = []
10000000.times do
   arr << Object.new
    end

    GC.start

    GC::Profiler.report

在这里,我们在 创建了一个空数组,并在 中保存了每个新对象。由于数组持有所有新对象的引用,它们保持活跃。垃圾回收器无法回收任何这些对象的内存。示例 12-11 显示了由 示例 12-10") 生成的 GC 性能分析报告。

示例 12-11. Ruby 必须增加堆的大小以容纳所有新的活跃对象。

    $ **ruby gc-profile-array.rb**
 GC 17 invokes.
    Invoke Time(sec)       Use Size(byte)     Total Size(byte)         Total Object      GC Time(ms)
               0.031               690920               700040                17501         0.575000
               0.034               708480               716320                17908         0.689000
               0.037              1261680              1269840                31746         1.077000
               0.043              2254280              2262920                56573         1.994999
               0.054              4044200              4053720               101343         3.454999
               0.074              7266080              7277160               181929         5.288000
               0.108             13058920             13072840               326821         9.417000
               0.170             23489240             23508320               587708        14.465000
               0.279             42267080             42311720              1057793        26.015999
               0.478             76096560             76157840              1903946        45.910000

这次的性能分析报告非常不同!垃圾回收器无法释放任何新对象,因为它们仍然活跃在数组中。这意味着 Ruby 则不得不反复分配更多内存来容纳它们。当你阅读 示例 12-11 时,注意到三个重要的值——使用大小总大小对象总数——都呈指数增长。这种增长就是为什么在 我们看到垃圾回收器只被调用了 17 次。(在调用 GC::Profiler.enable 之前,Ruby 也运行了几次垃圾回收,因为它解析并编译了我们的脚本。)每次垃圾回收器都会或多或少地将堆的大小加倍,从而使脚本能够持续运行更长时间。与我们在 示例 12-9 中看到的快速多次垃圾回收不同,Ruby 只运行了几次较慢的垃圾回收。

如果我们绘制每次回收所需时间(GC 时间)与堆的总大小(总堆大小)的图表,如 图 12-8 所示,我们可以得出另一个有趣的结论。

执行标记和清除所需的时间随着堆大小线性增加。

图 12-8. 执行标记和清除所需的时间随着堆大小线性增加。

图 12-8 使用对数刻度表示横轴(总堆大小)和纵轴(GC 时间)。因为 Ruby 在每次回收时都会将堆大小翻倍,所以数据点在对数横轴上大致均匀分布。由于时间是指数级增加的,数据点在对数纵轴上也大致均匀分布。

最重要的是,注意数据点形成了一条直线:这条直线意味着执行垃圾回收所需的时间随着总堆大小线性增加。随着你创建更多的 Ruby 对象,标记它们的时间也会更长。当垃圾对象增多时,清理过程也需要更多时间;然而,在这个例子中,我们没有看到清理时间,因为所有对象都保持活跃。

JRuby 和 Rubinius 的垃圾回收

由于 JRuby 使用 Java 虚拟机(JVM)来实现 Ruby,它能够利用 JVM 的高级垃圾回收系统来管理 Ruby 对象的内存。事实上,垃圾回收是使用 JVM 平台的主要优点之一:JVM 的垃圾回收器已经经过多年的优化。

Rubinius 的 C++ 虚拟机还包含一个复杂高效的垃圾回收器,使用了与 JVM 相同的一些底层算法。选择 Rubinius 作为你的 Ruby 平台的一个好处就是它的先进垃圾回收系统。

JRuby 和 Rubinius 使用的垃圾回收器与 MRI 的垃圾回收器在三个方面有所不同:

  • 它们并没有使用空闲列表,而是为新对象分配内存,并通过一种叫做复制垃圾回收的算法回收垃圾对象的内存。

  • 它们通过代际垃圾回收以不同的方式处理旧的和新的 Ruby 对象。

  • 它们使用并发垃圾回收在应用程序代码运行时执行一些垃圾回收任务。

注意

尽管 JRuby 和 Rubinius 使用的垃圾回收系统与 MRI 的标记-清除垃圾回收器有很大不同,但 MRI 已经开始引入一些这些概念。具体来说,Ruby 2.1 中的垃圾回收系统已经开始使用代际和并发垃圾回收。

在接下来的章节中,我们将探讨支撑复制、分代和并发垃圾回收的基本算法,同时了解 Rubinius 和 JRuby 中垃圾回收是如何工作的。

复制垃圾回收

1963 年,在约翰·麦卡锡(John McCarthy)构建了第一个 Lisp 垃圾回收器三年后,马文·明斯基(Marvin Minsky)开发了一种不同的内存分配和回收方式,称为复制垃圾回收。(明斯基的研究最初也用于 Lisp。该算法后来在 1969 年由 Fenichel 和 Yochelson 以及在 1978 年由 Baker 进行了改进。)与使用空闲列表跟踪可用对象的方式不同,复制垃圾回收器从一个大的堆或内存段中为新对象分配内存。当这个内存段用完时,这些回收器复制所有活跃对象到第二个内存段,并将垃圾对象遗留在原地。然后,交换这两个内存段,立即回收垃圾对象所占用的所有内存。(Rubinius 和 JVM 都使用基于这个原始思想的复杂算法。)

增量分配

当你使用复制垃圾回收器(例如 JVM 和 Rubinius 中的回收器)为新对象分配内存时,垃圾回收器使用一种称为增量分配的算法。增量分配通过增进或递增一个指针来分配来自大而连续堆的相邻内存段,以跟踪下一个分配的位置。图 12-9 展示了这个过程如何在三次重复分配中工作。(大矩形表示 Rubinius 或 JVM 的堆。)

使用增量分配分配三个对象

图 12-9。使用增量分配分配三个对象

复制回收器保持一个指针,用于跟踪下一个分配将在堆中的哪里发生。每次回收器为新对象分配内存时,它都会从堆中取出一些内存,并将指针向右移动。随着更多对象的创建,从堆中分配的内存也向右移动。还要注意的是,新对象的大小并不相同;每个对象使用的字节数不同。因此,这些对象在堆中并不均匀分布。

这种技术的优点是它非常快速且实现简单,并且提供了良好的引用局部性,意味着程序中相关的值应该在内存中相互靠近。局部性很重要,因为如果你的代码反复访问相同的内存区域,CPU 可以缓存这些内存并更快速地访问它。如果程序经常访问非常不同的内存区域,CPU 必须不断重新加载内存缓存,从而减慢程序的性能。

复制垃圾回收的另一个好处是能够创建不同大小的对象。与 MRI 中的RVALUE结构不同,JRuby 和 Rubinius 可以分配任意大小的新对象。

半空间算法

当初始堆用尽并发生垃圾回收时,复制垃圾回收器的真正优势和优雅之处显现出来。复制垃圾回收器以与标记清除(mark-and-sweep)回收器相同的方式识别存活对象和垃圾对象——通过遍历对象图,跟随对象引用或指针。然而,一旦垃圾对象被识别出来,复制垃圾回收器的工作方式就非常不同了。

复制垃圾回收器实际上使用两个堆:一个用来通过增量分配(bump allocation)创建新对象,另一个为空的堆,如图 12-10 所示。

半空间算法使用两个堆,其中一个最初为空。

图 12-10。半空间算法使用两个堆,其中一个最初为空。

上方的堆包含已经创建的对象,称为来自空间(from-space)。请注意,来自空间中的对象已经被标记为存活(灰色并带有M标记)或垃圾(白色)。下方的堆是目标空间(to-space),最初为空。接下来我将描述的算法被称为半空间算法,因为总的可用内存被分为来自空间和目标空间。

当来自空间(from-space)完全填满时,复制垃圾回收器将所有存活的对象复制到目标空间(to-space),并将垃圾对象留在原地。图 12-11 展示了复制过程。

半空间算法仅将存活的对象复制到第二个堆中。

图 12-11。半空间算法仅将存活的对象复制到第二个堆中。

来自空间(from-space)再次出现在图表的顶部,而目标空间(to-space)位于下方。注意存活的对象是如何被复制到目标空间中的。指向下方的箭头表示这一复制过程。类似于增量分配中使用的指针,会跟踪下一个存活对象应该复制到哪里。

一旦复制过程完成,半空间算法会交换堆,如图 12-12 所示。

复制存活对象后,半空间算法交换堆。

图 12-12。复制存活对象后,半空间算法交换堆。

在图 12-12 中,to-space 已经变成了新的 from-space,并且现在准备通过提升分配来为新对象分配更多内存。你可能会认为这个算法会很慢,因为涉及了大量的复制操作,但其实并不慢,因为只有活跃的、存活的对象才会被复制,垃圾对象会被留下并最终回收。

注意

所有活跃对象都被复制到堆的左侧;这使得垃圾回收器能够最有效地分配剩余未使用的内存。堆的压缩是半空间算法的自然结果。

虽然半空间算法是一种优雅的内存管理方式,但它在某种程度上存在内存低效的问题。它要求回收器分配的内存是实际使用内存的两倍,因为所有对象可能都保持活跃状态,并可能被复制到第二个堆中。这个算法也比较难以实现,因为当回收器移动活跃对象时,它还必须更新对这些对象的引用和指针。

伊甸堆

事实证明,Rubinius 和 JVM 都使用一种变体的半空间算法,并采用一种称为伊甸园(Garden of Eden)或伊甸堆(Eden heap)的第三种堆结构来分配新对象的内存。图 12-13 展示了这三种内存结构。

伊甸堆用于分配全新对象的内存。

图 12-13. 伊甸堆用于分配全新对象的内存。

伊甸堆是 JVM 和 Rubinius 为新对象分配内存的地方;from-space 包含了在上一次垃圾回收过程中复制过来的所有活跃对象;to-space 在下一次垃圾回收运行之前保持为空。每次垃圾回收过程运行时,回收器将你的对象从伊甸堆和 from-space 复制到 to-space,从而为新对象提供更多的内存,因为每次半空间复制操作后,伊甸堆都会保持为空。

世代垃圾回收

包括 JVM 和 Rubinius 虚拟机在内的许多现代垃圾回收器使用世代 GC算法,这是一种将新对象与旧对象区别对待的技术。新对象,或年轻对象,是你的程序刚刚创建的对象,而旧对象,或成熟对象,是你的程序正在继续使用的对象。一个对象被认为是成熟对象的标准通常是它在垃圾回收系统运行的次数。

弱世代假设

将对象分类为年轻对象或成熟对象的原因基于这样的假设:大多数年轻对象的生命周期较短,而成熟对象可能会持续很长时间。这一假设被称为弱代际假设。简单来说,新对象通常会“早逝”。由于年轻对象和成熟对象的生命周期不同,因此每个类别或需要采用不同的垃圾收集算法。

例如,考虑一个 Ruby on Rails 网站。为了生成每个客户端请求的网页,Rails 应用会创建许多新的 Ruby 对象。然而,一旦网页生成并返回给客户端,所有这些 Ruby 对象就不再需要,垃圾收集系统可以回收它们的内存。同时,应用可能还会创建一些在请求之间存活的 Ruby 对象,比如表示控制器、一些配置数据或用户会话的对象。这些成熟对象会有更长的生命周期。

使用半空间算法处理年轻对象

根据弱代际假设,年轻对象是程序不断创建的,但也经常变成垃圾。因此,JVM 和 Rubinius 对于年轻对象运行垃圾收集过程的频率远高于成熟对象(你将在实验 12-2:在 JRuby 中使用详细 GC 模式中看到详细数据)。半空间算法非常适合处理年轻对象,因为它只复制存活的对象。当伊甸园堆(Eden heap)充满新对象时,垃圾收集器会将其中大部分标记为垃圾,因为新对象通常早逝。由于存活的对象较少,垃圾收集器的复制工作量也较小。JVM 将这些对象称为幸存者,并将 from-space 和 to-space 称为幸存者空间

提升对象

当一个新对象变成旧对象(即它经历了垃圾收集系统的若干次运行后),它会在半空间复制过程中被提升,即被复制到成熟代堆中,如图 12-14 所示。

代际垃圾收集器将旧对象从年轻堆提升到成熟堆中。

图 12-14. 代际垃圾收集器将旧对象从年轻堆提升到成熟堆中。

请注意,from-space 包含五个活动对象,显示为灰色矩形。通过半空间算法,其中两个对象被复制到 to-space,而其他三个对象则被提升。它们的年龄已经超过了新对象生命周期,因为它们在若干次垃圾收集运行中保持活跃。

在 Rubinius 中,新对象生命周期的默认值为 2,这意味着一旦垃圾回收系统运行了两次并且您的代码仍然持有该对象的引用,该对象就会变成老年代对象。(这意味着 Rubinius 会使用半空间算法在从空间和到空间之间复制一个活动对象两次。)随着时间的推移,Rubinius 会根据各种统计数据调整对象生命周期值,尽可能优化垃圾回收。

JVM 的垃圾回收器会内部计算新对象的生命周期,尝试保持从空间和到空间的堆大约填充一半。如果这些堆开始填满,新对象的生命周期将缩短,且对象将更快地被提升。如果空间大部分是空的,JVM 将增加新对象的生命周期,使新对象能在此停留更长时间。

老年代对象的垃圾回收

一旦您的对象被提升到老年代,它们很可能会因为弱代际假设而长时间存活。因此,JVM 和 Rubinius 需要在老年代上进行垃圾回收的频率要少得多。垃圾回收会在为老年代对象分配的堆满时进行。由于大多数新对象在新对象生命周期内不会存活,老年代的收集速度较慢。

JVM 提供了许多命令行选项,使您能够配置年轻代和老年代堆的相对或绝对大小(JVM 文档中将老年代称为老年代)。JVM 还维护了第三代,用于 JVM 自身创建的内部对象:永久代。年轻代的垃圾回收被称为小规模回收,而老年代则是大规模回收

Rubinius 使用一种名为 Immix 的复杂垃圾回收算法来处理老年代对象。Immix 尝试通过将活动对象收集到连续区域中,减少总内存使用量和堆碎片化的程度。Rubinius 还使用第三代来处理非常大的对象,并使用标准的标记-清除过程来收集它们。

注意

MRI Ruby 2.1 版本实现了一种代际垃圾回收算法,类似于 JVM 和 Rubinius 多年来使用的算法。其主要挑战是检测哪些老年代对象引用了年轻代对象(参见 代际之间的引用)。MRI 通过使用写屏障来解决这个问题,跟踪每次老年代对象引用年轻代对象,尽管在 MRI 中实现写屏障非常复杂,因为现有的 C 扩展并不包含这些屏障。

代际之间的引用

除了新对象的生命周期外,代际垃圾回收器还必须追踪另一个重要细节:因旧对象的引用而活跃的年轻对象。由于年轻代的回收不会标记成熟对象,回收器可能会错误地认为某些年轻对象是垃圾,而它们实际上并非如此。图 12-15 展示了这个问题的一个例子。

代际垃圾回收器需要找到引用年轻代对象的成熟对象。

图 12-15. 代际垃圾回收器需要找到引用年轻代对象的成熟对象。

年轻代集合包含几个活动对象(灰色)和垃圾对象(白色)。在年轻代对象标记阶段,代际垃圾回收器仅跟踪年轻代对象的引用,以加速这一频繁发生的过程。然而,请注意标有问号的中心对象:它是活动对象还是垃圾对象?虽然没有其他年轻对象引用它,但有一个成熟对象引用它。如果 Rubinius 或 JVM 在标记完年轻代对象后运行半空间算法,中心对象将被错误地视为垃圾,其内容将被覆盖!

写屏障

代际垃圾回收器可以通过使用写屏障来解决这个问题。这些代码片段用于跟踪程序何时从一个成熟对象向年轻对象添加引用。当垃圾回收器遇到这样的引用时,它会将该成熟对象视为标记年轻代对象时的另一个根,从而使该对象被视为活动对象,并能通过半空间算法正确复制。

并发垃圾回收

Rubinius 和 JVM 都使用另一种复杂的技术来减少应用程序等待垃圾回收的时间:并发垃圾回收。使用并发垃圾回收时,垃圾回收器与应用程序代码同时运行。这消除了或至少减少了由于垃圾回收导致的程序暂停,因为应用程序在垃圾回收器运行时无需停止等待。

并发垃圾回收器与主应用程序在不同的线程中运行。尽管理论上这可能意味着应用程序会稍微变慢,因为部分 CPU 时间必须用来运行 GC 线程,但如今大多数计算机都配备了多核微处理器,允许不同的线程并行运行。这意味着可以将一个核心专门用于运行 GC 线程,其他核心则运行主应用程序。(实际上,这仍可能会使应用程序变慢,因为可用的核心减少了。)

注意

MRI Ruby 2.1 还通过并行执行标记-清扫算法中的清扫部分来支持一种形式的并发垃圾回收,同时您的 Ruby 代码继续运行。这有助于减少应用程序在垃圾回收运行时暂停的时间。

在对象图变化时进行标记

在应用程序运行时标记对象是并发垃圾回收器面临的一个大难题:如果在回收器标记对象时,应用程序改变了对象图该怎么办?为了更好地理解这个问题,请参见图 12-16 中的示例对象图。

此图展示了一组正在被并发垃圾回收器标记的对象。左侧是根对象,右侧是根对象引用的各种子对象。所有活跃对象都被标记为M并显示为灰色。垃圾回收器(通过大箭头表示)已经标记了活跃对象,现在正在处理底部附近的对象。回收器即将标记右下角的两个剩余白色对象。

垃圾回收器标记对象图

图 12-16. 垃圾回收器标记对象图

现在假设您的应用程序(在标记过程进行时仍在运行)创建了一个新对象,并将其作为先前标记的对象的子对象。图 12-17 展示了新的情况。

您的应用程序在标记过程进行时创建了一个新对象。

图 12-17. 您的应用程序在标记过程进行时创建了一个新对象。

请注意,某个已标记的活跃对象指向了一个尚未被标记的新对象。

现在假设垃圾回收器完成了对象图的标记。它已标记了所有活跃对象,这意味着任何剩余的对象都被假定为垃圾。图 12-18 展示了标记过程结束时对象图的样子。

回收器错误地将新的活跃对象认为是垃圾。

图 12-18. 回收器错误地将新的活跃对象认为是垃圾。

垃圾回收器已经完成了所有活跃对象的标记,但它错过了新的对象。回收器现在将回收其内存,但应用程序将丢失有效数据,或者会在某个对象中添加垃圾数据!

三色标记

解决这个问题的方法是维护一个标记堆栈,即一个仍需要通过标记过程检查的对象列表,如图 12-19 所示。

标记过程通过标记堆栈中的对象进行。

图 12-19. 标记过程通过标记堆栈中的对象进行。

初始时,所有根对象都被放置在标记堆栈中。当垃圾回收器标记对象时,它将对象从标记堆栈移到左侧的标记对象列表中,并将它找到的任何子对象添加到标记堆栈中。当标记堆栈耗尽时,垃圾回收器就完成了;它已经识别了所有存活的对象,右侧的任何剩余对象都被认为是垃圾。但在这种方案下,如果应用程序在标记过程中修改了某个对象,回收器可以将该修改过的对象移回标记堆栈,即使它之前已被标记,如图 12-20 所示。

收集器将标记的对象移回标记堆栈,因为应用程序对其进行了修改。

图 12-20. 收集器将标记的对象移回标记堆栈,因为应用程序对其进行了修改。

应用程序已经向系统添加了一个新对象,如图中右侧的剩余对象列表所示。然而,这次,回收器注意到一个现有对象被修改了,因为它现在包含对新对象的引用,并将修改后的对象移到中间的标记堆栈中。因此,回收器最终会在处理标记堆栈时找到并标记新对象。

这种修改后的标记算法被称为三色标记:已经处理的对象被认为是“黑色”的;标记堆栈中的对象是“灰色”的;其余的对象是“白色”的,如图 12-19 和图 12-20 所示。

注意

并发垃圾回收器可以使用写屏障来检测应用程序何时更改对象图。写屏障被代际和并发垃圾回收器共同使用。

JVM 中的三种垃圾回收器

为了支持不同类型的应用程序和服务器硬件,JVM 包含三种实现并发垃圾回收的独立垃圾回收器。你可以使用命令行参数选择在 JRuby 程序中运行哪种回收器。这三种回收器如下:

  • 串行(Serial)。这种回收器会暂停你的应用程序并在应用程序等待时执行垃圾回收。它完全不使用并发垃圾回收。

  • 并行(Parallel)。这种回收器会在应用程序运行时,在独立线程中执行许多垃圾回收任务,包括小规模回收。

  • 并发(Concurrent)。这种回收器会在应用程序运行的同时执行大部分垃圾回收任务。它经过优化,尽量减少 GC 暂停时间,但使用它可能会降低应用程序的整体吞吐量。

注意

除了这三种回收器,还有多种新的实验性垃圾回收器可供 JVM 使用。其中之一是垃圾优先(G1)回收器,另一个是持续并发压缩(C4)回收器。

除非你明确指示,否则 JVM 会自动选择这些垃圾回收器中的一种,具体取决于所使用的硬件类型。对于大多数计算机,JVM 默认使用并行回收器;对于服务器级机器,则使用并发回收器。你可以通过在启动 JRuby 程序时使用命令行选项来更改 JVM 默认的垃圾回收选择。有关更多详细信息,请参见文章《Java SE 6 HotSpot 虚拟机垃圾回收调优》(www.oracle.com/technetwork/java/javase/gc-tuning-6-140523.html)。

能够从这些不同的垃圾回收算法中进行选择,并通过其他配置选项进一步调优回收器的行为,是使用 JRuby 的一大优势。垃圾回收器的效果和性能取决于应用程序的行为以及所使用的底层算法。

为了帮助理解 JVM 提供的众多与垃圾回收(GC)相关的选项,JRuby 项目的主要开发者之一查尔斯·纳特(Charles Nutter)建议使用以下经验法则:

  • 如果不确定,保持 JVM 的默认设置。这些设置在大多数情况下表现良好。

  • 如果你有大量需要频繁或定期回收的数据,并发回收器或实验性的 G1 回收器可能比并行回收器表现更好。

  • 在调优垃圾回收之前,尽量优化代码以减少内存使用。当你分配过多内存时,调整 JVM 的垃圾回收器只能解决问题的一半。

实验 12-2:在 JRuby 中使用详细垃圾回收模式

实验 12-1:查看 MRI 垃圾回收的实际操作 探讨了 MRI 中的垃圾回收。在这个实验中,我们将通过要求 JVM 显示 JVM 垃圾回收器正在做什么的技术信息,来查看垃圾回收如何在 JRuby 中工作。示例 12-12") 展示了来自 实验 12-1:查看 MRI 垃圾回收的实际操作 的代码,该代码创建了 10 个 Ruby 对象。

示例 12-12. 使用Object.new创建 10 个 Ruby 对象 (jruby-gc.rb)

10.times do
  obj = Object.new
end

当我们使用 -J-verbose:gc 选项运行这个简单程序时,JVM 会显示有关垃圾回收的内部调试信息。以下是要使用的命令:

$ **jruby -J-verbose:gc jruby-gc.rb**

但这个命令不会产生任何输出。也许我们创建的对象不够多,无法触发垃圾回收。

让我们增加新对象的数量到 1000 万,如 示例 12-13") 中所示。

示例 12-13. 使用Object.new创建 1000 万个 Ruby 对象 (jruby-gc.rb)

10000000.times do
  obj = Object.new
end

新的输出显示在 示例 12-14 中。

示例 12-14. 运行 示例 12-13") 并使用 -J-verbose:gc 选项产生的输出

$ **jruby -J-verbose:gc jruby-gc.rb**
[GC 17024K->1292K(83008K), 0.0072491 secs]
[GC 18316K->1538K(83008K), 0.0091344 secs]
[GC 18562K->1349K(83008K), 0.0006953 secs]
[GC 18373K->1301K(83008K), 0.0006876 secs]
[GC 18325K->1289K(83008K), 0.0004180 secs]
[GC 18313K->1285K(83008K), 0.0006950 secs]
[GC 18309K->1285K(83008K), 0.0006597 secs]
[GC 18309K->1285K(83008K), 0.0007186 secs]
[GC 18309K->1285K(83008K), 0.0005617 secs]
[GC 18309K->1285K(83008K), 0.0006873 secs]
[GC 18309K->1285K(83008K), 0.0004944 secs]
[GC 18309K->1285K(83008K), 0.0006644 secs]
[GC 18309K->1285K(83008K), 0.0006448 secs]
[GC 18309K->1285K(83008K), 0.0007203 secs]

每次垃圾回收发生时,JVM 都会显示一行信息,说明垃圾回收的情况。在这里显示了 14 个 GC 事件。每一行包含以下信息:

  • GC... GC 前缀意味着该事件是一次小规模的回收。JVM 仅清理了 Eden 堆中的新对象或幸存空间中的年轻对象。

  • 17024K->1292K. 这些值显示了垃圾回收前(箭头左侧)和回收后(箭头右侧)存活对象所使用的数据量。在这个例子中,年轻代中的存活对象所占用的空间每次从大约 17MB 或 18MB 降低到大约 1.3MB。

  • (83008K). 括号中的值表示此进程的 JVM 堆的总大小。该值没有发生变化。

  • 0.0072491 secs. 该值显示了每次垃圾回收所花费的时间。

[示例 12-14 展示了每当我们创建更多 Ruby 对象时,JVM 的年轻代堆不断被填满。注意,每次 JVM 垃圾回收器通常花费不到 1 毫秒的时间来清理成千上万的垃圾对象。

另外需要注意的是,没有发生主要的垃圾回收。为什么?因为我们没有保存我们的 Ruby 对象。示例 12-13")创建了 1000 万个对象,但没有使用它们,因此 JVM 的垃圾回收器判断它们都是垃圾,并在它们被晋升为成熟对象之前立即回收它们的内存。

触发主要收集

为了触发主要垃圾收集,我们需要创建一些成熟的对象,通过创建不会过早死亡而是持续一段时间的 Ruby 对象来实现。我们可以通过将新对象保存在数组中来实现这一点,就像我们在实验 12-1:观察 MRI 垃圾回收过程中所做的那样。示例 12-15 再次重复了相同的脚本,以方便操作。

示例 12-15。将 1000 万个 Ruby 对象保存到数组中

 arr = []
    10000000.times do
   arr << Object.new
    end

注意在处,我们创建了一个空数组,然后在处将所有 1000 万个新对象插入其中。因为数组包含对所有对象的引用,这些对象将一直保持活动状态。

现在,让我们使用-J-verbose:gc命令重新运行实验。示例 12-16 展示了结果。

示例 12-16。运行示例 12-15 并使用-J-verbose:gc时输出的开始部分

    $ **jruby -J-verbose:gc jruby-gc.rb**
 [GC 16196K->8571K(**83008K**), 0.0873137 secs]
    [GC 25595K->20319K(83008K), 0.0480336 secs]
    [GC 37343K->37342K(83008K), 0.0611792 secs]
    [GC 37586K(83008K), 0.0029985 secs]
    [GC 54366K->54365K(83008K), 0.0617091 secs]
    [GC 65553K->65360K(83008K), 0.0586615 secs]
    [GC 82384K->82384K(100040K), 0.0479422 secs]
    [GC 89491K(100040K), 0.0124503 secs]
    [GC 95890K->95888K(147060K), 0.0795343 secs]
    [GC 96144K(147060K), 0.0030345 secs]
    [GC 130683K->130682K(148020K), 0.0941640 secs]
    [GC 147706K->147704K(165108K), 0.0925857 secs]
    [GC 150767K->151226K(168564K), 0.0226121 secs]
 [Full GC 151226K->125676K(168564K), 0.5317203 secs]
    [GC 176397K->176404K(**236472K**), 0.0999831 secs]

    --*snip*--

注意在处,输出[Full GC...]首次出现是在进行 13 次年轻代收集后。(输出继续超过示例 12-16 中所示内容。)这告诉我们,许多 Ruby 对象被晋升,填满了成熟代并迫使进行一次成熟代收集。

我们可以从这个输出中得出一些其他有趣的结论。首先,从第一次垃圾回收运行时的年轻代回收(在图片)到成熟代回收时的大小(在图片),年轻代回收的大小逐渐增加。这告诉我们,随着对象的创建,JVM 自动增加了总堆内存的大小。请注意,括号中的总堆大小值从约 83MB 开始,增加到超过 200MB,如加粗部分所示。此外,每次年轻代回收的时间仍然相对较短,不到 0.1 秒,尽管比我们在示例 12-14 中看到的更慢,后者用了不到 1 毫秒。请记住,半空间算法只复制存活的对象。这次我们所有的 Ruby 对象都保持存活,JVM 不得不重复复制它们。最后,请注意,成熟代(或完整代)回收在图片时耗时约 0.53 秒,这比任何年轻代回收都要长得多。

进一步阅读

垃圾回收这一主题有大量的相关资料。如果你想了解约翰·麦卡锡最初的自由列表实现,可以参阅他在 Lisp 中的文章:“符号表达式的递归函数及其机器计算,第一部分”(《ACM 通讯》,1960 年)。

如果你想了解现代垃圾回收的研究,可以阅读 Stephen M. Blackburn 和 Kathryn S. McKinley 的《一种具有空间效率、快速回收和变异器性能的标记区域垃圾回收器》(《ACM SIGPLAN 通知》,2008 年)。Oracle 的以下文章不仅解释了 JVM 的整体垃圾回收算法,还作为一个很好的参考,介绍了你可以用来定制和调整 JVM 垃圾回收器行为的许多命令行选项:“Java SE 6 HotSpot 虚拟机垃圾回收调优”(* www.oracle.com/technetwork/java/javase/gc-tuning-6-140523.html *)。

最后,关于垃圾回收算法的一些权威来源,以及它们在多年中的变化,包括 Jones 和 Lins 的《垃圾回收:自动动态内存管理的算法》(Wiley,1996 年)和 Jones、Hosking 与 Moss 的《垃圾回收手册:自动内存管理的艺术》(CRC Press,2012 年)。

总结

本章介绍了 Ruby 内部机制中最重要但最难理解的领域之一:垃圾回收。我们了解到,垃圾回收器为新对象分配内存并清理未使用的垃圾对象。我们检查了 MRI、Rubinius 和 JRuby 在垃圾回收中使用的基本算法,并发现 MRI 使用空闲列表来分配和回收内存,而 Rubinius 和 JVM 使用半空间算法。我们还看到 Rubinius 和 JRuby 如何采用并发和代际 GC 技术,而 MRI 从 Ruby 2.1 开始使用这些技术。

但我们仅仅触及了垃圾回收的表面。自 1960 年发明以来,已经开发了许多复杂的 GC 算法;事实上,垃圾回收仍然是计算机科学研究的一个活跃领域。MRI、Rubinius 和 JRuby 中的 GC 实现可能会随着时间的推移不断演化和改进。

posted @ 2025-11-26 09:19  绝不原创的飞龙  阅读(0)  评论(0)    收藏  举报