Ruby中闭包的详细指南

357 阅读8分钟

除非你从其他语言中理解了 "闭包 "的概念,否则在Ruby中,它最初可能会让你感到困惑。"闭包",除了Ruby(和一些函数式编程语言)外,是一个不合时宜的概念。

下面是Merriam-Webster对闭包的定义。

closure名词 clo-sure \ ˈklō-zhər \ 古代:包围的手段:包围物

这里也是一样。一个Ruby闭包 "包围了一堆要执行的代码"。这是一个 "封套"。根据维基百科,闭包是一种技术,用于在具有一级函数的语言中实现词义范围的名称绑定。

考虑一下这个片段:


2.times do
  topic = 'Ruby closures'
  puts "An adventure with #{topic}."
end

#=> An adventure with Ruby closures.
#=> An adventure with Ruby closures.
#=> 2

我们在doend 之间 "封闭 "了topicputs 。该区域外的任何东西都不能访问topic,但puts 方法可以,因为它与变量赋值(名称绑定)处于同一环境中(词义范围)。这种结合方法和变量的技术我们称之为闭合。注意: { ... }do ... end 在传递给一个方法时是等价的。我将会交替使用它们。

这段代码如何呢?

lam = lambda do |topic|
  puts "An adventure with #{topic}."
end

lam.call('Ruby closures')

#=> An adventure with Ruby closures

如果你仔细看,它做的事情和前面的片段类似。现在,最后一段呢?

prok = Proc.new do |topic|
  puts "An adventure with #{topic}."
end

prok.call('Ruby closures')

#=> An adventure with Ruby closures.

这些形式代表了在Ruby中封闭代码的方法。我们已经看到一个块、lambda和一个Proc分别表示为。{},lambda {}Proc.new {} 在前面的代码片断中。这些都是Ruby中的闭包。在这三个代码中,我们都设置了一个局部变量topic ,并将其传递给一个局部方法puts 。你可能会想,我们在lamprok 中设置了topic 这个变量。我们没有像在第一个代码段中那样明确地将其设置为topic = "Ruby closures" 。原因是;Proclambda#call 方法为我们做了这个。如果你看一下Proc#call 的文档,我们有。

call(params, ...) → obj 调用块,将块的参数设置为params中的值。

关于Ruby块(作为闭包),一个值得注意的细微差别是,在Ruby中,块不能被表示为对象,它们只是提供了一个简单的方法来分组代码,但你不能将它们绑定到变量。就其本身而言,它们是非常无用的。另一方面,Procs和Lambdas则是一些人所说的一等公民

区块

我同意被纠正,但正如我之前提到的,块本身并没有什么意义。它们通常作为参数传递给方法,但它们也能帮助我们对代码进行分组以修改其行为。这里有一个例子:

(1..10).each { |v| puts v if v.even?  }

# Output: #

# 2
# 4
# 6
# 8
# 10

很容易看出,我们在迭代一个Range并打印出所有的偶数。现在,如果我们想同时打印出所有偶数和质数的数字呢?这时,区块就显示出它们的用处了。我们只需要修改区块中的代码就可以做到这一点了:

require 'prime'

(1..20).each { |v| puts v if v.even? && v.prime? }

# => 2

我们只是通过对块中的内容进行分组来改变代码的行为。我们不得不要求Prime 标准库来利用prime? 方法。如果我们愿意的话,我们也可以实现我们挑选素数的方法。我们已经看到了我们如何使用Ruby块来分组我们的代码来列举。但在这篇文章中,我们要讨论的是闭包。你已经看到了上面的代码块是如何通过将1-20范围内的每个数字分配给变量v ,从而起到封闭作用的吗?如果没有,那么这里的代码如何呢?

def shout(word, position)
  position.times { |x| puts "#{ x }: #{ word }"}
end

shout("SHOUTING!", 2)

# Output: #

# 0: SHOUTING!
# 1: SHOUTING!

在这个代码中,我们将word 变量分配给字符串SHOUTING! ,同时将第二个参数,一个整数,分配给变量position ,并使用x 代表从0到position 的每个数字,以表示我们希望字符串打印的次数。请牢记闭合的定义。最后的代码是否满足它?

现在你对块有了一些了解,但在目前展示的例子中,我们只是用Ruby块来封装行为,并将它们传递给Ruby的方法。我们也可以写我们的方法,接受块。

在Ruby中,我们被赋予了yieldblock_given? 方法。这些方法也能帮助我们创建接受块的方法。只要你在Ruby中看到yield ,就把它翻译成 "嘿,做传来的块说的任何事情"。block_given? 是非常直观的。这个方法简单地返回一个布尔值,表示是否有一个块被传递。这里有一个简单的例子来说明这个概念:

def shout
  yield
end

# Call the #shout method with a block.
shout { puts "SHOUTING!" }

# => SHOUTING!

试试上面的方法,调用yield 两次或三次,看看会发生什么。这有什么意义吗?如果你在调用这个方法时没有给它传递一个块,你就会看到

