理解 Ruby - Blocks、Procs 和 Lambdas
介绍
块、过程和 Lambda
总结
介绍
Ruby 是一种使用多种编程范式的语言,通常是面向对象和函数式,并且其函数式特性带来了函数的概念。
Ruby 使用三种主要类型的函数:Blocks、Procs 和 Lambdas。
这篇文章将介绍所有这些工具、您可以在哪里找到它们以及使用它们时需要注意的事项。
您使用 Java 和 C++ 等语言越多,您遇到这些想法的可能性就越小,但如果您花了一些时间研究 Javascript,那么其中的很多内容看起来就会非常熟悉。
困难
基础
无需任何先修知识。本文重点介绍 Ruby 程序员的基础知识。
块、过程和 Lambda
首先需要注意的是,这三个概念都是匿名函数。事实上,我倾向于称它们为Block 函数、Proc 函数和Lambda 函数,以提醒自己它们除了行为略有不同之外,并没有什么特别之处。
综上所述,函数到底是什么,为什么我们在 Ruby 中关心它们?
函数的概念
什么是匿名函数?
为什么是匿名的?在 JavaScript 中,区别在于一个有名称,而另一个没有:
const anonymousAdder = function (a, b) { return a + b; }
function adder(a, b) { return a + b; }
anonymousAdder(1, 2)
// => 3
adder(1, 2)
// => 3
在 Ruby 中,我们实际上并不像方法那样了解命名函数:
def adder(a, b) = a + b
adder(1, 2)
# => 3
注意:以上是 Ruby 3.0 中引入的单行方法语法,非常适合注重返回结果且较短的短方法。
...对于匿名函数,我们有类似 lambda 的东西:
adder = -> a, b { a + b }
adder.call(1, 2)
# => 3
注意:此语法,作为参考,被称为“stabby lambda”。参数位于箭头右侧,函数主体位于括号之间。
a + b
在本例中,返回值隐含在最后一个表达式的求值中。
这在 Ruby 中什么时候出现过?嗯,确实经常出现,因为块函数也是匿名的。考虑一下map
,将一个函数应用到列表中的每个元素并返回一个新列表的想法:
[1, 2, 3].map { |v| v * 2 }
# => [2, 4, 6]
这使我们能够以简洁的方式表达将列表中每个元素加倍的想法。如果我们按照 Java 或 C 在现代版本和其他函数式概念引入之前的方式编写map
,它看起来更像这样:
def double_items(list)
new_list = []
for item in list
new_list << item * 2
end
new_list
end
警告:避免
for ... in
在 Ruby 中使用,each
我们稍后会提到。
上述匿名函数使我们能够将转换元素的整个想法抽象为一行,从而带来强大的功能。
在本文中,我们不会深入讨论使用函数可以做的所有有趣的事情,但请放心,我们很快就会介绍它。
函数用在何处?
在 Ruby 中随处可见。考虑一下each
,Ruby 倾向于使用这种方式遍历列表中的每个元素:
[1, 2, 3].each { |v| puts v }
# STDOUT: 1
# STDOUT: 2
# STDOUT: 3
# => [1, 2, 3]
注意:
STDOUT
表示标准输出,通常是控制台屏幕。=>
表示返回值,并each
返回原始值Array
。我将这些注释掉(#
用于注释),以防您在尝试代码时复制它们。
就是这个?嗯,这是一个块函数。它会遍历列表中的每个元素,并将结果赋值给名为 的函数v
。函数参数放在竖线 ( ) 中,如果参数很多,|
则用逗号 ( ) 分隔。函数本身则放在方括号 ( ) 中。,
{}
在这个特定的函数中,我们将的值输出v
到STDOUT
。
您可能还会看到如下所示的块函数:
[1, 2, 3].each do |v|
puts v
end
# STDOUT: 1
# STDOUT: 2
# STDOUT: 3
# => [1, 2, 3]
他们都做同样的事情。
大括号与 Do/End 的细微差别
通常,Ruby 中人们更喜欢使用{}
单行函数和do ... end
多行函数。我自己呢?我更喜欢Weirich 方法,这意味着它{}
用于主要作用是返回值并do ... end
执行副作用的函数。
两者都可以,但请确保在代码库中选择的任何一种保持一致,甚至遵循已建立的代码库的语义和规则,而不是将自己的意见强加于它们。
功能差异和语法问题
括号的使用方法以及它们的求值方式可能会有所不同,您可能会遇到这种情况。请考虑以下 RSpec 代码:
describe 'something fun' do
# ...
end
describe 'something fun' {
# ...
}
# SyntaxError (1337: syntax error, unexpected '{', expecting end-of-input)
# describe 'something fun' {
第二个会语法错误,而第一个会正常工作。这是因为第二个无法确定它是Hash
参数还是函数,导致 Ruby 抛出错误。如果你用括号括起来,'something fun'
它就能正常工作:
describe('something fun') {
# ...
}
...但大多数人更喜欢do ... end
RSpec 代码,我也是。
调用函数
那么,如何调用函数而不是方法呢?有几种方法,这里我们再次关注Lambda 函数:
adder = -> a, b { a + b }
adder.call(1, 2)
# => 3
adder.(1, 2)
# => 3
adder[1, 2]
# => 3
还有一些类似的函数===
仅适用于单参数函数,除非你做了一些非常讨厌的事情,例如:
adder === [1, 2]
# ArgumentError (wrong number of arguments (given 1, expected 2))
adder === 1, 2
# SyntaxError ((irb):157: syntax error, unexpected ',', expecting `end')
adder.===(*[1, 2])
=> 3
我不建议使用那个,也不建议明确地使用===
这样的方法。如果你想了解更多信息,可以阅读《===
理解Ruby - 三等号》 。
还有最后一种调用函数的方法,yield
但是我们暂时先保存它,并在下一节关于块的部分中对其进行解释。
函数参数
现在这里有一个有趣的但不常被提及的:所有有效的方法参数也是有效的函数参数:
def all_types_of_args(a, b = 1, *cs, d:, e: 2, **fs, &fn)
end
这意味着你可以非常有效地做到这一点:
lambda_map = -> list, &fn {
new_list = []
list.each { |v| new_list << fn.call(v) }
new_list
}
lambda_map.call([1, 2, 3]) { |x| x * 2 }
# => [2, 4, 6]
想想使用默认参数,尤其是关键字参数会多么有趣,作为以后的一个有趣的小实验潜力。
与号 ( &
) 和to_proc
您可能在 Ruby 中看到过这样的代码:
[1, 2, 3].select(&:even?)
# => [2]
搜索&
可能比较困难,所以我们在这里解释一下。&
调用to_proc
它后面的内容。在Symbol
这里,它调用的是Symbol#to_proc
(to_proc
类实例上的方法Symbol
)。
它有效地生成如下代码:
[1, 2, 3].select { |v| v.even? }
# => [2]
…Symbol
强制转换为Proc 函数的,就像一个方法,对传入函数的任何数据进行调用。因为 ,select
传入的数据就是 numbers 1, 2, 3
。
这也告诉 Ruby 将此参数视为一个块函数,以便将其传递给底层方法,我们稍后会在块函数部分中讨论这一点。
注意:
&
是 的语法糖to_proc
,但在此上下文之外不起作用。例如,您不能这样做:age_function = &:age
。这将导致语法错误。
函数类型
现在我们已经做好了一些准备,让我们来看看三种类型的函数:Block 函数、Proc 函数和 Lambda 函数。
注意:从技术上讲,
method
这是另一种类型的函数,但我们将跳过本文的该部分,并将其保存到稍后有关 Ruby 方法的更详细的描述中。
块函数
第一个可能是最熟悉的,并且最有可能出现在您日常的 Ruby 代码中:块函数。
正如您之前看到的,each
它需要一个块函数:
[1, 2, 3].each do |v|
puts v
end
# STDOUT: 1
# STDOUT: 2
# STDOUT: 3
# => [1, 2, 3]
我们如何定义一个像这样接受块函数作为参数的函数呢?让我们来看看几种方法。
显式块函数
第一种方法是明确地告诉 Ruby 我们正在将一个函数传递给一个方法:
def map(list, &function)
new_list = []
list.each { |v| new_list << function.call(v) }
new_list
end
map([1, 2, 3]) { |v| v * 2 }
# => [2, 4, 6]
函数以 为前缀&
。你经常会看到它被称为&block
,但对我来说,我更倾向于&function
明确地表明这是我想要使用的函数。我经常将其缩写为&fn
,但这只是我的偏好。
对于这个新map
方法,我们遍历列表中的每个项目,并new_list
在调用function
每个项目进行值转换后,将这些项目放入 中。完成后,我们new_list
在最后返回 。
我偏爱显式函数,因为它们让我从方法的参数中知道它需要一个函数。
隐式块函数
下一种方法是隐含的,并使用yield
关键字:
def map_implied(list)
new_list = []
list.each { |v| new_list << yield(v) }
new_list
end
map_implied([1, 2, 3]) { |v| v * 2 }
# => [2, 4, 6]
yield
这里很有趣,因为它可以在一个方法中出现任意多次。在本例中,我们只希望它在原始列表的迭代中被提及一次。从技术上讲,我们也可以这样做:
def one_two_three
yield 1
yield 2
yield 3
end
one_two_three { |v| puts v + 1 }
# STDOUT: 2
# STDOUT: 3
# STDOUT: 4
# => nil
尽管我还没有在我的代码中找到它的用途。
yield
是一个关键字,它会为调用该方法的隐含函数生成一个值。一旦用完yield
s,就会停止调用该函数。
就我个人而言,我不喜欢这样,因为它比上面的更让我困惑,但你很可能在其他 Ruby 代码中看到这种模式,所以知道它的存在是件好事。
与号 ( &
) 和块函数
那么 Ruby 怎么知道这是一个需要传递给方法的函数呢?在本例中,它是隐含的:
map([1, 2, 3]) { |v| v * 2 }
# => [2, 4, 6]
...但在这种情况下,使用 lambda 的情况就不是那么多了:
add_one = -> a { a + 1 }
map([1, 2, 3], add_one)
# ArgumentError (wrong number of arguments (given 2, expected 1))
它被视为一个附加参数,除非我们在它前面加上前缀&
来告诉方法这是一个需要这样处理的块函数:
add_one = -> a { a + 1 }
map([1, 2, 3], &add_one)
# => [2, 3, 4]
给定的块和丢失的块
那么如果我们忘记了块函数会发生什么呢?就我们的显式样式而言:
map([1, 2, 3])
# NoMethodError (undefined method `call' for nil:NilClass)
对于我们的隐式:
map_implied([1, 2, 3])
# LocalJumpError (no block given (yield))
我们可以通过检查方法是否被赋予了块函数来防止这种情况:
def map(list, &fn)
return list unless block_given?
# ...rest of implementation
end
在这种情况下,如果我们忘记了Block 函数,它将返回初始列表。对于 Ruby 本身,它将返回一个Enumerator
:
[1, 2, 3].map
# => #<Enumerator: [1, 2, 3]:map>
我们改天Enumerator
再讨论。
Proc 函数
我们要研究的下一类函数是Proc 函数。你可能to_proc
之前已经注意到了,但有趣的是,你可以从中返回一个Lambda 函数。
需要注意的是,Proc 函数是Lambda 函数的基础,这就是我首先介绍它们的原因。
Proc 函数可以通过几种方式定义:
adds_two = Proc.new { |x| x + 2 }
adds_two.call(3)
# => 5
adds_three = proc { |y| y + 3 }
adds_three.call(2)
# => 5
带参数的行为
Proc 函数的有趣之处在于它们不关心传递给它们的参数:
adds_two = Proc.new { |x| x + 2 }
adds_two.call(3, 4, 5)
# => 5
唯一会出错的情况是,如果第一个参数缺失,并且这会导致函数本身出错。它们在这方面可能相当懒惰。这也是我倾向于使用Lambda 函数的一个重要原因,我们稍后会详细讨论。
具有回报的行为
这是另一个有趣的例子。如果我们在Proc 函数return
内部使用它,它可能会做一些不好的事情:
adds_two = Proc.new { |x| return x + 2 }
adds_two.call(3, 4, 5)
# LocalJumpError (unexpected return)
...但如果我们在方法内部这样做:
def some_method(a, b)
adds_three_unless_gt_three = proc { |v|
return v if v > 3
v + 3
}
adds_three_unless_gt_three.call(a) +
adds_three_unless_gt_three.call(b)
end
some_method(1, 1)
# => 8, or 4 + 4, or (1 + 3) + (1 + 3)
some_method(5, 5)
# => 5
return
实际上是从方法本身跳出,而不是从函数返回值。如果我们想要类似的行为,return
可以使用这个方法next
,但这也是我更喜欢Lambda 函数的另一个原因。
Lambda 函数
我们要研究的下一类函数是Lambda 函数。首先,记住Lambda 函数如下所示:
adds_one = -> a { a + 1 }
adds_one.call(1)
# => 2
...但它也可以看起来像这样,尽管这种语法很少见:
adds_three = lambda { |z| z + 3 }
adds_three.call(4)
# => 7
有趣的是,Lambda 函数是一种Proc 函数,您可以自己尝试一下:
-> ruby {}
# => #<Proc:0x00007ff3d88c30d8 (irb):231 (lambda)>
注意:我一直使用不同的参数名称是为了提醒大家,名称是任意的,只要是有效的变量名就可以。如果你想知道另一个有趣的事实,任何有效的方法参数对于任何函数来说也是有效的参数。
带参数的行为
Lambda 函数与Proc 函数不同,对其参数非常严格:
adds_four = -> v { v + 4 }
adds_four.call(4, 5, 6)
# ArgumentError (wrong number of arguments (given 3, expected 1))
它们的行为更像方法,并且可以在以后减少令人困惑的错误。
具有回报的行为
Lambda 函数将被视为return
本地函数,return
而不是尝试从方法的外部上下文返回:
def some_method(a, b)
adds_three_unless_gt_three = -> v {
return v if v > 3
v + 3
}
adds_three_unless_gt_three.call(a) +
adds_three_unless_gt_three.call(b)
end
some_method(1,1)
# => 8
some_method(5,5)
# => 10
这意味着函数的两次执行都会return
返回值,5
而不是返回5
整个函数。
总结
本文对 Ruby 中的函数类型进行了较为宽泛的概述,但并未深入探讨使用它们的理由。请放心,后续的文章也会涵盖这方面的内容。
在 Ruby 中,做一件事有多种方法,因此了解每种语法的功能非常有用,尤其是在早期阶段。我个人倾向于在某些方面精简语法,例如,我几乎只使用Lambda 函数而不是Proc 函数,而且我实在想不出在什么情况下需要用它们来代替。
函数真正的乐趣在于你开始了解它能做什么。接下来关于Enumerable
函数式编程的文章会非常精彩,但今天就先讲到这里。
想了解我的写作和工作进展吗?快来看看我的新通讯:《宝石狐猴》
鏂囩珷鏉ユ簮锛�https://dev.to/baweaver/understanding-ruby-blocks-procs-and-lambdas-24o0