注意:这种情况也适用于 "老 "#method 方法的使用。我之所以在 "点号 "上下文中提到这一点,是因为由于增加了语法糖,这种风格的编码肯定会被更多地使用。
注意:这个功能已经被恢复了。
注意:基准和优化方法仍然适用于#method 方法的使用。
对我来说,即将发布的Ruby2.7 的最有趣的功能之一是方法引用的语法糖。我喜欢将#method 方法与#then (#yield_self) 操作符一起使用,以便以类似 "流水线 "的方式组成几个函数。当你处理数据流或建立ETL管道时,它特别有用。
这里有一个例子,说明你可以如何使用它。
class Parse
def self.call(string)
string.to_f
end
end
class Normalize
def self.call(number)
number.round
end
end
class Transform
def self.call(int)
int * 2
end
end
# Simulate a long-running data producing source with batches
# Builds a lot of stringified floats (each unique)
stream = Array.new(10_000) do |i|
Array.new(100) { |i| "#{i}.#{i}" }
end
stream.each do |batch|
batch
.map(&Parse.:call)
.map(&Normalize.:call)
.map(&Transform.:call)
end
这很好,很清楚,很短。那么,它有什么问题呢?
嗯,错的是Ruby本身。每次你使用#method 方法引用一个方法时,Ruby都会给你一个新的#Method 类的实例。甚至当你在获取一个对象的同一实例的方法时也是如此。这还不是全部!因为我们使用的是& 操作符,每一个获取的方法引用都会在之后使用#to_proc 方法转换为一个Proc 对象。
nil.:nil?.object_id #=> 47141222633640
nil.:nil?.object_id #=> 47141222626280
nil.:nil?.object_id #=> 47141222541360
# In general
nil.:nil?.object_id == nil.:nil?.object_id #=> false
nil.:nil?.to_proc == nil.:nil?.to_proc #=> false
这意味着,当你处理大量的数据样本时,你可能会旋转出大量的对象,并付出巨大的性能代价。特别是当你在每个实体的基础上操作时。
stream.each do |batch|
batch.each do |message|
message
.then(&Parse.:call)
.then(&Normalize.:call)
.then(&Transform.:call)
end
end
如果你运行与上面相同的代码,但以这样的方式运行。
stream.each do |batch|
batch.each do |message|
Transform.call(
Normalize.call(
Parse.call(message)
)
)
end
end
你最终会减少1200万个对象,而且你的代码运行速度会快近10倍!
自己看吧。
require 'benchmark/ips'
GC.disable
class Parse
def self.call(string)
string.to_f
end
end
class Normalize
def self.call(number)
number.round
end
end
class Transform
def self.call(int)
int * 2
end
end
# Builds a lot of stringified floats (each unique)
stream = Array.new(10_000) do |i|
Array.new(100) { |i| "#{i}.#{i}" }
end
Benchmark.ips do |x|
x.config(time: 5, warmup: 1)
x.report('std') do
stream.each do |batch|
batch.each do |message|
Transform.call(
Normalize.call(
Parse.call(message)
)
)
end
end
end
# This case was pointed out by Vladimir Dementyev
# See the comments for more details
x.report('std-then') do
stream.each do |batch|
batch.each do |message|
message.then do |message|
Parse.call(message)
end.then do |message|
Normalize.call(message)
end.then do |message|
Transform.call(message)
end
end
end
end
x.report('dot-colon') do
stream.each do |batch|
batch.each do |message|
message
.then(&Parse.:call)
.then(&Normalize.:call)
.then(&Transform.:call)
end
end
end
x.compare!
end
结果。
Warming up --------------------------------------
std 1.000 i/100ms
std-then 1.000 i/100ms
dot-colon 1.000 i/100ms
Calculating -------------------------------------
std 6.719 (± 0.0%) i/s - 34.000 in 5.060580s
std-then 3.085 (± 0.0%) i/s - 16.000 in 5.187639s
dot-colon 0.692 (± 0.0%) i/s - 4.000 in 5.824453s
Comparison:
std: 6.7 i/s
std-then: 3.1 i/s - 2.18x slower
dot-colon: 0.7 i/s - 9.70x slower
对对象的分配也是如此。
tao1 = GC.stat[:total_allocated_objects]
stream.each do |batch|
batch.each do |message|
Transform.call(
Normalize.call(
Parse.call(message)
)
)
end
end
tao2 = GC.stat[:total_allocated_objects]
stream.each do |batch|
batch.each do |message|
message.then do |message|
Parse.call(message)
end.then do |message|
Normalize.call(message)
end.then do |message|
Transform.call(message)
end
end
end
tao3 = GC.stat[:total_allocated_objects]
stream.each do |batch|
batch.each do |message|
message
.then(&Parse.:call)
.then(&Normalize.:call)
.then(&Transform.:call)
end
end
tao4 = GC.stat[:total_allocated_objects]
p "Std allocated: #{tao2 - tao1}"
p "Std-then allocated: #{tao3 - tao2}"
p "Dot-colon allocated: #{tao4 - tao3}"
Std allocated: 1
Std-then allocated: 2
Dot-colon allocated: 12000002
那么,我们根本就不应该使用这个新特性(以及一般的方法引用)吗?并非如此。如果你想使用它,并且不使你的应用程序变得那么慢,你需要做两件事。
将你的方法引用记忆化
与其为每个对象(或批次)获取方法引用,不如获取一次并重新使用。
parse = Parse.:call
normalize = Normalize.:call
transform = Transform.:call
stream.each do |batch|
batch.each do |message|
message
.then(&parse)
.then(&normalize)
.then(&transform)
end
end
这将使你不必创建300万个对象,并使你的代码比基本代码慢7倍。
将记忆化的方法转换成procs
既然Ruby无论如何都会为你做这件事(在一个循环中),为什么不更聪明一些,为他做这件事呢。
parse = Parse.:call.to_proc
normalize = Normalize.:call.to_proc
transform = Transform.:call.to_proc
stream.each do |batch|
batch.each do |message|
message
.then(&parse)
.then(&normalize)
.then(&transform)
end
end
这将使上面的代码只比基础代码慢2**.5倍**(通常是很好的),同时,它将为你节省几乎所有的 1200万个额外对象
点号和方法参考的进一步发展
你们中的一些人可能知道,我已经参与了这个功能。我提出并提交了一个补丁,它将使.: Method 对象冻结。这看起来似乎不多,但由于方法对象是不可变的,冻结可以为引入方法引用缓存保留一个机会,以备不时之需。
这个建议是我今年夏天在布里斯托尔与Ko1和Matz讨论后提出的。当使用#method 方法(不是语法-糖)时,由于向后兼容(我希望在这种情况下会被打破),Method 实例不会被冻结。然而,.: 会被冻结。这是一个罕见的角落案例(你为什么要这样突变一个对象?),但它确实创造了一个有趣的 "故障"。
nil.:nil? == nil.method(:nil?) #=> true
nil.:nil?.frozen? #=> true
nil.method(:nil?).frozen? #=> false
注意:我计划在2.7版本发布后,在我能够从统计学上证明大多数情况都像上面介绍的那样后,再努力增加最后方法的缓存。

