More than 1 year has passed since last update.

はじめに

この記事は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を使いましょう。