第四篇:Block、Proc、Lambda 与 `yield`

如果说对象与消息是 Ruby 的地基,那么 Block 就是 Ruby 开始拥有气质的地方。

Ruby 的很多代码第一次看起来都很轻:

ruby 复制代码
3.times do |index|
  puts index
end

或者:

ruby 复制代码
users.select { |user| user.active? }

甚至:

ruby 复制代码
File.open("data.txt") do |file|
  puts file.read
end

语法并不复杂,真正容易卡住的是:

do ... end 到底属于谁?

|index| 里的值是谁传进来的?

yield 又是在做什么?

这一篇先不急着记术语。我们从"谁在调用谁"开始。


1. 从一段熟悉的集合处理开始

Ruby:

ruby 复制代码
result = [1, 2, 3, 4, 5]
  .select { |number| number > 2 }
  .map { |number| number * 10 }

puts result

结果是:

text 复制代码
30
40
50

如果你来自 Java,很容易把它映射成 Stream:

java 复制代码
var result = List.of(1, 2, 3, 4, 5)
    .stream()
    .filter(number -> number > 2)
    .map(number -> number * 10)
    .toList();

JavaScript 也类似:

javascript 复制代码
const result = [1, 2, 3, 4, 5]
  .filter(number => number > 2)
  .map(number => number * 10);

今天看来,很多语言都有"把一段行为传进去"的能力。

但 Ruby 很早就把这种表达方式放到了语言中心:

方法不只接收数据,也可以在调用时附带一段尚未执行的代码。

Ruby 把这段附带的代码叫做 Block

不过如果只记住这句话,仍然很容易觉得 Block 很抽象。

所以先来看一次最简单的执行过程。


2. 别急着记定义:先看 Block 到底是谁在调用谁

先看:

ruby 复制代码
3.times do |index|
  puts "round #{index}"
end

第一次看到这段代码,可以先把它拆成两部分。

第一部分是普通的方法调用:

ruby 复制代码
3.times

它的意思很普通:

text 复制代码
对整数对象 3 调用 times 方法

第二部分是跟在这次方法调用后面的代码:

ruby 复制代码
do |index|
  puts "round #{index}"
end

这就是 Block。

可以先把整个调用想象成:

text 复制代码
3.times
   │
   ├── 调用 times 方法
   │
   └── 同时附带一段 Block
             │
             ▼
      ┌─────────────────────┐
      │ |index|             │
      │ puts "round ..."    │
      └─────────────────────┘

这里最重要的一点是:

不是 Block 自己决定执行三次,而是 times 方法决定执行 Block 三次。

Block 只负责描述:

"如果你什么时候想执行我,我要做什么。"

times 决定:

"我要什么时候执行你、执行几次、每次给你什么参数。"

这就是 Ruby Block 最核心的控制关系。


3. |index| 到底是谁赋值的

继续看:

ruby 复制代码
3.times do |index|
  puts index
end

这里很容易产生一个误解:

ruby 复制代码
|index|

看起来很像 times 方法的参数。

其实不是。

times 的方法调用本身仍然只是:

ruby 复制代码
3.times

而:

ruby 复制代码
|index|

Block 的参数列表

真正的问题变成了:

谁把值塞进 index

答案是:

times 方法。

为了理解它,我们可以写一个"概念版"的 times

注意,下面不是 Ruby 标准库 Integer#times 的真实实现,只是为了看清执行过程:

ruby 复制代码
def my_times(number)
  index = 0

  while index < number
    yield index
    index += 1
  end
end

调用:

ruby 复制代码
my_times(3) do |index|
  puts "round #{index}"
end

现在关键语句出现了:

ruby 复制代码
yield index

第一次循环时:

ruby 复制代码
yield 0

于是 Block 收到:

ruby 复制代码
index = 0

然后执行:

ruby 复制代码
puts "round 0"

第二次:

ruby 复制代码
yield 1

于是:

ruby 复制代码
index = 1

第三次同理。

