Advent Calendarの3日目です
2日目は @exotic-toybox さんによる「Java8の日時APIにおける期間の重複判定」でした。
はじめに
Java 8 でラムダ式や Stream API が導入されてから随分経ちましたが、いまだに読みづらいコードに出会うことがあります。
本稿では可読性を向上させるためのテクニックをいくつかご紹介します。
以降のサンプルコードの動作確認は AdoptOpenJDK 14.0.2 で行いました。
ロジックを抽出してストリームをすっきりさせる
filter
や map
などのメソッドに渡すラムダ式が長くなると、ストリーム処理の全体の見通しが悪くなります。
// かさばる本の一覧
List<String> bookTitles = null;
// BEFORE
bookTitles =
ownedBooks.stream()
.filter(b -> {
// 500ページ以上または800グラム以上の紙の本
if (b instanceof EBook) {
return false;
}
if (b.getPages() > 500) {
return true;
}
if (b.getWeight() > 800) {
return true;
}
return false;
}).map(Book::getTitle)
.collect(Collectors.toList());
このような場合、 Extract Method リファクタリングパターンを用いて処理をメソッドに抽出するのが定石です。
private boolean isBulky(Book b) {
// 500ページ以上または800グラム以上の紙の本
if (b instanceof EBook) {
return false;
}
if (b.getPages() > 500) {
return true;
}
if (b.getWeight() > 800) {
return true;
}
return false;
}
抽出したメソッドをメソッド参照で指定するように置き換えます。
// AFTER
bookTitles =
ownedBooks.stream()
.filter(this::isBulky)
.map(Book::getTitle)
.collect(Collectors.toList());
メソッドとして抽出せずとも、事前にラムダ式を定義して的確な名前を与えることでも可読性が向上します。
// AFTER(2)
Predicate<Book> byBulkiness = b -> {
// 500ページ以上または800グラム以上の紙の本
if (b instanceof EBook) {
return false;
}
if (b.getPages() > 500) {
return true;
}
if (b.getWeight() > 800) {
return true;
}
return false;
};
bookTitles =
ownedBooks.stream()
.filter(byBulkiness)
.map(Book::getTitle)
.collect(Collectors.toList());
static import を活用する
Comparator
や Collectors
はストリーム処理の中で繰り返し利用されがちです。
map<String, List<Book>> books = null;
// BEFORE
books = ownedBooks.stream()
.sorted(Comparator.comparing(Book::getPages).reversed())
.collect(Collectors.groupingBy(Book::getAuthor, Collectors.toList()));
タイプするが面倒なだけでなく、コードを読むときもノイズとなって邪魔なので、 static import しましょう。
import static java.util.stream.Collectors.*;
import static java.util.Comparator.*;
クラス名の指定が不要となります。
// AFTER
books = ownedBooks.stream()
.sorted(comparing(Book::getPages).reversed())
.collect(groupingBy(Book::getAuthor, toList()));
現場によってはコーディング規約で static import を禁止し、Checkstyle等で警告を出すようになっているかもしれません。
その場合は、 Collectors
や Comparator
を除外指定できないか相談してみましょう。 (筆者の個人的意見ですが、クラスを小さく保っていれば名前衝突の可能性やメソッドの所属の曖昧性は十分回避可能なので、 static import は禁止せずに可読性を優先すべきだと思います。)
独自の関数インタフェースを作成する
以下のレポート出力クラスを考えます。
static class BookReport {
private List<Book> books;
// 行のフォーマットを行う関数
private Function<Book, String> rowFormatter;
// フッターのフォーマットを行う関数
private Function<Integer, String> footerFormatter;
BookReport(List<Book> books, Function<Book, String> rowFormatter, Function<Integer, String> footerFormatter) {
this.books = books;
this.rowFormatter = rowFormatter;
this.footerFormatter = footerFormatter;
}
String create() {
String rows = books.stream().map(rowFormatter).collect(joining("\r\n"));
int numOfBooks = books.size();
String footer = footerFormatter.apply(numOfBooks);
return rows + "\r\n" + footer;
}
}
行のフォーマットやフッタのフォーマットという責務をを標準APIの関数型インタフェース java.util.functions.Function
型で定義することで、利用側では以下のようにラムダ式を使った簡潔な記述が可能です。
// BEFORE
Function<Book, String> rowFormatter = b -> "著者:" + b.getAuthor() + " タイトル:" + b.getTitle();
Function<Integer, String> footerFormatter = num -> "合計:" + num + "冊";
var report = new BookReport(ownedBooks, rowFormatter, footerFormatter);
var output = report.create();
上記コードはまったく問題ないのですが、あえて独自の関数型インタフェースを定義した方がプログラムの意図が明確になる場合もあります。
static class BookReport2 {
private List<Book> books;
private RowFormatter rowFormatter;
private FooterFormatter footerFormatter;
BookReport2(List<Book> books, RowFormatter rowFormatter, FooterFormatter footerFormatter) {
this.books = books;
this.rowFormatter = rowFormatter;
this.footerFormatter = footerFormatter;
}
@FunctionalInterface
interface RowFormatter {
String format(Book book);
}
@FunctionalInterface
interface FooterFormatter {
String format(int numOfBooks);
}
String create() {
String rows = books.stream().map(rowFormatter::format).collect(joining("\r\n"));
int numOfBooks = books.size();
String footer = footerFormatter.format(numOfBooks);
return rows + "\r\n" + footer;
}
}
呼び出し側は以下のようになります。ジェネリック型ではない独自型を使用しているので、変数の宣言時に型引数も不要となります。
// AFTER
BookReport2.RowFormatter rowFormatter2 = b -> "著者:" + b.getAuthor() + " タイトル:" + b.getTitle();
BookReport2.FooterFormatter footerFormatter2 = num -> "合計:" + num + "冊";
var report2 = new BookReport2(ownedBooks, rowFormatter2, footerFormatter2);
var output2 = report2.create();
また、プログラムの利用側に対して、振る舞いを実装するために(ラムダ式ではなく)専用の型を用意するという選択肢も与えられます。
static class TaggingRowFormatter implements BookReport2.RowFormatter {
@Override
public String format(Book book) {
return "<著者>" + book.getAuthor() + "</著者><タイトル>" + book.getTitle() + "</タイトル>";
}
}
static class TaggingFooterFormatter implements BookReport2.FooterFormatter {
@Override
public String format(int numOfBooks) {
return "<合計>" + numOfBooks + "</合計>";
}
}
// AFTER(2)
BookReport2.RowFormatter rowFormatter3 = new TaggingRowFormatter();
BookReport2.FooterFormatter footerFormatter3 = new TaggingFooterFormatter();
var report3 = new BookReport2(ownedBooks, rowFormatter3, footerFormatter3);
var output3 = report3.create();
まとめ
ラムダ式や Stream API の登場によって、以前の Java のいわゆるボイラープレート的な冗長なコードをすっきりさせることが可能となりました。ラムダ式や Stream API を使った処理自体が冗長な記述とならないように気をつけたいところです。