HOME | EDIT | RSS | INDEX | ABOUT | GITHUB

Functional Ruby

话题已入选 Rubyconf 2016, 没看懂的同学 不服来战 我们成都见 😉

data-port.gif

说到 ruby 都会觉得是纯面向对象语言,所有东西都是对象。但是,函数式与面向对象并无冲突(你看看Scala)。最近一个项目用 ruby 写了一个非常常用的 feeder,一不小心写得函数式了些,让我们看看fancy的ruby到底能干些什么fancy的函数式。

lambda

不出所料,函数式一定要先有 lambda,跟所有的 ruby 对象一样,lambda 也就是一个正常的对象

plus1 = ->(x) { x + 1 }
#<Proc:0x007fbaea988030@-:3 (lambda)>

明显,lambda 构造出一个 Proc 的实例,如果我们调用这个 lambda,效果跟 method 没有什么区别:

plus1 = ->(x) { x + 1 }
plus1.(3)
4

好玩的是,method 不能高阶

def plus1 x
  x + 1
end
[1,2,3,4].map &plus1
`plus1': wrong number of arguments (0 for 1) (ArgumentError)

因为 plus1 在引用时就已经调用了,解释器在调用 plus1 时发现并没有传参数,于是抛出参数不匹配错误。由于method 引用即invoke,你永远无法写出高阶函数的效果。

而 lambda 就可以:

plus1 = ->(x) { x + 1 }
[1,2,3,4].map &plus1
[2, 3, 4, 5]

神奇的 &

这里的 magic 是 &plus1 变成 Block 发给数组了,Block 也就是我们常见的 {} ,等价于:

[1,2,3,4].map {|x| x + 1}

也等价于:

[1,2,3,4].map &Proc.new{|x| x + 1 }

注意如果没有 & ,解释器无法分辨到底在调用 map 时,把 Proc 当成正常参数,而不是 block

当得知 & 的魔法之后,我们很容易解释 &:symbol 这个语法糖

%w(ouyang jichao).map &:capitalize 
["Ouyang", "Jichao"]

desuger 完其实就是

%w(ouyang jichao).map &Proc.new(|x| x.send(:capitalize))

为什么可以产生这样的语法糖,是 Symbol 类型有 to_proc 方法,当 & 尝试将后面的东西变成 Proc 类型后传给 map 当 Block, to_proc 就是用来转换成 proc 的方法。

所以就是:

%w(ouyang jichao).map &:capitalize.to_proc
["Ouyang", "Jichao"]

为什么 lambda 是 proc

话说回来,既然 lambda 也返回 Proc 实例, Proc.new 也返回 Proc 实例,为何要设计这两种匿名函数呢?

简单来说, Proc 只是一段代码块,你可以想象引用的地方会变成这块代码块,而 lambda 不仅是一块代码块,表现得更像一个函数。具体来讲,就是 return 与参数检查:

return

来看个诡异的,下面这段代码我们可能会期望是返回一个数组,只是 jichao 会变成 lulu 而已

%w(ouyang jichao).map { |x| return 'lulu' if x == 'jichao'; x}
"lulu"

显然 return 之后的代码就再也走不到了,整个map会直接返回

但是如果你用 lambda 而不是普通 Proc,你会发现

%w(ouyang jichao).map &->(x){ return 'lulu' if x == 'jichao'; x}
["ouyang", "lulu"]

嗒哒,输出我们的期望了,lambda 的表现跟一个普通函数是一样的,函数的 return 当然不会导致调用者的返回。

参数检查

确切的说是参数元数 arity 的检查,比如随便定义一个method,如果你给的参数元数不匹配,会得到一个异常

def heheda who
  "heheda #{who}"
end
heheda
`heheda': wrong number of arguments (0 for 1) (ArgumentError)

因为定义的是一元的函数,调用时并没有给任何参数,就挂了

但是 Proc 是不会管这个的