整个过程可以画成:

text 复制代码
调用 my_times(3),同时附带 Block
                │
                ▼
          进入 my_times
                │
          index = 0
                │
          yield 0
                │
                ▼
       执行调用者提供的 Block
            |index|
               0
                │
         puts "round 0"
                │
                ▼
          回到 my_times
                │
          index = 1
                │
          yield 1
                │
                ▼
       再执行一次 Block
                │
              ......

所以以后看到:

ruby 复制代码
something do |x|
  ...
end

可以先问自己:

x 是谁通过 yield 传进来的?

通常一旦找到这个答案,Block 就不会那么神秘了。


4. yield:执行调用者附带的 Block

现在正式看 yield

文章里如果只写一句:

yield 会把控制权交给 Block。

这句话当然没错,但第一次接触时还是有点抽象。

可以先把它翻译成一句更直白的话:

yield = "就在这里,执行一下调用这个方法时附带的那段 Block。"

例如:

ruby 复制代码
def hello
  puts "before"

  yield

  puts "after"
end

调用:

ruby 复制代码
hello do
  puts "Jack"
end

执行过程是:

text 复制代码
进入 hello
    │
    ▼
puts "before"
    │
    ▼
before
    │
    ▼
yield
    │
    ├── 暂时离开 hello
    │
    ▼
执行 Block
puts "Jack"
    │
    ▼
Jack
    │
    ▼
Block 结束
    │
    └── 回到 yield 后面
             │
             ▼
       puts "after"
             │
             ▼
           after

最终输出:

text 复制代码
before
Jack
after

所以初学时,你甚至可以在脑子里把:

ruby 复制代码
yield

临时替换成:

text 复制代码
【在这里执行调用者传进来的 Block】

那么:

ruby 复制代码
def hello
  puts "before"
  yield
  puts "after"
end

就变成:

text 复制代码
before

【调用者的代码】

after

Block 的控制结构感就出来了。


5. 方法不仅能执行 Block,还能给 Block 传参数

yield 后面可以带参数:

ruby 复制代码
def repeat(times)
  times.times do |index|
    yield index
  end
end

调用:

ruby 复制代码
repeat(3) do |index|
  puts "round #{index}"
end

对应关系非常直接:

ruby 复制代码
yield index

把值交给:

ruby 复制代码
|index|

也可以传多个值:

ruby 复制代码
def give_user
  yield "Jack", 18
end

give_user do |name, age|
  puts "#{name} is #{age} years old"
end

这里:

ruby 复制代码
yield "Jack", 18

对应:

ruby 复制代码
|name, age|

所以可以先记住一个非常实用的对应关系:

text 复制代码
yield 后面的值
      ↓
Block 竖线里的参数

例如:

ruby 复制代码
yield user

对应:

ruby 复制代码
|user|
ruby 复制代码
yield key, value

对应:

ruby 复制代码
|key, value|

6. Block 也会产生返回值,而且返回给 yield

理解参数以后,还有另一半很关键:

Block 执行完以后,结果去了哪里?

看一个最简单的例子:

ruby 复制代码
def calculate
  result = yield 10
  puts "Block returned: #{result}"
end

calculate do |number|
  number * 2
end

执行到:

ruby 复制代码
yield 10

Ruby 会执行:

ruby 复制代码
number * 2

得到:

text 复制代码
20

这个 20 会成为 yield 表达式的结果:

ruby 复制代码
result = yield 10

因此:

ruby 复制代码
result == 20

于是输出:

text 复制代码
Block returned: 20

所以 Block 和方法之间其实是双向交流:

text 复制代码
方法
 │
 │ yield 参数
 ▼
Block
 │
 │ 计算结果
 ▼
方法继续执行

这件事对于理解 selectmap 非常重要。


7. 再看 select:这次把执行过程完整拆开

之前看到:

ruby 复制代码
users.select { |user| user.active? }

现在已经可以一点点拆了。

可以想象一个简化版 select

