Ruby Closure 读书笔记
Ruby 是一门虽然很 OO (不像 Java 还有基本类型,Ruby 中哪怕是数字也是对象) 但是也很 FP (支持 lambda curry) 的语言。
而 Ruby 的 Closure 是一个非常牛逼的特性,想要学习它需要一定的 FP 和元编程的知识储备。
让我们直接开始吧,先来看一下下面这个计数器的例子:
class Counter
def initialize
@x = 0
end
def get_x
@x
end
def incr
@x += 1
end
def decr
@x -= 1
end
end
下面是对应的 lambda 方式的实现,通过这个例子的对比,我想你对 Lambda 应该有一个最基本的认识了。
Counter = lambda do
x = 0
get_x = lambda { x }
incr = lambda { x += 1 }
decr = lambda { x += 1 }
{ get_x: get_x, incr: incr, decr: decr }
end
当然这个例子并不能发挥 lambda 的优势,这这种情况明显使用 Class 是更好的方案,这里只作为演示。
下面再来看看回调函数的情况,这个例子我相信只要是写过 JavasScript 的同学都很熟悉了。
require 'ostruct'
class Notifier
attr_reader :generator, :callbacks
def initialize(generator, callbacks)
@generator = generator
@callbacks = callbacks
end
def run
result = generator.run
if result
callbacks.fetch(:on_success).call(result)
else
callbacks.fetch(:on_failed).call
end
end
end
class Generator
attr_reader :report
def initialize(report)
@report = report
end
def run
report.to_csv
end
end
good_report = OpenStruct.new(to_csv: "59.00,Great Success")
bad_report = OpenStruct.new(to_csv: nil)
n = Notifier.new(
Generator.new(good_report),
on_success: lambda { |r| puts "Send #{r} to boss" },
on_failed: lambda { puts "Send to me" }
)
n.run
使用 JavaScript 来处理异步请求时肯定也会用到这样类似 on_succes, on_failed 的写法。
这里展示的是使用 lambda 的实现方式。
下面的例子需要一定的 FP 知识,如果看过《The Little Schemer》的话就很容易理解了。
reduce 是一个 FP 中很常见(应该说是必备)的方法,在 Ruby 中也有,用法如下:
[1,2,3,4,5].reduce(10) { |acc, x| p "#{acc}, #{x}"; acc + x }
# "10, 1"
# "11, 2"
# "13, 3"
# "16, 4"
# "20, 5"
下面我们要用 lambda 实现这个 reduce 方法:
adder = lambda do |acc, arr|
if arr.empty?
acc
else
adder.call(acc + arr.first, arr.drop(1))
end
end
puts adder.call(10, [1,2,3])
这样很好,但是还不够灵活,因为上面的代码只能实现最简单的相加。
我们要把一个 lambda 当作参数传入,这样就能支持更多的操作了:
reducer = lambda do |acc, arr, binary_function|
if arr.empty?
acc
else
reducer.call(
binary_function.call(acc, arr.first),
arr.drop(1),
binary_function
)
end
end
puts reducer.call(2, [2,3,4], lambda {|a, b| a * b}) # => 48
这样一来,改成应用乘法也很容易了。
不过这样的写法,在 else 中还要每次都调用 binary_function, 有点不优雅,可以改成下面这样:
reducer = lambda do |acc, arr, binary_function|
reducer_aux = lambda do |acc, arr|
if arr.empty?
acc
else
reducer_aux.call(
binary_function.call(acc, arr.first),
arr.drop(1)
)
end
end
reducer_aux.call(acc, arr)
end
puts reducer.call(2, [2,3,4], lambda {|a, b| a * b}) # => 48
这个改动就是在 lambda 内部再创建了一个 lambda, 但是只接收 acc, arr 两个参数,在最后再去执行这个内部的 lambda.
这个重构很好的说明了 lambda 的灵活性。
下面再说说 Blocks, BLock 可以作为参数被方法接收,并在适当的时候被执行。
直接看例子:
def do_it
yield
end
do_it {puts "I'm doing it"}
do_it { [1,2,3] << 4} # => [1,2,3,4]
def do_it(x,y)
yield(x, y)
end
do_it(2, 3) { |x, y| x + y }
刚才说的适当的时候,就是方法中 yield 的时候,一旦看到 yile 就意味着传入的 block 要被执行了。
现在我们来实现一下 each 方法:
%w(look ma no for loops).each do |x|
puts x
end
each 方法接收一个 block, 并且依次对每个元素执行 block 中的代码。
下面是我们的实现:
class Array
def each
x=0
while x < self.length
yield self[x]
x += 1
end
end
end
是不是很容易,再试试 times 的实现把:
class Fixnum
def times
x=0
while x < self
x += 1
yield
end
self
end
end
3.times { puts "D'oh!" }
Blocks 除了上面的枚举以外,还有一个常见的使用场景是管理资源。
比如我们要打开一个文件输入一些内容:
f = File.open('Leo Tolstoy - War and Peace.txt', 'w')
f << "Well, Prince, so Genoa and Lucca"
f << " are now just family estates of the Buonapartes."
f.close
这样的写法,如果忘了写 f.close 的话那可能会有很大的问题,当然为了解决这个问题,有很多解决方案。
比如用一个方法包装起来,或者使用模板模式。这里我们要使用的当然是 block 了。
File.open('Leo Tolstoy - War and Peace.txt', 'w') do |f|
f << "Well, Prince, so Genoa and Lucca"
f << " are now just family estates of the Buonapartes."
end
这样的写法就很优雅了,不需要手动编写 file 的 close 语句了,下面我们来自己实现这个功能:
class File
def self.open(name, mode)
file = new(name, mode)
return file unless block_given?
yield(file)
ensure
file.close
end
end
File.open("file_open.rb", "r") do |f|
puts f.path
puts f.ctime
puts f.size
end
可以看到, block_given? 这个方法可以判断有没有 block 被传入,如果没有,就执行普通的 new 方法创建一个 File 对象。 这就是上面第一种写法,否则就执行 yield, 并且在最后执行 close 方法(使用 ensure 确保一定会关闭文件)。
之后是最后一个常见模式,优雅的对象初始化。
对于对象初始化,一般都是定义在 initialize 方法中的,参数可以随意设置。但是当碰到需要大量参数才能初始化时,就不够优雅。
比如我们有一个 Twiiter 的客户端,需要一些配置才能初始化:
client = Twitter::REST::Client.new(
{
consumer_key: "aaa"
consumer_secret: "bbb"
access_token: "ccc"
access_token_secret: "ddd"
}
)
这样写也不差,不过如果可以像下面的写法那样岂不是更好吗
client = Twitter::REST::Client.new do |config|
config.consumer_key = "aaa"
config.consumer_secret = "bbb"
config.access_token = "ccc"
config.access_token_secret = "ddd"
end
如果你觉得这个例子没说服力,那想想 Rails 的 routes 配置吧。
routes = Router.new do |r|
r.match '/about' => 'home#about'
r.match '/users' => 'users#index'
# .....
end
这样路由配置可能多达上百行,还使用基本的 new 的写法肯定不行了。所以接下来我们来试着实现一下 block 形式的对象初始化吧。
module Twitter
module REST
class Client
attr_accessor :consumer_key, :consumer_secret, :access_token, :access_token_secret
def initialize
yield self
end
end
end
end
意外的简单吧,只需要在初始化方法中 yield self 就可以了,这样 initialize 方法都不需要参数,更加灵活
当然可以改进一下,就像之前的 File 一样,加上 block_given? 方法,来兼容普通的 new 方式。
回到刚才提到的 Rails route 的例子,肯定有熟悉的同学会说,我的 Rails 路由不是上面这样的, 它是这样的:
routes = Router.new do
match '/about' => 'home#about'
match '/users' => 'users#index'
end
看来区别就是 do 后面的那个 |r| 以及 block 中对应的 r(Router) 对象了。
那么如何实现这种更简洁的写法呢?秘诀就是 instance_eval 了,这个方法可以改变代码的运行上下文。
如果没有 instance_eval 的话,在 blocks 中调用方法(比如例子中的 match),那它所属的对象就是 main (执行 irb 的顶层对象).
而使用了 instance_eval 之后,上下文就进入了 router 对象内部,那么就能调用到 router 对象的 match 方法了,直接看结果:
class Router
def initialize(&block)
instance_eval &block
end
def match(route)
puts route
end
end
routes = Router.new do
match '/about' => 'home#about'
end
这样就可以了,回到之前的 Twitter 例子,我们也可以用同样的方法去掉 config 参数。
不过这里我们要更进一步,实现下面的调用方法。
client = Twitter::REST::Client.new({consumer_key: "YOUR_CONSUMER_KEY"}) do
consumer_secret = "YOUR_CONSUMER_SECRET"
access_token = "YOUR_ACCESS_TOKEN"
access_token_secret = "YOUR_ACCESS_SECRET"
end
即可以直接在 new 中传参,也可以使用 block. 其实也很简单,只要知道 Ruby 元编程中的 send 方法就可以了
module Twitter
module REST
class Client
attr_accessor :consumer_key, :consumer_secret, :access_token, :access_token_secret
def initialize(options = {}, &block)
options.each { |k,v| send("#{k}=", v) }
instance_eval(&block) if block_given?
end
end
end
end
好了,最后说一下, initialize 方法的参数中,使用了 &block 这样的写法,这个 & 符号的作用下面就来解释一下:
使用 &block, 就意味着把 block 转换成了 Proc, 因为 instance_eval 接收的是一个对象,但 block 不能直接用作对象来看待,因此需要先转为 Proc.
所以我们下面先讲讲 Proc …
Proce 是对象,可以用来表示一个代码块(block of code),和 JavasScript 中的匿名函数差不多,只不过 Ruby 并不能直接把函数作为参数,所以需要转一下。
下面是创建 Proc 的几种方法:
p = proc { |x, y| x + y }
p = Kernel.proc { |x, y| x + y }
p = Proc.new { |x, y| x + y }
我觉得了解一下就可以了,有了第一种,完全没有使用其他两种方式的必要。
同样的,调用 Proc 也有多种方式:
p.call(4, 2)
p.(4, 2)
p === [4, 2]
call 是最容易接受的方式,至于直接的 .() 方法,它其实不限于 Proc, 任何实现了 call 方法的 Ruby 对象都可以这样调用。
class Carly
def call(who)
"call #{who}, maybe"
end
end
c = Carly.new
c.("me") # => "call me, maybe"
而 ===
的写法实质上是模式匹配,把 4 和 2 匹配到 x 和 y 上,是的 Ruby 这这方面是支持模式匹配的。再举个例子:
even = proc { |x| x % 2 == 0 }
case 11
when even
"number is even"
else
"number is odd"
end
可能这样一来对于 ===
的写法就更好理解了。
介绍了 Proc 后,我们来看看它和 Lambda 有什么区别把。直接看表吧,这种对比还是看表更直观。
class | lambda? | proc? | |
---|---|---|---|
lambda | Proc | true | false |
proc | Proc | false | true |
class, lambda?, proc? 都是方法名,依靠这三个方法的判断,可以有一个大致的了解了吧。。。
好了,其实它两主要就两个区别,一是接收参数,二是返回的语义,一个入一个出,都不一样。
l = lambda { |x, y| puts "x: #{x}, y: #{y}" }
p = proc { |x, y| puts"x:#{x}, y:#{y}" }
l 和 p 都可定义了两个参数 x 和 y, 如果我们只传一个会发生什么呢?
p.call("Ohai") #=> x: Ohai, y:
l.call("Ohai") #=> ArgumentError: wrong number of arguments (1 for 2)
看来 lambda 更严格,而 proc 更灵活
那如果传三个参数呢,不难推测,lambda 依旧报错,proc 只取了前两个参数。
再看下对于返回语义的问题,直接看代码:
class SomeClass
def method_that_calls_proc_or_lambda(procy)
puts "calling #{proc_or_lambda(procy)} now!"
procy.call
puts "#{proc_or_lambda(procy)} gets called!"
end
def proc_or_lambda(proc_like_thing)
proc_like_thing.lambda? ? "Lambda" : "Proc"
end
end
c = SomeClass.new
c.method_that_calls_proc_or_lambda lambda { return }
c.method_that_calls_proc_or_lambda proc { return } #=> 'block in <main>': unexpected return (LocalJumpError)
在 lambda 中 return 就是结束的意思,代码执行完毕。而 proc 中 return 会报错。
这是因为 proc 被创建的上下文是 main, 就是 irb 中的顶层对象,不信你直接在 irb 中输入 return, 会报一样的错。
好了,我们回到那个 & 符号,它其实不限于和 block 组合,实际上,它是一个语法糖,会调用 to_proc 方法。
words.map { |s| s.length }
words.map(&:length)
上面两行代码是等价的,那么它们为什么等价呢?我们可以试着自己实现一下。
我们来分析一下 :length
是一个 symbol, 那么我们又要开始元编程了。
class Symbol
def to_proc
puts "to proc ..."
end
end
["hello", "world"].map(&:length)
# => to proc ...
# => Traceback (most recent call last):
# => `<main>': wrong argument type Symbol (expected Proc) (TypeError)
可见 to proc … 已经被执行了,后面报了错,它期望又个 Proc, 那么我们满足它。
class Symbol
def to_proc
puts "to proc ..."
proc {}
end
end
["hello", "world"].map(&:length) # => to proc ...
这下不报错了,然后我们要加入参数,就是 array 中的元素,然后调用这个元素的 :length
方法。
这里 :length
是 Symbol 类的一个实例,所以就是 self, 那么结合起来可以这样写:
class Symbol
def to_proc
proc { |obj| obj.send(self) }
end
end
["hello", "world"].map(&:length)
当然这样的写法还有很大改进空间,之前我们已经知道,lambda 比 proc 更严格,这里使用 lambda 更合适。
另外,这样的写法不适合有参数的情况,比如下面这样:
[1, 2, 3].inject(0) { |result, element| result + element } # => 6
[1, 2, 3].inject(&:+)
inject 方法接收两个参数,这个例子中是把它们相加,那么如果用我们的实现的话,就会报错,因为我们没有处理接收参数的情况。
不过改动也很容易:
class Symbol
def to_proc
lambda { |obj, args = nil| obj.send(self, *args) }
end
end
[1, 2, 3].inject(&:+) # => 6
%w(underwear should be worn on the inside).map &:length # => [9, 6, 2, 4, 2, 3, 6]
这里为了兼容有参数和没参数的情况,使用了默认值的写法 args = nil
, 最后尴尬的发现,直接用 proc 就可以忽略不要的 args 了。改回来:
class Symbol
def to_proc
proc { |obj, args| obj.send(self, *args) }
end
end
可见,是选择严格,还是灵活,要视情况而定。
在一开始介绍 proc 的时候,我们就意识到,proc 可以只接收部分参数,想必有 FP 编程经验的同学已经联想到了一个关键词:curry
下面我们就来说说柯里化。
先看一个复杂的计算逻辑:
discriminant = lambda { |a, b, c| b**2 - 4*a*c }
discriminant.call(5, 6, 7)
如果是柯里化,我们可以这样调用:
discriminant.call(5).call(6).call(7)
# or
discriminant.call(5).call(6).call(7)
那该怎么定义这个 lambda 呢?下面是这种算是 hard code 了。
discriminant = lambda { |a| lambda { |b| lambda { |c| b **2 - 4*a*c } } }
当然 Ruby 提供了 curry() 方法,因此只需要这样:
discriminant = lambda { |a, b, c| b**2 - 4*a*c }.curry
下面我们用 curry 来把几个 lambda 抽象出一个更抽象的 lambda …
sum_ints = lambda do |start, stop|
(start..stop).inject { |sum, x| sum + x }
end
sum_of_squares = lambda do |start, stop|
(start..stop).inject { |sum, x| sum + x*x }
end
sum_of_cubes = lambda do |start, stop|
(start..stop).inject { |sum, x| sum + x*x*x }
end
三个 lambda, 干的事情差阿布多,区别就在 inject 后传的 block. 因此很容易想到把它给抽取出来。
sum = lambda do |fun, start, stop|
(start..stop).inject { |sum, x| sum + fun.call(x) }
end
这样就能基于这个 sum 来构建出上面的三个 lambda 了。
sum_of_ints = sum.(lambda { |x| x }, 1, 10)
sum_of_squares = sum.(lambda { |x| x*x }, 1, 10)
sum_of_cubes = sum.(lambda { |x| x*x*x }, 1, 10)
不过这样的写法还是需要一次性把三个参数都给出,而使用 curry 就能实现:
sum_of_squares = sum.curry.(lambda { |x| x*x })
sum_of_squares.(1).(10) => 385
sum_of_squares.(50).(100) => 295475
最后来讲一下 Lazy Enumerable, 熟悉 FP 的同学肯定也是懂的都懂了,直接看例子:
1.upto(Float::INFINITY).map { |x| x * x }.take(10) #=> [1, 4, 9, 16, 25, 36, 49, 64, 81, 100]
是不是很厉害,才怪。。。等着卡死吧。。。
那怎么才能直接从 1 遍历到无限呢?当然不是把所有整数都直接生成并加载到内存,那也太夸张了。
让我们深入理解一下, 1.upto(Float::INFINITY)
的返回值是一个 Enumerator
. 现在看来这个类还不够牛逼,我们来学习一下一个更厉害的类 Enumerator::Lazy
.
试试下面的代码:
1.upto(Float::INFINITY).lazy.map { |x| x * x }
它的返回值是 #<Enumerator::Lazy: #<Enumerator::Lazy: #<Enumerator: 1:upto(Infinity)>>:map>
再试试下面两行:
1.upto(Float::INFINITY).lazy.map { |x| x * x }.take(10)
返回 #<Enumerator::Lazy: #<Enumerator::Lazy: #<Enumerator::Lazy: #<Enumerator: 1:upto(Infinity)>>:map>:take(10)>
1.upto(Float::INFINITY).lazy.map { |x| x * x }.take(10).to_a
返回 [1, 4, 9, 16, 25, 36, 49, 64, 81, 100]
看到没,只有到了最后的 to_a
方法,值才会被真正计算出来。知道了 lazy 的用法,下面我们试着自己实现它。
首先我们之前看到了实现 Lazy 功能的类是 Enumerator::Lazy
. 那么我们可以模仿它,自己写个类,就叫它 Lax
吧。
class Lax < Enumerator
end
module Enumerable
def lax
Lax.new(self)
end
end
那么具体实现呢?这里要先介绍一下 Enumberator
的用法。
e = Enumerator.new do |yielder|
[1,2,3].each do |val|
yielder << val
end
end
e.next # => 1
e.next # => 2
e.next # => 3
e.next # => StopIteration: iteration reached an end
是不是和之前的 Counter 类很像,当然,原理完全不同。。。
这里的 yielder << val
定义了下一个 yield 的指令,而不是值本身,这样一来,每次调用 Enumberator 的 next 方法,它就能计算出下一个值并返回。
有了这些前置知识,我们可以写写出我们的 Lax 的初始化方法
class Lax < Enumerator
def initialize(receiver)
super() do |yielder|
receiver.each do |val|
puts "add: #{val}"
yielder << val
end
end
end
end
lax = Lax.new([1, 2, 3])
lax.map { |x| puts "map: #{x}"; x }
# => add: 1
# => map: 1
# => add: 2
# => map: 2
# => add: 3
# => map: 3
添加了一个 puts 方便调试。可见我们成功使用到了 yielder 的功能。
之后是实现 map
方法, 它接收一个 block, 然后由于可以链式调用,最后应该返回 self.
def map(&block)
Lax.new(self)
end
具体的实现需要两参数, yielder 和 val, 因此可以这样
def map(&block)
Lax.new(self) do |yielder, val|
yielder << block.call(val)
end
end
让每个 val 都被 block 调用过后 (比如 x + 1 ) 再进入 yielder
那么相应的,初始化方法也需要修改:
def initialize(receiver)
super() do |yielder|
receiver.each do |val|
if block_given?
yield(yielder, val)
else
yielder << val
end
end
end
end
现在我们已经可以执行下面的代码了:
1.upto(Float::INFINITY).lax.map { |x| x*x }.map { |x| x+1 }.first(5) # => [2, 5, 10, 17, 26]
之后就是实现 take
方法了。有了 map 的例子, take 就很容易了
def take(n)
taken = 0
Lax.new(self) do |yielder, val|
if taken < n
yielder << val
taken += 1
else
raise StopIteration
end
end
end
这里的 StopIteration
不陌生吧,之前的测试中,一直调用 next 到最后就会报这个错。
把这个错误的 rescue 加入到初始化方法后,整个实现的完整代码如下:
module Enumerable
def lax
Lax.new(self)
end
end
class Lax < Enumerator
def initialize(receiver)
super() do |yielder|
begin
receiver.each do |val|
if block_given?
yield(yielder, val)
else
yielder << val
end
end
rescue StopIteration
end
end
end
def map(&block)
Lax.new(self) do |yielder, val|
yielder << block.call(val)
end
end
def take(n)
taken = 0
Lax.new(self) do |yielder, val|
if taken < n
yielder << val
taken += 1
else
raise StopIteration
end
end
end
end
来个完整的例子:
1.upto(Float::INFINITY).lax.map { |x| x*x }.map { |x| x+1 }.take(5).to_a
# => [2, 5, 10, 17, 26]