Ruby 控制流用法详解

作者:袖梨 2022-06-25

之前我们见过方法的调用,它会让程序从调用方法的地方跳到方法的定义主体那里。程序并不是按直线运行的,执行的顺序会受到一些规则还有一些被称为控制流的程序设计结构的影响。

Ruby 的控制流有下面这些:

条件执行(Conditional execution)— 根据表达式的真实性执行。
循环(Looping)— 一块儿重复执行的代码。
迭代(Iteration)— 调用方法的时候给它提供一块儿代码,方法在它执行的时候可以多次调用这块代码。
异常(Exceptions)— 处理异常情况。
按条件执行代码

Ruby 提供了一些基于条件控制程序流的方法。有两大类,if 还有 case 。

if 跟它的朋友们

if 用起来像这样:

if condition
  # condition 如果是 true 就执行这里的代码
end
if 可以嵌套使用:

if condition
  # condition 如果是 true 就执行这里的代码
  if condition2
    # condition2 如果是 true 就执行这里的代码
  end
end
if 可以放到一行用,在条件的后面使用关键词 then,像这样:

if x > 10 then puts x end
或者使用分号:

if x > 10; puts x; end
条件执行经常关系到多个分支,你可能想在条件成功的时候做点事,失败的时候做另一些事儿。可以使用 else 或 elsif。用法是这样的:

if condition
  # condition 是 true 就执行这里的代码
else
  # condition 是 false 就执行这里的代码
end
加上 elsif 可以继续添加要判断的条件:

if condition1
  # condition1 是 true 就执行这里的代码
elsif condition2
  # condition1 是 false
  # condition2 是 true 就执行这里的代码
elsif condition3
  # condition1 与 condition2 都是 false
  # condition3 是 true 就会执行这里的代码
end
做个实验:

print "输入一个整数:"
n = gets.to_i

if n > 0
  puts "你输入的是正数"
elsif n < 0
  puts "你输入的是负数"
else
  puts "你输入的数字是 0"
end
再试一下否定形式的条件,可以使用 not 与 !,先试一下 not:

if not (x == 1)
再试一下 !  号:

if !(x == 1)
unless 跟 if not 与 if ! 的效果一样,试一下 unless:

unless x ==1
19:59 **

21:15 **

if 还可以这样用:

puts 'big number!' if x > 100
上面的代码可以解释为:

if x > 100
  puts 'big number!'
end
unless 也可以这样用:

puts 'big number!' unless x <= 100
如果一个 if 声明成功了,整个声明的值会用成功的那个分支来表示,做个实验:

x = 1

if x < 0
  '负数'
elsif x > 0
  '正数'
else
  '零'
end
irb 会告诉你上面的 if 整个声明的值会是字符串 '正数' 。如果 if 声明没成功,会返回 nil 。

21:37 **

条件主体里的分配

2016年9月9日 上午8:52 ***

分配语法与条件表达式在两个点有交叉,在条件表达式的主体里,分配有可能发生也可能不会发生,还有就是在条件测试上:

if x = 1
  y = 2
end
x = 1 是条件测试里的分配,y = 2 是条件主体里的分配。

条件主体里的分配

编译语言有编译时,还有运行时这两个说法。Ruby 是脚本语言,在运行脚本之前解释器会解析代码,这时候会做一些决定, 比如去识别与配置本地变量。

Ruby 解释器看到这样形式:识别符,等号,值,比如像这样:

x = 1
解释器会给 x 这个本地变量分配一个空间。

来看这个例子:

if false
  x = 1
end

p x
p y
上面的例子里,分配 x 的动作并没有被执行,因为它是在一个失败的条件测试里包装着。不过 Ruby 的解释器看到了 x = 1,推断程序可能会用到本地变量 x ,解释器不在乎 x 是不是得到了一个值。它会给 x 一个 nil 值,所以输出 x ,得到的结果是 nil ,但是你输出一个不存在的本地变量的时候,就会出现严重错误。

条件测试里的分配

看个例子:

if x = 1
  puts 'hi'