heheda = Proc.new{|who| p "heheda #{who}"}
heheda.()
"heheda "

Proc 完全不会理会参数,如果binding能找到,就用了,如果没有,也继续运行。

lambda,则更像一个method

heheda = lambda {|who| p "heheda #{who}"}
heheda.()
`block in main': wrong number of arguments (0 for 1) (ArgumentError)

闭包

通常面向对象的捕捉一个绑定通常会通过 @

class HeHe
def initialize who
  @who = who
end
def heheda
  "heheda #{@who}"
end
end

HeHe 对 who 进行了封装,如果需要访问 who 需要通过 heheda 方法。

同样的东西,在函数式叫闭包,通过闭包我们依然能找到闭包内的绑定

who = 'jichao'
heheda = ->(){ "heheda #{who}" }
def hehedaToOuyang &heheda
  who = 'ouyang'
  heheda.()
end
hehedaToOuyang &heheda
"heheda jichao"

注意看 heheda 找到的绑定不是离他调用最近的 who, 而是当初定义的 who=jichao

所以跟面向对象一样, heheda 完美的封装了 who ,调用者即无法直接获取到他绑定的 who , 也无法重新给他新的绑定

pattern matching

ruby 支持简单的几种模式匹配

destructure

first, *middle_and_last = ['Phillip', 'Jay', 'Fry']
p first, middle_and_last
Phillip (Jay Fry)

destructuring 一个数组如此简单,但是hash就不这么容易,好在,方法的参数会自带 destructure的功能:

  fry = {first: 'Phillip', middle: 'Jay', last: 'Fry'}
  def printFirstName first:, **rest
    p first, rest
  end
printFirstName fry
Phillip (:middle=> Jay :last=> Fry)

这玩意 ruby 叫它 keyword arguments, first: 会匹配 fry 中的 first 并将值绑定到 first**rest 绑定剩下的所有东西。

数组也可以这样搞:

1: fry = ['Phillip', 'Jay', 'Fry']
2: def printFirstName first, *rest
3: p first, rest
4: end
5: printFirstName *fry
Phillip (Jay Fry)

要注意第5行, 调用时记得给数组加 *, 这样解释器才知道不是把整个 fry 扔给 printFirstName 当参数,而是把 fry 的内容扔过去当参数。

case when

这个很简单,应该都有用过

me = 'ouyang'
case me
when 'ouyang' 
  "hehe #{me}"
else 'hehe jichao'
end
hehe ouyang

类型

class Me
  def initialize name
    @name = name
  end

  def heheda
    "heheda #{@name}"
  end
end

me = Me.new 'ouyang'

case me
when Me
  me.heheda
else
  'hehedale'
end
"heheda ouyang"

表达式

if else 一样用

require 'ostruct'
  me = OpenStruct.new(name: 'jichao', first_name: 'ouyang')
  case
  when me.name == 'jichao'
    "hehe #{me}"
  else 'gewuen'
  end
hehe #<OpenStruct name="jichao", first_name="ouyang">

lambda (aka guard)

require 'ostruct'
  me = OpenStruct.new(name: 'jichao', first_name: 'ouyang')
  case me
  when ->(who){who.name=='jichao'}
    "hehe #{me}"
  end
hehe #<OpenStruct name="jichao", first_name="ouyang">

正则

case 'jichao ouyang'
when /ouyang/
"heheda"
end
heheda

其实只是个简单的语法糖

case when 并不是magic,其实只是 if else 的语法糖, 比如上面说的正则

if(/ouyang/ === 'jichao')
  "heheda"
end

所以 magic 则是所有 when 的对象都实现了 === 方法而已

  • 值: object.=== 会代理到 ==
  • 类型: Module.=== 会看是否是其 instance
  • 正则: regex.=== 如果匹配返回 true
  • 表达式:取决于表达式返回的值的 === 方法
  • lambda: proc.=== 会运行 lambda 或者 proc

这样,我们可以随意给任何类加上 === 方法, 不仅如此,实现一个抽象数据类型(ADT)会变得是分简单

一个简单的例子

一个简单的 feeder 流程大概是,从一个或多个数据源获取数据并 feed 到一个地方(DB, S3, ElasticSearch之类)。通常是一个定期的任务,比如没多久就 feed 那么一次。

作为定期跑的任务,我们需要监控两个方面

  • feed 失败了多少
  • feeder 跑了没

不管是什么形式,监控都不应该跟我们的业务搞到一起去,比如

一个简单的 Either Monad http://hackage.haskell.org/package/base-4.8.2.0/docs/src/Data.Either.html#Either

创建一个刚好够用的 Either 非常简单

Functor

module Either
  def initialize v
    @v = v
  end

  def map
    case self
    when Right
      Right.new(yield @v)
    else
      self
    end
  end
  alias :fmap :map

Monad

def bind
  case self
  when Right
    yield @v
  else
    self
  end
end

alias :chain :bind
alias :flat_map :bind

一个好看的 inspect

  def inspect
    case self
    when Left
      "#<Left value=#{@v}>"
    else
      "#<Right value=#{@v}>"
    end
  end
end

联合类型 Left | Right

在实现了 Either 接口之后,我们可以很容易的实现 Left | Right

class Left
  include Either
  def initialize v=nil
    @v=v
  end

  def == other
    case other
    when Left
      other.left_map { |v| return v == @v }
    else
      false
    end
  end
end

class Right
  include Either
  def == other
    case other
    when Right
      other.map { |v| return v == @v }
    else
      false
    end
  end
end

这个Either非常轻量, 我还是把它抽成gem以便单独管理, 与其他一些 Maybe 和 Free 一块收到 cats.rb 中.

用 Either 做控制流

1: def run
2:   list_of_error_or_detail =
3:     listof_error_or_id.map do |error_or_id| # <-
4:     error_or_id.flat_map do |id| # <-
5:       error_or_detail_of(id) # <-
6:     end
7:   end
8:   list_of_error_or_detail.map { |error_or_detail| error_or_saved error_or_detail} # <-
9: end
  1. listof_error_or_id 是一个 IO, 去某个地方拿一串 id, 或者返回一串错误, 所以类型是 [Either error id]
  2. 所以 error_or_id 的类型是 Either error id, flat_map 可以把 id 取出来, 如果有的话
  3. 取出来的 id 交给 error_or_detail_of, 该函数也是 IO, 复杂获得对应 id 的 详细信息, 是IO就有可能会有错误, 所以返回值类型也是 Either error detail
  4. 这时, 如果是用 fmap 转换完成后会变成一个 Either error (Either error detail). 但显然我们不需要嵌套这么多层, flat 一些会变成 Either error detail
  5. 后面的 save 函数也是类似的 IO 操作, 返回 Either error saved

那么我们的业务逻辑的流程走完了,该负责监控的逻辑了,注意现在 run 的返回值类型是 Either[Error, [Either[Error, Data]]]

failures, success = run.partition {|lr| !lr.is_a? Right}
error_msg = failures.map do |failure|
  failure.left_map &:message
end.join "\n"
logger.error "processing failure #{failues.length}:\n#{error_msg}" unless error_msg.blank?
logger.info "processing success #{success.length}: #{success}"

actor model 多线程

当你的数据处理都是函数式的之后,或者说 immutable,应用多线程将是十分简单而且安全的事情, 下面也是一个简单的例子,使用 Celluloid 把我们的 feeder 改成多线程

pmap

require "celluloid/autostart"
module Enumerable
  def pmap(&block)
    futures = map { |elem| Celluloid::Future.new(elem, &block) }
    futures.map(&:value)
  end
end

你懂的,把我们feeder的 map 都换成 pmap ,多线程就这么简单

Footnotes: