s = "a b c d"
val = s.
ᐅ(~:split, " ").
ᐅ(~:join, "-").
ᐅ ~:capitalize
val
# => "A-b-c-d"
https://6ftdan.com/allyourdev/2017/08/03/elixir-envy-%e1%90%85-ruby/
虽然我更喜欢 Elixir 的 pipe,但它强行搬到 Ruby 里来确实没什么意义,而且还是用这种特殊三角字符…… 而且这种项目其实不是在借鉴其他语言的功能,而是证明原语言的强大(看我还能做到这个哦)。类似的还有 Elixir 的 OOP ,这玩意更强大,连继承都实现了。
从语法角度来说,pipe (|>
不是 ->
) 的用途是让函数的嵌套调用变得更优美,和 OO 的链式调用不是一回事。但如果从 用类似链条的形式来表示数据转换 的角度看,两者目的是相同的。这种情况下我觉得两者各有优劣。
比如楼主的例子写成 Elixir 应该是:
str = "..."
# pipe
str |> String.split(" ") |> Enum.join("-") |> String.capitalize()
# non pipe
String.capitalize(Enum.join(String.split(str, " "), "-"))
而 Ruby:
str.split(" ").join("-").capitalize
Ruby 的写法很容易让人心生好感,代码比 Elixir 短得多。实际上 大部分时候 Elixir 的代码都比 Ruby 要长,甚至比不用 pipe 的代码还长一点 。那 Elixir 这样做的好处是什么呢?
一个好处是更 Explicit 。从 Elixir 代码中你能很直观的看到每个环节的数据大概是什么类型,每个函数出自哪里,从而间接的少犯些错误。Eplicit 的思路贯穿 Elixir 语言和生态,pipe 只是其中一个缩影。是否要 Eplicit 则是见仁见智。我只能说个人更喜欢这种风格,上面的例子太简单看不出来优势,但在复杂的流程中把代码表述得更严格是有好处的。
另一个好处是 更好的职责划分和多态 。我们经常会碰到一个 object(或者一份数据)在不同的场景下需要不同的行为。这种情况下以 OO 的方式大概有以下几种做法:
现实情况下 1 和 2 比较常见。但有各自的缺点。1 会让 object 方法过多,混入的模块不受控制容易方法重名导致覆盖。2 中 wrapper 和 inner object 毕竟不是一个 context(我指的 self
),访问一些内部变量时会有点不方便。3 没有前两者的问题,但在 Ruby 中通常是以 Object#extend
来实现,因此有明显的性能问题。有一个模式 DCI 用的是这种技巧。引起注意不到一年后就没什么关注了,可能也跟性能原因有关。其实我觉得这个模式倒很适合 JavaScript。
相对而言 FP 把数据和行为(函数)分离的方式能更容易的应对这个问题。一个数据可以经过不同模块的函数转换后得到一个结果,这个过程用 pipe 表达很清晰自然。
举个例子,以下的 Elixir 代码把一段文本分词后形成一个列表,假设 Breaker
是分词的模块:
some_text
|> Breaker.break_words # => [%{text: "word one", type: "noun"}, %{text: "word two", type: "verb"}]
|> Enum.map(&Map.get(&1, :text)) # => ["word one", "word two"]
如果用 Ruby 来实现,可能会这样:
# 注入一些方法到 String 里去
some_text.break_words.map { |word| word[:text] }
some_text.to_breaker.break_words.map { |word| word[:text] }
# 现在更推荐 OO 的写法,不过没链式调用了。
breaker = Breaker.new(some_text)
breaker.break_words.map { ... }
第一个方法需要污染 String,第二个方法结构更好一些,其实省去 breaker
这个变量后写一行还是没问题的,但 如果流程中再加入一个模块调用呢? 这种情况下 pipe 可能还能用,但链式调用是没戏了。
再看另一个例子,对一组数据的转换:
(1..1_000_000)
|> Enum.map(&do_something/1) # 一种简写,代替 map(fn i -> do_something(i) end)
|> Enum.reduce(&merge_something/2)
|> OtherMod.save_to_db
如果觉得数据很大,每次转换步骤都会生成一个很大的列表,可以换成 Stream
:
(1..1_000_000)
|> Stream.map(&do_something/1)
|> Enum.reduce(&merge_something/2)
|> OtherMod.save_to_db
如果需要进一步增加效率,可以用 Flow
分多个进程去同时处理:
(1..1_000_000)
|> Flow.from_enumerable
|> Flow.map(&do_something/1)
|> Enum.reduce(&merge_something/2)
|> OtherMod.save_to_db
这个过程中参与的模块越来越多,但并没有因为模块加多而丧失表达力。而且就算把整个流程拆分到多个函数中去也能大概猜到每次转换的数据是什么。Ruby 则或多或少要受限于 self
能够响应哪些方法才能继续链式。强行链式就得往 self
里加方法来形成链条。
最后说一句,pipe 和链式调用一样都有其限制,不是所有地方都能用 pipe 表达,这时也不必强行修改函数签名让它能够 pipe 下去。
Breaker.break_workds
就行了吧?
Breaker.break_words(some_text).map {|word| word[:text] }
为了 OO 而 OO 就会连 print object
都得写成 object.print
了...
Ruby 里要不要 patch String, 得看这个方法是否真的属于 String, 怕泄漏只要局部 patch 你还可以用 using
, 但是感觉这个 using
也没什么人用,我们要的就是全局污染的爽快...
链起来可以省掉起变量名的麻烦,但是链长了也不好 debug ... pipe 比链是更强一点,加上一些组合子,不管怎么都能 pipe 上 (就和 jQuery 的链式调用一样了...), 如果能再简洁点我就入教了
但一步一个脚印的指令式代码其实有时还挺适合 debug 的
所以我才在后面接了一句,如果过程中再加一层抹开调用会如何?我暂时也想不到一个现实中的例子,但假如有两层模块调用大概是这样:
# 一般都会写成这样比较易读和 debug
s1 = ServiceOne.new(some_data)
r1 = s1.do_something
s2 = ServiceTwo.new(r1)
r2 = s2.do_something_else
# 写成一行就这样了
ServiceTwo.new(ServiceOne.new(some_data).do_something).do_something_else
这种情况下老实写过程式代码是更好的选择。但在 Elixir 中 有时候 还能用一下 pipe 并且不丧失可读性:
some_data
|> ServiceOne.do_something
|> ServiceTwo.do_something_else
我觉得根本上还是语言特性对职责划分的影响。OOP 更建议把职责跟某个相应的 object 绑在一起,所以方法调用会有点局限。这样的好处是因为 object 自带上下文所以通常可以少写一些代码。而 FP(或者说 Elixir,我对其他 FP 语言也不了解)因为一开始就分离了数据和函数,所以组合的时候得严格的写清楚。这是上面的例子中 Ruby 代码通常比 Elixir 代码简短得多的根本原因。
至于打 patch 则比较矛盾。Ruby 中的对象方法有一部分是遵循职责划分,但也有不少纯粹是一层代理。就是为了写的更爽(让程序员更开心)。以 OOP 的思想来看,很难说 10.times { ... }
和 "some date".to_time
真是按职责划分来的。不过大部分人喜欢用就是硬道理。Refinement 这个技术没推广开来也是有原因的,RubyConf 2015 上这个演讲 总结得很到位。大体上说就是到处 using
太麻烦,Rails 作为魔法界标杆都不用,还有 OOP 思想的回归为一些问题提供了另一种解决方法。本来喜欢魔法的用火球,不喜欢的觉得太危险就投奔科学去了。Refinement 告诉你为了安全扔火球之前先背一遍 xxx ……
顺带一提,在 pipe 过程中 debug 太方便了,当然 Ruby 也可以 obj.tap(&:inspect)
:
some_data
|> step1
|> IO.inspect
|> step2
其实 ruby 完全可以定义一些运算符,甚至超算符...
some_data
>> ServiceOne.do_something
>> ServiceOne.do_something_else
用这套配组合子可以玩的很开心……
但是要 ruby way 的话,我想应该是这样子:
some_data.pipe do
call ServiceOne.new(_).do_something
call ServiceTwo.new(_).do_something_else
end
因为管道本质上是要把占位符拿出来,这样也许不算太优雅,但是也许有些地方能用到,另外个人觉得 binding.pry 其实比 puts 要方便.....
抄送 @luikore ;-)
@mizuhashi 不太了解超算符是什么,不过看上去是挺有意思。
管道本质上并不是把占位符拿出来(我也不太明天你的意思),而是跟 Unix pipe 一样把上一个步骤的输出传递到下一个步骤的输入。让数据转换流程的写法更方便一点。
你的第二个例子里 call ServiceOne.new(_).do_something
应该是无效的,只能是类似 call ServiceOne, :do_something
这样。问题在于 do_something
的结果是计算好之后再传给 call
,无法做到把 some_data
或者上一个步骤的值传给 service 再运算。
可以的,_
就是上一行的结果,call
的作用就是赋值给_
,https://ruby-china.org/topics/31848
想玩魔法的话其实 call 也不用的,也不用 method_missing,在执行 block 之前做个宏替换:
require 'ruby_parser'
require 'ruby2ruby'
require 'method_source'
class Object
class Proxy < BasicObject
def call value
@_ = value
end
def _
@_
end
def initialize target
@_ = target
end
end
def pipe &block
source = block.source
block_body = RubyParser.new.process(source).to_a[3]
head, *sequences = block_body
sequences.map!{|x| [:call, nil, :call, x]}
ast = Sexp.from_array([:block, *sequences])
Proxy.new(self).instance_eval Ruby2Ruby.new.process(ast)
end
end
#==========业务代码分割线=========
class ServiceA
def initialize(n); @n = n; end
def do_something
p @n
@n + 1
end
end
class ServiceB
def initialize(n); @n = n; end
def do_something_else
p @n
end
end
some_data = 1
some_data.pipe do
ServiceA.new(_).do_something # => print 1
ServiceB.new(_).do_something_else # => print 2
end
来个 Clojure 版本,->
(-> "a b c d"
(str/split #" ")
(#(str/join "-" %))
str/capitalize)
;;=> "A-b-c-d"
Clojure ->
宏的源码太简单了,@mizuhashi
(defmacro ->
[x & forms]
(loop [x x, forms forms]
(if forms
(let [form (first forms)
threaded (if (seq? form)
(with-meta `(~(first form) ~x ~@(next form)) (meta form))
(list form x))]
(recur threaded (next forms)))
x)))
Lisp 强大的宏能力
不难看懂的,因为 Lisp 语言都会用 macroexpand 展开来看 代码是如何被 "编辑"了的 , 宏做了什么列表 (代码和数据,一切都是列表) 操作,如下面的两个例子:
(-> 100 (+ 1) (+ 2) (+ 3)) ;; => 106
(macroexpand-1 '(-> 100 (+ 1) (+ 2) (+ 3)))
;; => (+ (+ (+ 100 1) 2) 3) ;; 代码展开的样子
(defmacro unless [expr form]
(list `if expr nil form))
(macroexpand-1 '(unless false "Hi, Clojure!"))
;; => (if false nil "Hi, Clojure!") ;; 代码展开的样子
最重要的是,Clojure 的宏具有强大的杀伤力,却不会影响性能,因为宏的编译时和运行时是分开的。在运行时前,代码已经编译好了。
学了 Clojure 和 Emacs Lisp 之后,我才知道 Matz 为什么这么喜欢 Lisp 了
Elisp 改变了 Matz 的人生。
元编程的最高境界就是 Lisp 了