double型のアサーションにおける罠

98件のシェア(ちょっぴり話題の記事)

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

渡辺です。

Twitterでこんなことに困っている方がいたので、ブログのネタとして扱ってみました。

「いつからプリミティブ型で比較していると錯覚していた?」

プリミティブ型とプリミティブラッパークラス

はじめにJavaのプリミティブ型とプリミティブラッパークラスについて説明します。

Javaは歴史的な経緯からすべてのデータをオブジェクトとして保持していません。boolean, int, doubleなどの幾つかの型はプリミティブ型と呼ばれ、オブジェクトとは異なる扱いになっています。例えば、nullにできませんし、メソッドを持てません。ただ、それでは困る場合があるので、各プリミティブ型に対し、プリミティブラッパークラスが用意されています *1 *2。intであればjava.lang.Integerクラスです。

int num = 10;
Integer numObj = Integer.valueOf(10);

以前はこれらの変換を明示的にコードで行わなければならなかったのですが、Java5の時に導入されたオートボクシング/オートアンボクシングの機能により、次のように自然に変換を行うことができるようになりました。

int num = 10;
Integer numObj = num; // ボクシングでInteger型になる
int num2 = numObj; // アンボクシングでint型になる

これは非常に便利な機能で、現在は自然に利用しているわけですが、たまに落とし穴があります。例えば、次のコードはNPEが発生します。

Integer numObj = null;
int num = numObj;

nullであるIntegerオブジェクトに対するint値はないのです。

doubleのゼロ

さて、double型は浮動小数点数と呼ばれる型です。詳細は省略しますが、性質のひとつとして+0.0と-0.0の内部表現として異なる0があります。+0.0と-0.0が同値であるかについては処理系次第でが、Javaでは同値です。

System.out.println((+0.0d == -0.0d)); // => true

Doubleのequalsメソッド

次にdoubleのプリミティブラッパークラスであるjava.lang.Doubleクラスをみてみます。通常、Javaではふたつのオブジェクトが等しいかどうかを判定するとき、equalsメソッドを使います。なので、ふたつのDoubleオブジェクトを比較するならば次のようになります。

Double d1 = Double.valueOf(+0.0d);
Double d2 = Double.valueOf(-0.0d);
System.out.println(d1.equals(d2));

さて、この結果ですが予想に反してfalseとなります。

DoubleクラスのequalsのAPIドキュメントを確認してみると、次のように記述されています。

ほとんどの場合、Double クラスの d1 および d2 という 2 つのインスタンスについて、
d1.equals(d2) の値が true になるのは、次の式の値が true になる場合だけです。

   d1.doubleValue() == d2.doubleValue()
 
しかし、例外事項も 2 つあります。

・d1 と d2 の両方が Double.NaN を表し、
 Double.NaN==Double.NaN の値が false であるにもかかわらず、
 equals メソッドが true を返す場合
・d1 が +0.0 を表し、d2 が -0.0 を表すか、
 あるいは d1 が -0.0 を表し、d2 が +0.0 を表す場合で、
 +0.0==-0.0 の値が true であるにもかかわらず、equal テストの値が false の場合

この定義によって、ハッシュテーブルは正しく動作します。

理由はハッシュ値にあるわけですが、Doubleオブジェクトでは+0.0と-0.0が同値とならないのが仕様となっています。

JUnitのアサーション

JUnit実践入門でも少しだけ触れましたが、JUnit4のassetThatを使いアサーションを行う場合、オートボクシングにより、プリミティブラッパークラスのequalsメソッドで比較されます(P50)。これはほとんどの場合に期待通りに動作するわけですが、Doubleではequlasメソッドの例外となる部分が問題となります。

つまり、次のコードはアサーションに失敗します。

Double d1 = Double.valueOf(+0.0d);
Double d2 = Double.valueOf(-0.0d);
assertThat(d1, is(d2));

なお、equalToを使った時も内部的には同じロジックで検証しているので変わりません。

そして、問題となるのが次のアサーションです。

assertThat(+0.0, is(-0.0));

