Tag Archives: RGSS

Continuation

在 Ruby 1.8 中有一个内置类 Continuation,中文直翻是“延续”。这是计算机科学中一个比较底层的概念,它表示进程执行的某一特定时刻的计算过程的实例。通俗的讲,它就是 Ruby 中比 Binding 更加底层的一个能保存当前进程执行环境的物件,就好比游戏里,我们通过存档就能在环境改变之后重新读档,从档案记录的环境,记录的那一刻开始。“延续”一词,即是暗示了从记录的计算过程中的一个点继续执行之意。

Ruby 程序(CRuby 实现)的执行使用了相比于堆要更高效的栈结构来进行函数调用和局部变量的保存,而 Continuation 正是保存了进程运行在某一时刻时的栈内存状态,之后用户可以手动地去调用这个 Continuation 对象,让程序从这一个预先保存的记录点运行。这相当于不少语言中支持的 goto 语句跳转的一个超级版,因为 goto 只能进行局部跳转,而 Continuation 能上天入地,翻江倒海,一个筋斗十万八千里(实际上在 C 标准库中,Continuation 的功能是等同于 setjmp 和 longjmp 这两个函数所提供的功能的)。

有什么用?RM 有一个快捷键为 F12 的重置游戏的功能,当触发了这个命令时,RM 会让解释器从头开始重新执行脚本编辑器里的脚本,但并没有初始化符号表、GC 和堆结构。这使得原有的全局变量、常量、模块、类得以保存,但也可能会让我们与我们的老朋友——stack level too deep——再度重逢。由于脚本中大量使用 alias 别名方法,当 F12 按下后,没有进行判断的 alias 语句再次执行,就会导致两次同名一个方法后产生的经典 BUG(关于这个问题,这里不再赘述,论坛上已经有太多相关的帖子,请自行搜索 stack level too deep)。这个问题可以通过 method_defined? 等方法判断将要 alias 的方法是否已经定义来解决(alias 一次后方法就已定义了,第二次不会再定义),但并不是所有脚本编写者都有这个习惯。这里我们介绍一种使用 Continuation 的通用解法:

在 Main 脚本里的第一行插入一句:

callcc { |$__f12_no_reeval| }

这一句脚本让 RM 在执行到 Main 脚本时将一个保存了当前执行环境的 Continuation 对象赋给了 $__f12_no_reeval 这个全局变量;接着在脚本编辑器开头插入:

$__f12_no_reeval and $__f12_no_reeval.call

这句是先判断 $__f12_no_reeval 是否已初始化(即已经执行到 Main 脚本一次以上),如果是则调用这个 Continuation,让程序直接跳转到 Main 脚本开头的环境继续执行,避免了从开头到 Main 脚本之间的所有脚本的重新执行。由于产生冲突的、包含 alias 的脚本在这些脚本之中,保证只执行它们一次就从根本杜绝了这个问题的滋生。

在 Ruby 1.8 中,Continuation 的实现模型是与线程的实现模型一致的——线程调度器在进行绿色线程切换的时候,会进行类似 Continuation 的环境跳转。在 Ruby 1.9 中,由于 YARV 虚拟机采用的是本地线程,每个线程拥有自己的私有栈,他们之间就产生了区别。有趣的是,在 Ruby 1.9 中又引入了一个新的和 Continuation 类似的概念,那就是我们的 Fiber(纤程),一种轻量级的线程,它基本可以被看作是 Continuation 的替代品,同时由于效率比 Continuation 高(Continuation 保存栈内存状态的过程开销是巨大的),Ruby 社区就是否保留 Continuation 进行了激烈的辩论,就 1.9 而言,Continuation 仍然被作为标准库保留了下来。

[RMXP] Hangup 异常根除

通用 FSL 信息

脚本说明

灼眼的夏娜曾经发布过一个解决 RMXP 10 秒不刷新就抛出 Hangup 异常,提示“脚本已备份”(这个错误信息是汉化失误)问题的脚本,其原理是创建一个 Ruby 线程,定期调用 Graphics.update,这样就能防止异常的抛出。这个解决方法有一个弊端,就是当 RM 进程阻塞时,Ruby 线程也会停止运行,超过 10 秒后仍如果进程从阻塞中恢复,仍然会抛出 Hangup 异常。我们自然而然地会想:难道不能从根本杜绝 Hangup 的抛出吗?

