一个 Ruby 文件可以只有三行,但运行它需要的不只是这三行。解释器版本、加载的 gem、输入字节、当前目录和环境变量都可能改变结果。学习语言时,先把这些条件固定,后面的对象模型和并发结论才有可复核的依据。

本系列使用 Ruby 3.4.11 和 CRuby。这个版本是教学基线,不代表 Ruby 的最新版。官方发布包的 SHA256 是 5c22be44524312b3d433d68739bcc530633b1da5ef8ba0afa0a37680da17d3de;本文工程实际从该包构建。版本冻结、依赖锁和源码 revision 分别记录在工程与研究资料里。Ruby 3.4.11 发布说明

文章卡

项目 内容
先修 一种通用编程语言;能运行终端命令
核心问题 怎样证明命令使用了所选解释器,并且结果满足约定?
累计工程 examples/ruby/lib/taskbook.rb,内存任务校验、筛选和统计
实验入口 工程根目录执行 ruby labs/00/check.rb
验收 正确筛选、统计、输入拒绝和复制隔离都有可失败断言
版本边界 CRuby 3.4.11;Rails 与其他实现不参与本篇

从解释器开始确认环境

工程里的 .ruby-version 内容是 3.4.11。它供版本管理器读取,文件本身不会切换解释器;一个终端可以无视它继续调用系统 Ruby。进入工程后先执行:

1
2
3
4
5
6
cd examples/ruby
command -v ruby
ruby -v
ruby -e 'puts RUBY_ENGINE; puts RUBY_PLATFORM'
gem --version
bundle --version

command -v 说明 Shell 找到了哪个入口,ruby -v 说明这个入口实际启动的解释器,RUBY_ENGINE 区分 CRuby 与 JRuby 等实现。路径、版本和实现需要同时记录。只截图终端提示符中的版本名称,不能证明子进程用了同一个 Ruby。

这次实验在 arm64 macOS 上构建独立解释器,安装在临时目录,未替换系统 Ruby。读者可以使用已有的版本管理器,也可以从官方包构建;验收依据仍是实际输出,而不是安装工具的名称。把平台记录下来,是为了避免将后文的信号、进程和性能结果直接套到 Windows 或另一种 CPU 上。

用一个小任务工具贯穿系列

Taskbook 处理合成任务,每项只有五个字段:

1
2
3
4
5
6
7
task = {
id: 1,
title: '复核输入',
priority: 5,
tags: ['ruby', 'review'],
status: :todo
}

ID 必须是正整数且在清单内唯一;标题和标签必须是有效 UTF-8 编码的非空字符串;优先级是 1 到 5 的整数;状态只接受 :todo 与 :done。字段不齐全、出现额外字段、用字符串代替优先级,都会被拒绝。JSON 中的字符串键如何转换,留给第 26 篇的外部数据边界处理,核心库不顺便猜测输入意图。

这份约定不复杂,却足以暴露语言问题:0 在条件里是什么值?Hash 默认值会不会被共享?筛选后的标签还能否改动原输入?方法收到的是关键字,还是最后一个 Hash?同一份案例可以逐步验证这些问题,而不必先学数据库和 Web 框架。

核心 API 是三个普通模块方法:

1
2
3
4
5
6
7
8
9
10
11
require_relative 'lib/taskbook'

tasks = Taskbook.sample_tasks
picked = Taskbook.filter(tasks, min_priority: 4, tag: 'ruby')
raise 'wrong ids' unless picked.map { |task| task[:id] } == [1, 3]

stats = Taskbook.summary(tasks)
raise 'wrong summary' unless stats == {
total: 3,
by_status: { todo: 2, done: 1 }
}

require_relative 相对当前源文件定位库,与执行命令时的工作目录不同。这段代码保存到工程根目录才能使用这里的相对路径;正式 lab 已按自己的位置处理路径。加载库只定义能力,不自动读文件、开端口或处理任务,因此测试可以控制每次调用的输入。

filter 保留原始顺序。它先校验并复制任务,再筛选;summary 也先校验。这里选择明确拒绝非法数据,不把格式错误当成“没有结果”。复制不是为了让所有 Ruby 对象都深度不可变,而是针对已声明的五个字段复制可变字符串和标签数组。

成功、失败与别名都需要断言

打印 [1, 3] 只能供人观察。若程序后来打印 [3, 1],终端仍会正常退出。raise unless 将约定转化为退出状态:满足则继续,不满足则抛出异常并返回非零。第 00 篇暂时使用这种最小断言,第 23 篇再加入 Minitest 的用例、统计和统一入口。

1
2
3
4
5
6
7
8
9
10
11
12
source = Taskbook.sample_tasks
copy = Taskbook.validate(source)
source[0][:tags] << 'later'
raise 'shared tags' if copy[0][:tags].include?('later')

bad = Taskbook.sample_tasks
bad[0][:priority] = 6
begin
Taskbook.validate(bad)
raise 'bad priority accepted'
rescue ArgumentError
end

第二个实验有一个关键位置:raise 'bad priority accepted' 产生的是 RuntimeError,不会被这里的 rescue ArgumentError 吞掉。若把两个异常都统一捕获,反例可能永远通过。验证错误行为时,要捕获约定的异常类型,并让“没有发生预期错误”本身成为失败。