assertThatメソッドの引数はObject型です。このため、+0.0はボクシングによりDoubleオブジェクトとなります。またisメソッドの引数もObject型であるため、-0.0もDoubleオブジェクトとなります。したがって、Doubleオブジェクトとして+0.0と-0.0を比較するため、同値とならず、アサーションは失敗するのです。

doubleのアサーション

doubleを比較するには幾つかの方法があります。

プリミティブで比較する

ボクシングさせるとDouble型になってしまうので、プリミティブのまま比較してからアサーションメソッドを呼び出します。

assertThat("+0.0 == -0.0", (+0.0 == -0.0) ,is(true));

この時、第1引数にどのような比較を行ったのかをメッセージとして指定しなければ、「trueを期待したけどfalseだった」というアサーションメッセージになるので注意してください。この方法の良い点は、ボクシング変換とは無縁に検証出来ることです。

Assert#assetEqualsメソッドを使う

JUnit3系のメソッドですが、assetEqualsメソッドを利用して比較することができます。ただし、assetEquals(double, double)は非推奨となっており、assetEquals(double, double, double)を利用してください。3つ目の引数には比較する時に許容する誤差を指定します。+0.0と-0.0の比較であれば誤差は0で良いので、次のように記述できます。

assertEquals(+0.0, -0.0, 0.0);

誤差を考慮できるので、3.0 / 2.0と 1.5 を比較する場合などにも利用できます。

hamcrest-libのcloseToを使う

書き忘れていたので追記しました(At 2013.10.27 8:00)

JUnit4スタイルで貫きたい場合は、誤差を許容する、または、厳密にプリミティブのdouble値を比較するMatcherを使用しなければなりません。

Hamcrestライブラリに含まれるMatchers#closeToメソッドが誤差を許容するMatcherなのでこれを利用すると次のようになります。

assertThat(+0.0, is(closeTo(-0.0, 0.0)));

なお、org.hamcrest.Matchersクラスは、hamcrest-coreには含まれず、hamcrest-libに含まれている *3ので注意してください。

カスタムMatcherを作る

次のようなJUnit3と同等の誤差を許容するカスタムMatcherを作成することもできます。

public class DoubleMatcher extends TypeSafeMatcher<Double> {

    public static Builder consideredEqualTo(double expected) {
        return new Builder(expected);
    }
    
    public static class Builder {
        private double expected;
        Builder(double expected) {
            this.expected = expected;
        }
        public Matcher<Double> withDelta(double delta) {
            return new DoubleMatcher(expected, delta);
        }
    }

    private double expected;
    private double delta;

    public DoubleMatcher(double expected, double delta) {
        this.expected = expected;
        this.delta = delta;
    }

    @Override
    protected boolean matchesSafely(Double actual) {
        if (actual == null) return false;
        if (Double.compare(expected, actual) == 0) return true;
        return (Math.abs(expected - actual) <= delta);

    }

    @Override
    public void describeTo(Description description) {
        description.appendValue(expected);
    }

}
[/java]
<p>このMatcherは次のように利用します。</p>

assertThat(+0.0d, is(consideredEqualTo(-0.0d).withDelta(0.0)));

isやequalToでは標準のメソッドと名前が被ってしまうので、consideredEqualToというsaticメソッドとしました。また、第2引数にdeltaを指定すると折角のassertThat APIが美しくないので、ビルダーオブジェクトを返し、withDeltaメソッドを呼び出すことでMatcherを返しています。

まとめ

Javaのユニットテストでdouble型やfloat型を扱う場合はボクシング変換に注意してください。

とはいえ、Java5以降からJavaをはじめた人にとっては不可解に感じても仕方ないと思います。これを機会にJavaの歴史や仕様を勉強すると良いですね。ただ、Java言語仕様もEffective Javaもピアソンなんです・・・。

また、なにかJUnitで困っていることがあれば調査してまとめますので、お気軽にご相談ください。

脚注

  1. このようなメンドクサイことになっているのは、Javaが開発された時代のCPU性能やメモリに関係があります。
  2. 将来的にはプリミティブ型はなくなる予定です。
  3. hamcrest-1.3の場合