前不久在写精确获取窗口句柄的时候,发现 RMXP 游戏进程创建了不止一个的 Windows 线程,通过一个一个地结束线程发现,游戏进程最后创建的一个线程恰好就是控制 Hangup 异常抛出的线程。估计 10 秒的计时也是在这个线程内部的用户代码中进行的。那我们简单地、暴力地把这个线程咔嚓掉,不就搞定了吗?我这么做了,结果发现:原来处理程序最终化(即退出时)的也是这个线程,在我把这个线程咔嚓之后,无论是点窗口右上角的关闭按钮,所有脚本都解释完毕,还是在异常抛出到 Ruby 顶层后的正常退出都失效了,只能通过结束进程来关闭游戏。

想了几天,终于想到了解决方法:在程序初始化的时候就暂停掉这个线程,直到游戏需要退出的时候再恢复其用户代码的运行,这样,游戏过程中所有本来应该抛出 Hangup 异常的地方都被鞋盒掉了,这就是这个脚本的暴力之处。但关键就是——怎么在所有需要退出的场合下都进行线程的恢复?点窗口关闭按钮的场合很容易,可以重新定义 exit;所有脚本解释完毕的场合也很容易,可以在 main 脚本下面做相关的处理;而异常抛出到顶层的场合,似乎就不那么么简单了。你说可以在 main 脚本的 begin … rescue … end 那里捕获其它所有异常?那万一在前面定义 Game_Temp 啊 Scene_Title 啊之类的地方发生了异常(也就是解释到 main 脚本之前)怎么办呢?

现在的办法是这样的:游戏运行的时候,RM 会把脚本读取到一个全局数组 $RGSS_SCRIPTS 中,而实际在解释脚本的时候也是在访问这个数组的内容。我们可以越俎代庖,在 RM 解释所有脚本之前(不包括越俎代庖的这个脚本)先把脚本解释完了,然后直接退出程序。换句话说,就是通过 Ruby 的 eval 函数去代替了 RM 的 RGSSEval 函数,解释了所有的脚本。这样一来,在解释脚本过程中所有的异常(当然也不包括越俎代庖脚本中的异常,下面发布是经过调试后的脚本,应该不会有错误了,不过为了保险起见还望大家多多测试)都可以通过 rescue 来捕获到了!当然有利也有弊,这样做了之后,当异常抛出时,提示的消息就只有“eval:#{出错行数}#{出错信息}”,而没有为用户提供脚本的标题,并在脚本编辑器中把光标指向出错的地方了,这是这个脚本的副作用。

当然,发生错误的时候你完全可以暂时屏蔽掉这个脚本,这样就可以把异常后的效果恢复到从前那样了,反正调试的时候你也不一定需要这个脚本。另外,这个脚本在捕获到异常,恢复了线程的运行之后就直接把异常再次抛出给了顶层,所以异常错误信息没有脚本的标题。但其实在循环中,完全可以通过 $RGSS_SCRIPT[subscript] 的第二个元素获取到当前的脚本标题,有心人可以自己改一下,让脚本弹出消息框告诉你这些信息,而不是直接交给 RM 的异常处理机制来处理。

最后,这个脚本完全有可能引起未知的问题,因为被屏蔽掉的线程对我们普通 RM 用户来说还是未知的,万一它还处理了什么其它的东西呢?我们还需要更多的测试。

更新历史

  • 1.2.0827 By 紫苏

    • 更改了配置模块名
    • 更改了 FSL 注释信息
  • 1.2.0805 By 紫苏

    • 脚本开始遵循 FSL
    • 全局范围内改变了脚本结构
  • 1.1.1101 By 紫苏

    • 修正了脚本在 Windows XP 平台下失效的问题
  • 1.0.0927 By 紫苏

    • 初始版本完成

脚本源码