end
上面在条件测试里的东西是一个分配(x = 1),注意它并不是去比较是否相等,像 x == 1 。在条件测试里的分配跟在其它的地方分配是一样的,就是 x 的值设置成了 1。测试本身会认为条件是 if 1 ,也就是 true,这样条件主体里的东西会被执行,就会输出一个 hi 。

同时你会看到一个警告:

warning: found = in conditional, should be ==
Ruby 会认为要测试的 if x =1 会一直成功,条件主体也就会一直被执行,所以 Ruby 会认为你可能是输错了,就会给你个警告提示一下,警告会建议你使用 == 操作符,它可以测试两个值是否相等。

Ruby 知道这样做不是一个错误:

if x = y
跟 x = 1 不一样,x = y 作为条件测试不一定会成功。

上午9:37 ***

case

上午9:51 **

case 声明用一个表达式开始,一般就是一个单独的对象或者变量。然后是一些可能的匹配,每个可能的匹配都会包含在一个 when 声明里,它里面还有一块儿代码。最终只会有一个匹配胜出,然后就会执行这个 when 里面的代码。

了解 case 的使用,先来看个例子:

print '是否退出?(yes/no):'
answer = gets.chomp

case answer
when 'yes'
  puts '再见!'
  exit
when 'no'
  puts '我们继续!'
else
  puts '不知道您在说啥,假设是 no'
end

puts '继续程序 ... '
一个 case 声明,使用 case 这个关键词开始,然后就是一个 when 区块,还有一个可选的 else ,结束 case 要使用一个 end 关键词。只有一个匹配会最终胜出,然后去执行它里面的代码。上面的例子里,如果输入的是 “yes”,程序就会退出。如果是 “no" 或其它的值,会让程序可以继续运行。

在一个 when 里面,你可以添加多个匹配:

case answer
when 'y', 'yes'
  puts '再见!'
  exit
  # etc
上面的例子,我们在 when 里面使用逗号分隔开了一些可能的匹配,它有点像是 或 操作符。上面的意思是如果输入是 “y” 或 “yes” 就输出 “再见” 并退出程序。

when 是怎么一回事儿

每个 Ruby 对象都有一个 case equality 方法,名字是 === ,调用 === 的结果就是判定一个 when 是不是被匹配了。

上面的例子,我们可以这样重写一下:

if 'yes' === answer
  puts '再见!'
  exit
elsif 'no' === answer
  puts '我们继续!'
else
  puts '不知道您在说啥,假设是 no'
end
站在中缀操作符(infix operator)的角度看 === ,它其实就是方法的 Syntactic sugar:

if 'yes' .===(answer)
再看一下为什么当 answer 包含 yes 的时候会返回 true:

'yes' === answer
调用三等(===)方法会返回 true,是因为三等方法是为字符串定义的。当你问一个字符串它自己是不是三等另一个字符串(string1 === string2),你问的其实就是字符串自己的内容,一个一个的跟另一个字符串去比较,如果匹配,就会返回 true,不然就会返回 false。

每个类都可以定义自己的 === 方法,可以用自己的 case-equality 逻辑。

case/when 就是 object === other_object 的伪装,object === other_object 其实是 object. === (other_object) 的伪装。

对象 case 行为

class Ticket
  attr_accessor :venue, :date
  def initialize(venue, date)
    self.venue = venue
    self.date = date
  end

  def ===(other_ticket)
    self.venue === other_ticket.venue
  end
end

ticket1 = Ticket.new('Town Hall', "07/08/13")
ticket2 = Ticket.new('Conference Center', "07/08/13")
ticket3 = Ticket.new('Town Hall', "08/09/13")

puts "ticket1 的位置是: #{ticket1.venue}"

case ticket1
when ticket2
  puts '跟 ticket2 在同一个位置'
when ticket3
  puts '跟 ticket3 在同一个位置'
else
  puts '没有匹配'
end
执行的结果是:

ticket1 的位置是: Town Hall
跟 ticket3 在同一个位置
返回值

如果在 when/else 从句里有成功的,case 声明返回的值就是执行这个双句里的代码的结果。如果要匹配的全都失败了,整个声明就会返回 nil 。