ruby 复制代码
def my_select(array)
  result = []

  for item in array
    keep = yield item

    if keep
      result << item
    end
  end

  result
end

调用:

ruby 复制代码
active_users = my_select(users) do |user|
  user.active?
end

假设:

text 复制代码
users = [Tom, Jack, Lucy]

第一轮:

text 复制代码
item = Tom
   │
   ▼
yield Tom
   │
   ▼
|user| 收到 Tom
   │
   ▼
Tom.active?
   │
   ▼
true
   │
   ▼
yield 的结果就是 true
   │
   ▼
把 Tom 放进 result

第二轮:

text 复制代码
item = Jack
   ↓
yield Jack
   ↓
Jack.active?
   ↓
false
   ↓
不保留

第三轮同理。

最终:

text 复制代码
[Tom, Lucy]

所以:

ruby 复制代码
users.select { |user| user.active? }

真正表达的是一种合作:

text 复制代码
select:
"遍历、保存结果这些事情我负责。"

Block:
"这个 user 到底该不该留下,我负责判断。"

这就是 Ruby 很喜欢的设计方式:

方法掌握流程,Block 填入变化的行为。


8. 再看 map:方法定义流程,Block 定义转换规则

map 也是同一个模型。

ruby 复制代码
numbers = [1, 2, 3]

result = numbers.map do |number|
  number * 10
end

可以想象成:

ruby 复制代码
def my_map(array)
  result = []

  for item in array
    mapped = yield item
    result << mapped
  end

  result
end

于是:

text 复制代码
1 → yield 1 → 10 → 放进 result
2 → yield 2 → 20 → 放进 result
3 → yield 3 → 30 → 放进 result

得到:

ruby 复制代码
[10, 20, 30]

现在再回头看文章开头:

ruby 复制代码
result = [1, 2, 3, 4, 5]
  .select { |number| number > 2 }
  .map { |number| number * 10 }

其实就是两次"方法控制流程 + Block 提供行为":

text 复制代码
select
  │
  └── 你告诉它:谁应该留下
             ↓
          [3,4,5]
             │
             ▼
            map
             │
             └── 你告诉它:每个元素怎么转换
                         ↓
                    [30,40,50]

9. Block 究竟是什么:现在再回来记定义

到这里再看定义,会容易很多。

Block 是跟随一次方法调用的一段代码:

ruby 复制代码
3.times do |index|
  puts index
end

也可以写成花括号:

ruby 复制代码
3.times { |index| puts index }

惯例上:

  • 多行逻辑常用 do ... end
  • 单行逻辑常用 { ... }

二者在 Ruby 中还存在结合优先级差异,但初学阶段先遵循这个惯例即可。

Block 参数写在竖线中:

ruby 复制代码
|index|

现在可以更准确地定义:

Block 是附属于一次方法调用的特殊代码结构。方法可以通过 yield 执行它、给它参数,并接收它的返回值。

还有一个重要边界:

Block 本身不是一个普通 Ruby 对象。

因此你不能像普通对象那样直接把一个裸 Block 随意存进变量。

如果想把"这段行为"保存起来、以后再调用,就会进入后面的 Proc

所以不要急着把:

text 复制代码
Block = Java Lambda

直接画等号。

它们有相似的思想,但 Ruby 的 Block 首先是一种依附于方法调用的语言结构。


10. Block 的真正力量:控制反转

传统循环:

java 复制代码
for (int i = 0; i < 3; i++) {
    System.out.println(i);
}

循环流程由调用者自己写。

Ruby:

ruby 复制代码
3.times do |i|
  puts i
end

这里调用者不再自己维护:

text 复制代码
初始值
循环条件
递增
结束判断

这些都交给 times

你只提供:

ruby 复制代码
puts i

也就是说:

text 复制代码
传统方式:
调用者控制整个流程

Block 方式:
方法控制流程
调用者只填入变化部分

这是一种很小的"控制反转"。

它也解释了为什么 Ruby 的很多 API 看起来像在说话:

