💬

【Ruby 3.4 Advent Calender】Range#step が #+ で加算されてイテレーションされるようになる【20日目】

2024/12/20に公開

Ruby 3.4 Advent Calender 1日目の記事です。

これはなに

今年 2024年12月25日にリリースされる予定の Ruby 3.4 の新機能や変更点などを1つずつ紹介していく Advent Calender になります。
基本的には NEWS に載っている機能を紹介すると思うんですがここにない機能についても書くかもしれません。
また、記事を書いてる時点ではまだ Ruby 3.4 はリリースされる前なので Ruby 3.4 がリリースされた時点で機能が変わっている 可能性があるかもしれないので注意してください。
記事のまとめは ここを参照 してください。

Range#step の挙動が #+ で加算されるようになった

Range#step は以下のように range のオブジェクトを引数分だけ進めてイテレーションするメソッドになります。

# 1 ~ 10 の範囲を 2ずつ加算してイテレーションする
pp (1..10).step(2).to_a
# => [1, 3, 5, 7, 9]

これを踏まえた上で、以下のように『日付を1日ずつ進めてイテレーションする』みたいなコードを書きたいときがあります。
しかし、以下のコードは Ruby 3.3 ではエラーになります。

require "active_support/all"

# 2021-12-01 ~ 2021-12-24 で1日ずつ加算してイテレーションしたいがエラーになる
# error: can't iterate from Time (TypeError)
pp (Time.parse('2021-12-01')..Time.parse('2021-12-10')).step(1.day).to_a

これは Rnage#step の内部実装が『 #succ で begin を N 回進めて、結果を返す』という挙動になっており 1.day のような数値ではない値を渡すことができないからです。

これが Ruby 3.4 からは #+ で加算されるようになり『begin + step で繰り返して結果を返す』というような実装に置き換わりました。
なので先程のコードは Time.parse('2021-12-01') + 1.day でイテレーションするような処理になり、動作するようになります。

require "active_support/all"

# Ruby 3.4 では動作する
# Time.parse('2021-12-01') + 1.day が繰り返し実行される
pp (Time.parse('2021-12-01')..Time.parse('2021-12-10')).step(1.day).to_a
# => [2021-12-01 00:00:00 +0900,
#     2021-12-02 00:00:00 +0900,
#     2021-12-03 00:00:00 +0900,
#     2021-12-04 00:00:00 +0900,
#     2021-12-05 00:00:00 +0900,
#     2021-12-06 00:00:00 +0900,
#     2021-12-07 00:00:00 +0900,
#     2021-12-08 00:00:00 +0900,
#     2021-12-09 00:00:00 +0900,
#     2021-12-10 00:00:00 +0900]

これにより #+ で加算できれば #step でイテレーションすることができるようになりました。

# 以下のようなコードが動くようになる
pp (''..).step('#').take(7)
# => ["", "#", "##", "###", "####", "#####", "######"]

pp ([1]..).step([1]).take(7)
# => ["", "#", "##", "###", "####", "#####", "######"]

互換性の話

今回 begin + step というような挙動になったんですがいくつか互換性の問題がありました。
例えば以下のようなコードの場合 Ruby 3.3 では『 String#succ が 3回繰り返される』みたな挙動になります。

# String#succ が 3回呼ばれるだけなので Ruby 3.3 でも動作する
pp ("a".."z").step(3).to_a
# => ["a", "d", "g", "j", "m", "p", "s", "v", "y"]

これが Ruby 3.4 の挙動である『 "a" + 3 で繰り返される』だとエラーになってしまいます。
この互換性を保つために Ruby 3.4 でも上記のコードは動くように対応されています。

# Ruby 3.4 同じ結果が得られる
pp ("a".."z").step(3).to_a
# => ["a", "d", "g", "j", "m", "p", "s", "v", "y"]

またこの改善に付随して以下のように #step に負の値を渡したときにエラーにならなくもなりました。

# これは Ruby 3.3 でも Ruby 3.4 でも変わらない
p (1..-10).step(-3).to_a
# Ruby 3.3 => [1, -2, -5, -8]
# Ruby 3.4 => [1, -2, -5, -8]

# これがブロック引数を受け取る場合はエラーになっていた
(1..-10).step(-3) { p _1 }
# Ruby 3.3 => error: step can't be negative (ArgumentError)
# Ruby 3.4 => no error

もっと細かい互換性の話

今までは #step の引数を #to_int で変換して、その数値分だけ繰り返し処理するような実装になっていました。

require 'active_support/all'

pp 2.minutes.to_int # => 120

# 内部では 2.minutes.to_int 回分繰り返しが行われていた
pp (1.0..).step(2.minutes).take(3)
# => [1.0, 121.0, 241.0]

それが今後は #coerce を用いて変換がされるようになります。
なので上記のコードでは Ruby 3.3 と Ruby 3.4 で結果が異なるようになります。

require 'active_support/all'

pp 2.minutes.coerce(1.0)
# => [#<ActiveSupport::Duration::Scalar:0x00007b1b44b16608 ...>, 2 minutes]

# 2.minutes 分だけ加算されるようになる
pp (1.0..).step(2.minutes).take(3)
# Ruby 3.3 => [1.0, 121.0, 241.0]
# Ruby 3.4 => [1.0, 2 minutes and 1.0 second, 4 minutes and 1.0 second]

このあたりは非互換な挙動になってしまうので注意してください。

関連

GitHubで編集を提案

Discussion