在 Ruby 中,我们为什么不能迭代“反向范围”?

124

我尝试使用 Range 和 each 反向迭代:

(4..0).each do |i|
  puts i
end
==> 4..0

迭代 0..4 会输出数字。而另一种范围 r = 4..0 看起来也没什么问题,r.first == 4r.last == 0

我认为上面的语法结构不产生预期结果很奇怪。这是什么原因?什么情况下这种行为是合理的?


我不仅对如何实现这种明显不支持的迭代感兴趣,而且对语言设计者的意图更感兴趣,为什么它会返回范围 4..0 本身。在哪些情况下是好的?我还在其他 Ruby 结构中看到了类似的行为,但仍然不清楚它何时有用。 - fifigyuri
1
按照惯例,范围本身会被返回。由于.each语句没有修改任何内容,因此没有计算出的“结果”可返回。在这种情况下,Ruby通常在成功时返回原始对象,在错误时返回nil。这使您可以将这样的表达式用作if语句的条件。 - bta
13个回答

118

一个范围(range)就是这样的:由其起始和结束定义,而不是由其内容定义。在一般情况下,“迭代”范围并没有什么意义。例如,考虑一下如何“迭代”两个日期之间的范围。你会按天、按月、按年、按周迭代吗?这并没有明确定义。在我看来,仅允许正向范围进行迭代应该被视为一种方便的方法。

如果你想反向迭代这样的范围,你可以使用 downto

$ r = 10..6
=> 10..6

$ (r.first).downto(r.last).each { |i| puts i }
10
9
8
7
6

以下是其他人对于为何同时允许迭代和处理反向区间会很困难的一些思考


11
我认为从1到100或从100到1的迭代在直觉上意味着使用步长为1。如果有人想要不同的步长,则更改默认值。同样,对我来说(至少),从1月1日到8月16日的迭代意味着按天计步。我认为通常有些事情我们可以达成共识,因为我们直观地认为是这样的。谢谢您的回答,同时您给出的链接也很有用。 - fifigyuri
3
我仍然认为对许多范围定义“直观的”迭代是具有挑战性的,我不同意以这种方式迭代日期直觉上意味着步长等于1天 - 毕竟,一天本身就是时间范围(从午夜到午夜)。例如,“1月1日至8月18日”(正好20周)不一定暗示按天迭代,也可能是按周迭代。 为什么不按小时、分钟或秒进行迭代? - John Feminella
9
这里的.each是多余的,5.downto(1) {|n| puts n} 就可以了。另外,不需要用 r.first r.last 这样的代码,只需要使用 (6..10).reverse_each 即可。 - mk12
@Mk12:完全同意,我只是为了新手 Ruby 程序员而尽可能明确。不过也许这样反而更加令人困惑。 - John Feminella
尝试向表单添加年份时,我使用了以下代码:= f.select :model_year, (Time.zone.now.year + 1).downto(Time.zone.now.year - 100).to_a - Eric Norcross

108

对于(0..1).reverse_each,它会倒序迭代范围。


1
它会构建临时数组,至少在2.4.1版本中。https://ruby-doc.org/core-2.4.1/Enumerable.html#method-i-reverse_each - kolen
1
这对我很有用,可以对一系列日期进行排序 (Date.today.beginning_of_year..Date.today.yesterday).reverse_each。谢谢。 - daniel

19

在Ruby中使用each迭代一个范围时,会调用该范围中第一个对象的succ方法。

$ 4.succ
=> 5

而且5超出了范围。

您可以使用此技巧模拟反向迭代:

(-4..0).each { |n| puts n.abs }

John指出,如果跨越0,这种方法将无法奏效。这将会:

>> (-2..2).each { |n| puts -n }
2
1
0
-1
-2
=> -2..2

我不能说我真的喜欢它们中的任何一个,因为它们有点模糊了意图。


2
不过,你可以通过乘以-1而不是使用.abs方法来实现。 - Jonas Elfström

16

另一种方法是 (1..10).to_a.reverse


值得注意的是,(10..1).to_a.reverse 不起作用。 - Blake Gearin

12

根据《Programming Ruby》一书,Range对象存储范围的两个端点,并使用.succ成员生成中间值。根据您在范围中使用的数据类型,您可以始终创建Integer子类并重新定义.succ成员,以使其像反向迭代器一样运作(您可能还想重新定义.next)。

您也可以在不使用Range的情况下实现所需的结果。请尝试以下操作:

4.step(0, -1) do |i|
    puts i
end

这将以-1的步长从4到0进行步进。但是,我不知道除了整数参数之外是否适用于其他任何类型的参数。


9

你甚至可以使用 for 循环:

for n in 4.downto(0) do
  print n
end

打印以下内容:

4
3
2
1
0

4
如果列表不是很大,我认为以下代码是最简单的方式:[*0..4].reverse.each { |i| puts i }

3
一般而言,认为它很大是一个好的假设。我认为这通常是正确的信仰和习惯。由于魔鬼永远不会休息,我不相信自己能记住在数组中迭代的位置。 但你是对的,如果我们有0和4常数,迭代数组可能不会引起任何问题。 - fifigyuri

1

我添加了另一种实现反向范围迭代的可能性。虽然我没有使用它,但这是一种可能性。猴子补丁 Ruby 核心对象有点冒险。

class Range

  def each(&block)
    direction = (first<=last ? 1 : -1)
    i = first
    not_reached_the_end = if first<=last
                            lambda {|i| i<=last}
                          else
                            lambda {|i| i>=last}
                          end
    while not_reached_the_end.call(i)
      yield i
      i += direction
    end
  end
end

1
正如bta所说,原因是Range#each会将succ发送给其起始值,然后发送给succ调用的结果,依此类推,直到结果大于结束值。你无法通过调用succ从4到0,实际上你的起始值已经大于结束值。

1
非常古老的问题,但今天我发现了以下方法(之前没有提到过):
(4..0).step(-1).each {|x| puts x} 

使用显式步骤让Ruby“知道”如何继续到下一个值(而不是使用默认的`succ`)。
这种方法的优点是不需要先实例化完整的数组,而且在我看来仍然非常可读/紧凑。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接