【Ruby 3.4 Advent Calender】Range#step が #+ で加算されてイテレーションされるようになる【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]
このあたりは非互換な挙動になってしまうので注意してください。
Discussion