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
ざっくり解説
使い方
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
クラスの実装は以下です。
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
を扱うということはLocalDate
やLocalTime
を扱うことも考慮したからです。
実際にLocalDate
やLocalTime
版も作成して、ほぼ同じコードとなっています。
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();
}
}
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);
}
}
ということで、ほとんどの実装は抽象クラスにまとめています。
@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);
}
Interface
にdefault
実装が許されたJava8以降、あまり書く機会がなかった抽象クラスを使っています。
というのも、期間を表すFrom/Toの情報をプロパティとして保持したいからですね。
将来的にサブクラス側でFrom/Toの値を使用するメソッドが必要になるかもしれないので、
可視性はprotected
にしてあります。
工夫点
抽象メソッドのtoEpoch
は、LocalDateTime#toEpochSecond
やLocalDate#toEpochDay
と行ったエポック変換を行うメソッドが各クラスで異なるので、そこを吸収するためのメソッドです。
このメソッドを使って、contains
、equals
、overlapsAsOpen
、overlapsAsClosed
の各メソッドで判定を行っています。
AsOpen
とかAsClosed
ってなんぞや、となりますが、これは数学の区間の分類で、
開区間(Open interval)は境界点を含む、いわゆる黒丸で表される数直線です。
閉区間(Closed interval)は境界点を含まない、いわゆる白丸で表される数直線です。
実際に使用する際は、デフォルトとしてどちらを扱うのかを決めて、overlapsAsOpen
とoverlapsAsClosed
はprivate
メソッドにし、
public
のoverlaps
メソッドでデフォルトを呼び出すような形にするのもいいかもしれません。
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の可読性を向上させるテクニックです!