ruby 复制代码
3.times do
  retry_request
end
ruby 复制代码
users.each do |user|
  send_email(user)
end
ruby 复制代码
File.open("data.txt") do |file|
  process(file)
end

共同模式都是:

"框架性的流程由方法负责,业务行为由 Block 提供。"


11. block_given?:Block 可以是可选的

如果方法直接执行:

ruby 复制代码
yield

但调用者没有提供 Block,就会报错。

所以 Ruby 提供:

ruby 复制代码
block_given?

例如:

ruby 复制代码
def log(message)
  detail = block_given? ? yield : ""
  puts "#{message} #{detail}"
end

既可以:

ruby 复制代码
log("request completed")

也可以:

ruby 复制代码
log("request completed") do
  "status=200"
end

第二种情况下:

ruby 复制代码
yield

会得到:

text 复制代码
"status=200"

于是输出详细信息。

在工程代码中,Block 常被用于:

  • 集合遍历与转换;
  • 资源管理;
  • 事务边界;
  • 重试与超时;
  • 回调;
  • DSL 配置;
  • 测试描述。

12. Ruby 的资源管理:把样板代码包在 Block 外面

现在再看:

ruby 复制代码
File.open("data.txt", "r") do |file|
  puts file.read
end

这段代码就没那么神秘了。

你可以把 File.open 的思想粗略想象成:

ruby 复制代码
def open_file(path)
  file = 打开文件(path)

  yield file

  关闭文件(file)
end

真实实现当然更完善,比如需要保证异常情况下也能正确清理资源,但控制结构就是类似的。

于是调用者只需要描述:

ruby 复制代码
puts file.read

File.open 负责:

text 复制代码
打开文件
   ↓
把 file 交给 Block
   ↓
执行调用者的代码
   ↓
Block 结束
   ↓
关闭文件

Java 的 try-with-resources:

java 复制代码
try (var reader = Files.newBufferedReader(path)) {
    System.out.println(reader.readLine());
}

C++ 的 RAII 则依靠对象生命周期释放资源。

三者都在表达资源边界,只是选择不同:

语言 主要机制 边界由什么表达
Ruby 方法 + Block + ensure 等机制 Block 的执行范围
Java AutoCloseable + try-with-resources 特殊语法块
C++ RAII + 确定性析构 对象生命周期

Ruby 的方式尤其适合把:

text 复制代码
前置
 ↓
执行用户代码
 ↓
清理

包装成一个普通方法。

例如计时:

ruby 复制代码
def with_timing
  started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
  result = yield
  result
ensure
  finished_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
  puts "elapsed: #{finished_at - started_at}s"
end

调用:

ruby 复制代码
result = with_timing do
  expensive_operation
end

执行结构就是:

text 复制代码
开始计时
   ↓
执行你的 Block
   ↓
结束计时

所以 Block 不只是一个回调。

它经常直接成为控制结构的一部分。


13. 再做一个微型事务:真正感受"方法包住 Block"

假设我们想要这种调用方式:

ruby 复制代码
with_transaction do
  create_order
  decrease_inventory
end

先不连接真正数据库,只打印事务边界:

ruby 复制代码
def with_transaction
  puts "BEGIN"

  yield

  puts "COMMIT"
rescue => error
  puts "ROLLBACK"
  raise error
end

如果 Block 正常结束:

text 复制代码
BEGIN
create_order
 decrease_inventory
COMMIT

如果中间抛异常:

text 复制代码
BEGIN
   ↓
执行 Block
   ↓
出现异常
   ↓
ROLLBACK

这里值得特别注意:

调用者写的是:

ruby 复制代码
create_order
decrease_inventory

但真正包围它们的是方法内部的:

text 复制代码
BEGIN
...
COMMIT / ROLLBACK

这正是 Block 在 Ruby 框架设计中非常强大的原因。


14. 到这里先做一次停顿:你应该掌握什么

在继续 Proc 之前,最好先确认自己能够解释下面这段:

