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 有什么区别把。直接看表吧,这种对比还是看表更直观。

classlambda?proc?
lambdaProctruefalse
procProcfalsetrue

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]