上午10:40 ***

Loops

上午11:02 ***

无条件循环

使用 loop,像这样:

loop codeblock
代码块有两种形式,一种是使用花括号({}),一种使用 do 还有 end 操作符。下面的例子演示了这两种形式:

loop { puts 'looping forever!' }

loop do
  puts 'looping forever!'
end
你不能让循环永远继续下去,你需要让它在某个点上停下来。

控制循环

使用 break 关键词:

n = 1
loop do
  n = n + 1
  break if n > 9
end
使用 next 跳过当前的循环:

n = 1
loop do
  n = n + 1
  next unless n == 10
  break
end
有条件循环

有条件的循环用的关键词是 while 与 until 。

while

一个条件如果是 true 就去循环。用 while 开始,end 结束,在它们之间的代码就是每次循环要执行的东西:

n = 1
while n < 11
  puts n
  n = n + 1
end
puts '完成!'
上面这块代码输出的是:

1
2
3
4
5
6
7
8
9
10
完成!
n < 11 这个条件是真,循环就会被执行。每次循环的时候 n 的值都会增加 1 。

while 也可以放到循环的结束,要配合使用 begin/end 关键词:

n = 1
begin
  puts n
  n = n + 1
end while n < 11
puts '完成!'
while  放在开始与结尾有点区别,如果你把 while 放在开始的地方,条件如果是 false ,循环一次都不会被执行。但是如果你把 while 放在结尾,如果 while 条件是 false,循环会被执行一次。

until

n = 1
until n > 10
  puts n
  n = n + 1
end
循环的主体会被执行,直到条件是 true。

while 与 until 修饰符

使用 until:

n = 1
n = n + 1 until n == 10
puts '数到 10 了!'
也可以使用 while:

n = 1
n = n + 1 while n < 10
puts '数到 10 了!'
做个实验:

a = 1
a += 1 until true
a 的值仍然是 1 ,因为 true 已经是 true 。

再做个实验:

a = 1
begin
  a += 1
end until true
在 begin 与 end 中间的代码会被执行一次。

循环一个列表的值

做个实验:

numbers = [1, 2, 3]
for n in numbers
  puts n
end
执行的结果是:

1
2
3
上午11:30 ***

迭代

下午6:51 **

迭代器的原料

在一个对象上调用方法,控制会传递给方法的主体(一个不同的作用域),方法执行完以后,控制会返回给调用方法的那个地方之后的点上。再来认识两个新的结构,代码块(code block) 还有关键词 yield 。

之前我们看见过这样一个例子:

loop { puts 'looping forever!' }
上面的代码会一直输出那个信息,想一下,这到底发生了什么?在 loop 里为什么会执行 puts 。答案是:loop 是一个迭代器。迭代器是一个 Ruby 方法,调用这种方法的时候需要加点特别的料,它会期望你给它提供一个代码块。一个大括号就会划分出来一个代码块。

loop 方法可以访问代码块里的代码,就是方法可以调用或者叫执行这个代码块。想让自己的迭代器可以做这件事,你就可以使用关键词 yield。代码块与 yield 就是一个迭代器的主料。

自制的迭代器

写一个自己的 loop:

def my_loop
  while true
    yield
  end
end
或者再简单点:

def my_loop
  yield while true
end
调用它:

my_loop { puts 'my-looping forever!' }
为 my_loop 提供一个代码块,方法可以把控制让给它,当方法把控制让给代码块以后,代码块里的代码会被运行,完成以后控制又会交还给方法。

剖析方法调用

Ruby 里的方法调用有几种形式:

接收者对象或变量

方法名
参数列表(可选,默认是( ) )
代码块(可选,无默认)
注意参数列表与代码块分别是两种不同的调用方法的形式。下面这些都是合法的 Ruby 方法调用:

loop { puts 'hi' }
loop() { puts 'hi' }
string.scan(/[^,]+/)
string.scan(/[^,]+/) {|word| puts word}
调用方法时使用代码块与不使用代码块的区别是,方法是否可以 yield。如果有代码块,方法就可以 yield,如果没有,就不能,因为没有可以 yield 的东西。