ruby 复制代码
def repeat(times)
  times.times do |index|
    yield index
  end
end

repeat(3) do |index|
  puts index
end

至少能回答四个问题:

  1. repeat(3) 是一次普通方法调用;
  2. do ... end 是附在这次调用上的 Block;
  3. yield index 会执行这个 Block,并把 index 传进去;
  4. Block 执行完之后,程序会回到 yield 后面继续。

再进一步:

ruby 复制代码
result = yield value

说明:

Block 自己最后计算出来的值,还能返回给方法。

如果这些已经顺畅,后面的 Proc、Lambda 和 & 就只是:

"怎么把这段行为保存起来,以及怎么在 Block 和对象之间转换。"

而不再是一堆彼此孤立的语法。


15. 从 Block 到 Proc:让行为成为对象

Block 很方便,但它有一个边界:

Block 依附于一次方法调用,本身不是普通对象。

如果我们想把行为保存到变量里:

ruby 复制代码
doubler = ???

然后以后再执行:

ruby 复制代码
doubler.call(10)

这时候就需要 Proc

ruby 复制代码
doubler = Proc.new { |number| number * 2 }

puts doubler.call(10)
# 20

也可以写:

ruby 复制代码
doubler = proc { |number| number * 2 }

Proc 是一个真正的 Ruby 对象,所以可以:

  • 赋值给变量;
  • 放进数组或 Hash;
  • 作为参数传递;
  • 从方法返回;
  • 在之后调用。

例如:

ruby 复制代码
operations = {
  double: proc { |n| n * 2 },
  square: proc { |n| n * n }
}

puts operations[:square].call(5)
# 25

现在可以得到第一张关系图:

text 复制代码
Block
  │
  │ 依附于一次方法调用
  │
  │ 如果需要对象化
  ▼
Proc
  │
  ├── 可以保存
  ├── 可以传递
  └── 可以稍后 call

16. &block:把 Block 显式捕获成 Proc

前面我们一直使用:

ruby 复制代码
yield

方法其实可以不把 Block 存进变量,直接执行它。

但如果我们确实希望在方法内部把这个 Block 当成对象使用,可以这样:

ruby 复制代码
def execute(&block)
  block.call
end

调用:

ruby 复制代码
execute do
  puts "running"
end

这里最重要的是:

ruby 复制代码
&block

不是说参数名必须叫 block

真正有作用的是:

ruby 复制代码
&

它让 Ruby 把调用时附带的 Block 转成一个 Proc,然后赋值给局部变量:

ruby 复制代码
block

所以:

ruby 复制代码
def execute(&block)
  block.call
end

和前面的:

ruby 复制代码
def execute
  yield
end

都能执行调用者给出的 Block。

区别在于:

text 复制代码
yield
  ↓
直接执行 Block

&block
  ↓
先把 Block 变成 Proc 对象
  ↓
然后可以 block.call、保存、继续传递......

如果只是立即执行,通常:

ruby 复制代码
yield

更简单。

如果需要把它当对象处理,才更需要:

ruby 复制代码
&block

17. & 的反方向:把 Proc 当成 Block 传进去

& 不只可以把 Block 捕获成 Proc。

反过来,如果手里已经有 Proc:

ruby 复制代码
doubler = proc { |number| number * 2 }

可以:

ruby 复制代码
numbers = [1, 2, 3]
result = numbers.map(&doubler)

这里:

ruby 复制代码
&doubler

表示:

把这个 Proc 转换成这次 map 调用所需要的 Block。

所以 & 可以先记成一座桥:

text 复制代码
                 &
Block  <────────────────>  Proc

方法调用附带的代码结构      可保存、可传递的对象

两个方向分别是:

ruby 复制代码
def execute(&block)
text 复制代码
Block → Proc

和:

ruby 复制代码
numbers.map(&doubler)
text 复制代码
Proc → Block

这就是后面理解 &:active? 的基础。


18. Lambda:一种更像"独立函数"的 Proc

