請求データの「1,200.0」と入金データの「1,200.00」は、数字としては同じ金額です。それでもBigDecimal.equalsで照合するとfalseになります。compareToに変えればその照合は通りますが、HashMapに登録した入金を別の桁数で探す処理は、その一行を直しただけでは通りません。
この記事でわかること
- 値とscaleの両方を比べる
equalsと、数値を比べるcompareToの違い。 - 同じ入力で
HashSet、HashMap、TreeSetの結果が分かれる理由。 - 小数2桁に固定できる条件を決め、端数を丸めずにキーを揃える方法。
同額なのにequalsがfalseになるのは、scaleも比べるから
まず、どの判定を直すかを決めます。今回の架空の請求照合で必要なのは「表現の桁数にかかわらず、数値が同じか」です。元の入力形式まで同じかを調べる別の処理なら、同じ修正をしてはいけません。
BigDecimalは、整数の係数とscaleを保持します。1200.0は係数12000・scale 1、1200.00は係数120000・scale 2です。どちらも数値は1200ですが、equalsは値に加えてscaleも一致する場合にtrueです。scaleは負になることもあり、どの値でも単純に小数点以下の表示桁数とみなす説明では足りません。
| 確認したいこと | 比較の例 | 1200.0と1200.00 |
|---|---|---|
| 数値とscaleが一致 | left.equals(right) | false |
| 金額として同額 | left.compareTo(right) == 0 | true |
| 上限を含めて許可 | amount.compareTo(limit) <= 0 | 上限1200なら許可 |
BigDecimal.ZEROはscale 0です。入力の0.00はscale 2なので、ゼロのチェックをBigDecimal.ZERO.equals(amount)で書くと拒否されます。テストでも定数ZEROだけを渡すと、この差を検出できません。サンプルのB01は、scaleの異なる0.0と0.00を比較します。
こちらが実際に使う判定の全文です。sameAmountは、金額が必須というサンプルの仕様に合わせてnullを拒否します。withinLimitは上限ちょうどを含めます。
package info.syachiku.samples.value;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.Objects;
public final class MoneyRules {
private MoneyRules() {}
public static boolean sameAmount(BigDecimal left, BigDecimal right) {
Objects.requireNonNull(left, "left amount is required");
Objects.requireNonNull(right, "right amount is required");
return left.compareTo(right) == 0;
}
public static BigDecimal fixedTwoDecimals(BigDecimal amount) {
Objects.requireNonNull(amount, "amount is required");
return amount.setScale(2, RoundingMode.UNNECESSARY);
}
public static boolean withinLimit(BigDecimal amount, BigDecimal limit) {
Objects.requireNonNull(amount, "amount is required");
Objects.requireNonNull(limit, "limit is required");
return amount.compareTo(limit) <= 0;
}
}未入力と0を同じ結果にしたい場合は、比較の都合で置換する前に業務仕様を決めてください。任意の入力なら、まず「入力なし」と「入力あり」を分け、値が揃った場合だけ比較する設計もできます。サンプルのNullPointerExceptionはJavaの内部契約であり、画面のエラーメッセージまでは規定していません。
同額判定を直しても、HashSetの件数とHashMapの検索は変わらない
同じ2つの値を使って、「照合のif文が通るか」と「集合が重複と判定するか」を別々に調べます。サンプルではHashMapの値として文字列paidだけを登録し、nullの値は入れていません。したがって、この例でgetがnullなら、別のscaleのキーで検索して一致しなかったことを示します。
Set<BigDecimal> hashSet = new HashSet<>();
hashSet.add(left);
hashSet.add(right);
System.out.println("HashSet.size=" + hashSet.size());
System.out.println("HashSet.contains(1200)=" + hashSet.contains(new BigDecimal("1200")));
Map<BigDecimal, String> map = new HashMap<>();
map.put(left, "paid");
System.out.println("HashMap.get(1200.00)=" + map.get(right));
Set<BigDecimal> treeSet = new TreeSet<>();
treeSet.add(left);
treeSet.add(right);
System.out.println("TreeSet.size=" + treeSet.size());| 操作 | 実行結果 | 判断の根拠 |
|---|---|---|
| HashSetに1200.0と1200.00を追加 | 2件 | equalsでは別要素 |
| そのHashSetにscale 0の1200があるか | false | 既存2件のどちらともequalsで一致しない |
| HashMapに1200.0を入れ、1200.00で検索 | null | 登録したキーとequalsで一致しない |
| 自然順序のTreeSetに2値を追加 | 1件 | 順序比較で0になる |
HashSetとHashMapは、ハッシュ値と等価性を使って登録先・検索候補を決めます。業務コードの別の場所でcompareToを呼んでも、キー自身のequalsやhashCodeは変わりません。
TreeSetは指定した比較器、ここでは指定なしなので自然順序で要素を判断します。並べ替えも必要な一覧なら選択肢ですが、BigDecimalの自然順序はequalsと一致しないので、他のSetと値の扱いを揃えたい場合には、そのまま置き換えると食い違います。特に、保存された表現を後で見せたい処理では、どちらの桁数の値が残るかも考える必要があります。
金額が同じだからといって、請求書2枚を同じ1件にまとめてよいわけではありません。この例のSetは「金額の種類」を集めています。取引の重複を防ぐキーなら、請求IDや業務上の識別子を使う場面です。ここを明確にすると、コレクションの変更と業務データの削除を混同しなくなります。
小数2桁と決まった金額は、登録と検索の両方で正規化する
ここでは「この項目は丸めず、小数2桁で正確に表現できる値だけを受け付ける」と決めます。その契約なら、setScale(2, RoundingMode.UNNECESSARY)を境界で適用し、同じ数値が同じscaleになるようにします。比較のたびに桁を変える必要はありません。
| 入力 | 2桁化の結果 | 理由 |
|---|---|---|
| 1200 / 1200.0 / 1200.00 | 1200.00 | 数値を変えずに揃えられる |
| 1200.000 | 1200.00 | 末尾の0は落としても同額 |
| 1200.001 | ArithmeticException | 2桁にすると金額が変わる |
| null | NullPointerException | 未入力を0に読み替えない |
このルールは「入力文字列に小数点以下3桁が含まれていたら拒否」とは異なります。1200.000を拒否する要件なら、数値変換前に文字列の桁数を別に検査します。逆に、税や重量を計算途中から小数2桁に絞ると必要な精度を失うかもしれません。このサンプルの2桁を、すべての業務項目に適用しないでください。
HashMapで揃えるなら、保存する側だけでなく検索する側にも同じ正規化が必要です。C04は正規化したキーでpaidを取得できるところまで確認します。既存データのキーを移行する場合は、正規化で重なる値と、その場合の扱いを決めてから切り替えます。
@Test void C04_normalizeAtInsertionAndLookup() {
Set<BigDecimal> values = new HashSet<>();
values.add(MoneyRules.fixedTwoDecimals(new BigDecimal("1200.0")));
values.add(MoneyRules.fixedTwoDecimals(new BigDecimal("1200.00")));
assertEquals(1, values.size());
assertTrue(values.contains(MoneyRules.fixedTwoDecimals(new BigDecimal("1200"))));
Map<BigDecimal, String> map = new HashMap<>();
map.put(MoneyRules.fixedTwoDecimals(new BigDecimal("1200.0")), "paid");
assertEquals("paid", map.get(MoneyRules.fixedTwoDecimals(new BigDecimal("1200.00"))));
}小数桁が決まっていないのに、数値だけで同じキーとみなしたい場合もあります。その場合は「末尾の0を除いた値」など、業務に沿った別のキー表現を検討します。stripTrailingZeros()は数値を変えませんが、1200のような値で負のscaleや指数表記が出ることがあります。表示用の値までその形へ揃える必要はありません。桁数が入力形式の意味を持つ項目は、そのままequalsで区別します。
比較の前に四捨五入すると、別の値まで同額になる
0.004と0.003をそのまま比較すると別の値です。両方を小数2桁へHALF_UPで丸めれば、どちらも0.00になります。照合条件を通すために丸めを足すと、この差も同額として扱われます。
請求額を確定する処理に「確定時に四捨五入」という仕様があるなら、丸めを行うのはその確定処理です。照合側ではすでに確定した金額を受け取って比較します。元の精度で照合する仕様なら、比較側で丸めません。どの値を見ているかを、変数名や入出力の契約で明確にすると、後の修正で丸めが二重に入るのを防げます。
生成元も確認してください。new BigDecimal(0.1)は、doubleの近似値をそのまま十進表現へ移します。固定値や入力文字列ならnew BigDecimal("0.1")を使えます。BigDecimal.valueOf(0.1)はdoubleの文字列表現を経由しますが、すでにdoubleで計算して生じた誤差を元の金額へ戻す機能ではありません。D01はコンストラクタの違いを示すテストで、通貨計算全体の精度を保証するものではありません。
24件を実行し、equalsへ戻すと同額の3ケースが落ちることを確認する
ZIPを展開し、pom.xmlのある場所で実行します。最初のコマンドでテストし、次のコマンドで記事と同じ観察結果を出せます。
mvn -B test
java -cp target/classes info.syachiku.samples.value.MoneyComparisonDemoequals=false
sameAmount=true
scales=1,2
HashSet.size=2
HashSet.contains(1200)=false
HashMap.get(1200.00)=null
TreeSet.size=1
normalized.size=1
normalized.contains(1200)=true
fixed(1200.000)=1200.00
fixed(1200.001)=rejected
differentBeforeRounding=true
equalAfterRounding=trueテストは同額とnullの6件、2桁化と拒否の8件、コレクションの4件、丸め・生成元の各1件、上限判定の4件で計24件です。上限1200に対して1199.99と1200.00は許可し、1200.01は拒否します。scaleの異なる上限ちょうどを入れる理由は、境界と同時に数値比較の条件を確認できるためです。
検証用コピーでsameAmountをequalsへ戻すと、B01(0.0と0.00)、B02(1200.0と1200.00)、B04(-1.0と-1.00)の3件が失敗しました。配布版には故障の変更を入れていません。テストの正解をcompareToの実行結果から計算せず、仕様のtrue/falseを先に決めたため、比較条件の誤りを検出できます。
実行クラス MoneyComparisonDemo.javaの全文
package info.syachiku.samples.value;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
public final class MoneyComparisonDemo {
public static void main(String[] args) {
BigDecimal left = new BigDecimal("1200.0");
BigDecimal right = new BigDecimal("1200.00");
System.out.println("equals=" + left.equals(right));
System.out.println("sameAmount=" + MoneyRules.sameAmount(left, right));
System.out.println("scales=" + left.scale() + "," + right.scale());
Set<BigDecimal> hashSet = new HashSet<>();
hashSet.add(left);
hashSet.add(right);
System.out.println("HashSet.size=" + hashSet.size());
System.out.println("HashSet.contains(1200)=" + hashSet.contains(new BigDecimal("1200")));
Map<BigDecimal, String> map = new HashMap<>();
map.put(left, "paid");
System.out.println("HashMap.get(1200.00)=" + map.get(right));
Set<BigDecimal> treeSet = new TreeSet<>();
treeSet.add(left);
treeSet.add(right);
System.out.println("TreeSet.size=" + treeSet.size());
Set<BigDecimal> normalized = new HashSet<>();
normalized.add(MoneyRules.fixedTwoDecimals(left));
normalized.add(MoneyRules.fixedTwoDecimals(right));
System.out.println("normalized.size=" + normalized.size());
System.out.println("normalized.contains(1200)="
+ normalized.contains(MoneyRules.fixedTwoDecimals(new BigDecimal("1200"))));
System.out.println("fixed(1200.000)="
+ MoneyRules.fixedTwoDecimals(new BigDecimal("1200.000")));
try {
MoneyRules.fixedTwoDecimals(new BigDecimal("1200.001"));
} catch (ArithmeticException ex) {
System.out.println("fixed(1200.001)=rejected");
}
BigDecimal a = new BigDecimal("0.004");
BigDecimal b = new BigDecimal("0.003");
System.out.println("differentBeforeRounding=" + !MoneyRules.sameAmount(a, b));
System.out.println("equalAfterRounding=" + a.setScale(2, RoundingMode.HALF_UP)
.equals(b.setScale(2, RoundingMode.HALF_UP)));
}
}24ケースのMoneyRulesTest.javaの全文
package info.syachiku.samples.value;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.*;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.ValueSource;
import static org.junit.jupiter.api.Assertions.*;
class MoneyRulesTest {
@ParameterizedTest(name = "{0}: sameAmount({1},{2})={3}")
@CsvSource({"B01,0.0,0.00,true", "B02,1200.0,1200.00,true",
"B03,1200,1200.01,false", "B04,-1.0,-1.00,true"})
void numericEquality(String id, String left, String right, boolean expected) {
assertEquals(expected, MoneyRules.sameAmount(new BigDecimal(left), new BigDecimal(right)), id);
}
@Test void B05_nullLeftIsNotZero() {
assertThrows(NullPointerException.class, () -> MoneyRules.sameAmount(null, BigDecimal.ZERO));
}
@Test void B06_nullRightIsNotZero() {
assertThrows(NullPointerException.class, () -> MoneyRules.sameAmount(BigDecimal.ZERO, null));
}
@ParameterizedTest(name = "normalize {0} -> {1}")
@CsvSource({"0,0.00", "1200,1200.00", "1200.0,1200.00",
"1200.00,1200.00", "1200.000,1200.00"})
void N01_N05_normalizeWithoutRounding(String input, String expected) {
assertEquals(new BigDecimal(expected), MoneyRules.fixedTwoDecimals(new BigDecimal(input)));
}
@ParameterizedTest
@ValueSource(strings = {"1200.001", "-1.234"})
void N06_N07_nonZeroExtraDigitsAreRejected(String input) {
assertThrows(ArithmeticException.class, () -> MoneyRules.fixedTwoDecimals(new BigDecimal(input)));
}
@Test void N08_missingAmountIsRejected() {
assertThrows(NullPointerException.class, () -> MoneyRules.fixedTwoDecimals(null));
}
@Test void C01_hashSetDoesNotUseNumericComparison() {
Set<BigDecimal> values = new HashSet<>();
values.add(new BigDecimal("1200.0"));
values.add(new BigDecimal("1200.00"));
assertEquals(2, values.size());
assertFalse(values.contains(new BigDecimal("1200")));
}
@Test void C02_hashMapLookupUsesEquals() {
Map<BigDecimal, String> values = new HashMap<>();
values.put(new BigDecimal("1200.0"), "paid");
assertNull(values.get(new BigDecimal("1200.00")));
}
@Test void C03_naturalTreeSetUsesOrdering() {
Set<BigDecimal> values = new TreeSet<>();
values.add(new BigDecimal("1200.0"));
values.add(new BigDecimal("1200.00"));
assertEquals(1, values.size());
}
@Test void C04_normalizeAtInsertionAndLookup() {
Set<BigDecimal> values = new HashSet<>();
values.add(MoneyRules.fixedTwoDecimals(new BigDecimal("1200.0")));
values.add(MoneyRules.fixedTwoDecimals(new BigDecimal("1200.00")));
assertEquals(1, values.size());
assertTrue(values.contains(MoneyRules.fixedTwoDecimals(new BigDecimal("1200"))));
Map<BigDecimal, String> map = new HashMap<>();
map.put(MoneyRules.fixedTwoDecimals(new BigDecimal("1200.0")), "paid");
assertEquals("paid", map.get(MoneyRules.fixedTwoDecimals(new BigDecimal("1200.00"))));
}
@Test void R01_roundingBeforeComparisonCanHideDifference() {
BigDecimal left = new BigDecimal("0.004");
BigDecimal right = new BigDecimal("0.003");
assertFalse(MoneyRules.sameAmount(left, right));
assertEquals(left.setScale(2, RoundingMode.HALF_UP), right.setScale(2, RoundingMode.HALF_UP));
}
@Test void D01_doubleConstructorCarriesBinaryApproximation() {
assertNotEquals(new BigDecimal("0.1"), new BigDecimal(0.1));
assertEquals(new BigDecimal("0.1"), BigDecimal.valueOf(0.1));
}
@ParameterizedTest(name = "{0}: amount={1}, withinLimit={2}")
@CsvSource({"L01,1199.99,true", "L02,1200.00,true", "L03,1200.01,false"})
void upperLimitIncludesEquality(String id, String input, boolean expected) {
assertEquals(expected, MoneyRules.withinLimit(new BigDecimal(input), new BigDecimal("1200")), id);
}
@Test void L04_missingLimitIsRejected() {
assertThrows(NullPointerException.class,
() -> MoneyRules.withinLimit(BigDecimal.ZERO, null));
}
}実行設定 pom.xmlの全文
<?xml version="1.0" ?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>info.syachiku.samples</groupId>
<artifactId>bigdecimal-equality</artifactId>
<version>1.0.0</version>
<properties>
<maven.compiler.release>17</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.12.2</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-resources-plugin</artifactId>
<version>3.2.0</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.8.1</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.5.6</version>
</plugin>
</plugins>
</build>
</project>Java 17・Maven 3.9.5の依存キャッシュがある環境で確認しました。DBのDECIMAL列や別OSでの動作、初回依存取得、実際の請求・入金処理は検証範囲に含めていません。
レビューでは、比較の目的とキーを揃える場所を説明する
「同額判定は1200.0と1200.00を許可するのでcompareToです。金額の種類を数えるSetは、入力境界で2桁へ正規化しています。1200.001を丸めて通すと契約が変わるため、例外にしました。」
このように、要件、採用した比較、選ばなかった方法の理由をセットにします。原因を調べるときも、顧客の請求データを無条件にログへ出す必要はありません。まず架空の同額データを2種類のscaleで作り、デバッガーでtoPlainString()とscale()を確認します。実データが必要なら情報管理ルールに従います。
- 数値だけを比べるのか、入力時の表現を区別するのか決めたか。
- 比較式と、HashSet/HashMapのキー検索を別々に試したか。
- 正規化するなら登録と検索で同じ規則を使ったか。
- 空欄、0、上限ちょうど、桁の違いをテストしたか。
- 丸めを追加した場合、その業務上の根拠と場所を説明できるか。
値の比較を修正したあと、List・Set・Mapの使い分けも学び直したい場合に。コレクションを扱うJavaの実践編です。
スッキリわかるJava入門 実践編 第5版
基礎文法の次に必要な、現場寄りのJava知識を補う。
コレクション、ジェネリクス、ラムダ式、ストリームなど、業務コードで出会いやすい機能を入門編の次に学べます。
- Java基礎の次に何を学ぶか迷っている
- コレクションやStreamを整理したい
当サイトはAmazonアソシエイト・プログラムの参加者です。価格・在庫・配送条件はAmazonでご確認ください。
この記事とあわせて読みたい
同額の照合と集合のキーは、それぞれの契約を決めて直す
数値が同じことを確認するならcompareTo(...) == 0、scaleも一致させるならequalsを使います。SetやMapでも同額を同じキーにするなら、入力・登録・検索の規則を揃えます。金額の比較式だけ直して終わりにせず、何が同じとみなされるかを、入力と期待値で残してください。



