Tap, Inject and Each_with_object in Ruby

中文翻译说明:

  1. 分析 inject 和 Each_with_object 时用到“叠加器”这个词,其代表调用这两个方时,块中的 memo 和 memo_obj 参数
  2. 类枚举类型指的是 Hash, Array 等能够被遍历的数据类型

在工作中使用 Ruby 作为主编程语言两年有余,从刚开始的惊讶到喜欢其优雅,这篇文章总结三个有助于编写可读性强且简洁的代码的方法,重构代码时会经常用到。

Object#tap

tap 方法把当前的对象传给块(block), 并且返回当前调用对象。返回值为当前调用对象的模式能够继续调用对象的公有方法,以此形成了一种链式调用的效果。

查看源码,其实现如下:

class Object
  def tap
    yield self
    self
  end
end

在来看看一个实际的例子,假设在 Rails controller 中我们需要对用户传递的参数处理,如果没有使用 tap ,我们可能会写出这样的代码

def update_params(params)
  params[:foo] = "bar"
  params
end

对一个参数值处理了之后,改方法返回参数哈希。但是现在我们知道 tap 方法会返回对象,所以改造之后的方法如下:

def update_params(params)
  params.tap {|p| p[:foo] = "bar"}
end

改造之后的代码是不是很简洁优雅?不言而明~另外再来看看充分显示链式调用的例子:

User
  .active                      .tap { |users| puts "Users so far: #{users.size}" }
  .non_admin                   .tap { |users| puts "Users so far: #{users.size}" }
  .at_least_years_old(25)      .tap { |users| puts "Users so far: #{users.size}" }
  .residing_in('USA')

Enumerable#each_with_object #inject methods

枚举类型中这两个方法用的好能极大地优化代码,但同时因为忽略两者的差异写出有 bug 的代码。

inject

通过二元运算对枚举类型(Array, Hash ...)的所有元素操作,可通过块(block),或传符号(symbol) 的参数调用。

【这里的符号可以是方法名或者操作符,例如 :method_name, :+ 但请注意,:operator 的方式使用比较多】

每遍历一个元素,就会把块的返回值赋值给 memo,修改了叠加器的值,该方法最后的返回值为最后一次遍历块的返回值,如果想返回叠加器的值,应当在块中返回叠加器(下文有例子讨论)

inject(initial, sym) → obj
inject(sym) → obj
inject(initial) { |memo, obj| block } → obj
inject { |memo, obj| block } → obj

有两种使用方式:传递符号参数调用(symbol)、块调用(block)。

  • 传递符号这种使用方式又分两种情况,一种是有初始值(inject(initial, sym) → obj),另外一种只有符号(inject(sym) → obj)。来看看两个实际例子:
    2.3.1 :003 > (5..10).inject(:+)    # 有初始值
    => 45
    2.3.1 :004 > (5..10).inject(1, :+) # 只有符号
    => 46
    
  • 块调用块有两个参数,分别是累加器和遍历类枚举类型的值 |accumulator, element|
    (1..5).inject(0){ |sum, num| sum + num } # 15
    
    初始值为 0 ,数组每次被遍历的元素赋值给 num ,每次遍历过程中累加器都被更新,遍历结束后 inject 方法返回块的返回值。讲到这里另外据一个例子说明一个常见的坑,在 [1, 2, 3, 4, 5] 数组中,我们期望小于 5 的元素累加。
    [1, 2, 3, 4, 5].inject {|sum, number| sum += number if number < 5} # nil
    
    上面这种写法返回 nil ,和我们预期的结果不符。原因是遍历最后一个元素时不满足 number < 5 条件,块返回 nil ,然后整个遍历结束,最后得出来的值也是 nil正确的写法应该是
    [1, 2, 3, 4, 5].inject do |sum, number|
      if number < 5
        sum + number
      end
      sum
    end
    
    每次遍历时,块的返回值应该是 sum ,下次遍历取出来元素值即使不满足判断条件,也不至于返回 nil ,而是累加器 sum 的值。另外再现一个常见的业务遇到这个坑的例子,我们期望在多个用户对象组成的数组加工成 { user_a_name: phone, user_b_name: phone } 这样的数据结果。
    # 错误的写法
    User.all.inject({}) do |memo, user|
      memo[user.name] = user.phone
    end
    
    错误的原因在于,遍历第一个元素结束后,块返回 user.phone 为 String 类型,并且赋值给 memo ,遍历第二个元素时 memo[user.name] 会报错。
    2.2.4 :065 > users.inject({}) do |memo, user|
    2.2.4 :066 >     puts memo.class
    2.2.4 :067?>   end
    Hash
    NilClass
    NilClass
    NilClass
    NilClass
     => nil
    
    正确的方法应该是在块中返回 memo 本身,类型一样为 Hash ,所以就下次遍历时 memo[user.name] 就不会报错。当然这里还有一处可优化的地方,块应该返回:memo.merge!(user.name => user.phone)正确的写法应该是
    users.inject({}){|memo, user| memo.merge!(user.name => user.phone)|
    
    inject 的返回值是最后遍历枚举类型之后,块返回的值。通常情况下,我们期望最后得到叠加器(在例子中体现在 sum 和 memo),虽然把每次遍历计算出来的叠加器的值放在块的最后也可以做到(在举例时我们在块中返回了 sum 和 memo.merge!(user.name => user.phone))显然这不符合 Ruby 程序的优雅。有没有一种方法直接遍历枚举类型结束后返回叠加器的值而不是块的值呢?Enumerable#each_with_object 能实现。

    each_with_object

    each_with_object(obj) { |(*args), memo_obj| ... } → obj
    each_with_object(obj) → an_enumerator
    
    each_with_object 的返回值是 obj 原始对象,所以不需像 inject 一样在块中返回 迭代器的值。
    users.each_with_object({}) do |user, memo| # note object and memo reversed from #inject
    memo[user.name] = user.email
    end
    

    参考阅读

  • Better Hash Injection using each_with_object
  • “_” parameter of Ruby block
  • inject vs each_with_object
0 条评论
您想说点什么吗?