Ruby Lambda 可以写成:

ruby 复制代码
adder = lambda { |a, b| a + b }

也可以用更常见的箭头形式:

ruby 复制代码
adder = ->(a, b) { a + b }

调用:

ruby 复制代码
adder.call(1, 2)

Lambda 本质上仍然属于 Proc 家族,但语义更接近独立函数。

最需要知道的两个差异是:

  • 参数数量检查;
  • return 的行为。

18.1 参数数量

Lambda 更严格:

ruby 复制代码
strict = ->(a, b) { a + b }
strict.call(1)
# ArgumentError

普通 Proc 相对宽松:

ruby 复制代码
loose = proc { |a, b| [a, b] }
loose.call(1)
# [1, nil]

18.2 return 的范围

Lambda 中的:

ruby 复制代码
return

只从 Lambda 自己返回。

而普通 Proc 中的 return 会试图从定义它的外层方法返回;如果那个外层方法上下文已经不存在,还可能抛出 LocalJumpError

这件事暂时不用死背每个边界案例。

先记住它表达出的设计差异:

Lambda 更像一个独立函数;普通 Proc 更像从外层控制流中截下来的一段代码。


19. 闭包:行为会记得它诞生时的环境

看这个例子:

ruby 复制代码
def multiplier(factor)
  ->(number) { number * factor }
end

double = multiplier(2)
triple = multiplier(3)

puts double.call(10)
# 20

puts triple.call(10)
# 30

最有意思的是:

ruby 复制代码
multiplier(2)

早就已经执行结束了。

但是:

ruby 复制代码
double

这个 Lambda 以后再执行时,仍然记得:

ruby 复制代码
factor == 2

这就是闭包。

可以把它理解成:

一段可调用的行为,不只携带代码,也携带它定义时能看到的词法环境。

JavaScript 中也非常常见:

javascript 复制代码
function multiplier(factor) {
  return number => number * factor;
}

Java Lambda 也能捕获 effectively final 的局部变量。

不同语言的限制和实现方式不同,但"行为连同环境一起保存"这一思想是相通的。


20. 终于回到 &:active?:现在它不应该再神秘了

之前看到:

ruby 复制代码
users.select(&:active?)

第一次看非常容易卡在:

ruby 复制代码
&:

现在拆开。

先看正常 Block:

ruby 复制代码
users.select do |user|
  user.active?
end

或者:

ruby 复制代码
users.select { |user| user.active? }

Ruby 提供了一种常见简写:

ruby 复制代码
users.select(&:active?)

其中:

ruby 复制代码
:active?

是一个 Symbol。

而:

ruby 复制代码
&

要求 Ruby 把后面的对象转换成适合作为 Block 使用的可调用对象。

Symbol 提供了 to_proc,于是:

ruby 复制代码
:active?.to_proc

会得到一个 Proc,它的行为大致类似:

ruby 复制代码
proc { |object| object.active? }

因此:

ruby 复制代码
users.select(&:active?)

大致可以沿着下面的路线理解:

text 复制代码
:active?
   ↓
Symbol#to_proc
   ↓
proc { |object| object.active? }
   ↓
& 把 Proc 作为 Block 交给 select
   ↓
users.select { |user| user.active? }

所以现阶段最实用的记忆方式仍然是:

ruby 复制代码
collection.xxx(&:method_name)

大致等价于:

ruby 复制代码
collection.xxx { |item| item.method_name }

例如:

ruby 复制代码
users.map(&:name)

等价于:

ruby 复制代码
users.map { |user| user.name }
ruby 复制代码
numbers.select(&:even?)

等价于:

ruby 复制代码
numbers.select { |number| number.even? }
ruby 复制代码
names.map(&:upcase)

等价于:

ruby 复制代码
names.map { |name| name.upcase }

这里终于可以把前面的所有概念串起来:

text 复制代码
Block
  ↓
Proc
  ↓
& 转换
  ↓
Symbol#to_proc
  ↓
&:method_name

