7
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

JavaでKotlinと同じくらい安全なコードを書く(前編)

Last updated at Posted at 2021-11-30

この記事はKotlin Advent Calendarの1日目の記事です。

この記事の内容は完全にJavaですが、現在Javaを書いている人がKotlinを導入するきっかけになることを目指して書きますのでご容赦ください。

前書き

KotlinJavaに対する大きな利点の1つは、コードの安全性を簡単に担保できることです。
一方、安全性はアピールが難しく、「安全だからKotlinを導入したい!」と主張しても「それ、Javaでもできるのでは?」「どれ位メリット有るの?」と突っ込まれることも有るでしょう。

実際、自分が経験した限りJavaでも安全なコードを書く(正確には、書ける環境を整える)ことは可能です。
一方、それを実現するには非常に大きな労力が必要で、現実的には安全性を妥協する部分が出てくると思っています。

このシリーズでは、Kotlinの導入材料の1つになれることを目指しながら、Javaで安全なコードを書く大変さと、Kotlinで安全なコードを書く簡単さについて書きます。

前編では、Javaで安全性を高めるための工夫として、以下の4点を書きます。

  • nullを安全に取り扱う
  • メソッド・クラス・変数を基本的に変更できないようにする
  • Collectionを読み取り専用にする
  • 一致を安全に判定する

後編では、これらがKotlinでどのような表現になるかと、Javaで実現した場合と比べた簡単さについて書きます。

nullを安全に取り扱う

素のJavaでは、プリミティブ型等を除いてnullabilityを指定する文法は無く、IDEで得られる警告も限定的です。
この状態では、nullチェックの抜け漏れで実行時エラーになる危険性が有ります。

// 引数や戻り値のnullabilityはJavadoc等に書く以外の形で表現できない
public String func(String foo, String bar) { /* 略 */ }

これを安全に扱うための方法は幾つか考えられますが、ここではSpotBugsの静的解析と、spotbugs-annotationsから導入されるjavax.annotation.Nonnull/Nullableアノテーションを組み合わせる例を紹介します。
また、今後のコード例はこれらのアノテーションを付与した状態にします。

補足として、アノテーション等によってnullabilityを示し、解析や実行時のチェックに用いることは、LombokJetBrains Annotationsといったライブラリ・フレームワークでも実現できます。
どのライブラリ・フレームワークを用いるかは、プロジェクトで利用しているIDEや、ライブラリの導入状況に応じて決めることになります。

実際にやってみた様子

以下はspotbugs-gradle-pluginspotbugs-annotationsを導入した上で、引数と戻り値にNonnull/Nullableアノテーションを付与した様子です。
これによって、利用方法が間違っている個所が有ればSpotBugsの静的解析により警告が得られるようになります(SpotBugsの具体的な利用方法については触れません)。

import javax.annotation.Nonnull;
import javax.annotation.Nullable;

@Nonnull
public String func(@Nonnull String foo, @Nullable String bar) { /* 略 */ }

ただし、これを行っていても今回紹介した状態ではコンパイルエラーや実行時エラーは発生しません12
この対策としては、静的解析をCI等に組み込むこと3、アノテーションの付け忘れをコードレビューで見逃さないよう意識することなどが考えられます。

また、IDEによってはこの警告をGUI上にリアルタイムで表示できます。
以下はNonnull指定した引数fooにわざとnullを渡すコードを書き、Intellij IDEAで警告を表示した様子です。
メッセージの通り、null禁止の引数にnullを渡していることが警告されています。
image.png

メソッド・クラス・変数を基本的に変更できないようにする

Javaでは基本的に全てのものが変更(継承・再代入)可能になっています。
一方、変更が可能ということは、それらの操作を誤って行ってしまう危険性が有るということです。

このため、メソッド・クラス・変数はそれぞれfinalで修飾して変更できないようにしておく方が安全です。
なお、基本的にfinalにするとしても、結局は手動で修飾するしかない4ため、こちらも最後はコードレビューでのチェックを徹底する必要が有ります。

ここでは、メソッド・クラス・変数を変更できないようにする方法について紹介します。

メソッド/クラスを変更できないようにする

