LoginSignup
3

More than 1 year has passed since last update.

posted at

updated at

Java8の日時APIにおける期間の重複判定

Advent Calendarの2日目です

初めてAdvent calendarに寄稿しました。
初日の@tkxlab さんの記事Jakarta EEをはじめよう! - Qiitaが大作なので、翌日がこれですみませんという気持ちでいっぱいです。

初めに

SQLなどではよくある(かもしれません)が、Javaでは珍しい期間同士の重複判定を行う方法です。
数直線的に記述するとこんな感じのイメージです。

|-----期間A-----|
           |-----期間B-----|
           |---|
             ↑ここの重複している期間があるかどうかを確認したい

元々Java8になる前ではJoda Time - Maven Repositoryを使用して日付の計算をすることが多かったです。(もちろん単純な加算減算レベルならjava.util.Calendarクラスなんかを使用することもありました)

Java8で日時APIが導入されてから、ほぼ完結していて不満もありませんでしたが、
今回行った期間の重複判定についてはいろいろ探したのですが見つからなかったので、
ついに拡張コードを書く日がきたか、という印象です。

ソースコード

Githubに置いてあります!

バージョン等

  • Java 11 (8以上なら大丈夫です)
  • Lombok 1.18.16

ざっくり解説

使い方

Main.java
public class Main {
    public static void main(String[] args) {
        LocalDateTime originFrom = LocalDateTime.now();
        LocalDateTime originTo = originFrom.plusDays(30L);
        LocalDateTimeInterval origin = new LocalDateTimeInterval(originFrom, originTo);

        LocalDateTime otherFrom = originFrom.plusDays(15L);
        LocalDateTime otherTo = originTo.plusDays(15L);
        LocalDateTimeInterval interval = new LocalDateTimeInterval(otherFrom, otherTo);

        System.out.println(origin.overlapsAsOpen(other)); // -> true
    }
}

このように、日付の組み合わせで表現される期間同士を比較しています。

このLocalDateTimeIntervalクラスの実装は以下です。

LocalDateTimeInterval.java
public class LocalDateTimeInterval extends AbstractTemporalInterval<LocalDateTime, LocalDateTimeInterval> {
    public LocalDateTimeInterval(LocalDateTime from, LocalDateTime to) {
        super(from, to);
    }

    @Override
    protected long toEpoch(@NonNull LocalDateTime dateTime) {
        return dateTime.toEpochSecond(ZoneOffset.UTC);
    }
}

非常に少ないコードで記述してあります。
なぜかというと、やはりLocalDateTimeを扱うということはLocalDateLocalTimeを扱うことも考慮したからです。

実際にLocalDateLocalTime版も作成して、ほぼ同じコードとなっています。

LocalDateInterval.java
public class LocalDateInterval extends AbstractTemporalInterval<LocalDate, LocalDateInterval> {
    public LocalDateInterval(LocalDate from, LocalDate to) {
        super(from, to);
    }

    @Override
    protected long toEpoch(@NonNull LocalDate date) {
        return date.toEpochDay();
    }
}
LocalTimeInterval.java
public class LocalTimeInterval extends AbstractTemporalInterval<LocalTime, LocalTimeInterval> {
    public LocalTimeInterval(LocalTime from, LocalTime to) {
        super(from, to);
    }

    @Override
    protected long toEpoch(@NonNull LocalTime time) {
        return time.toEpochSecond(LocalDate.EPOCH, ZoneOffset.UTC);
    }
}

ということで、ほとんどの実装は抽象クラスにまとめています。

AbstractTemporalInterval.java
@Getter(AccessLevel.PROTECTED)
public abstract class AbstractTemporalInterval<T extends Temporal, I extends AbstractTemporalInterval<T, I>> {
    @NonNull protected final T from;
    @NonNull protected final T to;

    protected AbstractTemporalInterval(@NonNull T from, @NonNull T to) {
        if (toEpoch(from) >= toEpoch(to)) {
            throw new IllegalArgumentException("from must be before to");
        }
        this.from = from;
        this.to = to;
    }

    public final boolean contains(@NonNull T temporal) {
        return toEpoch(from) <= toEpoch(temporal) && toEpoch(temporal) <= toEpoch(to);
    }

    public final boolean equals(@NonNull I other) {
        return toEpoch(from) == toEpoch(other.getFrom()) && toEpoch(to) == toEpoch(other.getTo());
    }

    public final boolean overlapsAsOpen(@NonNull I other) {
        return toEpoch(from) < toEpoch(other.getTo()) && toEpoch(other.getFrom()) < toEpoch(to);
    }

    public final boolean overlapsAsClosed(@NonNull I other) {
        return toEpoch(from) <= toEpoch(other.getTo()) && toEpoch(other.getFrom()) <= toEpoch(to);
    }

    protected abstract long toEpoch(@NonNull T temporal);
}

Interfacedefault実装が許されたJava8以降、あまり書く機会がなかった抽象クラスを使っています。
というのも、期間を表すFrom/Toの情報をプロパティとして保持したいからですね。

将来的にサブクラス側でFrom/Toの値を使用するメソッドが必要になるかもしれないので、
可視性はprotectedにしてあります。

工夫点

抽象メソッドのtoEpochは、LocalDateTime#toEpochSecondLocalDate#toEpochDayと行ったエポック変換を行うメソッドが各クラスで異なるので、そこを吸収するためのメソッドです。
このメソッドを使って、containsequalsoverlapsAsOpenoverlapsAsClosedの各メソッドで判定を行っています。

AsOpenとかAsClosedってなんぞや、となりますが、これは数学の区間の分類で、
開区間(Open interval)は境界点を含む、いわゆる黒丸で表される数直線です。
閉区間(Closed interval)は境界点を含まない、いわゆる白丸で表される数直線です。

実際に使用する際は、デフォルトとしてどちらを扱うのかを決めて、overlapsAsOpenoverlapsAsClosedprivateメソッドにし、
publicoverlapsメソッドでデフォルトを呼び出すような形にするのもいいかもしれません。

AbstractTemporalInterval.java
    public final boolean overlaps(@NonNull I other) {
        return overlapsAsOpen(other);
    }

    private final boolean overlapsAsOpen(@NonNull I other) {
        return toEpoch(from) < toEpoch(other.getTo()) && toEpoch(other.getFrom()) < toEpoch(to);
    }

    private final boolean overlapsAsClosed(@NonNull I other) {
        return toEpoch(from) <= toEpoch(other.getTo()) && toEpoch(other.getFrom()) <= toEpoch(to);
    }

一番苦労したのはクラス宣言部分AbstractTemporalInterval<T extends Temporal, I extends AbstractTemporalInterval<T, I>>です。
この宣言により、各メソッドで引数の型を各サブクラスでLocalDateTimeInterval extends AbstractTemporalInterval<LocalDateTime, LocalDateTimeInterval>のように指定し、取り扱う型をコンパイル時に判別できるようにしています。

終わりに

Githubの方ではテストやコメントもしっかり書いてあるので、ぜひ一度ご覧になってください!
また、コメントの翻訳ミスや、こんなメソッドもあった方がいいんじゃない?みたいなのもあればコメントやGithubのPull Requestで教えていただけると嬉しいです!

明日は@yonetty さんのJavaのラムダ式やStream APIの可読性を向上させるテクニックです!

参考

Javaの "? extends" や "? super" の使い方をC#やScala風に考える - Qiita

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
What you can do with signing up
3