LoginSignup
32
28

More than 5 years have passed since last update.

Timexのよくある罠

Last updated at Posted at 2015-12-23

はじめに

この記事はElixir Advent Calendar 2015の23日目の記事です。

Timexとは

Elixirで時間や日付を扱う場合、よく使われるのがTimexです。それなりのサービスやアプリを作る際には、大抵時間や日付の処理が必要になってきますので、利用頻度の高いライブラリだと思います。

Timexを使ってみてヒヤリ・ハットな挙動がいくつかあったので、その紹介をします。

ケース1: タイムゾーンの取得

Timexでタイムゾーンの情報を取得するにはDate.localを使います。

iex(1)> use Timex
nil
iex(2)> Date.local
%Timex.DateTime{calendar: :gregorian, day: 23, hour: 20, minute: 45, month: 12,
 ms: 755, second: 49,
 timezone: %Timex.TimezoneInfo{abbreviation: "JST",
  from: {:saturday, {{1951, 9, 8}, {1, 0, 0}}}, full_name: "Asia/Tokyo",
  offset_std: 0, offset_utc: 540, until: :max}, year: 2015}
iex(3)>

このDate.localですが、どうやってローカルタイムゾーンの情報を取得しているかというと、OSの種類毎(OSX,Win,Unix)にタイムゾーン情報が定義された設定ファイル/コマンドを参照してタイムゾーンを取得します。

例えば、OSXの場合は、systemsetup -gettimezoneの実行結果をパース、Unixの場合は、/etc/timezone,/etc/sysconfig/clock,/etc/conf.d/clock,/etc/localtime,/usr/local/etc/localtimeを順番に参照してタイムゾーン情報を取得しています。

OSがUnixで、タイムゾーン情報が/etc/sysconfig/clock,/etc/conf.d/clockに定義されている場合、設定情報は以下の

ZONE="Asia/Tokyo"
TIMEZONE="Asia/Tokyo"

の様に定義されている事をTimexは前提としています。OSのディストリビューションによってはこれが、

ZONE=Asia/Tokyo

の様な記述のケースがあり、その場合は

iex(1)> use Timex
nil
iex(2)> Date.local
** (MatchError) no match of right hand side value: ["ZONE=Asia/Tokyo\n"]
    lib/timezone/timezone_local.ex:202: Timex.Timezone.Local.read_timezone_data/3
    lib/timezone/timezone_local.ex:100: Timex.Timezone.Local.localtz/2
    lib/timezone/timezone_local.ex:52: Timex.Timezone.Local.lookup/1
    lib/timezone/timezone.ex:48: Timex.Timezone.local/1
    lib/date/date.ex:170: Timex.Date.local/0
iex(2)>

といったエラーになるので、その時は上記のタイムゾーン設定を確認してみてください。

ケース2: 日付/日時の比較

日付を比較する場合、RubyのDateの場合だと以下の様に記述できます

irb(main):001:0> require 'date'
=> true
irb(main):002:0> day_12_01 = Date.new(2015,12,01)
=> #<Date: 2015-12-01 ((2457358j,0s,0n),+0s,2299161j)>
irb(main):003:0> day_11_30 = Date.new(2015,11,30)
=> #<Date: 2015-11-30 ((2457357j,0s,0n),+0s,2299161j)>
irb(main):004:0> day_11_30 < day_12_01
=> true
irb(main):005:0>

しかし、ElixirのTimexで同様の記述をすると、結果は逆になってしまいます。

iex(1)> use Timex
nil
iex(2)> day_12_01 = Date.from({{2015,12,1},{0,0,0}})
%Timex.DateTime{calendar: :gregorian, day: 1, hour: 0, minute: 0, month: 12,
 ms: 0, second: 0,
 timezone: %Timex.TimezoneInfo{abbreviation: "UTC", from: :min,
  full_name: "UTC", offset_std: 0, offset_utc: 0, until: :max}, year: 2015}
iex(3)> day_11_30 = Date.from({{2015,11,30},{0,0,0}})
%Timex.DateTime{calendar: :gregorian, day: 30, hour: 0, minute: 0, month: 11,
 ms: 0, second: 0,
 timezone: %Timex.TimezoneInfo{abbreviation: "UTC", from: :min,
  full_name: "UTC", offset_std: 0, offset_utc: 0, until: :max}, year: 2015}