メソッドをfinal修飾した場合は継承先クラスでのoverrideが、クラスをfinal修飾した場合はクラスそのものの継承が出来なくなります。
これによって意図せぬ継承を防ぐことができ、安全になります。

メソッドをfinal修飾した場合
public class Temp {
    public final void func() {}
}
// コンパイルエラーになる
public class TempEx extends Temp {
    @Override
    public void func() { super.func(); }
}
クラスをfinal修飾した場合
public final class Temp {}
// コンパイルエラーになる
public class TempEx extends Temp {}

変数を変更できないようにする

変数を再代入可能な状態で宣言していると、誤って書き換えてしまう危険性が有ります。
よって、一時変数やフィールドはできるだけfinal修飾しておいた方が安全です5

// funcの戻り値をfinalで設定
@Nonnull final String foo = func(bar);

Javaでは、他にも引数、拡張for文の変数、ラムダ式のレシーバーも再代入可能です。
これらもfinal修飾することで、より安全に取り扱うことができます。

引数のfinal化
// コンパイルエラーにならない
public void f1(@Nonnull Integer arg) { arg = 0; }
// コンパイルエラーになる
public void f2(@Nonnull final Integer arg) { arg = 0; }
拡張for文の変数のfinal化
// コンパイルエラーにならない
for (@Nonnull Integer i : list) { i = 0; }
// コンパイルエラーになる
for (@Nonnull final Integer i : list) { i = 0; }
ラムダ式のレシーバーのfinal化
// コンパイルエラーにならない
list.forEach(i -> { i = 0; });
// コンパイルエラーになる
list.forEach((@Nonnull final Integer i) -> { i = 0; });

補足: lombokのvalについて

Java10より、varで変数を宣言することで型推論が出来るようになりました。
lombok.valは、final varと同等の変数宣言になります。

乱用すると可読性を損なうため注意が必要ですが、これを用いると少なくともローカル変数は一々final修飾しなくて済むため、安全なコードを書きやすくなります。

import javax.annotation.Nonnull;
import lombok.val;

void func(@Nonnull final String str) {
    val foo = str; // final varで宣言したのと同等
    foo = ""; // コンパイルエラーになる
}

Collectionを読み取り専用にする

JavaListMapは基本的にmutableで、値の追加・削除・置き換えといった処理が自由に行えます。
一方、変更を自由に行える状況では、意図しない書き換えでバグが発生する危険性が有ります。

このため、Collectionは読み取り専用にする方が安全です。

ここでは、Collections.unmodifiable...を用いる方法と、Collectionをラップするクラスを定義する方法を紹介します。

Collections.unmodifiable...を用いる方法

java.util.Collectionsには、値の変更処理を呼び出した際にUnsupportedOperationExceptionを発生させるラッパークラスと、そのインスタンスを生成するファクトリメソッドが定義されています。
例えばListunmodifiableにする場合以下のように記述します。

import javax.annotation.Nonnull;
import java.util.Collections;
import java.util.List;

@Nonnull List<Integer> unmodifiableList = Collections.unmodifiableList(list);

この方法ではListなどのインターフェースはそのままになるため、外部ライブラリなどで扱いやすいという利点が有ります。
一方、値の変更処理を不用意に呼び出すと実行時エラーになる危険性は残っています。

仮に型レベルで変更を防ぎたい場合、後述するCollectionをラップするクラスを定義する方法が考えられます。

Collectionをラップするクラスを定義する方法

こちらはaddremoveといった変更処理を省いたラッパークラスを定義する方法です。
例えば以下のように定義する方法が考えられます。

ライブラリなどに値を渡したり、内容を変更する処理も考慮して、mutableな値はgetUnsafeValueで取得できるようにしています。

Collectionのラッパークラス
import javax.annotation.Nonnull;
import java.util.Collection;
import java.util.Iterator;

/**
 * 変更できないCollection
 */
public abstract class ImCollection<E> implements Iterable<E> {
    @Nonnull private final Collection<E> rawValue;

    protected ImCollection(@Nonnull Collection<E> rawValue) {this.rawValue = rawValue;}

    @Override
    @Nonnull
    public final Iterator<E> iterator() { return rawValue.iterator(); }

