深入 Ruby 20:require、load 与 autoload
一个文件执行了几次
在文件顶层增加计数器,连续加载四次,不一定得到四:
1 | |
第一次 require 执行文件,第二次返回 false。两次 load 都执行文件,因此计数器最后是 3。这个实验把“找到文件”“执行文件”“记录已经加载”拆开了。只有分清这几件事,才能解释为什么修改文件后重新 require 没变化,或为什么重复 load 会重复注册回调。
前置是常量作用域、Module 和异常传播。本篇只讨论 Ruby 核心加载行为,实验是 CRuby 3.4.11 的单线程进程;Rails 的自动加载和代码重载不是这些核心方法的同义词。完整脚本为 examples/ruby/labs/20/run.rb。
require 的缓存是当前进程的状态
$LOADED_FEATURES 保存已经加载的特性。require 的查找与扩展名处理还会涉及 Ruby 文件和原生扩展,不能把它简化成一次普通的 File.read。实验用绝对路径排除了查找位置的干扰,再检查该路径进入已加载特性集合。
这个状态属于当前 Ruby 进程。另起一个进程再次 require,会从新进程自己的加载状态出发;一台机器上“已经安装”某个 gem,并不意味着每个进程都已经执行其入口。第 21 篇会进一步区分安装、依赖选择和文件加载。
require 缓存的是特性加载,不缓存文件内所有方法的调用结果。入口文件只在第一次加载时定义 Taskbook.filter,之后每次调用该方法仍会执行方法体。若把耗时读取、网络访问或线程启动写在文件顶层,它们会变成加载副作用,使 require 的使用者难以控制启动顺序。
库入口因此应主要定义常量、方法和必要依赖;实际业务操作由调用者显式触发。这样,测试可以加载库而不创建文件,命令行帮助可以展示参数而不启动后台任务,打包验证也能检查入口是否可加载。加载阶段越接近声明阶段,越容易隔离失败。
查找路径和相对路径的参照物
require 'taskbook' 根据加载路径寻找入口。ruby -Ilib 把工程的 lib 加入这次运行的查找路径,常用于开发阶段。gem 安装并激活后,RubyGems 会让 gem 的相关库目录参与加载。这里的目录名 lib 是打包约定,语言不会自动把项目里任何名为 lib 的目录识别成当前工程。
文件内部引用同一库的邻近文件时,require_relative 将路径按当前源文件的位置解释。工作目录则属于启动进程的调用环境。把相邻文件写成 require './helper',通常会把关系绑定到当前工作目录,而不是调用这个语句的源文件所在位置。
区别可以通过两个目录启动同一脚本观察。路径 bug 在仓库根目录运行时经常消失,打包后从临时目录调用才暴露,因此第 22 篇会把“离开源码目录运行”设为验收条件。只有在项目根目录可用的库,交付边界仍然没有建立。
不应通过把所有目录都加入全局 $LOAD_PATH 来掩盖错误的相对关系。多个库包含同名文件时,查找先后顺序会影响实际加载对象。定位这类问题时,需要同时检查 $LOAD_PATH、$LOADED_FEATURES、方法的 source_location 与实际激活的 gem 版本,而非只看编辑器里打开了哪个文件。Kernel 文档 是核心加载规则的入口。
load 会重新执行,但不会撤销旧世界
load 适合需要明确重执行脚本的局部场景。它没有承诺把上次运行产生的常量、实例、监听器、线程或其他副作用全部恢复到未加载状态。
例如文件第一次定义了两个方法,第二次修改只保留一个方法。重执行类体会更新这次出现的方法定义,却不天然删除没有再次出现的旧方法。已经创建的对象还可能引用旧的类对象、旧闭包或旧数据。重复执行与完整应用重载是两种不同的问题。
load 的包装选项可以给顶层定义提供匿名模块环境,但也不等于安全沙箱。脚本仍可能访问全局状态、文件系统和网络;它依旧是运行于当前进程权限下的 Ruby 代码。将用户配置文件用 load 执行,意味着把代码执行权交给了配置提供者。Taskbook 的外部输入采用 JSON/CSV,保持数据与执行能力分离。
重复顶层副作用是更直接的风险。一个文件每次加载都向全局数组添加处理器,第二次就会处理两遍事件。只有业务确实需要重执行时才选择 load,并把可重复调用作为文件自身的契约,而不是事后清空加载缓存来猜测行为。
autoload 推迟的是常量相关加载
autoload 可以登记常量与文件的关系,在实际访问常量时才执行加载:
1 | |
实验中的文件内容为:
1 | |
登记结束时,目标文件尚不需要完成普通常量定义;常量访问触发加载之后,定义才可用。这个顺序把某些启动成本推迟到首次使用,但也把文件不存在、定义拼写错误和加载内部异常推迟到了实际访问时。
因此延迟加载不一定适合所有小型工具。Taskbook 的入口关系很短,显式 require 更容易让启动失败尽早暴露。大型库可以有意使用延迟加载缩短未使用模块的启动成本,但需要确认加载文件实际定义了约定的常量,并对并发访问条件进行专门测试。Module 文档 描述了登记与查询接口。
文档中的核心 autoload 与框架提供的文件命名推断、开发环境重载、依赖追踪不在同一层。把框架里“文件名决定常量名”的经验直接用于普通 Ruby,会漏掉显式加载关系。反过来,手工清理核心常量也不能替代框架的重载生命周期。
循环依赖可能看到半初始化状态
两个文件可以互相 require,却不意味着所有定义都已经准备好。实验写出以下加载结构:
1 | |
路径在实验运行时由临时目录生成,上面的字面路径只展示依赖结构。A 开始执行后进入 B,B 再要求 A;此时 A 位于加载过程中,其后面的常量赋值还没有发生。实验最终得到 CycleComplete == true 与 CycleObserved == nil。A 结束时状态完整,并不能改变 B 先前观察到的值。
真正的问题是初始化依赖方向。把公共声明移到双方都能加载的较低层,或把需要另一方结果的动作推迟到显式方法调用,通常比调整两条 require 的顺序更稳妥。只靠改变启动入口暂时修复,可能在测试单独加载某个文件时再次失败。
并发条件还可能引入加载锁与等待关系。Ruby 官方问题库的 21719 记录过涉及显式加载、autoload 和线程的死锁问题。本篇单线程案例没有重现或验证该问题的全部条件,引用它是为了限制推论:循环能够避免无限递归,不等于循环依赖在任意并发调用下都正确。
加载失败需要追踪最先失败的位置
文件不存在、原生扩展加载失败、文件语法错误和顶层执行抛异常,都会使“加载这个库”失败,但它们发生在不同阶段。捕获一个宽泛异常后继续启动,可能留下只有部分常量已经定义的进程。此时后续错误看起来离真正原因很远,增加诊断成本。
入口文件已经开始执行,不意味着它的所有依赖都完成。一个文件先定义常量再加载另一个失败文件,前面的常量定义可能已经发生。因此启动失败后直接在同一长生命周期进程中重复加载,需要考虑上次残留状态。测试时新进程可以提供更干净的隔离,避免把手工修改缓存当成初始化恢复方案。
排查步骤应沿依赖链定位首个失败。先看异常类和最早相关源位置,确认要求的特性名称、实际加载路径与依赖版本;再检查是否由于当前工作目录、扩展构建或顶层业务副作用导致。只根据最后一条 NameError 补一个 require,容易让循环依赖更复杂。
加载策略也影响测试粒度。测试只加载一个内部文件,若该文件暗中假定主入口已经先定义其他常量,单独运行就会失败。可以把“内部文件只能由主入口加载”作为明确约定,也可以让内部文件声明自己的必要依赖,但不能两种方式混用却不说明。Taskbook 的扩展文件显式 require 主模块,使 IO 与 HTTP 的公共入口能够独立使用。
运行与练习
1 | |
脚本检查 require 的返回值与计数、load 重执行、autoload 触发和循环中的半初始化观察。成功输出以 PASS 20 开头,记录见 evidence/20/run.txt。临时文件由脚本创建和清理,不能把文中示意路径直接复制到生产配置。
练习一:把顶层计数器替换为向数组追加一个处理器,验证两次 load 的重复注册,再改成显式注册函数。练习二:移除 A 与 B 的循环,把共同常量放入第三个文件,从两个不同工作目录分别加载 A 和 B,检查结果是否仍然一致。
系列导航
导读 · 上一篇:19:文件、IO 与路径的资源边界 · 下一篇:21:RubyGems、Bundler 与版本激活 · 完整源码包