没有给定块(yield)(LocalJumpError)。

你可以试试。而这正是block_given? 的用武之地。这里是你如何利用block_given? 方法的:

def shout
  block_given? ? yield : puts("Sir, you didn't pass any block.")
end

# Call the #shout method without a block.
shout

# => Sir, you didn't pass any block.

这几乎就是它的全部内容了!但是你可能想知道这些东西的实际用途是什么。漂亮的对象初始化!就像这样:

module Genus
  class Species
    attr_accessor :name, :height, :weight, :ethnicity

    def initialize
      yield self if block_given?
    end
  end
end

human = Genus::Species.new do |characteristics|
  characteristics.name = "Emmanuel Hayford"
  characteristics.height = 188 # cm
  characteristics.weight = 104 # kg
  characteristics.ethnicity = "black"
end

p human.ethnicity

# => "black"

我们正在将self (这是一个实例化的物种类的对象)设置为characteristics 变量。

Procs

尽管Ruby中的所有东西都是一个对象,但诸如块和方法这样的结构体却不是。然而,我们可以用Procs将它们变成对象。所以大部分Procs是表示为对象的块,你可以调用方法或分配给内存。

让我们重构我们的第一个例子,使用Proc

所以我们将这段代码:

2.times do
  topic = 'Ruby closures'
  puts "An adventure with #{topic}."
end

改为这样:

prok = Proc.new {
  topic = 'Ruby closures'
  puts "An adventure with #{topic}."
}

# `Proc.new` is equivalent to `proc`

2.times do
  prok.call
end

# => An adventure with Ruby closures.
# => An adventure with Ruby closures.

因为proc 现在是一个对象,你可以对它调用call 方法来返回块。我们可以进一步重构,使块或Proc对象接受一个参数,使其更加动态:

prok = Proc.new { |topic|
  puts "An adventure with #{topic}."
}

2.times do
  prok.call('Ruby closures')
end

# => An adventure with Ruby closures.
# => An adventure with Ruby closures.

这里有一些有趣的东西;对于一个需要一个参数的Proc对象,比如说,你可以用几种不同的方式来调用它。Ruby允许我们省略对call 的调用。因此上面代码的第6行相当于下面的内容。

prok.('Ruby closures') 和 。prok === 'Ruby closures'

然而,如果你想处理多个参数,你可以将你的参数作为一个数组的元素传递给Proc对象。因此,让我们假设这是我们的代码:

prok = Proc.new { |x, y| puts x + y }

下面所有调用prok 的方法都是有效的:

prok.call [7,3]
prok === [3, 7]
prok[6, 4]

# => 10
# => 10
# => 10

以上所有的方法对lambdas都是有效的。

Lambdas。

兰姆达是尊重arity的Proc对象,对待return 关键字的方式有点不同。

下面是值得注意的关于Proc和Lambdas的行为。请考虑以下情况:

cool_lambda = lambda { |first_name, nationality| puts "#{first_name} is #{nationality}."}
cool_proc = proc  { |first_name, nationality| puts "#{first_name} is #{nationality}."}

cool_lambda.call("Matz", "Japanese")
cool_proc.call("Aaron", "American")

一切按预期进行。cool_lambda'的签名接受两个参数,它对此很严格,如果你多传一个或少传一个参数,它就会爆炸,比如:

错误的参数数(给定3个,预期2个)(ArgumentError)。

cool_proc 另一方面,它将只做区块指定的事情,默默地忽略额外的参数或没有参数。

下面是关于return 关键字与闭包的情况:

class ClosureExperiment
  def call_closure(closure)
    puts "Calling #{ check_closure_type(closure) }."

    closure.call

    puts "#{ check_closure_type(closure) } got called!"
  end

  def check_closure_type(unknown_closure)
    unknown_closure.lambda? ? "Lambda" : "Proc"
  end
end

closure = ClosureExperiment.new.call_closure(lambda { return })

# => Calling Lambda.
# => Lambda got called!

closure = ClosureExperiment.new.call_closure(proc { return })

# => Calling Proc.

从上面可以看出,当Procs看到return ,就会立即弹出,而Lambda则犹豫不决,直到其上下文中的所有内容都被执行。

这两个闭包之间还有一个区别是,当Lambda定义了一个默认参数并传递了一个数组时,它总是返回传递给它的默认参数,而Proc会忽略它,除非没有额外的参数,那么默认参数就会像下面这样实现:

lam = lambda { |a, b = :foo| p a, b }.call([1,3,5])
# => [[1, 3, 5], :foo]

prok = proc { |a, b = :foo| p a, b }.call([1,2,3])
# => [1, 2]

prok = proc { |a, b = :foo| p a, b }.call([1])
# => [1, :foo]

这就是Proc和Lambdas的不同之处了。

最后,我喜欢把Procs看作是以对象形式表示的块,而Lambdas是Procs的一个变体,当你传入错误的参数数量时,会引发错误。