いものやま。

雑多な知識の寄せ集め

「日付」と「日時」の話。

プログラミングで時間を扱おうとすると出てくるのが「日付」と「日時」。 これらに関してちょっと考えたことがあるので、その話をしてみたい。

「日付」と「日時」を構成するデータ

「日付(date)」を構成する要素を考えると「年(year)」「月(month)」「日(day)」。

一方で、「日時(datetime)」を構成する要素を考えると、「年(year)」「月(month)」「日(day)」「時(hour)」「分(minute)」「秒(second)」(とミリ秒などもあるけど省略)。

なので、差分プログラミング的に考えると、「日付」クラスを継承して「日時」クラスを作るとするのが自然。

たとえば、(あまりいいコードではないけど)Pythonでデータクラスを使ってシンプルに書くと、次のようになりそう:

from dataclasses import dataclass

@dataclass
class Date:
    year: int
    month: int
    day: int

@dataclass
class DateTime(Date):
    hour: int = 0
    minute: int = 0
    second: int = 0

date = Date(2024, 3, 31)
datetime = DateTime(2024, 3, 31, 12, 0, 0)

実際、Pythonの標準ライブラリにあるdatetimeモジュールでは、日付はdatetime.dateクラス、日時はそれを継承したdatetime.datetimeクラスとして実装されている。

これはRubyでも同様で、標準ライブラリのdateライブラリで、日付はDateクラス、日時はそれを継承したDateTimeクラスとして実装されている。

ただし、面白いことにRubyDateTimeクラスはDeprecatedとされていて、代わりに組み込みライブラリのTimeクラスを使うことが推奨されている。 これがDateクラスもDeprecatedになってるならdateライブラリ自体がよくないのかなとなるけど、継承したDateTimeクラスだけがDeprecatedになってるのが不思議なところ。 で、実はこれは自分が気になったところが原因じゃないかと思ってる。

「日時」は「日付」なのか?

さて、そんな感じでデータ構造を考えると「日時」を「日付」のサブクラスとして作るのは自然なんだけど、疑問が出てくる。

はたして、「日時」は「日付」なのか?

クラスの継承では「is-a」関係があるのが望ましいとされている。

たとえば「動物」を継承して「犬」とか「猫」を作るという例が代表的だけど、この場合「『犬』は『動物』の一つ」だし「『猫』も『動物』の一つ」なので、たしかに「is-a」関係が成り立ってる。

これは集合関係として捉えることもできて、「『サブクラスのオブジェクトの集合』⊆『スーパークラスのオブジェクトの集合』」となっているのが妥当な継承関係と言える。

たとえば「『犬の集合』⊆『動物の集合』」だし「『猫の集合』⊆『動物の集合』」とこの関係が成り立っている。

それを踏まえたうえで、「日付」と「日時」でそういった妥当な関係が成り立ってるのかどうか?

「日時」は「日付」の一つかというと、ちょっと違う。 というのは、もしそうであるのなら、「日付」をたくさん集めてきた集合の中に「日時」も含まれることになるわけだけど、そこには日付しかなくて、日時というのは入っていない。 「日付」を集めた集合の中に、たとえば「2024-03-31」という日時は含まれていても、「2024-03-31 12:00:00」という日時は含まれてない。

「日付」と「日時」がどういう関係なのかを考えると、1対多の関係とみるのが妥当そう。 1つの「日付」の中に複数の「日時」が含まれている感じ。 これは逆に「日時」側からみると1つの「日付」が対応付くので、「has-a」関係ともいえる。 なので、継承ではなくコンポジションを使うのが設計としては妥当そう。

簡単なコードで書くと(あまりいいコードではないけど)以下:

from dataclasses import dataclass

@dataclass
class Date:
    year: int
    month: int
    day: int

@dataclass
class DateTime:
    date: Date
    hour: int = 0
    minute: int = 0
    second: int = 0

date = Date(2024, 3, 31)
datetime = DateTime(date, 12, 0, 0)

継承だと起きる問題

とはいえ、継承で何か問題があるの?ともなりそうなところ。

実は問題があって、引数で「日付」の型でくるとなってたときに、データが「日付」の場合も「日時」の場合もあるのが変なことを起こしたりする。

たとえば、「ある日時がある日付に含まれるかどうか」という関数を作りたいとする。 素直な実装は次のようになる:

import datetime as dt

def is_datetime_in_date(datetime: dt.datetime, date: dt.date) -> bool:
    return datetime.date() == date

is_datetime_in_date(
    dt.datetime(2024, 3, 31, 12), dt.date(2024, 3, 31)
)  # => True
is_datetime_in_date(
    dt.datetime(2024, 4, 1, 12), dt.date(2024, 3, 31)
)  # => False

何も問題なく動いてるように見えるけど、実は問題があって、次のようなコードが型として問題なく実行できてしまう:

is_datetime_in_date(
    dt.datetime(2024, 3, 31, 12), dt.datetime(2024, 3, 31, 9)
)  # => False

datetime.datetimedatetime.dateを継承してるので、datetime.dateが指定されるべき引数でdatetime.datetimeが指定されても、型としては何も問題がない。 そしてこれはdatetime.date(2024, 3, 31)datetime.datetime(2024, 3, 31, 9)の比較がされるので、結果はFalseとなる。 日付はどちらも2024-03-31であるにも関わらず。

一応、実装を次のようにすれば、結果をTrueに変えることはできる:

def is_datetime_in_date(datetime: dt.datetime, date: dt.date) -> bool:
    return (
        (datetime.year == date.year)
        and (datetime.month == date.month)
        and (datetime.day == date.day)
    )

is_datetime_in_date(
    dt.datetime(2024, 3, 31, 12), dt.date(2024, 3, 31)
)  # => True
is_datetime_in_date(
    dt.datetime(2024, 4, 1, 12), dt.date(2024, 3, 31)
)  # => False
is_datetime_in_date(
    dt.datetime(2024, 3, 31, 12), dt.datetime(2024, 3, 31, 9)
)  # => True

ただ、冷静に最後の式の意味を考えると「2024-03-31 12:00は2024-03-31 9:00に含まれるか?」となってるので、やっぱりおかしい。 日付しか指定できないところで日時も指定できてしまってるのが本質的な問題で、この問題が起きてるのは継承してるからに他ならない。

これが継承を使わない実装になってれば、「日付」を指定するところで「日時」を指定するのは型としておかしいと弾けるので、問題を防げる。 本来くるべきではない値は型として弾くのがいい。 で、継承を濫用してスーパークラスを型として指定すると、都合の悪いサブクラスが指定されてしまったときにおかしくなる、と。


これに似た話が直和型でもあったりする。 それについてはまた別に話したい。

今日はここまで!