但不要为了追求短而到处使用 &:method

当 Block 中有多个步骤、额外参数、条件判断,或者显式变量名更能表达业务意义时:

ruby 复制代码
users.select do |user|
  user.active? && user.age >= 18
end

通常比强行压缩更清晰。


21. 与 Java Lambda、Stream 的联系

Java:

java 复制代码
users.stream()
    .filter(User::isActive)
    .map(User::getName)
    .toList();

Ruby:

ruby 复制代码
users
  .select(&:active?)
  .map(&:name)

两者都在描述:

text 复制代码
哪些元素保留
       ↓
每个元素如何转换

而不是手动维护:

text 复制代码
循环变量
临时数组
下标
append

从思想上说,都在做一件事情:

把"变化的行为"交给一个已经掌握流程的抽象。

Java 中可能是:

text 复制代码
Stream.filter
Stream.map

Ruby 中可能是:

text 复制代码
Enumerable#select
Enumerable#map

22. 与 Reactor 的联系:重点不是语法,而是控制权

再看 Reactor:

java 复制代码
Mono.zip(userMono, orderMono)
    .flatMap(tuple -> buildResult(tuple.getT1(), tuple.getT2()));

Ruby Block 和 Reactor 当然不是同一种机制。

但是它们可以训练一种相似的思维:

不要只盯着"这一行马上做什么",还要看"我正在把什么行为交给谁,由谁决定什么时候执行"。

在 Ruby:

ruby 复制代码
users.select do |user|
  user.active?
end

select 控制遍历,并决定什么时候执行 Block。

在 Reactor:

java 复制代码
mono.flatMap(value -> ...)

你提供一段函数,但真正什么时候执行,会受到响应式订阅和信号流控制。

两者不是技术等价,而是共享一个更宽泛的思想:

行为可以被描述、传递、组合,并由另一个抽象决定何时执行。

理解这一点之后,flatMap 也不再只是一个难记的 API 名字。


23. Block 为什么能孕育 DSL

看一段 Ruby 配置:

ruby 复制代码
database do
  adapter :postgresql
  pool 10
  timeout 5
end

如果你现在已经理解 Block,就可以拆开:

ruby 复制代码
database do
  ...
end

首先是调用:

ruby 复制代码
database

然后给它一个 Block。

Block 里面:

ruby 复制代码
adapter :postgresql
pool 10
timeout 5

又都是方法调用。

由于 Ruby 允许省略很多括号,普通方法调用和 Block 嵌套在一起以后,就会逐渐长得像一门专门的配置语言。

测试框架也是如此:

ruby 复制代码
describe User do
  it "has a name" do
    expect(user.name).to eq("Jack")
  end
end

Ruby 编译器并不认识什么特殊关键字:

text 复制代码
describe
it
expect

它们本质上仍然是方法。

Block 让这些普通方法获得了嵌套结构和控制范围,于是框架就可以创造出非常接近自然语言的 DSL。

Ruby 的魅力之一就在这里:

很多看起来像"语言魔法"的东西,并不是编译器增加了新语法,而是普通方法、Block、对象模型组合后的结果。


24. 一张完整一点的心智地图