#==============================================================================
# ■  Hangup 异常根除
#    Hangup Exception Eradication
#----------------------------------------------------------------------------
#
#    Hangup 异常是 RMXP 底层引擎内置的一个异常类,游戏进程会在 Graphics.update
#    没有调用超过 10 秒时抛出这个异常。这个脚本使用了 Windows API 暴力地解除
#    了这个限制。
#    使用方法:Hangup 异常根除脚本必须插入到脚本编辑器的最顶端,所有脚本之前,无
#    例外。
#
#----------------------------------------------------------------------------
#
#    更新作者: 紫苏
#    许可协议: FSL -MEE
#    项目版本: 1.2.0827
#    引用网址: 
#    http://bbs.66rpg.com/forum.php?mod=viewthread&tid=134316
#    https://szsu.wordpress.com/2010/08/09/hangup_eradication
#
#----------------------------------------------------------------------------
#
#    - 1.2.0827 By 紫苏
#      * 更改了配置模块名
#      * 更改了 FSL 注释信息
#
#    - 1.2.0805 By 紫苏
#      * 脚本开始遵循 FSL
#      * 全局范围内改变了脚本结构
#
#    - 1.1.1101 By 紫苏
#      * 修正了脚本在 Windows XP 平台下失效的问题
#
#    - 1.0.0927 By 紫苏
#      * 初始版本完成
#
#==============================================================================

$__jmp_here.call if $__jmp_here

#----------------------------------------------------------------------------
# ● 登记 FSL。
#----------------------------------------------------------------------------
$fscript = {} if !$fscript
$fscript['HangupEradication'] = '1.2.0827'

#==============================================================================
# ■ FSL
#------------------------------------------------------------------------------
#  自由RGSS脚本通用公开协议的功能模块。
#==============================================================================

module FSL
  module HangupEradication
    #------------------------------------------------------------------------
    # ● 定义需要的 Windows API。
    #------------------------------------------------------------------------
    OpenThread = Win32API.new('kernel32', 'OpenThread', 'LIL', 'L')
    CloseHandle = Win32API.new('kernel32', 'CloseHandle', 'L', 'I')
    Thread32Next = Win32API.new('kernel32', 'Thread32Next', 'LP', 'I')
    ResumeThread = Win32API.new('kernel32', 'ResumeThread', 'L', 'L')
    SuspendThread = Win32API.new('kernel32', 'SuspendThread', 'L', 'L')
    Thread32First = Win32API.new('kernel32', 'Thread32First', 'LP', 'I')
    GetCurrentProcessId = Win32API.new('kernel32', 'GetCurrentProcessId', 'V', 'L')
    CreateToolhelp32Snapshot = Win32API.new('kernel32', 'CreateToolhelp32Snapshot', 'LL', 'L')
  end
end

#==============================================================================
# ■ HangupEradication
#------------------------------------------------------------------------------
#  处理根除 Hangup 异常的类。
#==============================================================================

class HangupEradication
  include FSL::HangupEradication
  #--------------------------------------------------------------------------
  # ● 初始化对像。
  #--------------------------------------------------------------------------
  def initialize
    @hSnapShot = CreateToolhelp32Snapshot.call(4, 0)
    @hLastThread = OpenThread.call(2, 0, self.getLastThreadId)
    #@hLastThread = OpenThread.call(2097151, 0, threadID)
    ObjectSpace.define_finalizer(self, self.method(:finalize))
  end
  #--------------------------------------------------------------------------
  # ● 获取当前进程创建的最后一个线程的标识。
  #--------------------------------------------------------------------------
  def getLastThreadId
    threadEntry = [28, 0, 0, 0, 0, 0, 0].pack("L*")
    threadId = 0                                          # 线程标识
    found = Thread32First.call(@hSnapShot, threadEntry)   # 准备枚举线程
    while found != 0
      arrThreadEntry = threadEntry.unpack("L*")           # 线程数据解包
      if arrThreadEntry[3] == GetCurrentProcessId.call    # 匹配进程标识
        threadId = arrThreadEntry[2]                      # 记录线程标识
      end
      found = Thread32Next.call(@hSnapShot, threadEntry)  # 下一个线程
    end
    return threadId
  end
  #--------------------------------------------------------------------------
  # ● 根除 Hangup 异常。
  #     2       : “暂停和恢复线程访问权限”代码;
  #     2097151 : “所有可能的访问权限”代码(Windows XP 平台下无效)。
  #--------------------------------------------------------------------------
  def eradicate
    SuspendThread.call(@hLastThread)
  end
  #--------------------------------------------------------------------------
  # ● 恢复 Hangup 异常。
  #--------------------------------------------------------------------------
  def resume
    while ResumeThread.call(@hLastThread) > 1; end        # 恢复最后一个线程
  end
  #--------------------------------------------------------------------------
  # ● 最终化对像。
  #--------------------------------------------------------------------------
  def finalize
    CloseHandle.call(@hSnapShot)
    CloseHandle.call(@hLastThread)
  end
