Ruby 枚举器链接究竟是如何工作的?

How does Ruby Enumerators chaining work exactly?

考虑以下代码:

[1,2,3].map.with_index { |x, i| x * i }
# => [0,2,6]

这究竟是如何工作的?

我对 map 的心智模型是它迭代并在每个元素上应用一个函数。 with_index 是否以某种方式将函数传递给枚举器 [1,2,3].map,在这种情况下该函数是什么?

这个 SO thread 显示了枚举器如何传递数据,但没有回答问题。实际上,如果将 map 替换为 each,则行为会有所不同:

[1,2,3].each.with_index { |x, i| x * i }
# => [1,2,3]

map 似乎携带了必须应用函数的信息,除了携带要迭代的数据之外。它是如何工作的?

使用 Array#map without a block simply returns an Enumerator, where each element is then fed to Enumerator#with_index 块的结果被 return 编辑为一个集合。它并不复杂,并且类似于(但可能比)以下代码。使用 Ruby 3.0.1:

results = []
[1, 2, 3].each_with_index { results << _1 * _2 }
results
#=> [0, 2, 6]

使用 Array#each 不会 return 块中的集合。它只是 return 自身或另一个枚举器,因此预期行为在设计上有所不同。

Todd 的回答非常好,但我觉得多看一些 Ruby 代码可能会有所帮助。具体来说,让我们自己尝试在 Array 编写 eachmap

我不会直接使用任何 EnumerableEnumerator 方法,所以我们看看它是如何在引擎盖下工作的(我仍然会使用 for 循环,以及那些技术上在幕后调用 #each,但这只是作弊而已)

首先是 eacheach 很简单。它遍历数组并对每个元素应用一个函数,然后返回 原始 数组。

def my_each(arr, &block)
  for i in 0..arr.length-1
    block[arr[i]]
  end
  arr
end

很简单。现在,如果我们不通过一个街区怎么办。让我们稍微改变一下以支持它。我们实际上想要 延迟 执行 each 的行为以允许 Enumerator 执行其操作

def my_each(arr, &block)
  if block
    for i in 0..arr.length-1
      block[arr[i]]
    end
    arr
  else
    Enumerator.new do |y|
      my_each(arr) { |*x| y.yield(*x) }
    end
  end
end

因此,如果我们不传递块,我们会创建一个 Enumerator,在使用时调用 my_each,使用枚举器 yield 对象作为块。 y 对象是一件有趣的事情,但您可以将其视为基本上是您最终将传入的块。因此,在

my_each([1, 2, 3]).with_index { |x, i| x * i }

y 视为 { |x, i| x * i } 位。它比那更复杂一点,但就是这个想法。

顺便说一句,在 Ruby 2.7 及更高版本中,Enumerator::Yielder 对象有自己的 #to_proc,因此如果您使用的是最新的 Ruby 版本,您可以只是做

Enumerator.new do |y|
  my_each(arr, &y)
end

而不是

Enumerator.new do |y|
  my_each(arr) { |*x| y.yield(*x) }
end

现在让我们将这种方法扩展到 map。用块写 map 很容易。就像 each 但我们累积结果。

def my_map(arr, &block)
  result = []
  for i in 0..arr.length-1
    result << block[arr[i]]
  end
  result
end

很简单。现在如果我们不通过一个街区怎么办?让我们做 exactmy_each 相同的事情。也就是说,我们只是要创建一个 Enumerator,然后在 Enumerator 中调用 my_map.

def my_map(arr, &block)
  if block
    result = []
    for i in 0..arr.length-1
      result << block[arr[i]]
    end
    result
  else
    Enumerator.new do |y|
      my_map(arr) { |*x| y.yield(*x) }
    end
  end
end

现在,Enumerator 知道,无论何时它最终得到一个块,它都会在最后对该块使用 my_map。我们可以看到这两个函数在数组上的实际行为类似于 mapeach do

my_each([1, 2, 3]).with_index { |x, i| x * i } # [1, 2, 3]
my_map ([1, 2, 3]).with_index { |x, i| x * i } # [0, 2, 6]

所以你的直觉是正确的

map seems to carry the information that a function has to be applied, on top of carrying the data to iterate over. How does that work?

这正是它的作用。 map 创建一个 Enumerator 其块知道在末尾调用 map,而 each 做同样的事情但使用 each。当然,实际上,出于效率和引导的原因,所有这些都是用 C 实现的,但基本思想仍然存在。