Ruby で現在日付に最も近い日付を求める方法を色々考えてみた

## はじめに

最近、日付の配列から現在日付に最も近い日付を求める方法を考える機会がありました。

いざやってみると案外一筋縄ではいかない題材でした。また、Ruby では色々な書き方ができそうだったので、試した実装をまとめてみました。 

## 実装の前提

### 日付の配列リスト

ランダムに格納された2018年の日付リストを1 ~ 12月分作成しました。ただし、日付が重複していないこととします。

```ruby
require 'date'
dates = 12.times.map{|n| Date.new(2018, n + 1)}.shuffle
dates

[Wed, 01 Aug 2018,
 Mon, 01 Oct 2018,
 Thu, 01 Feb 2018,
 Thu, 01 Nov 2018,
 Sun, 01 Apr 2018,
 Thu, 01 Mar 2018,
 Tue, 01 May 2018,
 Mon, 01 Jan 2018,
 Sun, 01 Jul 2018,
 Sat, 01 Dec 2018,
 Fri, 01 Jun 2018,
 Sat, 01 Sep 2018]
```

### 現在日付の扱い方について

現在日付は `Date.today` で取得できますが、今回は場合を分けて結果を再現しやすいように、`Date.new` で日付を切り替えて試してみます。

```ruby
require 'date'
today = Date.new(2018, 11, 15)
today

Thu, 15 Nov 2018
```

## ① 日付リストに過去日付のみ含まれる場合は `max` メソッドで OK

過去日付のみの場合、**最も新しい日付が現在日付に最も近い日付**となります。

この場合は、`Array#max` メソッドが使えます。配列の中の Date オブジェクトの中から最も新しい日付を返却してくれます。

現在日付を日付リストの日付より未来(2019/11/15)として試してみると、想定通り `2018/12/01` の日付が取得できます。

```ruby
# 初期設定
require 'date'
today = Date.new(2019, 11, 15)
dates = 12.times.map{|n| Date.new(2018, n + 1)}.shuffle

# 実装
dates.max

# 結果
=> Sat, 01 Dec 2018
```

## ② 日付リストに未来日付のみ含まれる場合は `min` メソッドで OK

未来日付のみの場合、過去日付の場合とは逆に、**最も古い日付が現在日付に最も近い日付**となります。

こちらは `Array#min` メソッドが使えます。配列の中の Date オブジェクトの中から最も古い日付を返却してくれます。

現在日付を日付リストの日付より過去(2017/11/15)として試してみると、想定通り `2018/01/01` の日付が取得できます。

```ruby
# 初期設定
require 'date'
today = Date.new(2017, 11, 15)
dates = 12.times.map{|n| Date.new(2018, n + 1)}.shuffle

# 実装
dates.min

# 結果
=> Mon, 01 Jan 2018
```

## ③ 日付リストに過去・未来いずれの日付も含まれる場合は…？

過去日付のみ、未来日付のみの場合は割とシンプルに扱えたのですが、過去日付・未来日付いずれも含まれる場合になるとちょっと複雑になります。

この場合は、**(現在日付 - 比較対象の日付) の日数が最小になる日付** を配列の中からピックアップする必要があります。

こちらを実現する方法をいくつか考えました。
なお、現在日付は `2018/11/15` として扱います。結果の想定は `2018/11/01`となります。

### パターン1. `each` を使う

まず直感的に思いつきやすい、`each` で単純に配列をループさせて評価していく方法から試しました。

```ruby
# 初期設定
require 'date'
today = Date.new(2018, 11, 15)
dates = 12.times.map{|n| Date.new(2018, n + 1)}.shuffle

# 実装
most_recent_date = dates.first
dates.each do |date|
  if (today - most_recent_date).abs > (today - date).abs 
    most_recent_date = date
  end
end
most_recent_date

# 結果
=> Thu, 01 Nov 2018
```

過去・未来いずれの日付もあるため、日付の計算結果が負になったりすることがあります。そのため、`abs` で絶対値にしています。
その絶対値が最終的に最も少ない日付を返却しています。

直感的な実装でできましたが、一時的な変数も使っているため、処理が閉じていない印象があります。

### パターン2. `map`, `min`を使う

次に `map`、`min` を使ってできるか試しました。以下のような感じです。

```ruby
# 初期設定
require 'date'
today = Date.new(2018, 11, 15)
dates = 12.times.map{|n| Date.new(2018, n + 1)}.shuffle

# 実装
dates.map{|date| {"#{(today - date).abs.to_i}" => date}}
     .min{|a, b| a.keys.first.to_i <=> b.keys.first.to_i }
     .values.first
     
# 結果
=> Thu, 01 Nov 2018
```

最初の `map` で、現在日付から引いた日数の絶対値をキーとした日付のハッシュを作ります。

データイメージはこんな感じです。

```ruby
[{"137"=>Sun, 01 Jul 2018},
 {"106"=>Wed, 01 Aug 2018},
...
]
```

また日付の計算の戻り値は `(2/1)` のような `Rational` クラスとなるので、比較しやすいように `to_i` メソッドで整数にしています。

次の `min` で、ハッシュのキーのうち最小の日数のデータを抽出しています。

```ruby
{"14"=>Thu, 01 Nov 2018}
```

あとは `values.first` でハッシュの値として入れていた日付を取得します。

一時的な変数の利用がなくなり1つの処理にまとまりましたが、メソッドを複数使っていたり、途中でハッシュに変換したりと、複雑な実装になっています。

### パターン3. `inject` を使う

次は畳み込み計算の `inject` メソッドを使ってできるかやってみました。以下のような感じです。

```ruby
# 初期設定
require 'date'
today = Date.new(2018, 11, 15)
dates = 12.times.map{|n| Date.new(2018, n + 1)}.shuffle

# 実装
dates.inject do |most_recent_date, date|
  if (today - most_recent_date).abs > (today - date).abs
    date
  else
    most_recent_date
  end
end

# 結果
=> Thu, 01 Nov 2018
```

畳み込み計算のイメージは以下のような感じです。それぞれの日数比較を畳み込み計算で進める形にしました。

回数|most_recent_date|date|ループの計算結果
:--|:--|:--|:--
1|最初の要素|最初の要素|最初の要素
2|1回目の結果|2番目要素|1回目の結果と2番目で日数小さい方の日付
3|2回目の結果|3番目要素|2回目の結果と3番目で日数小さい方の日付
4|...|...|...

一時変数も無く、ハッシュの変換なども不要になりましたが、プログラムの行数をもう少し削りたいところです。

### パターン4. `sort_by` を使う

最後に、`sort_by` を使った方法です。以下のような感じです。

```ruby
# 初期設定
require 'date'
today = Date.new(2018, 11, 15)
dates = 12.times.map{|n| Date.new(2018, n + 1)}.shuffle

# 実装
dates.sort_by{|date| (today - date).abs}.first

=>Thu, 01 Nov 2018
```

`sort_by` で要素それぞれで現在日付との差分を取り、その差分の小さい順にソートしています。その後、`first` で最初の要素を取得しています。

上記のパターンの中で最もシンプルで理解しやすい実装になりました。

### パターン5. `min_by` を使う(2018/12/01 追記)

パターン4をより簡潔にするために、`min_by`メソッドを利用すると以下のような感じになります。

```ruby
# 初期設定
require 'date'
today = Date.new(2018, 11, 15)
dates = 12.times.map{|n| Date.new(2018, n + 1)}.shuffle

# 実装
dates.min_by{|date| (today - date).abs}

=>Thu, 01 Nov 2018
```

`min_by` メソッドでは、ブロックに要素を順番に渡してブロック内の式でそれぞれを評価し、最小であった値に対応する元の要素を返します。
こちらを使うと、`sort_by` メソッドを使う場合では必要だった `first` メソッドが不要になり、更に簡潔になります。

@yusuke23 さん、コメントありがとうございました！

## 終わりに

複数の実装パターンが想定できる場合は、何が良いかを実際実装してみて比較すると面白いですね。

もしもっとスマートな実装がありましたら教えていただけるとありがたいです！

## 参考

* [ruby - rails helper function to return the most recent date - Stack Overflow](https://stackoverflow.com/questions/17457482/rails-helper-function-to-return-the-most-recent-date)
* [instance method Array#max (Ruby 2.5.0)](https://docs.ruby-lang.org/ja/latest/method/Array/i/max.html)
* [instance method Array#min (Ruby 2.5.0)](https://docs.ruby-lang.org/ja/latest/method/Array/i/min.html)
* [instance method Enumerable#collect (Ruby 2.5.0)](https://docs.ruby-lang.org/ja/latest/method/Enumerable/i/collect.html)
* [instance method Enumerable#inject (Ruby 2.5.0)](https://docs.ruby-lang.org/ja/latest/method/Enumerable/i/inject.html)
* [instance method Enumerable#sort_by (Ruby 2.5.0)](https://docs.ruby-lang.org/ja/latest/method/Enumerable/i/sort_by.html)
* [instance method Array#each (Ruby 2.5.0)](https://docs.ruby-lang.org/ja/latest/method/Array/i/each.html)
* [instance method Enumerable#min_by (Ruby 2.5.0)](https://docs.ruby-lang.org/ja/latest/method/Enumerable/i/min_by.html)