Groovyの'=='演算子は'equals()'と厳密に同じではない

最近、Groovyでテストコードを書いていて、インスタンスの比較でハマりました。
それは「インスタンスを'=='演算子で比較すると、同一ではないのに同一と判定されてしまう」というものでした。
とはいえ、equalsメソッドは正しく実装されているはずなのに…。

Groovyでテストコードを書いている方は少なくないと思いますが、世の中でもあまり知られていないようだったので、理解の整理を兼ねてまとめてみました。

Groovyにおいて==equals()の結果が一致しない一例

「Groovyでは==equals()のどちらで比較しても結果は同じである。そのためequals()を適切に実装していれば、==の比較は同様に正しく機能する。」と理解していました。
しかし、これは誤りであることが分かりました。

これを再現するためのコードをGroovy + spockで書いたものが以下です。

def "use '==' to compare"() {
    expect:
    result == (entity1 == entity2)

    where:
    entity1                              | entity2                              | result
    new ComparableEntity("Test1", date1) | new ComparableEntity("Test1", date1) | true
    new ComparableEntity("Test1", date1) | new ComparableEntity("Test1", date2) | false
    new ComparableEntity("Test1", date1) | new ComparableEntity("Test2", date1) | true
    new ComparableEntity("Test1", date1) | new ComparableEntity("Test2", date2) | false
}

def "use 'equals()' to compare"() {
    expect:
    result == entity1.equals(entity2)

    where:
    entity1                              | entity2                              | result
    new ComparableEntity("Test1", date1) | new ComparableEntity("Test1", date1) | true
    new ComparableEntity("Test1", date1) | new ComparableEntity("Test1", date2) | false
    new ComparableEntity("Test1", date1) | new ComparableEntity("Test2", date1) | false
    new ComparableEntity("Test1", date1) | new ComparableEntity("Test2", date2) | false
}

spockフレームワークではwhereで定義されているテストパターンについて、expectを実行して評価されます。
このテストコードでは==演算子を使用した比較と、equals()メソッドを使用した比較について、whereの中に定義された4パターンをそれぞれテストしています。
そして、このテストコードは全てPassするように書かれています。

このテストコードにおいて、注目すべき箇所は以下の点です。

  • new ComparableEntity("Test1", date1)new ComparableEntity("Test2", date1)を比較した場合の結果が異なる
  • equals()で比較した結果はfalse==で比較した結果はtrueになっている

==equals()が同等の機能であれば比較結果はすべて同じになるはずなのですが、そのような結果にはなりませんでした。
従ってGroovyでは==equals()の結果は必ずしも一致しないということが分かりました。

これはテストコードを書く上で検証しているつもりだったが、検証になっていないことが起こり得るという点で問題になります。

Groovyの言語仕様における==演算子とは

その原因を調べるため、まずはGroovyの言語仕様を見てみます。

Groovyの言語仕様には、以下のように書かれています。(Groovy Language Documentationより引用)

In Java == means equality of primitive types or identity for objects. In Groovy == means equality in all cases. It translates to a.compareTo(b) == 0, when evaluating equality for Comparable objects, and a.equals(b) otherwise. To check for identity (reference equality), use the is method: a.is(b). From Groovy 3, you can also use the === operator (or negated version): a === b (or c !== d).

この中で、重要なのは以下の点です。

  • Javaの==は、プリミティブ型の等価性、もしくはオブジェクトの同一性を意味する。 Groovyの==は、いかなる場合も等価性を意味する。
  • Groovyにおける==の結果
    • Comparableなオブジェクト:a.compareTo(b) == 0によって判定される
    • Comparableなオブジェクト以外:a.equals(b)によって判定される

つまりComparableなオブジェクトにおいてはequals()メソッドは使用されず、compareTo()のみ使用されるということのようです。
となると、compareTo()の実装が不適切なのかもしれない…という仮説が浮かび上がってきます。

JavaにおけるComparableなクラスが満たすべき要件とは

というわけで、次はそもそもComparableなクラスについてです。

Comparableとは、クラスの順序性を比較できるようにするためのインタフェースです。
よくあるのは「複数インスタンスをリストに持たせ、その中でソートする」というような場合でしょうか。

Java8のリファレンスを見てみると、Comparableクラスには以下の記載があります。(Comparable (Java Platform SE 8)より引用)

クラスCの自然順序付けがequalsと一貫性があると言われるのは、クラスCのすべてのe1とe2について、e1.compareTo(e2)== 0とe1.equals(e2)のboolean値が同じになる場合だけです。nullはいずれのクラスのインスタンスでもないため、e.equals(null)がfalseを返す場合でもe.compareTo(null)はNullPointerExceptionをスローすべきです。

