• https://www.kancloud.cn/imxieke/ruby-base/107298


块是什么

块就是在调用方法时,能与参数一起传递的多个处理的集合。之前在介绍 each 方法、time 方法等与循环有关的部分时,我们就已经接触过块。接收块的方法会执行必要次数的块。块的执行次数由方法本身决定,因此不需事前指定,甚至有可能一次都不执行。

在下面的例子中,我们使用 each 方法,把保存在 Array 对象中的各个整数依次取 2 次幂后输出。doend 之间的部分就是所谓的块。在本例中,块总共被执行了 5 次。

[1, 2, 3, 4, 5].each do |i|
  puts i ** 2
end

我们把这样的方法调用称为“调用带块的方法”或者“调用块”。块的调用方法一般采用以下形式。

对象. 方法名( 参数列表) do | 块变量 |
 希望循环的处理
end

或者

对象. 方法名( 参数列表) { | 块变量 |
 希望循环的处理
}

块的开头是块变量。块变量就是在执行块的时候,从方法传进来的参数。不同方法的块变量个数也不相同。例如,在 Array#each 方法中,数组的元素会作为块变量被逐个传递到块中。而在 Array#each_with_index 方法中,则是 [ 元素 , 索引 ] 两个值被传递到块中。

块的使用方法

循环

在 Ruby 中,我们常常使用块来实现循环。在接收块的方法中,实现了循环处理的方法称为迭代器(iterator)。each 方法就是一个典型的迭代器。

在下面的例子中,我们把数组的各个元素转换为大写后输出。

alphabet = ["a", "b", "c", "d", "e"]
alphabet.each do |i|
  puts i.upcase
end

和数组一样,散列也能将元素一个个拿出来,但与数组不同的是,散列会将 [key, value] 的组合作为数组来提取元素。可以成对地提取散列的全部键、值。本例中使用 pair[1] 提取并合计了散列的值,提取散列的键时则可以使用 pair[0]

sum = 0
outcome = {"参加费"=>1000, "挂件费用"=>1000, "联欢会费用"=>4000}
outcome.each do |pair|
  sum += pair[1] # 指定值
end
puts "合计:#{sum}"

在接收块变量时,多重赋值规则也是同样适用的。我们稍微把代码修改一下,这样一来,键、值就可以被分别赋值给不同的变量了。

sum = 0
outcome = {"参加费"=>1000, "挂件费用"=>1000, "联欢会费用"=>4000}
outcome.each do |item, price|
  sum += price
end
puts "合计:#{sum}"

File 对象被用于读写文件的内容。使用 File 对象可将文件数据从头到尾读取出来。

根据文件内容的不同,我们需要考虑是以字符为单位,还是以行为单位来做读取处理。代码里使用了 File 类的 each 方法的一个程序示例,它会把 sample.txt 文件的内容按顺序逐行读取出来并输出。

file = File.open("sample.txt")
file.each_line do |line|
  print line
end
file.close

除了 each_line 方法外,File 对象中还有以字符为单位来循环读取数据的 each_char 方法、以及以字节为单位进行循环读取的 each_byte 方法等等。而其他对象也有很多以 each_XX 命名的循环读取数据的方法。

隐藏常规处理

上文中我们介绍了将块用于循环的迭代器的例子。但正如本章开头所介绍的那样,除了迭代器以外,块还被广泛使用在其他地方。其中一个用法就是确保后处理被执行。下面我们来看一个典型的例子——File.open 方法。File.open 方法在接收块后,会将 File 对象作为块变量,并执行一次块。

File.open("sample.txt") do |file|
  file.each_line do |line|
    print line
  end
end

与改写之前的程序相比,File 对象读取数据的部分一样,不同点在于没有了最后的 close 方法的调用。如果使用完打开的文件后没有将文件关闭的话,有可能会产生其他程序无法打开该文件,或者到达一次性可打开的文件数的上限时无法再打开新文件等问题。而在上面程序中,即使遇到无法打开文件等错误也可以正常关闭文件,因为块内部进行了类似下面的异常处理。