    /**
     * @return 変更するためのCollection
     */
    // 継承先で具体的な型を指定するためにabstractで定義している
    @Nonnull
    public abstract Collection<E> getUnsafeValue();

    public int size() { return rawValue.size(); }
    public boolean isEmpty() { return rawValue.isEmpty(); }
    public boolean contains(E e) { return rawValue.contains(e); }
    // 以降Collectionに定義された変更しない操作
}
Listのラッパークラス
import javax.annotation.Nonnull;
import java.util.List;

/**
 * 変更できないList
 */
public final class ReadOnlyList<E> extends ImCollection<E> {
    @Nonnull private final List<E> value;

    public ReadOnlyList(@Nonnull List<E> value) {
        super(value);
        this.value = value;
    }

    @Override
    @Nonnull
    public List<E> getUnsafeValue() {return value;}

    public E get(int index) { return value.get(index); }
    public int indexOf(E e) { return value.indexOf(e); }
    public int lastIndexOf(E e) { return value.lastIndexOf(e); }
    // 以降Listに定義された変更しない操作
}

実用上は以下のような処理全てを実装する必要が有り、この方法は多少手間がかかりますが、Collectionの操作だけを考えるなら最も安全な方法です。

  • Collectionインターフェースに定義された処理全て
  • StreamCollector
  • JSON等へのシリアライズ/デシリアライズ時の変換処理

一致を安全に判定する

Javaで一致を判定する場合、primitive型とオブジェクト型で異なる方法を用いる必要が有ります6
また、オブジェクト型の一致判定で両方ともnullableだった場合、少なくとも片方がnullでないことをチェックする必要が有ります。

// primitive型同士
boolean isEquals(int i, int j) { return i == j; }
// primitive型とオブジェクト型
boolean isEquals(int i, @Nonnull Integer j) { return i == j; }
// オブジェクト型同士
boolean isEquals(@Nonnull Integer i, @Nonnull Integer j) { return i.equals(j); }

この比較方法の違いとnullチェックの必要性はバグに繋がる危険性が有ります。
またInteger等のラッパー型であれば、特定の値の範囲では==で比較しても正常な結果が返ってくる場合が有るなど、深いバグになりやすいという点で更に高い危険性が有ります。

比較方法の違いやnullチェックを吸収して安全に比較する方法としては、java.util.Objects.equalsが提供されています。
安全性を重視するのであれば、比較は必ずこちらを用いる形とするのが良いでしょう。

import java.util.Objects;

// a, bの型やnullabilityに関わらず判定できる
final bool result = Objects.equals(a, b);

一々Objects.equalsと書くのが長いようであれば、以下のようなショートハンドを定義し、static importして利用することも考えられます。

final class Util {
    public static boolean eq(@Nullable Object a, @Nullable Object b) { return Objects.equals(a, b); }
}

終わりに

この記事ではJavaで安全性を高めるための工夫について書きました。

書き上げた上で、自分は「例え新規プロジェクトでもこれ全部やるのは難しそうだな」と感じましたがいかがでしょうか?

Kotlinでは、これらの安全性を簡単に実現できます。
明日公開の後編ではこの安全性とJavaで実現した場合と比べた簡単さについて書きますので、お読み頂ければ幸いです。

追記: 後編を公開しました

  1. 不正が有ればコンパイルエラーにする方法も探しましたが、軽く調べただけでは見つけられませんでした。

  2. 例えばLombokの場合、アノテーション処理によってコードにnullチェックが加えられ、引数がnullならその場でエラーになるという効果が有ります。

  3. サラッと言っていますが、特に後付けで導入する場合、これをCIに組み込むのは結構大変な作業になると思います。

  4. 全てfinalが基本になるようなコンパイラオプションが無いかと探しましたが、Java8のドキュメントを読んでも見つけられませんでした。

  5. フィールドをfinalにする場合、コンストラクタ等で初期化する形にする必要が有ります。

  6. 正確には、オブジェクト型同士を==で比較した場合、同一インスタンスか否かの判定結果が返ってきますが、実用上同一インスタンスかの判定は滅多に行わないため、ここでは言及していません。

7
5
0

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
  3. You can use dark theme
What you can do with signing up
7
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?