自然順序付けでは、equalsと一貫性があることは、必須ではありませんが強く推奨されます。これは、明示的なコンパレータを指定しないソート・セットやソート・マップを、自然順序付けがequalsと一貫性のない要素またはキーと一緒に使用すると、セットとマップの動作が保証されなくなるからです。特に、このようなソート・セットまたはソート・マップは、セットまたはマップの一般的な規約に違反します。この規約は、equalsメソッドの用語を用いて定義されています。

たとえば、(!a.equals(b) && a.compareTo(b) == 0)となるような2つのキーaとbを、明示的なコンパレータを使用しないソート・セットに追加する場合、2つ目の追加操作ではfalseが返され、ソート・セットのサイズは増えません。これは、ソート・セットから見るとaとbは等価であるためです。

Comparableを実装するほとんどのJavaコア・クラスは、equalsと一貫性のある自然順序付けを持ちます。1つの例外はjava.math.BigDecimalです。このクラスの自然順序付けでは、異なる精度の同じ値(4.0と4.00など)を持つBigDecimalオブジェクトは等価と見なされます。

またcompareTo()メソッドの箇所には以下の記載もあります。

必須というわけではありませんが、(x.compareTo(y)==0)==(x.equals(y))であることが強く推奨されます。一般に、Comparableインタフェースを実装しているクラスで、この条件に違反するクラスはすべて、明確にこの事実を示す必要があります。「注: このクラスはequalsと一貫性のない自然順序付けを持ちます」などと明示することをお薦めします。

以上から、重要となる点を以下にまとめます。

  • Comparableによる自然順序付けは、equalsとの一貫性をもたせることが強く推奨されている
  • equalsとの一貫性とは、具体的には(x.compareTo(y)==0)==(x.equals(y))が成立すること
  • equalsとの一貫性を持たない場合は、SortedSetやSortedMapの動作が保証されない

また、BigDecimalはequalsと一貫性を持たないクラスのようなので、BigDecimalをGroovyで比較する場合についても注意が必要となりそうです。

わかったこと

調査してわかったことをまとめると、以下の通りです。

  • Groovyの==演算子は、厳密にequalsと同じではない
    • Comparableなクラスに対してはcompareTo()が使用され、equals()は使用されない
    • Comparableでないクラスに対してはequals()が使用される
  • Comparableの実装(自然順序付け)において、equalsとの一貫性をもたせることが強く推奨されている
    • あくまで強く推奨。BigDecimalのように、標準ライブラリでも満たしていないクラスは存在する
    • しかしGroovyの==演算子は、この一貫性を前提としている。一貫性がなければ、equalsメソッドとは結果が一致しない

以上から、基本的には以下の対応をすれば回避できそうです。

  • Comparableは基本的に自分で実装しない。ソートしたい場合はComparatorを渡すようにする
  • Groovyで==演算子を使用してComparableなクラスを比較する際は、equalsとの一貫性のある実装かどうかを確認する

Comparableとしては「equalsとの一貫性を強く推奨」という位置づけなのに、Groovyとしては「equalsとの一貫性を前提とした言語仕様」となっているので、ここはユーザが注意深く使うしかなさそうです。
また、IntelliJ IDEAはGroovyでequals()を使うと「==で置き換えられるよ!」と波線を引いてくれますが、思考停止して従ってはいけない…ということになりそうです。

学ぶべき教訓

今回の一件から学ぶべき教訓を、自分なりにまとめてみました。

  • 安易にインタフェースを実装しないこと
  • きちんと指摘できるように知識を身に付けること
  • 原理原則・物事の本質から外れるようなことは、やらない
  • 言語、ライブラリ等は正しく理解した上で使用すること

教訓1:安易にインタフェースを実装しないこと

インタフェースを実装する際には、そのクラスとして保証すべきことが増えるため、注意が必要です。
Effective Javaには「Serializableを実装するよりも代替策を検討すること、実装する場合は注意深く実装すること」と書かれていますが、これはSerializableに限った話ではなく、インタフェースを実装する場合には満たすべき要件やデメリットについても、きちんと検討して判断すべきということでしょう。

安易にインタフェースを実装するとバグの原因になったり、保守性の悪化に繋がる可能性があります。
ソートするためにComparableインタフェースを実装するようなケースを見たことがありますが、これはまさにそのような事例に該当すると思います。

教訓2:きちんと指摘できるように知識を身に付けること

「自分で実装する場合は、このような実装はしない」ということはよくあります。
今回のケースについてもまさにそのパターンだと思いますが、それが開発の現場で組織的に実践できるかどうかは別の問題です。

今回においては不適切な実装とは以下の様なパターンが挙げられます。

  • equalsと一貫性を持っていないComparableが実装されている
  • equalsと一貫性を持っていないComparableが実装されたクラスのインスタンスについて、==で比較したり、SortedSetやSortedMapで使用している

これらの不適切な実装が、具体的に以下のような場面で対応できるでしょうか?

  • プルリクが出てきたときに、不適切な実装を明確に言語化・指摘できること
  • 既存のコードにおいて不適切な実装を見つけた場合に、問題点を指摘し、修正案が提示できること
  • リリースまで時間がない状況下において、問題点を明確に指摘して修正できること