end

hangupEradication = HangupEradication.new
hangupEradication.eradicate

callcc { |$__jmp_here| }                                  # F12 后的跳转标记

#==============================================================================
# ■ 游戏主过程
#------------------------------------------------------------------------------
#  游戏脚本的解释从这个外壳开始。
#==============================================================================

for subscript in 1...$RGSS_SCRIPTS.size
  begin
    eval(Zlib::Inflate.inflate($RGSS_SCRIPTS[subscript][2]))
  rescue Exception => ex
    # 异常发生并抛出给解释器时恢复线程。
    hangupEradication.resume unless defined?(Reset) and ex.class == Reset
    raise ex
  end
end

hangupEradication.resume
exit

已知 BUG 与冲突

  • 脚本在 Windows XP 平台下失效(已修复)

RMXP 事件“脚本”的 BUG

首先要知道,事件中写的脚本是利用 Ruby 的 eval 这个全局函数,产生一个子解释器来执行的,那我们就来看看脚本中调用这个函数来解释脚本语句的地方——Interpreter 7。

在 Interpreter 7 的最后一个函数 command_355 中,事件脚本中的语句被一行一行地读取到了 script 这个局部变量中,然后有这样一段脚本:

    # 评价
    result = eval(script)
    # 返回值为 false 的情况下
    if result == false
      # 结束
      return false
    end

eval 的返回值是解释到的最后一个语句的返回值,比如 eval 这两个语句就会返回 false:

eval("a = false")

而在 command_355 中, eval 的返回值被保存到了 result 中,如果 result 是 false,那么 command_355 就会返回 false;返回 false 之后,调用 command_355 的 execute_command 函数(Interpreter 2)就会接着返回 command_355 的返回值,也就是 false;而在调用 execute_command 的函数 update 中(Interpreter 1)有这样一段:

      # 尝试执行事件列表、返回值为 false 的情况下
      if execute_command == false
        return
      end
      # 推进索引
      @index += 1

当 execute_command 返回了 false 之后,整个 update 会直接结束(返回),这样的话 @index += 1 就没有被执行。

@index 是事件指令的索引,事件页中一行就是一个指令。当事件脚本框中只有一句脚本,且返回值是 false 时,@index 会一直保持在当前这句脚本命令行所对应的索引值,这是关键。

接下来场景继续刷新,再次调用了这个事件的 Interpreter 的 update,再次调用了 execute_command, execute_command 会根据 @index 的值来执行索引所对应的那条事件指令。可是由于前一次调用 execute_command 时它返回了 false,导致 @index 没有递增 1,所以这时 @index 还是对应之前 command_355 返回 false 时的那一句脚本,于是 command_355 再次被调用,这一句脚本再次被执行。可是,eval 这句脚本之后,它还是返回 false!于是上述的步骤再次重复,形成了一个无限循环。

以上就是 RMXP 事件脚本中存在的 BUG,当你直接在事件脚本中写一句“false”,整个事件就会循环被执行,除非这个事件是并行处理,否则你的人物会无法做出任何行动,因为你触发的事件一直在执行。

回过头再来看楼主的这一句脚本:

$game_switches[1] = !$game_switches[1]

按上面所说的这个流程来推导,就不难发现这句脚本会导致的问题。