概念 现在应该怎样理解
Block 附在一次方法调用上的代码结构
` x
yield 在方法中执行调用者提供的 Block
yield value 执行 Block,并把 value 传给 Block 参数
Block 返回值 Block 最后表达式的结果会成为 yield 的结果
block_given? 判断当前方法调用有没有提供 Block
Proc 对象化后的可调用行为,可以保存和传递
&block 把调用时的 Block 捕获为 Proc
&proc 把 Proc 作为一次方法调用的 Block 传入
Lambda 一种参数与返回语义更像独立函数的特殊 Proc
闭包 可调用行为会携带定义时能看到的环境
&:name 借助 Symbol#to_proc 生成常见的单方法 Block 简写

如果还想再压缩成一句话:

Block 是调用者交给方法的一段行为;方法通过 yield 决定什么时候执行它。Proc 则是把这段行为对象化。


25. 本篇练习:不要只练语法,要画执行过程

练习一:预测输出

ruby 复制代码
def wrapper
  puts "A"
  yield
  puts "B"
end

wrapper do
  puts "C"
end

不要先运行,自己画:

text 复制代码
进入谁
 ↓
执行到 yield
 ↓
跳到哪里
 ↓
Block 结束后回哪里

再预测输出顺序。


练习二:自己实现 repeat

ruby 复制代码
def repeat(times)
  # TODO
end

repeat(3) do |index|
  puts "round #{index}"
end

目标输出:

text 复制代码
round 0
round 1
round 2

重点不是循环本身,而是搞清楚:

ruby 复制代码
yield index

和:

ruby 复制代码
|index|

之间的关系。


练习三:自己实现一个简化版 select

ruby 复制代码
def my_select(array)
  # TODO
end

numbers = [1, 2, 3, 4, 5]

result = my_select(numbers) do |number|
  number > 2
end

p result

目标:

ruby 复制代码
[3, 4, 5]

这道题如果能独立写出来,说明你已经真正理解:

text 复制代码
yield 参数
Block 参数
Block 返回值
方法继续处理返回值

这一整条链。


练习四:事务边界

实现:

ruby 复制代码
with_transaction do
  create_order
  decrease_inventory
end

正常时打印:

text 复制代码
BEGIN
COMMIT

Block 抛异常时打印:

text 复制代码
BEGIN
ROLLBACK

然后思考:

  1. Java/Spring 的 @Transactional 把同样的边界放在哪里?
  2. Ruby Block 方案更显式还是更隐式?
  3. 注解、AOP、Block 都能消除样板代码,它们分别把控制权交给了谁?

结尾:先理解控制流,再理解语法糖

第一次看 Ruby 时,最容易犯的错误之一,就是看到:

ruby 复制代码
users.select(&:active?)

马上开始研究:

text 复制代码
& 是什么?
: 是什么?
to_proc 又是什么?

结果越挖越深,却忘了最原始的形式:

ruby 复制代码
users.select do |user|
  user.active?
end

而真正应该先理解的是:

text 复制代码
select 控制遍历
       ↓
yield 把 user 给 Block
       ↓
Block 判断 active?
       ↓
true / false 返回给 select
       ↓
select 决定是否保留

当这条控制流已经变成本能之后:

ruby 复制代码
&:active?

就只剩一层很薄的语法糖。

Block 也不再是一块需要死记的 Ruby 语法,而会逐渐变成一种设计直觉:

把稳定的流程留在方法里,把变化的行为留给调用者。

下一篇,我们会继续沿着这条线,看 Ruby 如何利用开放类、动态方法与 Block,让普通语言能力逐渐长成 Rails 那种近似魔法的 DSL。

相关推荐
飞鸟真人1 天前
C++ lambda 捕获完整梳理
c++·闭包·lamda
额鹅恶饿呃2 天前
随着CentOS官方停服的时间越来越久,大量仍在使用CentOS7的企业和运维从业者
java·python·算法·c#·ruby
jyOverQ5 天前
RabbitMQ 延迟消息怎么实现?TTL 与死信队列
分布式·后端·rabbitmq·ruby
洋不写bug7 天前
绕过权限检查,访问修改私有属性,反射,枚举,lambda
java·枚举·lambda·反射
StarRocks_labs11 天前
StarRocks x Fluss x Paimon 湖流一体方案:构建秒级响应、湖流一体的实时数据引擎
starrocks·kafka·lambda·查询·paimon·fluss·湖流一体
for_ever_love__16 天前
python基础语法学习: 闭包
开发语言·python·学习·闭包
正儿八经的少年17 天前
函数式接口和lambda表达式
lambda·函数式接口
水无痕simon23 天前
3 RabbitMQ基础
分布式·rabbitmq·ruby
知无不研25 天前
lambda表达式的使用(3)
开发语言·c++·lambda