こういった状況下においては、「自分ならこういう実装はしないけど…まぁいいか」と、見逃してしまいがちです。1
しかし問題点を正しく理解し、見逃すことでどのような問題が起きるかを把握していれば、修正すべきかどうかを正しく判断できるようになります。

正しく指摘ができればメンバーも理解し、「ああ、それなら直さなきゃいけないね」と納得してもらうことができるはずです。

教訓3:原理原則・物事の本質から外れるようなことは、やらない

そもそも「本質的に比較可能なものを比較しようとしているのか」という点です。

具体例を挙げると「ソートをしたいのでComparableインタフェースを実装する」ようなケースを見かけることがありますが、そもそもそのクラスは本質的に比較可能であるのかということです。
もし「たまたま名前や日付でソートしたいだけ」なのであれば、それはそのクラスの本質的な特徴とは言えないのでComparableを実装すべきではなく、ソート処理にComparatorを渡すほうが適切であると考えます。

DDDの言い回しで表現すると、ComparableなValueObjectというのは多数存在すると思いますが、ComparableなEntityは思い浮かびません。 Entityをソートしたいと感じるケースは多々あると思いますが、何によってソートするかはその場面によりけりなので、Comparableインタフェースを実装すべきではないことが大半だと考えています。 強いて挙げるとすればEntityをIDによってComparableにしておく程度でしょうが、そもそもEntityとは本質的に自然順序付けができるのかどうかは疑問です。

これは自身の座右の銘なのですが、原理原則や本質から外れたことを行うと、必ずどこかで歪みが生じることになります。 「こうすれば手軽でいいんじゃない?」と感じることは多々ありますが、それがあるべき姿なのかどうかは常に意識すべきです。

教訓4:言語、ライブラリ等は正しく理解した上で使用すること

プログラミング言語、ライブラリ等、何にせよ、正しく理解した上で使うことの重要性を再認識しました。
言語やツールが進化するほど「IntelliJ IDEAが指摘してるから直そう」とか「Groovyの言語仕様だから変な仕様ではないはず」みたいな思考になりがちですが、あくまでツールですので、自分の頭で正しく理解して使いこなす必要があります。

今回の件で読んでおくべきものは、以下の2つ。

  • Comparableインタフェースのリファレンス
  • Groovyの言語仕様

Comparableを実装する際に前者を読むのは現実的ですし、読むべきだと思います。
が、Groovyについては一体何割のエンジニアが言語仕様を通読しているだろうか…。
気になったときにリファレンスを読む人はそれなりにいると思いますが、使っているツールのドキュメントをすべて通読しているエンジニアはいないんじゃないかと…。

ただいずれにしても、誰かが作った既存のものを使わせてもらう以上は、使う人の責任です。 Javaにせよ、Groovyにせよ、ライブラリにせよ、これらを使うのは便利ですし効率的ですが、正しく理解して正しく使うことが求められます。

所感

というわけで、Groovyでの==equals()は厳密に同じではないよ、というお話でした。
特にGroovyについて理解が不十分であったと認識させられましたが、これを機に理解を深められたのが良かったです。

基本的には「Comparableを実装する際にはequalsとの一貫性を持たせましょう」という話ではありますが、誰かが誤ったComparableの実装をすればこのような問題は発生し得るので、これをチームや組織として仕組みで防ぐのは非常に難しそうです。 また、テストコードやTDDでも防止できない2ので、とても厄介だと感じました。

その他にも、BigDecimalのようにJavaの標準ライブラリで自然順序付けの一貫性を持っていないクラスも存在するので、正しく使いこなすためには等価性や同一性などの概念からGroovyの言語仕様まで、正しく理解しておかなければいけないことを実感しました。

教訓に書いたことに関しては今まで理解していたつもりですが、まだ実践が不十分であるという点が理解できました。
今後も、これらの教訓を常に念頭におくことを心がけようと思います。

References


  1. そして不具合が顕在化し「あぁ…やっぱり修正してもらえばよかった…」という所までがお約束 ↩︎

  2. 「自然順序付けの場合にはequalsとの一貫性をもたせるべき」「Groovyの==演算子ではcompareToも使用される」という前提知識や観点がなければ、適切なテストコードやテストパターンを書くこともできないため ↩︎

関連記事


  1. ModelMapperで1対1に対応しないフィールドのマッピング
  2. Javaでオブジェクト配列からフィールド配列を生成
  3. Javaサーブレットにおける部分URLやファイルパスの取得
  4. DDLを自動生成してJavaと各DBのデータ型を比較してみた
  5. 既存コードへのCheckstyle導入におけるルールの選定
  6. Checkstyleで汎用的に使えそうなルールをピックアップしてみた
  7. JaCoCoでJavaのテストカバレッジのレポートを出力する

comments powered by Disqus