labs/00/check.rb 还验证空筛选、重复 ID、非法标题、错误标签类型、错误状态和未知字段。合法 UTF-16 与坏 UTF-8 都应得到 ArgumentError;筛选参数 tag 遵守同一编码限制。通过说明这些固定输入的结果符合契约,不等于任意规模和任意输入已经被证明正确。后面的测试会补充边界,保持每次证明的范围清楚。

语言实验与累计应用分开组织

工程分成 lib、exe、test、labs 和 evidence。lib 承担实际任务能力,exe 提供 CLI,test 检查应用行为。labs/NN 保存某个语言机制的正反实验;evidence/NN 保存环境、命令、实际输出和判定。

单例类、非局部 return、Fiber scheduler 和字节码观察适合独立 lab,不必全部进入任务工具。为了演示元编程而把五个普通字段改成动态方法生成器,会增加阅读成本;案例只在能力确实需要时增长。

依赖阶段再执行:

1
2
3
bundle install
bundle exec ruby labs/00/check.rb
bundle exec ruby -Ilib test/engineering_boundaries_test.rb

Gemfile.lock 冻结该工程验证时使用的依赖;gemspec 声明库对外兼容的范围。锁文件不是所有用户安装同一版本的保证,也不证明任何新平台均可安装。第 21、22 与 39 篇分别检查版本激活、包内容和临时目录中的实际执行。

阅读路线与实验材料

第 00 到 08 篇讨论表达式、参数和枚举。第 09 到 17 篇讨论名字绑定、方法查找与元编程。第 18 到 29 篇形成有资源边界和反馈工具的应用。第 30 到 39 篇用受控实验观察并发、内存、执行路径与完整交付。Rails 等框架属于写作计划的选修,不是理解核心语言的前提。

完整源码包提供所有主线实验、固定依赖和运行记录:下载 Taskbook 与 Ruby labs。解压后以 examples/ruby 为工程根目录;先检查 Ruby 版本,再按篇目运行 lab。源码包内的结果文件供对照,重新运行时应保存本机输出,尤其不能把另一台机器的性能数字当成本机结果。

本篇练习是在副本中删掉优先级上界检查,再运行第 00 篇实验。应出现“非法优先级被接受”的失败。随后恢复检查,把一项状态改成字符串 'todo',确认核心库拒绝它。这两个操作分别检查验收是否能发现错误、输入契约是否明确,完成后不用扩充业务功能。

主线目录

主线 40 篇与选修 8 篇均已写成,每篇附独立实验,共 48 篇。主线以 CRuby 3.4.11 为基线;选修另行冻结 Rails、JRuby、原生构建与数据库环境。

篇 主题
00 导读与可重复运行
01 表达式与控制流
02 数字与转换
03 String、Symbol 与编码
04 Array、Hash 与 Range
05 方法与参数绑定
06 block、Proc 与 lambda
07 闭包与非局部控制流
08 Enumerable、Enumerator 与 Lazy
09 作用域、self 与常量:名字怎样绑定到对象
10 身份、相等、复制与冻结:谁共享了可变状态
11 类、实例与可见性:对象怎样维护合法状态
12 单例类、方法查找与 super:调用命中了哪个方法
13 Module、include、prepend 与 refinement:复用影响哪条查找链
14 鸭子类型与委托:不继承也能满足什么契约
15 Struct、Data 与模式匹配:数据如何被拆解
16 动态方法、反射与钩子:元编程怎样保持可诊断
17 DSL、instance_eval 与 class_eval:配置怎样获得执行上下文
18 异常、ensure 与 throw/catch
19 文件、IO 与路径的资源边界
20 require、load 与 autoload
21 RubyGems、Bundler 与版本激活
22 gemspec 与可安装的交付边界
23 Minitest 与可失败的行为测试
24 调试器、警告与语法诊断
25 RBS、Steep 与静态检查边界
26 JSON、CSV 与领域输入边界
27 CLI、OptionParser 与退出码
28 Net::HTTP、Rack 与本地请求实验
29 配置、日志、信号与有序关闭
30 Thread、Mutex、Queue 与 GVL
31 Fiber 与 scheduler
32 Ractor 与对象隔离
33 进程、超时与取消
34 对象分配与 GC
35 Prism、YARV 与字节码
36 YJIT 与性能实验
37 Ruby 惯用法与必要优化
38 输入与安全边界
39 综合交付与可复现使用

选修目录

篇 主题
E01 深入 Ruby E01:Rails 与 ActiveSupport 的语言边界
E02 深入 Ruby E02:RSpec、契约测试与可重放的生成式测试
E03 深入 Ruby E03:JRuby 与 JVM
E04 深入 Ruby E04:C扩展与 FFI
E05 深入 Ruby E05:gem 维护与版本升级的兼容证据
E06 深入 Ruby E06:SQL 与事务:对象状态何时成为数据库事实
E07 深入 Ruby E07:受限规则语言:从 block DSL 到独立解析器
E08 深入 Ruby E08:后台任务与消息处理:重试怎样改变副作用

选修按各篇前置概念进入。Rails 与 SQL 使用各自锁文件,JRuby 需要 JDK 21,原生扩展需要编译器;全部实验入口为 bundle exec ruby verify.rb,首次运行前按各 lab 安装说明准备依赖。

参考资料

系列导航

导读 · 下一篇:01:表达式与控制流 · 完整源码包