首先,所有开关初始为 false;!$game_switches[1] 这个表达式相当于 !false,也就是 true;于是这条语句变成了:$game_switches[1] = true,eval 的返回值这时是 true,事件脚本执行结束,开关被打开,一切都没有问题。

当你再次触发这个事件时,!$game_switches[1] 就相当于 !true 了,所以这条语句就变成了:$game_switches[1] = false,这时 eval 返回了 false,上述所说的糟糕情况发生了,@index 没有被增加,导致事件解释器重新刷新时这条语句再次被执行。

但是,和上面提到的无限循环不同,这次的语句是一个逻辑取反的过程,当这条语句第二次被执行时,开关已经被改为了 false,于是取反后变为了 true,eval 也就返回 true,事件脚本结束。也就是说,在第一次你触发事件后,开关由 false 变为了 true;之后每再触发这个事件,开关就会由 true 变为 false,但马上又会被紧跟着的第二次刷新变回为 true,导致了之后你无论怎么按该开关都无法将开关变回 false 的情况。

解决办法一:问题的根本在于 eval 返回了 false,我们可以让在事件中写下的脚本永远能返回 true,于是我们把这个开关操作的语句改为:

$game_switches[1] = !$game_switches[1]
true

这样一来,脚本的最后一条语句是 true,eval 也就永远返回 true 了。

解决办法二:问题的根本还在于返回 false 的事件脚本语句只有一行——第一行脚本的事件代码(355)和非第一行脚本的事件代码(655)是不同的,只有 @index 对应的事件代码是 355 的时候,execute_command 才会被调用。所以 @index 保持在第一行脚本命令的索引的话,触发一次事件就会多次调用 command_355。我们可以在 $game_switches[1] = !$game_switches[1] 后面按一下回车,让事件脚本变成两行(虽然第二行只是空行),这样一来在 command_355 按行循环添加事件脚本语句的时候 @index 就会增加,接着索引所对应的事件代码变为了 655,execute_command 会判断这个事件代码,发现它并不是第一行事件脚本的代码,于是就会直接返回 true。这时 eval 虽然返回的还是 false,但 @index 已经增加到达了事件页的最大索引,于是事件结束了。撒花!

论 Ruby 顶层及 Object、Kernel 的关系

Ruby 的顶层环境、Kernel 模块和 Object 类之间的关系是一个让很多人都觉得费解的问题,这篇文章便是要对这个话题进行深入的探讨。考虑以下在 Ruby 顶层环境中运行的代码:

p "hello"

这个看似纯命令的语句,在完全面向对象的 Ruby 语言中,实际上也是一个面向对象的方法调用。因为在 Ruby 中,任何没有接收者的方法调用,解释器都会隐式地让 self 成为接收者。即是说,上面的代码等同于:

self.p "hello"

但是如果直接用第二种方式调用,会有一个访问权限的问题,这个稍后再说。我们知道 self 在一个类上下文中可以引用到类本身,在一个类的实例上下文中可以引用实例/对象本身(当然在单例环境中,self 引用的就是这个单一实例本身),那么在顶层环境中它引用的是什么呢?

p self.class

这一行代码告诉了你 self 是什么,那就是一个 Object 类型的直接实例,而它的 to_s 返回 “main”(既一个描述对象的字符串),所以我们可以把它看作是一个名字叫 main 的 Object 类型对象。这是你的程序开始运行后就生成的一个默认对象,纯粹是为了满足 Ruby 完全面向对象编程“在任何上下文中都有且仅有一个 self 指向当前的对象”的概念。

这里有一个 RMXP 的 Ruby 和标准 Ruby 的区别,在 RMXP 中,顶层的 self 很神奇地指向 nil 这个 NilClass 的单一实例,即是说在顶层中调用的方法实际上其接收者都是 nil。

那么顶层函数在 Ruby 中到底属于谁呢?由于底层获取的 self 是 Object 的一个实例,所以顶层实际上就是 Object 的实例上下文,顶层函数自然也是属于 Object 类的。

class Object
  def self.method_added(id)
    print "添加方法 ", id.id2name
  end
end

def foo
  p "foo"
end

