やりがちな Date
のアンチパターンが何故悪いのか解説します。Swiftを例にとっていますが、 Date
型は多くの言語で似た責務を持っているので、あまり言語を問わない記事内容といえます。
この記事はDeNA Advent Calendar 2020の2日目の記事です。
筆者はtakasekといいます。DeNA SWETの仕様分析サポートチームで形式手法のプロダクト開発への適用可能性を模索したり、ライブコミュニケーションアプリ Pococha のiOSアプリ開発チームで開発したりしています。
extension Date のアンチパターン
Dateインスタンスから年月日を求めるのは Calendar
DateComponents
といくつもの型を経由する必要があり面倒です。それをドットアクセスひとつで簡単に書けるようにしてくれる便利なextensionを作りました。
extension Date {
var year: Int {
Calendar.current.component(.year, from: self)
}
var month: Int {
Calendar.current.component(.month, from: self)
}
var day: Int {
Calendar.current.component(.day, from: self)
}
var hyphened: String {
String(format: "%04d-%02d-%02d", year, month, day)
}
}
let now = Date()
now.year // 2020
now.month // 12
now.day // 2
now.hyphened // "2020-12-02"
よさそうに見えますね。
しかし、これは大抵の開発シーンでは悪い設計だといえます。
なぜ悪いのか
Calendar.current.component(...)
というアクセスが頻繁に発生するから?
文字列化に DateFormatter
を使っていないから?
いいえ、そんな理由ではありません。もっと本質的な問題があります。
それは日付計算が Date
の責務を超えてしまうという点です。
Date
とはどういう型なのか、リファレンスを見てみましょう。
https://developer.apple.com/documentation/foundation/date
A specific point in time, independent of any calendar or time zone.
Date
はいかなるカレンダーやタイムゾーンからも独立した、ある時間軸上の一点の表現です。単なる unixtime のラッパーだと思ったほうがよいです。1
リファレンスをさらに読み進めると以下のように書かれています。
The Date structure provides methods for comparing dates, calculating the time interval between two dates, and creating a new date from a time interval relative to another date. Use date values in conjunction with DateFormatter instances to create localized representations of dates and times and with Calendar instances to perform calendar arithmetic.
Date
のメソッドはdate同士の比較やインターバルの計算のためにあります。文字列表現を得るためには DateFormatter
と、暦計算のためには Calendar
と組み合わせてくれとリファレンスは言っています。
より詳しく実務的な Date
周りの考え方・ベストプラクティスについては、以下の素晴らしい記事をご参照ください。
【Swift】Dateの王道 【日付】 - Qiita
https://qiita.com/rinov/items/bff12e9ea1251e895306
本当に悪いのか
そうはいってもリファレンスに書いてあるからダメっていうのは教条的すぎじゃないか。now.hyphend
と書けば 2020-12-02
が得られるというのはシンプルだ。複雑性を考えなくてすむようにするのが関心の分離ということだろう。内部処理として Calendar
が利用されていればいいだけで、利用者としてはそこまで知る必要はない。
そう思われるかもしれません。
具体的な問題を考えてみましょう。
この hyphend
は「サーバになんらかの年月日を送るとき」に使われているとします。しかしあるときサーバで不具合が起こり、調査した結果 0002-12-02
という年月日が特定のユーザーからのみ送られてきていることがわかりました。
問題の根本は、そのユーザーが端末を和暦設定にしていたことです。
var year: Int {
Calendar.current.component(.year, from: self)
}
0002-12-02
の 0002
というのは令和2年のこと。 Calendar.current
なので、日付表現が端末の設定に依存してしまっていたのですね。
しかし hyphened
のコードをいくら眺めていてもこの問題には気づけません。
var hyphened: String {
String(format: "%04d-%02d-%02d", year, month, day)
}
なぜなら Calendar.current
に依存しているという知識が暗黙になってしまっているからです。暗黙の依存はコードの理解を阻み、再利用性を落とし、バグを埋め込み、改修のコストを上げる、厄介な代物です。
先ほどの主張をもう一度振り返ってみましょう。
now.hyphend
と書けば2020-12-02
が得られるというのはシンプルだ。複雑性を考えなくてすむようにするのが関心の分離ということだろう。内部処理としてCalendar
が利用されていればいいだけで、利用者としてはそこまで知る必要はない。
これは勘違いだったのです。どういった Calendar
が利用されているかというのは、じつは暗黙にしてはいけない知識でした。それを覆い隠すのは、シンプルではなく本来必要な知識のオミットでしかなかったのです。
Rich Hickey はしばしば勘違いしがちなこの「シンプル」の捉え方について素晴らしい言語化をしてくれています。孫引きにはなりますが、以下の日本語解説記事を引用します。
Clojureと「Simple Made Easy」 - 紙箱
http://boxofpapers.hatenablog.com/entry/simple_made_easy
つまりRichのいう「シンプル」というのは、「ひとつのものに、複数のことがらを混ぜ込まない」ということなのです。
...
シンプルというのは、まっすぐの糸のようなもので、シンプルでないものというのは、糸が絡まっている状態です。
...
シンプルでないものを作ろうとしてるときに「おい、それは○○してる(シンプルじゃなくしてる)ぞ」と言えれば、シンプルさを保つ役に立ちそうです。
そこでRichは「コンプレクト(complect)」という英語(自動詞)を提案しています。
Date
と Calendar
がコンプレクトしている状態は、シンプルじゃなさそうです。
どうやったら悪い設計を避けられるのか
テストを書きましょう。
extension Date {
var year: Int {
Calendar.current.component(.year, from: self)
}
...
}
XCTAssertEqual(now.year, 2020)
このテストケースを考えていると、
「Calendarの設定を変えてのテストが書きづらいな」
「そもそもCIの環境によって、同じDateインスタンスでも日付表現が変わってテストが落ちてしまうな」
ということに気付くはずです。
暗黙の依存への対策は、外から注入可能にすることです。
extension Date {
func year(in calendar: Calendar = Calendar.current) -> Int {
calendar.component(.year, from: self)
}
...
}
let calendar = Calendar(identifier: .gregorian)
calendar.timeZone = ...
calendar.locale = ...
XCTAssertEqual(now.year(in: calendar), 2020)
これでテスト可能になりました…が、よく見るとこのコードは、ほとんど引数で受けたcalendarを使っているだけのコードです。だとしたらこれでいいじゃないですか。
extension Calendar {
func year(of date: Date) -> Int {
component(.year, from: date)
}
...
}
XCTAssertEqual(calendar.year(of: now), 2020)
日付計算は Date
ではなく Calendar
のextensionにするのです。Dateは自分の実体であるtimestampのことだけを気にしていれば良くて、自分がCalendarに使われうることなんて知る必要はありません。それこそが関心の分離です。
これでコンプレクトが途端に消えて、「あるカレンダー上での現在の年」という意図がシンプルに伝わってくる、悪くない設計になりました!
誰が悪いのか
悪かった。悪い悪い言い過ぎました。
じつのところ、 Date
で日付計算をしたくなってしまうあなたは悪くありません。
本当に悪いのはネーミングです。 Date
という型名を見たらそりゃあ日付の知識があると思うのは自然です。
Dateで日付計算をしてはいけないし、そもそも日付計算をしてはいけない型にDateとつけてはいけなかったんです。
私は以前Twitterで、そういう会話をしたことがあります。
これはそのとおり。このコードをみて TimePointという命名はよいなと思った。https://t.co/hiVFCQdpyQ https://t.co/53HDmdTL0p
— かとじゅん (@j5ik2o) September 12, 2019
いいですね、 TimePoint
。今からでも世界中のすべての Date
が TimePoint
にならないかな。
無理ですね。
ネーミングの議論をしていると、ときおり自転車置き場の議論に感じるかもしれません。どんな名前でもコンパイルは通りますからね。しかしネーミングというのは重要な作業です。名前が悪いと責務がぼやけて、本来はあるべきではない機能拡張が行われてしまいます。そして負債はずっと残り続けます。
私たちも、自分で型や関数を作るときには名前には拘りましょう。それが、責務の輪郭をはっきりとさせ関心を切り分けるための第一歩です。
最後に
この記事を読んで「面白かった」「学びがあった」と思っていただけた方、よろしければ Twitter や facebook、はてなブックマークにてコメントをお願いします!
また DeNA 公式 Twitter アカウント @DeNAxTech では、 Blog記事だけでなく色々な勉強会での登壇資料も発信してます。ぜひフォローして下さい!
Follow @DeNAxTech
-
正確には違います。unixtime は
1970-01-01 00:00:00 UTC
からの経過時間ですが、SwiftのDateは2001-01-01 00:00:00 UTC
からの経過時間を示していることがNSDate
のリファレンスやapple/swiftのソースコードから確認できます。 ↩