Rails で、二つの DateTime の日付と時刻とを組み合わせようと思った無駄な時間の記録

やりたいこととしては、

  • 2012/03/04 AA:AA:AA
  • BB/BB/BB 12:34:56

の二つを組み合わせて 2012/03/04 12:34:56 を作るにはどうしたらいいか考えた話です。

あんな書き方こんな書き方の練習的なメモになります。 Ruby 嫌いな人は読まないほうがいいかもです。

Ruby 2.4.2 / Rails 5.2.0pre1 で確認しています。

最終的に採用した方法

datetime_a.midnight.since(datetime_b.seconds_since_midnight)

midnightbeginning_of_day でも同じです。1

https://qiita.com/hirokishirai/items/93ca9b566dddc815063c これを見て time of day も良さそうだと思いましたが、 since に入れることを考えると意味がなさそうです。

もっと簡潔な方法はあるのでしょうか。

日付と時刻を別々に取得して Time.new を使う方針で作ってみる

Time.new を使おうと思いました。

Time.new(*[:year, :month, :day].map(&datetime_a.method(:send)) + [:hour, :min, :sec].map(&datetime_b.method(:send)))

見やすくすると

Time.new(
  *
    [:year, :month, :day].map(&datetime_a.method(:send))
    +
    [:hour,   :min, :sec].map(&datetime_b.method(:send))
)

こういうことです。個人的には気に入っています。

追記: %i を使えば少し短くなります。あと、splat 展開 * を二回使えば + の演算は不要になります (コメント より)

もう一段 map したけど

上記だと、 .method(:send) あたりが重複しているのでまとめてみようと思いました。

Time.new(*{[:year, :month, :day] => datetime_a, [:hour, :min, :sec] => datetime_b}.flat_map { |keys, time| keys.map(&time.method(:send)) })

却って長くなってしまいました。ハッシュの記号と map の仮引数(?)部分が結構長くつくようです 2

時間をの構成要素を to_a で取り出す

> datetime_b.to_a
=> [56, 34, 12, 1, 1, 2018, 3, 101, false, "UTC"]

to_a は、配列で、ただし逆順で返ってきます。ここから一部を取り出したいのですが、 Range を使うと昇順しか指定できなさそうなので、 values_at を使います。

Time.new(*datetime_a.to_a.values_at(5, 4, 3) + datetime_b.to_a.values_at(2, 1, 0))

これも、splat展開を二回使えば + は不要になります。

しかし、 values_at は長いのが玉に傷です。代わりに、一旦 Range で拾ってから全体を reverse することもできそうです。この場合は使うレシーバーの順も変わります。

Time.new(*(datetime_b.to_a[0..2] + datetime_a.to_a[3..5]).reverse)

これは割と短めです。

seconds_since_midnight を再発明する悲しい作業記録

seconds_since_midnight を知るまでは、これでいこうかと思っていました。

datetime_a.midnight.since(datetime_b - datetime_b.midnight)

datetime_b が二回出てくるなあと思っていました。

instance_eval 使っうちゃう

datetime_a.midnight.since(datetime_b.instance_eval { self - midnight })

残念。記述量が増えました(そういう問題?

愚直に計算

datetime_a.midnight.since([:hour, :min, :sec].map(&datetime_b.method(:send)).zip([3600, 60, 1]).map { |value_and_unit| value_and_unit.inject(&:*)}.inject(:+))

見やすく (?) すると

datetime_a.midnight
.since(
  [:hour, :min, :sec].map(&datetime_b.method(:send))
                     .zip([3600, 60, 1])
                     .map { |value_and_unit| value_and_unit.inject(&:*) }
                     .inject(:+)
)

友達をなくしそうなコードです。

どういうことかというと、

> [:hour, :min, :sec].map(&datetime_b.method(:send))
=> [12, 34, 56]

こうやって取れた [時・分・秒] の、それぞれを秒化するため [3600, 60, 1] とそれぞれの成分を掛け合わせます。

> [12, 34, 56].zip([3600, 60, 1]).map { |value_and_unit| value_and_unit.inject(&:*) }
=> [43200, 2040, 56]

んで、得られたこれの和をとる (最後の.inject(:+)) と内積が取れるということです。

ところで掛け算を .inject(&:*) で表現したことについて、掛け算の単位元は 1 なわけで、 .inject(1, &:*) のように初期値として 1 を渡さないと 0 への掛け算になってしまうと思ったのですが、そうではないようでした。
=> 追記: コメント より、引数で初期値を渡さない場合はそもそも演算が行われないそうです

map は配列を展開できる

sum とか普通に実装されているし、二者の掛け算は inject よりも配列にバラして受け取り、計算式を書いたほうが短いことに気づきました。

datetime_a.midnight
.since(
  [:hour, :min, :sec].map(&datetime_b.method(:send))
                     .zip([3600, 60, 1])
                     .map { |value, unit| value * unit }.sum
)

少し短くなりました。そして sum のほうが動作も速いそうです https://qiita.com/Nabetani/items/c46ab71353ad9b0c621d#comment-a2c765323abf500968e4

Vector

内積なら Vector を使ったほうがいいでしょうか。

require 'matrix'

datetime_a.midnight
.since(
  Vector[:hour, :min, :sec].map(&datetime_b.method(:send))
                           .dot(Vector[3600, 60, 1])
)

長さはどっこいどっこいですが、 Vector#dot って書いてありゃ内積っていうことはわかるので、いいかもしれません。

ところで、 Vector って map しても Vector なんですね。

内積だとしても式を書いちゃったほうがよい

instance_eval 使って計算すれば普通に短くなりました。数も固定長だし変数も要らないので、内積の方針でいくのであればこうしたほうがよさそうです。

datetime_a.midnight.since(datetime_b.instance_eval { 3600 * hour + 60 * min + sec })

  1. そもそもそれに気づいていなかったので、 seconds_since_midnight に気づくのも遅れました。 

  2. 試すと分かるのですが、変数記号を短くしたりスペースを削っても、上回ることはなさそうです。 

Sign up for free and join this conversation.
Sign Up
If you already have a Qiita account log in.