Qiita Teams that are logged in
You are not logged in to any team

Log in to Qiita Team
Community
OrganizationEventAdvent CalendarQiitadon (β)
Service
Qiita JobsQiita ZineQiita Blog
81
Help us understand the problem. What is going on with this article?
Organization

extension Dateで日付計算をしてはいけない

やりがちな 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-020002 というのは令和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)」という英語(自動詞)を提案しています。

DateCalendar がコンプレクトしている状態は、シンプルじゃなさそうです。

どうやったら悪い設計を避けられるのか

テストを書きましょう。

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 。今からでも世界中のすべての DateTimePoint にならないかな。

無理ですね。
ネーミングの議論をしていると、ときおり自転車置き場の議論に感じるかもしれません。どんな名前でもコンパイルは通りますからね。しかしネーミングというのは重要な作業です。名前が悪いと責務がぼやけて、本来はあるべきではない機能拡張が行われてしまいます。そして負債はずっと残り続けます。
私たちも、自分で型や関数を作るときには名前には拘りましょう。それが、責務の輪郭をはっきりとさせ関心を切り分けるための第一歩です。

最後に

この記事を読んで「面白かった」「学びがあった」と思っていただけた方、よろしければ Twitter や facebook、はてなブックマークにてコメントをお願いします!
また DeNA 公式 Twitter アカウント @DeNAxTech では、 Blog記事だけでなく色々な勉強会での登壇資料も発信してます。ぜひフォローして下さい!
Follow @DeNAxTech


  1. 正確には違います。unixtime は 1970-01-01 00:00:00 UTC からの経過時間ですが、SwiftのDateは 2001-01-01 00:00:00 UTC からの経過時間を示していることが NSDate のリファレンスapple/swiftのソースコードから確認できます。 

81
Help us understand the problem. What is going on with this article?
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
81
Help us understand the problem. What is going on with this article?