iex(4)> day_11_30 < day_12_01
false
iex(5)>

何故こうなるかというと、day_12_01,day_11_30の実態はMapの為、不等号での比較を行った場合は、カレンダー上での比較にならないからです。
Mapの不等号での比較について、もうすこしわかりやすく簡略化した例が以下になります。

iex(1)> %{year: 2015, month: 11, day: 30} < %{year: 2015, month: 12, day: 1}
false # 2015/11/30 < 2015/12/1 trueだけどfalseになってしまう!
iex(2)>

何故こうなるかというと、month(11月と12月)の比較の前にday(30日と1日)の比較が先に走ってしまい、2015/11/30よりも2015/12/1のほうが小さいと判定されてしまうからです。

Mapの比較の定義についてはこちらを参照していただくとして、この比較が恐ろしいのは、一見比較の処理は実行されているように「見える」所です。

例えば以下の様に月や年を跨いだ比較の場合は、挙動が月や年を跨ぐ時に変わってしまいます。

月を跨ぐケース
iex(1)> %{year: 2015, month: 11, day: 28} < %{year: 2015, month: 11, day: 29}
true # 2015/11/28 < 2015/11/29 #=> true
iex(2)> %{year: 2015, month: 11, day: 29} < %{year: 2015, month: 11, day: 29}
false # 2015/11/29 < 2015/11/29 #=> false
iex(3)> %{year: 2015, month: 11, day: 30} < %{year: 2015, month: 11, day: 29}
false # 2015/11/30 < 2015/11/29 #=> false
iex(4)> %{year: 2015, month: 12, day: 1} < %{year: 2015, month: 11, day: 29}
true # 2015/12/1 < 2015/11/29 #=> falseなのにtrueになってしまった!
年を跨ぐケース
iex(5)> %{year: 2015, month: 12, day: 28} < %{year: 2015, month: 12, day: 29}
true # 2015/12/28 < 2015/12/29 #=> true
iex(6)> %{year: 2015, month: 12, day: 29} < %{year: 2015, month: 12, day: 29}
false # 2015/12/29 < 2015/12/29 #=> false
iex(7)> %{year: 2015, month: 12, day: 30} < %{year: 2015, month: 12, day: 29}
false # 2015/12/30 < 2015/12/29 #=> false
iex(8)> %{year: 2015, month: 12, day: 31} < %{year: 2015, month: 12, day: 29}
false # 2015/12/31 < 2015/12/29 #=> false
iex(9)> %{year: 2016, month: 1, day: 1} < %{year: 2015, month: 12, day: 29}
true # 2016/1/1 < 2015/12/29 #=> falseなのにtrueになってしまった!

これらはMapでの比較をおこなったケースですが、Timex.DateTimeでも同様の結果となります。

では日付の比較を行うにはどうすれば良いのかというと、日付の比較を行う為の関数Date.compareがあるので、そちらを使えばOKです。

日付の比較
iex(1)> use Timex
nil
iex(2)> day_11_30 = Date.from({{2015,11,30},{0,0,0}})
%Timex.DateTime{calendar: :gregorian, day: 30, hour: 0, minute: 0, month: 11,
 ms: 0, second: 0,
 timezone: %Timex.TimezoneInfo{abbreviation: "UTC", from: :min,
  full_name: "UTC", offset_std: 0, offset_utc: 0, until: :max}, year: 2015}
iex(3)> day_12_01 = Date.from({{2015,12,1},{0,0,0}})
%Timex.DateTime{calendar: :gregorian, day: 1, hour: 0, minute: 0, month: 12,
 ms: 0, second: 0,
 timezone: %Timex.TimezoneInfo{abbreviation: "UTC", from: :min,
  full_name: "UTC", offset_std: 0, offset_utc: 0, until: :max}, year: 2015}
iex(4)> Date.compare(day_12_01, day_11_30)
1 # 第1引数の日付が、第2引数の日付より未来
iex(5)> Date.compare(day_11_30, day_12_01)
-1 # 第1引数の日付が、第2引数の日付より過去

また、Date.compareの代わりに、日付をsecに変換してその比較を行っても良いかもしれません。

まとめ

日付の比較には不等号ではなく、Date.compareを使いましょう。

32
28
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
32
28