这段代码中的 method_added 方法是 Module 的一个回调函数,每当一个 Module(当然,包括其子类 Class 类)被(动态地)添加了实例方法时,这个函数都会被调用。

当我们在顶层中定义了一个 foo 时,输出了 “添加方法 foo”,可见在顶层中定义的方法确实是被作为一个实例方法加入到了 Object 类中。

这里就有了第二个 RMXP 的 Ruby 的特点——用户在顶层中定义的方法加入到 Object 中后,其访问权限被设为了公有(public);然而在标准 Ruby 中,加入到 Object 后其访问权限应该是私有的(private)。这个的区别就在于,上面定义的方法 foo 在 RMXP 的 Ruby 中可以任意使用任何接收者,因为 Object 是所有对象的泛型,所以 foo 也同时属于任何类(包括各种 Module 和 Class 类型,所以下面的 Module.foo 也是合法的,这是 Ruby 完全面向对象概念中一个很容易产生混淆的地方)。

 
3.1415926.foo
true.foo
nil.foo
Module.foo

而这样的调用在纯 Ruby 中就会因为无权限访问而报错,因为添加了任何接收者后,不管这个接收者是不是这个方法所属类的实例,Ruby 都会认为方法的调用者是来自 Object 外部的,那么接收者那个类自然就不能访问 Object 类私有的成员方法。换句话说就是私有的方法永远不能有显式指定的接收者。以下代码证明了这一点:

 
class A
  def fn
    p "hello"
  end
  private :fn
  def func
    fn        # OK
    self.fn   # Error
  end
end

A.new.func

所以在标准 Ruby 中,永远不能显式地去指定顶层方法的接收者(包括显式指定 self)。

然而在 RMXP 中,类似 self.p “hello” 的语句就会和标准 Ruby 一样发生越权访问的错误了。要解释清楚这个,还得先解释清楚 Kernel 和 Object 的关系。

首先,Object 类本身只定义了一个方法,那就是 initialize,除此之外,可以说 Object 就是个空的框架。证明:

p Object.singleton_methods                 # Object 定义的类方法
p Object.public_instance_methods(false)    # Object 定义的公有实例方法
p Object.protected_instance_methods(false) # Object 定义的受保护实例方法
p Object.private_instance_methods(false)   # Object 定义的私有实例方法

这几行代码都是获取 Object 类定义的各种方法,传递一个 false 表示除去父类(当然,Object 在 Ruby 1.8 中没有可见的父类,只有在 mixin 了 Kernel 后隐式添加的匿名父类)和 mixin 的模块中的实例方法,也就是只获取这个类自己定义的方法。输出发现只有一个私有实例方法 initialize 是 Object 定义的。

那么平时我们用的那些 Object 的实例方法到底是从哪儿来的?答案是:全都是在 Kernel 中定义的。有兴趣的可以去翻一翻 Ruby 的手册,你会发现 Object 类的帮助文档开头就写着“虽然 Object 的实例方法是由 Kernel 模块定义的,但为了泾渭分明,我们还是选择在这里(即 Object 类的说明中)记载这些方法。”像 __id__ 、clone、,equal? 之类的方法,都是在 Kernel 中定义的私有实例方法。证明:

p Kernel.public_instance_methods(false)

输出了所有 Kernel 本身定义的公有实例方法,而这些方法都是我们平时耳熟能详的所谓的“Object 实例方法”!Object 类建立时 mixin 了 Kernel 模块的所有实例方法,所以每当我们调用某个对象继承自 Object 的方法时,解释器实际上并没有在 Object 中找到该方法,而是在 Kernel 被 mixin 后产生的 Object 的匿名父类中找到的。

那么我们用得更多的那些来自 Kernel 的 p、print、exit 等方法呢?

p Kernel.private_instance_methods(false) # Kernel 定义的私有实例方法
p Kernel.singleton_methods(false)        # Kernel 定义的模块方法

