企业🤖AI智能体构建引擎,智能编排和调试,一键部署,支持私有化部署方案 广告
# **第 11 章 块** Ruby 中大量使用了块(block)。块原本只是为了循环而产生的语法结构,但现在程序中许多地方也都使用了块。因此,如何灵活地使用块,也是 Ruby 的重点之一。 下面就让我们来讨论一下块的作用及其用途。 ### **11.1 块是什么** 块 1 就是在调用方法时,能与参数一起传递的多个处理的集合。之前在介绍 `each` 方法、`time` 方法等与循环有关的部分时,我们就已经接触过块。接收块的方法会执行必要次数的块。块的执行次数由方法本身决定,因此不需事前指定,甚至有可能一次都不执行。 1有时也称代码块。——译者注 在下面的例子中,我们使用 `each` 方法,把保存在 `Array` 对象中的各个整数依次取 2 次幂后输出。`do` 和 `end` 之间的部分就是所谓的块。在本例中,块总共被执行了 5 次。 ~~~ [1, 2, 3, 4, 5].each do |i| puts i ** 2 end ~~~ 正如在第 7 章中所介绍的那样,我们把这样的方法调用称为“调用带块的方法”或者“调用块”。块的调用方法一般采用以下形式。 **对象. 方法名( 参数列表) `do` | 块变量 |  希望循环的处理 `end`** 或者 **对象. 方法名( 参数列表) { | 块变量 |  希望循环的处理 }** 块的开头是块变量。块变量就是在执行块的时候,从方法传进来的参数。不同方法的块变量个数也不相同。例如,在 `Array#each` 方法中,数组的元素会作为块变量被逐个传递到块中。而在 `Array#each_with_index` 方法中,则是 [ 元素 , 索引 ] 两个值被传递到块中。 > **执行示例** ![{%}](https://box.kancloud.cn/2015-10-26_562e01ec04111.png) 而在第 6 章中介绍的 `loop` 方法则不需要传递块变量。 ### **11.2 块的使用方法** ### **11.2.1 循环** 在 Ruby 中,我们常常使用块来实现循环。在接收块的方法中,实现了循环处理的方法称为迭代器(iterator)。`each` 方法就是一个典型的迭代器。 在下面的例子中,我们把数组的各个元素转换为大写后输出。 ~~~ alphabet = ["a", "b", "c", "d", "e"] alphabet.each do |i| puts i.upcase end ~~~ 和数组一样,散列也能将元素一个个拿出来,但与数组不同的是,散列会将 `[key, value]` 的组合作为数组来提取元素。如代码清单 11.1 所示,可以成对地提取散列的全部键、值。本例中使用 `pair[1]` 提取并合计了散列的值,提取散列的键时则可以使用 `pair[0]`。 **代码清单 11.1 hash_each.rb** ~~~ sum = 0 outcome = {"参加费"=>1000, "挂件费用"=>1000, "联欢会费用"=>4000} outcome.each do |pair| sum += pair[1] # 指定值 end puts "合计:#{sum}" ~~~ 在接收块变量时,多重赋值规则也是同样适用的。我们稍微把代码清单 11.1 的程序修改一下,使之变成代码清单 11.2 那样,这样一来,键、值就可以被分别赋值给不同的变量了。 **代码清单 11.2 hash_each2.rb** ~~~ sum = 0 outcome = {"参加费"=>1000, "挂件费用"=>1000, "联欢会费用"=>4000} outcome.each do |item, price| sum += price end puts "合计:#{sum}" ~~~ `File` 对象被用于读写文件的内容。使用 `File` 对象可将文件数据从头到尾读取出来。 根据文件内容的不同,我们需要考虑是以字符为单位,还是以行为单位来做读取处理。代码清单 11.3 是使用了 `File` 类的 `each` 方法的一个程序示例,它会把 sample.txt 文件的内容按顺序逐行读取出来并输出。 **代码清单 11.3 file_each.rb** ~~~ file = File.open("sample.txt") file.each_line do |line| print line end file.close ~~~ 除了 `each_line` 方法外,`File` 对象中还有以字符为单位来循环读取数据的 `each_char` 方法、以及以字节为单位进行循环读取的 `each_byte` 方法等等。而其他对象也有很多以 `each_XX` 命名的循环读取数据的方法。 ### **11.2.2 隐藏常规处理** 上文中我们介绍了将块用于循环的迭代器的例子。但正如本章开头所介绍的那样,除了迭代器以外,块还被广泛使用在其他地方。其中一个用法就是确保后处理被执行。下面我们来看一个典型的例子——`File.open` 方法。`File.open` 方法在接收块后,会将 `File` 对象作为块变量,并执行一次块。这里,我们可以使用块把代码清单 11.3 改写为代码清单 11.4 那样。 **代码清单 11.4 file_open.rb** ~~~ File.open("sample.txt") do |file| file.each_line do |line| print line end end ~~~ 与改写之前的程序相比,`File` 对象读取数据的部分一样,不同点在于没有了最后的 `close` 方法的调用。如果使用完打开的文件后没有将文件关闭的话,有可能会产生其他程序无法打开该文件,或者到达一次性可打开的文件数的上限时无法再打开新文件等问题。而在代码清单 11.4 的程序中,即使遇到无法打开文件等错误也可以正常关闭文件,因为块内部进行了程序清单 11.5 那样的处理。 **代码清单 11.5 file_open_no_block.rb** ~~~ file = File.open("sample.txt") begin file.each_line do |line| print line end ensure file.close end ~~~ `File.open` 方法使用块时,块内部的处理完毕并跳出方法前,文件会被自动关闭,因此就不需要像代码清单 11.3 那样使用 `File#close` 方法。 文件使用完毕后,由方法执行关闭操作,而我们只需将必要的处理记述在块中即可。这样一来可以减少程序的代码量,二来可以防止忘记关闭文件等错误的发生。 ### **11.2.3 替换部分算法** 下面我们再来介绍一个块的常见用法。这一次我们以数组排序为例,来了解一下指定处理顺序时块的使用方法。 - **自定义排列顺序** `Array` 类的 `sort` 方法是对数组内元素进行排序的方法。对数组元素进行排序,可以采取多种方法。 - **按数字的大小顺序** - **按字母顺序** - **按字符串的长度顺序** - **按数组元素的合计值的大小顺序** 如果按照这样的条件分别定义相应的排序方法,就会使方法的数量过多,不便于记忆。因此,在 `Array#sort` 方法中,元素的排序步骤由方法决定,用户只能指定元素间关系的比较逻辑。 `Array#sort` 方法没有指定块时,会使用 `<=>` 运算符对各个元素进行比较,并根据比较后的结果进行排序。`<=>` 运算符的返回值为`-1`、`0`、`1` 中的一个。 **表 11.1 a <=> b 的结果** | `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 方法会根据块的执行结果判断元素的大小关系。当需要比较元素的大小关系时,块中需要比较的两个对象就会被作为块变量调用。对块变量 `a` 和 `b` 进行比较后,数组整体就会按该顺序排列。 在这里,我们需要注意块中最后一个表达式的值就是块的执行结果,因此 `<=>` 运算符必须在最后一行使用。 > **备注** 块的最后一个表达式不是指块的最后一行表达式,而是指在块中最后执行的表达式。 按字符串的长度排序时,可以采用如下方法。 ~~~ array = ["ruby", "Perl", "PHP", "Python"] sorted = array.sort{ |a, b| a.length <=> b.length } p sorted #=> ["PHP", "ruby", "Perl", "Python"] ~~~ 在之前的例子中,我们只是单纯地比较了字符串 `a`、`b`,这里我们使用 `String#length` 方法,来比较字符串的长度。用 `<=>` 运算符比较数值时,得到的是由小到大的排列顺序,因此,比较字符串长度时,结果就是按照由短到长的顺序进行排列。 像这样,块经常被用来在 `sort` 方法中实现自定义排列顺序。 - **预先取出排序所需的信息** 我们再来详细看看 `sort` 方法的块。每次比较元素时,`sort` 方法都会调用一次将两个元素作为块变量的块。这里,我们仍以刚才介绍的按字符串长度排序的程序为例,来看看程序调用了 `length` 方法多少次。 **代码清单 11.6 sort_comp_count.rb** ~~~ 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 次)只需与元素个数一样就可以了。 总结一下,元素排序算法中公共的部分由方法本身提供,我们则可以用块来替换方法中元素排列的顺序(或者取得用于比较的信息),或者根据不同的目的来替换需要更改的部分。 ### **11.3 定义带块的方法** 在第 7 章中我们简单地介绍了如何定义带块的方法,接下来我们就来详细地讨论一下。 ### **11.3.1 执行块** 首先让我们重温一下第 7 章中的 `myloop` 方法(代码清单 11.7)。 **代码清单 11.7 myloop.rb** ~~~ 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` 方法,来执行后面的处理。 ### **11.3.2 传递块参数,获取块的值** 在刚才的例子中,块参数以及块的执行结果都没有被使用。接下来,我们会定义一个方法,该方法接收两个整数参数,并对这两个整数之间的整数做某种处理后进行合计处理,而“某种处理”则由块指定(代码清单 11.8)。 **代码清单 11.8 total.rb** ~~~ 1: def total(from, to) 2: result = 0 # 合计值 3: from.upto(to) do |num| # 处理从from 到to 的值 4: if block_given? # 如果有块的话 5: result += yield(num) # 累加经过块处理的值 6: else # 如果没有块的话 7: result += num # 直接累加 8: end 9: end 10: return result # 返回方法的结果 11: end 12: 13: p total(1, 10) # 从1 到10 的和 => 55 14: p total(1, 10){|num| num ** 2 } # 从1 到10 的2 次幂的和 => 385 ~~~ `total` 方法会先使用 `Integer#upto` 方法把 `from` 到 `to` 之间的整数值按照从小到大的顺序取出来,然后交给块处理,最后再将块处理后的值累加到变量 `result`。程序第 5 行中,对 `yield` 传递参数后,参数值就会作为块变量传递到块中。同时,块的运行结果也会作为 `yield` 的结果返回。 程序第 4 行的 `block_given?` 方法被用来判断调用该方法时是否有块被传递给方法,如果有则返回 `true`,反之返回 `false`。如果方法没有块,则在程序第 7 行中直接把 `num` 相加。 在本例中,对 `yield` 传递 1 个参数,就有 1 个块变量接收。下面我们来看看对 `yield` 传递 0 个、1 个、3 个等多个参数时,对应的块变量是如何进行接收的(代码清单 11.9)。 **代码清单 11.9 block_args_test.rb** ~~~ 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|` 接收的情况是将所有块变量整合为一个数组来接收。这与定义方法时接收可变参数的情况非常相似。 另外,在第 4 章中介绍的抽取嵌套数组的元素的规则,同样也适用于块变量。例如,`Hash#each_with_index` 方法的块变量有 2 个,并以 `yield([ 键 , 值 ], 索引 )` 的形式传递。像代码清单 11.10 那样,在接收块变量后,我们就可以把 `[ 键 , 值 ]` 部分分别赋值给不同的变量。 **代码清单 11.10 param_grouping.rb** ~~~ 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] ~~~ ### **11.3.3 控制块的执行** 在调用代码清单 11.8 的 `total` 方法时,如果像下面那样在中途使用 `break`,`total` 方法的结果会变成什么样子呢? ~~~ 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` 的用法,注意不要使程序陷入死循环。 ### **11.3.4 将块封装为对象** 如前所述,在接收块的方法中执行块时,可以使用 `yield` 关键字。 而 Ruby 还能把块当作对象处理。把块当作对象处理后,就可以在接收块的方法之外的其他地方执行块,或者把块交给其他方法执行。 这种情况下需要用到 `Proc` 对象。`Proc` 对象是能让块作为对象在程序中使用的类。定义 `Proc` 对象的典型的方法是,调用 `Proc.new` 方法这个带块的方法。在调用 `Proc` 对象的 `call` 方法之前,块中定义的程序不会被执行。 在代码清单 11.11 的例子中,定义一个输出信息的 `Proc` 对象,并调用两次。这时,程序就会把 `call` 方法的参数作为块参数来执行块。 **代码清单 11.11 proc1.rb** ~~~ 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` 对象。 下面,我们将代码清单 11.8 中块的接收方法加以改写,如下所示: **代码清单 11.12 total2.rb** ~~~ 1: def total2(from, to, &block) 2: result = 0 # 合计值 3: from.upto(to) do |num| # 处理从from 到to 的值 4: if block # 如果有块的话 5: result += # 累加经过块处理的值 6: block.call(num) 7: else # 如果没有块的话 8: result += num # 直接累加 9: end 10: end 11: return result # 返回方法的结果 12: end 13: 14: p total2(1, 10) # 从1 到10 的和 => 55 15: p total2(1, 10){|num| num ** 2 } # 从1 到10 的2 次幂的和 => 385 ~~~ 我们在首行的方法定义中定义了 `&block` 参数。像这样,在变量名前添加 `&` 的参数被称为 Proc 参数。如果在调用方法时没有传递块,`Proc` 参数的值就为 `nil`,因此通过这个值就可以判断出是否有块被传入方法中。另外,执行块的语句不是 `yield`,而是 `block.call(num)`,这一点与之前的例子也不一样。 在第 7 章中我们提到过方法可以有多个参数,而且定义参数的默认值等时都需要按照一定的顺序。而 `Proc` 参数则一定要在所有参数之后,也就是方法中最后一个参数。 将块封装为 `Proc` 对象后,我们就可以根据需要随时调用块。甚至还可以将其赋值给实例变量,让别的实例方法去任意调用。 此外,我们也能将 `Proc` 对象作为块传给其他方法处理。这时,只需在调用方法时,用“`&Proc` 对象”的形式定义参数就可以了。例如,向 `Array#each` 方法传递块时,可以像代码清单 11.13 那样定义。 **代码清单 11.13 call_each.rb** ~~~ 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 ~~~ ### **11.4 局部变量与块变量** 块内部的命名空间与块外部是共享的。在块外部定义的局部变量,在块中也可以继续使用。而被作为块变量使用的变量,即使与块外部的变量同名,Ruby 也会认为它们是两个不同的变量。请看代码清单 11.14。 **代码清单 11.14 local_and_block.rb** ~~~ 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 为我们提供了定义块变量以外的块局部变量的语法。使用在块变量后使用 `;` 加yiqufen以区分的方式,来定义块局部变量。这里我们再稍微修改一下刚才的例子,如下所示。可以看出,块执行后 `x` 和 `y` 的值并没有变化。 **代码清单 11.15 local_and_block2.rb** ~~~ 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] ~~~