Ruby
プロを目指す人のためのRuby入門

each_with_indexには引数を渡せない、わけではない

はじめに:Rubyで添え字付きでループを回し、かつ0以外の数値で開始する方法

配列や範囲(range)をループさせながら、添え字(インデックス)も同時に取得したい場合はeach_with_indexを使います。

以下は「プロを目指す人のためのRuby入門」の4.8.1項で使用したサンプルコードです。

fruits = ['apple', 'orange', 'melon']
# ブロック引数のiには0、1、2・・・と要素の添え字が入る
fruits.each_with_index { |fruit, i| puts "#{i}: #{fruit}" }
#=> 0: apple
#   1: orange
#   2: melon

each_with_indexでは0から添え字が始まりますが、要件によっては0以外の数値から始めたい場合もあります。その場合はeach_with_indexの代わりにeach.with_indexeach + with_index)を使います。

以下は「プロを目指す人のためのRuby入門」の4.8.3項で使用したサンプルコードです。

fruits = ['apple', 'orange', 'melon']

# eachで繰り返しつつ、1から始まる添え字を取得する
fruits.each.with_index(1) { |fruit, i| puts "#{i}: #{fruit}" }
#=> 1: apple
#   2: orange
#   3: melon

人によっては「いや、そうじゃなくてeach_with_index(1)って書きたいんだけど!」と思うかもしれません。ですが、実際にそんなコードを書くとエラーになります。

fruits = ['apple', 'orange', 'melon']
fruits.each_with_index(1) { |fruit, i| puts "#{i}: #{fruit}" }
#=> ArgumentError (wrong number of arguments (given 1, expected 0))

そのため、「プロを目指す人のためのRuby入門」では「each_with_indexメソッドには引数を渡せない」と書いていました(4.8.3項参照)。

が!

each_with_indexに引数を渡してもエラーにならないケースがあります。
たとえば次のようなコードを書いた場合は、each_with_indexに引数を渡してもエラーになりません。

require "stringio"
sio = StringIO.new('apple,orange,melon')
# each_with_indexの引数を渡してもエラーにならない!
sio.each_with_index(',') {|fruit, i| puts "#{i}: #{fruit}" }
#=> 0: apple,
#   1: orange,
#   2: melon

なぜeach_with_indexに引数を渡せる場合と渡せない場合があるのでしょうか?

each_with_indexに引数を渡すと、そのままeachメソッドに渡される

実はeach_with_indexに引数を渡すと、内部的にレシーバ(each_with_indexを呼びだしたオブジェクト)のeachメソッドにその引数が渡されます。

fruits = ['apple', 'orange', 'melon']
# 内部的にfruits.each(1)が呼ばれる(エラー)
fruits.each_with_index(1) { |fruit, i| puts "#{i}: #{fruit}" }
require "stringio"
sio = StringIO.new('apple,orange,melon')
# 内部的にsio.each(',')が呼ばれる(OK)
sio.each_with_index(',') {|fruit, i| puts "#{i}: #{fruit}" }

そして、クラスによってはeachメソッドに引数を渡せるものがあります。
たとえば、先ほど出てきたStringIOクラスはeachメソッドに行の区切り文字を引数として渡せるようになっています(省略した場合は改行区切りになります。ドキュメントはこちらです)。

そのため、レシーバのeachメソッドに引数が渡せるかどうかによって、each_with_indexメソッドに引数が渡せるか渡せないかが決まります。

結局each_with_index単体では添え字の開始値を指定できない

each_with_indexメソッドに引数を渡せるケースと渡せないケースがあることはわかりましたが、そもそもやりたかったのはfruits.each_with_index(1)のように、添え字の開始値を指定することでした。

ですが、「each_with_indexメソッドに引数を渡した場合はeachメソッドに引数が渡される」という仕様になっているため、結局each_with_indexメソッドで添え字の開始値を指定することはできません。
なので、必ずeach.with_index(1)のように、each + with_indexの形で書く必要があります。

おまけ:モンキーパッチでeach_with_indexの挙動を変えてしまう💀

eachメソッドに引数を渡すようなケースなんて滅多にないから、なんとかしてeach_with_indexに直接添え字の開始値を指定したい!」という場合は、次のようなモンキーパッチを書いてeach_with_indexの挙動を変えてしまうこともできます。

# 注意:こんなモンキーパッチはあまりお勧めしない
module Enumerable
  # オリジナルの実装を上書きして引数の仕様を変えてしまう
  def each_with_index(offset = 0, &b)
    each.with_index(offset, &b)
  end
end
fruits = ['apple', 'orange', 'melon']
# 添え字の開始値が直接指定できるようになった
fruits.each_with_index(1) { |fruit, i| puts "#{i}: #{fruit}" }
#=> 1: apple
#   2: orange
#   3: melon

ただし、自分で書いたコードはこれでよくても、ライブラリ等の自分の見知らぬ場所で引数付きのeach_with_indexが使われていると、整合性が合わなくなってエラーが起きる可能性があります。なので、これはあまり好ましい対応ではありません。

単純なモンキーパッチではなく、refinementsを使うとそのファイル内(もしくはクラス内)だけに影響範囲を留めることができます。

# 以下のコードはirbではなくファイルに保存して実行する
module MyEachWithIndex
  refine Enumerable do
    def each_with_index(offset = 0, &b)
      each.with_index(offset, &b)
    end
  end
end
using MyEachWithIndex

fruits = ['apple', 'orange', 'melon']
# このファイル内でのみ、添え字の開始値が直接指定できる
fruits.each_with_index(1) { |fruit, i| puts "#{i}: #{fruit}" }

が、個人的にはそこまで頑張らなくても、素直にeach.with_index(1)と書く方が良いと思います。

なお、モンキーパッチやrefinementsについては「プロを目指す人のためのRuby入門」の中でも説明しています(7.10.6項および8.9.5項参照)。

まとめ

というわけで、この記事ではeach_with_indexメソッドで引数を指定できるケースがあることを説明しました。

ただし、日常的なプログラミングでeach_with_indexメソッドに引数を渡す(つまり、eachメソッドに引数を渡す)ケースはあまりないと思います。
ですので、「each_with_indexメソッドには引数を渡せない」と覚えていてもほとんど問題ないと思いますが、例外的なケースがあることも知っておくと、どこかで役に立つかもしれません。

参考文献

実は僕自身も@znzさんのこちらのコメントを読むまでは、each_with_indexに引数が渡せることを知りませんでした😅

今回の記事は、@znzさんが上記のコメントの中で紹介していたこちらの議論を、僕なりにかみ砕いてまとめた内容になっています。

each_with_indexに引数が渡せることは、英語版のAPIドキュメントで説明されています(2018年8月時点では、日本語版には載っていません)。

each_with_index(*args) { |obj, i| block } → enum
each_with_index(*args) → an_enumerator

Calls block with two arguments, the item and its index, for each item in enum. Given arguments are passed through to each().