输出发现,原来这些函数同时是 Kernel 的模块方法和私有实例方法。按照常理来想,这些函数都是有关 Ruby 程序核心的一些重要方法,它们应该有全局和静态的特性,所以把他们写为 Kernel 的模块方法再合适不过。而这里 Kernel 把所有这些静态的方法都复制了一份,弄成了私有的实例模块方法,想来纯粹是为了让 Object 类进行 Kernel 的 mixin 时能够一并 mixin Kernel 的这些特殊方法(因为 mixin 一个模块并不能 mixin 它的模块方法,即属于该模块本身这个对象的单例方法),这样它们同时就变成了 Object 的私有实例方法,那么只要不指定接收者,程序员就可以在 Ruby 的任何上下文中访问这些函数,因为不显式指定接收者就会默认接收者为 self,而 self 永远都是 Object 类型,自然也就永远能够访问 Object 的私有实例方法。

由于 p 是 Object 通过 Kernel mixin 而得到的私有实例方法,当你像 self.p “hello” 这样直接指定了 self 为接收者时,不管 self 是什么,Ruby 都会认为调用者对 p 的调用是来自 Object 外部,于是发生了越权访问的错误。

以下两种方式,调用的是完全不同、相互独立的两个方法:

Kernel.exit
exit

前者是在 Kernel 中定义的模块方法,定义方式类似 def Kernel.exit … end;后者则是 Object 中的私有实例方法,只不过它不是在 Object 中定义的,所以会路由到 Kernel 中的同名私有实例方法。这两者的区别差不多就是下面两种 foo 方法的区别:

module Kernel
  def Kernel.foo
    p "类方法 foo 调用"
  end
  private
  def foo
      p "Object/Kernel 私有实例方法 foo 调用"
  end
end

foo        # => "Object/Kernel 私有实例方法 foo 调用"
Kernel.foo # => "类方法 foo 调用"

这就是为什么你直接在顶层重定义 print 能够覆盖不加接受者时的 print,而不能覆盖 Kernel.print,因为这样的重定义是相当于给 Object 类定义了一个公有的(在 RMXP 中是公有的)实例方法,而不是覆盖了 Kernel 的模块方法(实际上连 Kernel 的同名私有实例方法都没有覆盖)。由于这样的覆盖是在 Object 中定义了 print,当你直接调用 print 的时候就不会再次路由到 Kernel 中的那个私有实例方法 print 了。你可以很容易地发现覆盖 print 前后的区别——覆盖前 self.print 报错,而覆盖之后 self.print 就能正常运行,这便是因为覆盖之后你定义的就是公有的方法了。

如果直接采用这样的写法:

module Kernel
  def print(x)
    # ...
  end
end

那就是覆盖的 Kernel 的私有实例方法;如果:

module Kernel
  def Kernel.print(x)
    # ...
  end
end

那就是覆盖的 Kernel 的模块方法。Kernel.print 这样的调用方法就是在调用 Kernel 的模块方法 print。

至于在顶层定义的局部变量,是真正仅属于顶层的局部变量,在其它上下文中,只能通过顶层的 Binding 进行 eval 来访问这些局部变量。而在顶层的常量,又涉及到另一个概念——常量定义点——一个被 Ruby 核心开发团队的成员称为 cref 的东西。这不属于本文讨论的范畴,我们目前只需要知道,顶层的常量会被定义到 Object 这个命名空间中。所以像——

ORZ = 1
p Object::ORZ

这样的常量,就相当于全局的常量了,因为它们属于 Object 这个命名空间,所以在任何其它上下文中都能访问。

最后再给一个例子:

module Mod
  def self.foo
    p "模块方法"
  end
  def foo
    p "模块实例方法"
  end
end

class A
  include Mod
  def foo
    p "类实例方法"
  end
end
class B
  include Mod
end

A.new.foo               # "类实例方法"
B.new.foo               # "模块实例方法"
Mod.foo                 # "模块方法"

module Mod
  def foo
    p "lmao"
  end
end

A.new.foo               # "类实例方法"
B.new.foo               # "lmao"
Mod.foo                 # "模块方法"

模块方法 foo 自始至终没有被改变,B 类的实例方法 foo 自始至终指向 Mod 模块的实例方法 foo,而 A 类的实例方法 foo 一开始就被 A 本身重定义、覆盖了。