file = File.open("sample.txt")
begin
  file.each_line do |line|
    print line
  end
ensure
  file.close
end

File.open 方法使用块时,块内部的处理完毕并跳出方法前,文件会被自动关闭,因此就不需要那样使用 File.close 方法。

文件使用完毕后,由方法执行关闭操作,而我们只需将必要的处理记述在块中即可。这样一来可以减少程序的代码量,二来可以防止忘记关闭文件等错误的发生。

替换部分算法

下面我们再来介绍一个块的常见用法。这一次我们以数组排序为例,来了解一下指定处理顺序时块的使用方法。

  • 自定义排列顺序

    Array 类的 sort 方法是对数组内元素进行排序的方法。对数组元素进行排序,可以采取多种方法。

  • 按数字的大小顺序

  • 按字母顺序

  • 按字符串的长度顺序

  • 按数组元素的合计值的大小顺序

    如果按照这样的条件分别定义相应的排序方法,就会使方法的数量过多,不便于记忆。因此,在 Array.sort 方法中,元素的排序步骤由方法决定,用户只能指定元素间关系的比较逻辑。

    Array.sort 方法没有指定块时,会使用 <=> 运算符对各个元素进行比较,并根据比较后的结果进行排序。<=> 运算符的返回值为 -1、0、1 中的一个。

    状态 | 结果

    • | - a <> 时 | -1(比 0 小) a == b 时 | 0 a > b 时 | 1(比 0 大)

    使用 <=> 运算符比较字符串时,会按照字符编码的顺序进行比较。比较字母时,会按先大写字母后小写字母的顺序排列。

    array = ["ruby", "Perl", "PHP", "Python"]
    sorted = array.sort
    p sorted    #=> ["PHP", "Perl", "Python", "ruby"]

    我们可以通过调用块来指定排列顺序。下面的例子与不使用块时的执行结果是一样的。

    array = ["ruby", "Perl", "PHP", "Python"]
    sorted = array.sort{ |a, b| a <=> b }
    p sorted    #=> ["PHP", "Perl", "Python", "ruby"]

    sort 方法的末尾添加了块 { |a, b| a <=> b }sort 方法会根据块的执行结果判断元素的大小关系。当需要比较元素的大小关系时,块中需要比较的两个对象就会被作为块变量调用。对块变量 ab 进行比较后,数组整体就会按该顺序排列。

    在这里,我们需要注意块中最后一个表达式的值就是块的执行结果,因此 <=> 运算符必须在最后一行使用。

    备注 块的最后一个表达式不是指块的最后一行表达式,而是指在块中最后执行的表达式。

    按字符串的长度排序时,可以采用如下方法。

    array = ["ruby", "Perl", "PHP", "Python"]
    sorted = array.sort{ |a, b| a.length <=> b.length }
    p sorted    #=> ["PHP", "ruby", "Perl", "Python"]

    在之前的例子中,我们只是单纯地比较了字符串 ab,这里我们使用 String.length 方法,来比较字符串的长度。用 <=> 运算符比较数值时,得到的是由小到大的排列顺序,因此,比较字符串长度时,结果就是按照由短到长的顺序进行排列。

    像这样,块经常被用来在 sort 方法中实现自定义排列顺序。

  • 预先取出排序所需的信息

    我们再来详细看看 sort 方法的块。每次比较元素时,sort 方法都会调用一次将两个元素作为块变量的块。这里,我们仍以刚才介绍的按字符串长度排序的程序为例,来看看程序调用了 length 方法多少次。

    ary = %w(
    Ruby is a open source programming language with a focus
    on simplicity and productivity. It has an elegant syntax
    that is natural to read and easy to write
    )
     
    call_num = 0    # 块的调用次数
    sorted = ary.sort do |a, b|
    call_num += 1 # 累加块的调用次数
    a.length &lt;=> b.length
    end
     
    puts "排序结果 #{sorted}"
    puts "数组的元素数量 #{ary.length}"
    puts "调用块的次数 #{call_num}"

    执行示例

    > ruby sort_comp_count.rb
    排序结果 ["a", "a", "on", "to", "It", "to", "is", "an", ......]
    数组的元素数量 28
    调用块的次数 91

    可以看出,在这个例子中,我们对 28 个元素进行了排序,块总共被调用了 91 次。由于每调用 1 次块,length 方法就会被调用 2 次,因此最终就会被调用 182 次。而实际上,我们只需对所有的字符串都调用 1 次 length 方法,然后再用得出的结果进行排序就可以了。像这样,在能够通过 < = > 运算符对转换后的结果进行比较的情况下,使用 sort_by 方法会使排序更加有效率。

    ary = %w(
        Ruby is a open source programming language with a focus
        on simplicity and productivity. It has an elegant syntax
        that is natural to read and easy to write
    )
    sorted = ary.sort_by{ |item| item.length }
    p sorted

    sort_by 方法会将每个元素在块中各调用一次,然后再根据这些结果做排序处理。这种情况下,虽然比较的次数不变,但获取排序所需要的信息的次数(本例中为 28 次)只需与元素个数一样就可以了。

    总结一下,元素排序算法中公共的部分由方法本身提供,我们则可以用块来替换方法中元素排列的顺序(或者取得用于比较的信息),或者根据不同的目的来替换需要更改的部分。

