Functional Ruby
话题已入选 Rubyconf 2016, 没看懂的同学 不服来战 我们成都见 😉
- slides 👉 http://git.io/fprb
- cats.rb https://github.com/jcouyang/cats.rb
- hehe
说到 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
ruby 中的 case http://docs.ruby-lang.org/en/2.2.0/syntax/control_expressions_rdoc.html#label-case+Expression 可以搞定四种模式匹配
值
这个很简单,应该都有用过
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
listof_error_or_id
是一个 IO, 去某个地方拿一串 id, 或者返回一串错误, 所以类型是[Either error id]
- 所以
error_or_id
的类型是Either error id
,flat_map
可以把id
取出来, 如果有的话 - 取出来的
id
交给error_or_detail_of
, 该函数也是 IO, 复杂获得对应 id 的 详细信息, 是IO就有可能会有错误, 所以返回值类型也是Either error detail
- 这时, 如果是用
fmap
转换完成后会变成一个Either error (Either error detail)
. 但显然我们不需要嵌套这么多层,flat
一些会变成Either error detail
- 后面的 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
,多线程就这么简单