有些方法不管你是不是给它提供一个代码块,它都会去做一些事情。比如说 String#split,这个方法会根据你传递给它的分割符,去分离它的字符串接收者,然后把分离出来的元素放到一个数组里。但是如果你传递给这个方法一个代码块,split 会把分离的项目 yield 给一个代码块,在代码块里你可以随便处理每个子字符,可以输出它,可以把它放到数据库里等等。

大括号与 do/end 代码块

do/end ,还有大括号,使用这两种形式都可以划分出一个代码块。先做个实验:

>> array = [1,2,3]
=> [1, 2, 3]
>> array.map {|n| n * 10}
=> [10, 20, 30]
>> array.map do |n| n * 10 end
=> [10, 20, 30]
>> puts array.map {|n| n * 10}
10
20
30
=> nil
>> puts array.map do |n| n * 10 end
#
=> nil
上面的:

puts array.map {|n| n * 10}
相当于是:

puts(array.map { |n| n * 10 })
上面的:

puts array.map do |n| n * 10 end
相当于是:

puts(array.map) do |n| n * 10 end
使用 do/end 的那个版本,其实就相当于是:

puts array.map
times

times 是 Integer 类的一个实例方法,就是你可以整数上调用这个方法。运行代码块 n 次,最终返回的值也会是 n 。

在 irb 上试一下:

>> 5.times { puts 'i love u' }
i love u
i love u
i love u
i love u
i love u
=> 5
yielding 给一个代码块,从一个方法那里返回,它们不是一回事儿,上面的 times 可以很好的证明这点。一个方法可以 yield 给它的区块很多次,从零到无限。但是每个方法在完成它要做的事情以后只能返回一次。比如上面这个例子,输出了 5 次 i love u ,完成以后返回了一个 5 。

有点像是花样滑冰,跳起来,在空中做一些旋转动作,然后落地。不管你在空中转了多少圈,你只能是跳起来一次,落地也只有一次。方法的调用让方法运行一次,然后返回一次。但是在它们中间,就像是在空中的旋转动作,方法可以 yield 控制给代码块零次或多次。

>> 5.times { |i| puts "i love u #{i}" }
i love u 0
i love u 1
i love u 2
i love u 3
i love u 4
=> 5
创建一个自己的 times:

class Integer
  def my_times
    c = 0
    until c == self
      yield(c)
      c += 1
    end
  end
end
用一下自己定义的 my_times:

>> 5.my_times {|i| puts "i love u #{i}"}
i love u 0
i love u 1
i love u 2
i love u 3
i love u 4
=> nil
yield 的概念我还是有点模糊,比如这个词本身的意思,我就有点迷乎,是 “让出” ?yield 一个值是什么意思? 是产生一个值?带来一个值?
each

在一个集合的对象上运行 each 方法,each 会一个一个地 yield 集合里的每个项目到你的代码块里。

做个简单的 each 操作实验:

>> array = [1,2,3,4,5]
=> [1, 2, 3, 4, 5]
>> array.each {|e| puts "处理 #{e}"}
处理 1
处理 2
处理 3
处理 4
处理 5
=> [1, 2, 3, 4, 5]
再做一个自己的 each:

class Array
  def my_each
    c = 0
    until c == size
      yield(self[c])
      c += 1
    end
    self
  end
end
用一下:

>> array = [1,2,3,4,5]
=> [1, 2, 3, 4, 5]
>> array.my_each {|e| puts "处理 #{e}"}
处理 1
处理 2
处理 3
处理 4
处理 5
=> [1, 2, 3, 4, 5]
下午9:06 **

从 each 到 map

2016年9月10日 上午9:10 **

音乐:《Everything Will Flow》http://music.163.com/#/song?id=28000793

map 一次会处理一个数组的元素同时把它每个元素带给代码块。each 与 map 的区别是,each 返回的值是它的接收者,map 返回的是一个新的数组。这个新的数组的大小跟原来的数组是一样的,不过新的数组里的项目是迭代的时候从代码块那里返回来的。

试下 map:

>> names = ["David", "Alan", "Black"]
=> ["David", "Alan", "Black"]
>> names.map {|name| name.upcase }
=> ["DAVID", "ALAN", "BLACK"]
上面得到的结果是一个新的数组,数组里的每个元素的位置跟原始的数组是一致的,只不过是通过代码块处理过的。map 告诉我们,在迭代里,代码块会返回方法交给它的值。这个返回的值会作为调用 yield 的值。

为了实施我们自己的 my_map,我们得先安排一个累加器数组,成功执行了代码块返回的值会放到这个数组里。然后我们再把这个累加器数组作为调用 my_map 的结果。

先来实施一个简单版本的 map 功能:

class Array
  def my_map
    c = 0
    acc = []
    until c == size
      acc << yield(self[c])
      c += 1
    end
    acc
  end
end
再用一下我们自己定义的 my_map:

>> names = ["David", "Alan", "Black"]
=> ["David", "Alan", "Black"]
>> names.my_map {|name| name.upcase }
=> ["DAVID", "ALAN", "BLACK"]
在 each 的基础上创建个 map:

class Array
  # Put the definition of my_each here
  def my_map
    acc = []
    my_each {|e| acc << yield(e) }
    acc
  end
end
区块的参数与变量作用域

代码块里的参数是用竖线包围的,方法的参数是用括号包装的。

先看一个方法:

def args_unleashed(a,b=1,*c,d,e)
  puts '参数:'
  p a,b,c,d,e
end
用代码块的方法改装一下上面的方法:

def block_args_unleashed
  yield(1,2,3,4,5)
end

block_args_unleashed do |a,b=1,*c,d,e|
  puts '参数:'
  p a,b,c,d,e
end
输出的结果是:

参数:
1
2
[3]
4
5
方法定义会开启一个新的使用域,代码块的作用域有一点点复杂。

做个实验,看一下代码块里要输出的 x 是多少:

def block_scope_demo
  x = 100
  1.times do
    puts x
  end
end
执行 block_scope_demo 输出的结果是 100 。

再做个实验:

def block_scope_demo_2
  x = 100
  1.times do
    x = 200
  end
  puts x
end
执行 block_scope_demo_2 输出的结果是 200 。区块里面还有外面的 x 是同一个。

再看个例子:

def block_local_parameter
  x = 100
  [1,2,3].each do |x|
    puts "参数 x 是 #{x}"
    x = x + 10
    puts "在区块里重新分配 x,现在它是 #{x}"
  end
  puts "在外面 x 仍然是 #{x}"
end
输出的结果是:

参数 x 是 1
在区块里重新分配 x,现在它是 11
参数 x 是 2
在区块里重新分配 x,现在它是 12
参数 x 是 3
在区块里重新分配 x,现在它是 13
在外面 x 仍然是 100
在区块内与区块外不是同一个 x ,因为 x 会作为区块的一个参数。即使在区块内重新分配了 x ,但是也不会影响到外面的 x 。也就是你可以在区块的参数里使用任何的变量。

有时候你可能希望在区块里面使用一些临时变量,它们也没有在调用区块的时候作为参数,你又不想这些在区块里影响到外面定义的变量,可以这样做:

def block_local_variable
  x = '原始 x'
  3.times do |i;x|
    x = i
    puts "在区块里 x 现在是 #{x}"
  end
  puts "结束了区块,现在 x 是 #{x}"
end
结果是:

在区块里 x 现在是 0
在区块里 x 现在是 1
在区块里 x 现在是 2
结束了区块,现在 x 是 原始 x
一个分号跟着是 x ,表示区块要使用自己的 x 。分号后面的变量并不是区块的参数,它们是预留的名字,也就是你想临时在区块里使用的变量,不想影响到外界已经定义了的变量。

上午10:53 ***

处理错误

上午10:53 ***

Ruby 在运行的时候如果遇到不能接受的行为就会触发异常(raising an exception)。

拯救异常

异常是 Exception 类或者它的后代类的一个实例对象。触发异常的意思就是停止执行程序,然后处理遇到的异常或者彻底退出程序。是否处理异常要看你是不是提供了 rescue 。

做个实验:

ruby -e '1/0'

-e:1:in `/': divided by 0 (ZeroDivisionError)
 from -e:1:in `

'
用一个数除以零,出现的异常是 ZeroDivisionError ,它其实是 Exception 的一个后代类。来看一些常见的异常:

RuntimeError:raise 方法触发的默认的异常。
NoMethodError:调用的方法在对象里不存在,method_missing 会触发这个异常。
NameError:解释器遇到它不懂的标签符。
IOError:读取已经关掉的流,读入只读流这些操作。
Errno::error:跟 File I/O 相关。
TypeError:方法收到它不能处理的参数。
ArgumentError:使用的参数的数量有问题。
rescue

遇到异常可以使用 rescue 来拯救。一个 begin 与 end,它们中间是 rescue :

print '输入一个数字:'
n = gets.to_i

begin
  result = 100 / n
rescue
  puts '没法弄,你输入的是 0 吗?'
  exit
end

puts "100/#{n} 的结果是 #{result}"
执行代码,如果你输入一个零,程序会触发 ZeroDivisionError,我们用 rescue 来拯救,方法就是输出一条提示信息,然后退出程序。

上面的例子里 rescue 的是 StandardError,如果你想拯救个特别的错误,可以这样:

rescue ZeroDivisionError
在方法与代码块里的 rescue

方法与代码块里提供了一个隐式 begin/end 。所以可以这样:

def open_user_file
  print "要打开的文件:"
  filename = gets.chomp
  fh = File.open(filename)
  yield fh
  fh.close
rescue
  puts "不能打开你想要的文件!"
end
打开文件的操作如果出现了异常,控制会交给 rescue 分句。使用 begin/end 可以更好的来控制要处理的异常:

def open_user_file
  print "要打开的文件:"
  filename = gets.chomp
  begin
    fh = File.open(filename)
  rescue
    puts "不能打开你想要的文件!"
  end
  yield fh
  fh.close
end
触发异常

使用 raise 加上你想要触发的异常的名字。

def fussy_method(x)
  raise ArgumentError, "我需要一个 10 以下的数字" unless x < 10
end

fussy_method(20)
执行的结果是:

demo.rb:2:in `fussy_method': 我需要一个 10 以下的数字 (ArgumentError)
 from demo.rb:5:in `

'
rescue 可以像这样来拯救上面的异常:

begin
  fussy_method(20)
rescue ArgumentError
  puts "不能接受的数字"
end
下面两行代码有一样的效果:

raise "问题!"
raise RuntimeError, "问题!"
在 rescue 里捕获异常

把异常对象交给一个变量,可以使用 => 这个操作符,再加上 rescue 命令。异常对象上有一些方法,比如 backtrace 与 message 方法。backtrace 会返回一个数组,里面表示的是出现异常的时候的调用堆:方法名,文件名,行号。message 方法会返回 raise 提示的信息。

def fussy_method(x)
  raise ArgumentError, "我需要一个 10 以下的数字" unless x < 10
end

begin
  fussy_method(20)
rescue ArgumentError => e
  puts "这个数字不能接收"
  puts "backtrace:"
  puts e.backtrace
  puts "信息:"
  puts e.message
end
执行的结果是:

这个数字不能接收
backtrace:
demo.rb:2:in `fussy_method'
demo.rb:6:in `

'
信息:
我需要一个 10 以下的数字
ensure

假如你想读取一个文件里的一行内容,如果这行内容里没有找到一个特定的子字符串,就触发一个 ArgumentError 异常,如果找到了就返回这个子字符串。但是不管怎么样,完成方法以后你都想要关掉文件的处理。可以这样做:

def line_from_file(filename, substring)
  fh = File.open(filename)
  begin
    line = fh.gets
    raise ArgumentError unless line.include?(substring)
  rescue
    puts "无效行"
    raise
  ensure
    fh.close
  end
  return line
end
创建自己的异常类

自己的异常类可以继承 Exception 类:

class MyNewException < Exception
end

raise MyNewException, "出现了新的错误!"

相关文章

精彩推荐