定义带块的方法

执行块

首先让我们重温一下 myloop 方法

def myloop
  while true
    yield               # 执行块
  end
end

num = 1                 # 初始化num
myloop do
  puts "num is #{num}"  # 输出num
  break if num > 100    # num 超过100 后跳出循环
  num *= 2              # num 乘2
end

myloop 方法在执行 while 循环的同时执行了 yield 关键字,yield 关键字的作用就是执行方法的块。因为这个 while 循环的条件固定为 true,所以会无限循环地执行下去,但只要在块里调用 break,就可以随时中断 myloop 方法,来执行后面的处理。

传递块参数,获取块的值

在刚才的例子中,块参数以及块的执行结果都没有被使用。接下来,我们会定义一个方法,该方法接收两个整数参数,并对这两个整数之间的整数做某种处理后进行合计处理,而“某种处理”则由块指定。

def total(from, to)
  result = 0                # 合计值
  from.upto(to) do |num|    # 处理从from 到to 的值
    if block_given?         #   如果有块的话
      result += yield(num)  #     累加经过块处理的值
    else                    #   如果没有块的话
      result += num         #     直接累加
    end
  end
  return result             # 返回方法的结果
end

p total(1, 10)                  # 从1 到10 的和 => 55
p total(1, 10){|num| num ** 2 } # 从1 到10 的2 次幂的和 => 385

total 方法会先使用 Integer.upto 方法把 fromto 之间的整数值按照从小到大的顺序取出来,然后交给块处理,最后再将块处理后的值累加到变量 result。程序第 5 行中,对 yield 传递参数后,参数值就会作为块变量传递到块中。同时,块的运行结果也会作为 yield 的结果返回。

程序第 4 行的 block_given? 方法被用来判断调用该方法时是否有块被传递给方法,如果有则返回 true,反之返回 false。如果方法没有块,则在程序第 7 行中直接把 num 相加。

在本例中,对 yield 传递 1 个参数,就有 1 个块变量接收。下面我们来看看对 yield 传递 0 个、1 个、3 个等多个参数时,对应的块变量是如何进行接收的。

def block_args_test
  yield()             # 0 个块变量
  yield(1)            # 1 个块变量
  yield(1, 2, 3)      # 3 个块变量
end

puts "通过|a| 接收块变量"
block_args_test do |a|
  p [a]
end
puts

puts "通过|a, b, c| 接收块变量"
block_args_test do |a, b, c|
  p [a, b, c]
end
puts

puts "通过|*a| 接收块变量"
block_args_test do |*a|
  p [a]
end
puts

执行示例

