[4, 7, 9, 2, 3] から "2-4, 7, 9" を得る


# はじめに

この記事は以下の記事を見て書いた。
[Rubyのパターンマッチを使って簡単なプログラミング問題を解いてみた - Qiita](https://qiita.com/jnchito/items/36c1c28fa82d1133d5ac)

上記の記事は，

```rb
"1, 5, 10-12, 15, 18-20"
```

という文字列から

```rb
[1, 5, 10, 11, 12, 15, 18, 19, 20]
```

という配列を Ruby で得る方法について書かれている。

一方，こっちの記事はその逆。つまり，正の整数の配列が与えられたとき，それをソートしたうえで，さらに続き番号になっている箇所をハイフンで繋いだ表記にしてカンマ区切りの文字列を作るということ。

こういった処理は，例えば書籍の索引のページ番号の表記を作るのに使われる。

なお，このような表記には，

* 続き番号が 4, 5 のように二つしかなくても `4-5` とまとめる
* 二つしか続かない場合は `4, 5` とバラバラにしておく

という二つの流儀が考えられるが，この記事では前者を取る（後者の場合についても書く）。

# コード

[test-unit](https://github.com/test-unit/test-unit) を使ったテストコードも書いておいた。

```rb
require "test/unit"

def gather(array)
  array.uniq.sort
    .chunk_while{ |a, b| a.succ == b }
    .map{ |c| (c.size == 1) ? c : "#{c[0]}-#{c[-1]}" }
    .join(", ")
end

# ここから下がテストコード
# スクリプトを実行すれば以下に記述したテストは自動的に行われる

class TestGather < Test::Unit::TestCase
  test "バラバラ" do
    assert_equal "1, 3, 5", gather([3, 5, 1])
  end

  test "重複あり" do
    assert_equal "2, 4", gather([4, 2, 2, 4, 4])
  end

  test "まとめ" do
    assert_equal "1-2", gather([2, 1])
    assert_equal "1-3", gather([2, 3, 1])
    assert_equal "2-4, 7, 9, 11-12",
      gather([12, 11, 7, 9, 2, 4, 3])
  end
end
```

# 解説

## 重複を省きソートする

最初に

```rb
array.uniq.sort
```

として，重複を省き，ソートしている。あまり説明は要らないと思う。
ただ，効率を考えると，このように先に `uniq` してあとで `sort` したほうがいい，ということに注意したい[^uniq-sort]。

[^uniq-sort]: 重複が少ない場合は大して変わらない。とはいえ，教条的に `.uniq.sort` と覚えておいてもよいかもしれない。

## 続き番号をカタマリにする

次に，このコードの肝といえるのが

```rb
chunk_while{ |a, b| a.succ == b }
```

の部分。
実は公式リファレンスの [Enumerable#chunk_while](https://docs.ruby-lang.org/ja/2.7.0/method/Enumerable/i/chunk_while.html) に，この記事のコードとそっくりなサンプルが書かれているのだが。

`chunk_while` は実に Ruby 的な面白いメソッドだ。`chunk_while` とは何者なのか？

これは，配列などの Enumerable なオブジェクトについて，要素を端から<ruby>舐<rt>な</ruby>めていって，隣り合う二つの要素の間をつなげるか切断するかを**ある判断基準に従って決定し**，そうやってカタマリ（chunk）を作っていくメソッドだ。
ただ，返り値はカタマリの配列ではなく [Enumerator](https://docs.ruby-lang.org/ja/2.7.0/class/Enumerator.html) である。Enumerator とは何か，については本記事では解説しないが，Enumerable なオブジェクトなので `to_a` すればカタマリの配列になる。

たとえば，

```rb
[2, 6, 0, 3, 5, 8]
```

という配列があって，これを偶数同士，奇数同士のカタマリに分けたいとしよう。
つまり

```rb
[[2, 6, 0], [3, 5], [8]]
```

という配列が欲しいわけ。
重要なのは**隣り合う**偶数同士，奇数同士**しか**カタマリにしないということ。
だから，最後の `8` は `2, 6, 0` とは一緒にならず，飛び地になっている。

ところで，二つの整数 `a` と `b` が「共に偶数であるか，あるいは共に奇数である」という条件式はどう書けばいいだろう？
一つのやり方は

```rb
(a.even? && b.even?) || (a.odd? && b.odd?)
```

なのだが，こんな面倒なことをする必要はない。
「偶数と偶数の差は偶数」「奇数と奇数の差は偶数」「偶数と奇数の差は奇数」ということに気づけば

```rb
(a - b).even?
```

でよいと分かる。

さて，隣り合う偶数同士，奇数同士のカタマリの配列を得るには，`chunk_while` を使って以下のように書く。

```rb
numbers = [2, 6, 0, 3, 5, 8]

p numbers.chunk_while{ |a, b| (a - b).even? }.to_a
# => [[2, 6, 0], [3, 5], [8]]
```

この `chunk_while` はどのように働くのだろうか。
`chunk_while` は　`each` を使ってレシーバーから一つ一つ要素を取り出す。
まず最初に，そうやって二つの要素を取り出す。今の場合，`2` と `6` が取り出される。この二つをブロックに渡し，ブロックを評価する。すると `(2 - 6).even?` は真。このとき `chunk_while` は「ふむふむ，では `2` と `6` の間は切断しないぞ」と決めるのだ。
次に，既に取り出している `6` と，その次に取り出した `0` をブロックに渡す。ここでもブロックは真を返すので，切断しない。
同様にして，`0` と `3` をブロックに渡すが，これは偽を返す。そうして `chunk_while` は「よっしゃ！　ここで断ち切ったるで！」と決めるのだ。

以下同様。

`chunk_while` が一つずつ位置をずらしながら隣り合う 2 要素を取り上げてゆくさまは，`each_cons(2)` に似ている。
`each_cons(2)` はただ 2 要素を拾っていくだけだが，`chunk_while` は結合・切断の箇所を判定するために 2 要素を拾っていくのだ。

`chunk_while` の働きが分かったところで，

```rb
chunk_while{ |a, b| a.succ == b }
```

を検討しよう。
`Integer#succ` は自身に 1 を足した整数を返すメソッド。
`a.succ == b` でもって「`a` の次が `b` になっている」という条件を表している。

## 続き番号のところはハイフンでまとめる

その次の

```rb
map{ |c| (c.size == 1) ? c : "#{c[0]}-#{c[-1]}" }
```

は簡単。

おっとその前に一言。`chunk_while` の返り値は Enumerator オブジェクトなので，`to_a` で配列にする必要はなく，そのまま `map` を続けることができる。

さて，この `map` のブロックだが，個々のカタマリが長さ 1 ならそのまま，2 以上なら先頭と末尾をハイフンで繋ぐ，ということを行っている。

例えば `[7]` なら `[7]` のままにし，`[2, 3, 4]` なら `"2-4"` に変えている。
うーん，変換後の配列は要素が配列だったり文字列だったりしてちょっと気持ち悪いな。いやこれで何の問題もない（後述）のだが，ちょっとムズムズするのは確か。

## カンマで繋げる

最後に

```rb
join(", ")
```

で仕上げ。
これはまあ要素を単に `", "` で繋ぐだけなのだが，前段で「要素が配列だったり文字列だったりするが問題ない」と述べたことに確信を持つためには，[Array#join](https://docs.ruby-lang.org/ja/2.7.0/method/Array/i/join.html) の正確な理解が必要だ。

`join` は，要素がすべて文字列であれば，間に引数を挟んで繋げた文字列を返す。
要素に文字列でないものがあった場合，繋げる前にまずそいつを文字列化するのだが，そいつがもし配列だった場合，`to_s` ではなく `join` を使って文字化するのだ。その際の `join` には元の `join` と同じ引数が使われる。

だから，

```rb
p [1, [2, 3]].join("-") # => "1-2-3"
```

のようになる。

以上で動作が完全に理解できた。

# バリエーション

「はじめに」の節で予告したが，続き番号が `4, 5` のように二つしか続いていない場合にハイフンでまとめないようにするにはどう改変すればよいか。

つまり，

```rb
p gather([1, 2, 4, 5, 6]) # => "1, 2, 4-6"
```

となるようにするには？

実は，[Enumerable#chunk_while](https://docs.ruby-lang.org/ja/2.7.0/method/Enumerable/i/chunk_while.html) のサンプルコードはこの流儀にしたがって書かれている。

そのようにするには，

```rb
.map{ |c| (c.size == 1) ? c : "#{c[0]}-#{c[-1]}" }

```

を

```rb
.map{ |c| (c.size < 3) ? c : "#{c[0]}-#{c[-1]}" }
```

に変えればよい。
これだけの改変で済むのは，既に述べた `join` の仕様から明らか。

# 余談

Ruby の同系統のユニットテストライブラリーが test-unit と minitest に分裂したままなのは不幸というほか無い。

私の目には test-unit のほうが優秀な気がするのだが[^tu]，Rails が minitest（を魔改造したもの？）を採用しちゃってるので，世間的には minitest のほうが優勢なのかもしれない。

[^tu]: test-unit だとこの記事のようにテスト名を文字列で与えることができるし，データ駆動テストもできる。

え？　RSpec？　いやー，ヘタレの私には意味不明すぎて書ける気がしない。`it` って何よ？