> ruby block_args_test.rb
通过|a| 接收块变量
[nil]
[1]
[1]

通过|a, b, c| 接收块变量
[nil, nil, nil]
[1, nil, nil]
[1, 2, 3]

通过|*a| 接收块变量
[[]]
[[1]]
[[1, 2, 3]]

首先我们注意到,yield 参数的个数与块变量的个数是不一样的。从 |a||a, b, c| 的例子中可以看出,块变量比较多时,多出来的块变量值为 nil,而块变量不足时,则不能接收参数值。

最后的通过 |*a| 接收的情况是将所有块变量整合为一个数组来接收。这与定义方法时接收可变参数的情况非常相似。

抽取嵌套数组的元素的规则,同样也适用于块变量。例如,Hash.each_with_index 方法的块变量有 2 个,并以 yield([ 键 , 值 ], 索引 ) 的形式传递。在接收块变量后,我们就可以把 [ 键 , 值 ] 部分分别赋值给不同的变量。

hash = {a: 100, b: 200, c: 300}
hash.each_with_index do |(key, value), index|
  p [key, value, index]
end

执行示例

> ruby param_grouping.rb
[:a, 100, 0]
[:b, 200, 1]
[:c, 300, 2]

控制块的执行

在调用代码清单 total 方法时,如果像下面那样在中途使用 breaktotal 方法的结果会变成什么样子呢?

n = total(1, 10) do |num|
  if num == 5
    break
  end
  num
end
p n     #=> ??

答案是 nil。在块中使用 break,程序会马上返回到调用块的地方,因此 total 方法中返回计算结果的处理等都会被忽略掉。但作为方法的结果,当我们希望返回某个值的时候,就可以像 break 0 这样指定 break 方法的参数,这样该值就会成为方法的返回值。

此外,如果在块中使用 next,程序就会中断当前处理,并继续执行下面的处理。使用 next 后,执行块的 yield 会返回,如果 next 没有指定任何参数则返回 nil,而如果像 next 0 这样指定了参数,那么该参数值就是返回值。

n = total(1, 10) do |num|
  if num % 2 != 0
    next 0
  end
  num
end
p n     #=> 30

最后,如果在块中使用 redo,程序就会返回到块的开头,并按照相同的块变量再次执行处理。这种情况下,块的处理结果不会返回给外部,因此需要十分小心 redo 的用法,注意不要使程序陷入死循环。

n = total(1, 10) do |num|
  if num % 2 != 0
    next 0
  end
  num
end
p n     #=> 30

最后,如果在块中使用 redo,程序就会返回到块的开头,并按照相同的块变量再次执行处理。这种情况下,块的处理结果不会返回给外部,因此需要十分小心 redo 的用法,注意不要使程序陷入死循环。

将块封装为对象

如前所述,在接收块的方法中执行块时,可以使用 yield 关键字。

而 Ruby 还能把块当作对象处理。把块当作对象处理后,就可以在接收块的方法之外的其他地方执行块,或者把块交给其他方法执行。

这种情况下需要用到 Proc 对象。Proc 对象是能让块作为对象在程序中使用的类。定义 Proc 对象的典型的方法是,调用 Proc.new 方法这个带块的方法。在调用 Proc 对象的 call 方法之前,块中定义的程序不会被执行。

在下面的代码例子中,定义一个输出信息的 Proc 对象,并调用两次。这时,程序就会把 call 方法的参数作为块参数来执行块。

hello = Proc.new do |name|
  puts "Hello, #{name}."
end

hello.call("World")
hello.call("Ruby")

执行示例

> ruby proc1.rb
Hello, World.
Hello, Ruby.

把块从一个方法传给另一个方法时,首先会通过变量将块作为 Proc 对象接收,然后再传给另一个方法。在方法定义时,如果末尾的参数使用“& 参数名”的形式,Ruby 就会自动把调用方法时传进来的块封装为 Proc 对象。

下面,我们将代码中块的接收方法加以改写,如下所示:

def total2(from, to, &block)
  result = 0               # 合计值
  from.upto(to) do |num|   # 处理从from 到to 的值
    if block               #   如果有块的话
      result +=            #     累加经过块处理的值
           block.call(num)
    else                   #   如果没有块的话
      result += num        #     直接累加
    end
 end
 return result            # 返回方法的结果
end

p total2(1, 10)                   # 从1 到10 的和 => 55
p total2(1, 10){|num| num ** 2 }  # 从1 到10 的2 次幂的和 => 385

我们在首行的方法定义中定义了 &block 参数。像这样,在变量名前添加 & 的参数被称为 Proc 参数。如果在调用方法时没有传递块,Proc 参数的值就为 nil,因此通过这个值就可以判断出是否有块被传入方法中。另外,执行块的语句不是 yield,而是 block.call(num),这一点与之前的例子也不一样。

方法可以有多个参数,而且定义参数的默认值等时都需要按照一定的顺序。而 Proc 参数则一定要在所有参数之后,也就是方法中最后一个参数。

将块封装为 Proc 对象后,我们就可以根据需要随时调用块。甚至还可以将其赋值给实例变量,让别的实例方法去任意调用。

此外,我们也能将 Proc 对象作为块传给其他方法处理。这时,只需在调用方法时,用“&Proc 对象”的形式定义参数就可以了。例如,向 Array.each 方法传递块时,可以像下面那样定义。

def call_each(ary, &block)
  ary.each(&block)
end

call_each [1, 2, 3] do |item|
  p item
end

这样一来,我们就可以非常方便地把调用 call_each 方法时接收到的块,原封不动地传给 ary.each 方法。

执行示例

> ruby call_each.rb
1
2
3

局部变量与块变量

块内部的命名空间与块外部是共享的。在块外部定义的局部变量,在块中也可以继续使用。而被作为块变量使用的变量,即使与块外部的变量同名,Ruby 也会认为它们是两个不同的变量。

x = 1            # 初始化x
y = 1            # 初始化y
ary = [1, 2, 3]

ary.each do |x|  # 将x 作为块变量使用
  y = x          # 将x 赋值给y
end

p [x, y]         # 确认x 与y 的值

执行示例

> ruby local_and_block.rb
[1, 3]

ary.each 方法的块中,x 的值被赋值给了局部变量 y。因此,y 保留了最后一次调用块时块变量 x 的值 3。而变量 x 的值在调用 ary.each 前后并没有发生改变。

相反,在块内部定义的变量不能被外部访问。在刚才的例子中,如果把第 2 行的代码删掉,程序就会出错。

x = 1            # 初始化 x
#y = 1           # 初始化 y
ary = [1, 2, 3]

ary.each do |x|  # 将 x 作为块变量使用
  y = x          # 将 x 赋值给y
end

p [x, y]         # 引用 y 时会出错误(NameError)

块中变量的作用域之所以这么设计,是为了通过与块外部共享局部变量,从而扩展变量的有效范围。在块内部给局部变量赋值的时候,要时刻注意它与块外部的同名变量的关系。大家一定要小心 Ruby 中的这个小陷阱。

块变量是只能在块内部使用的变量(块局部变量),它不能覆盖外部的局部变量,但 Ruby 为我们提供了定义块变量以外的块局部变量的语法。使用在块变量后使用 ; 加以区分的方式,来定义块局部变量。这里我们再稍微修改一下刚才的例子,如下所示。可以看出,块执行后 xy 的值并没有变化。

x = y = z = 0       # 初始化x、y、z
ary = [1, 2, 3]
ary.each do |x; y|  # 使用块变量x,块局部变量y
  y = x             # 代入块局部变量y
  z = x             # 代入不是块局部变量的变量z
  p [x, y, z]       # 确认块内的 x、y、z 的值
end
puts
p [x, y, z]         # 确认x、y、z 的值

执行示例

> ruby local_and_block2.rb
[1, 1, 1]
[2, 2, 2]
[3, 3, 3]

[0, 0, 3]