forked from jqno/equalsverifier
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
jqno#540 BigDecimal equality using compareTo
- Loading branch information
Aaron Clark
committed
Nov 26, 2021
1 parent
8751847
commit 223518a
Showing
6 changed files
with
292 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,30 @@ | ||
--- | ||
title: "`BigDecimal` equality using `compareTo(BigDecimal val)`" | ||
permalink: /manual/bigdecimal-equality/ | ||
--- | ||
The `Comparable` interface strongly recommends but does not require that implementations consider two objects equal using `compareTo` whenever they are equal using `equals` and vice versa. `BigDecimal` is a class where this is not applied. | ||
|
||
{% highlight java %} | ||
BigDecimal zero = new BigDecimal("0"); | ||
BigDecimal alsoZero = new BigDecimal("0.0"); | ||
|
||
// prints true - zero is the same as zero | ||
System.out.println(zero.compareTo(alsoZero) == 0); | ||
// prints false - zero is not the same as zero | ||
System.out.println(zero.equals(alsoZero)); | ||
{% endhighlight %} | ||
|
||
This is because `BigDecimal` can have multiple representations of the same value. It uses an unscaled value and a scale so, for example, the value of 1 can be represented as unscaled value 1 with scale of 0 (the number of places after the decimal point) or as unscaled value 10 with scale of 1 resolving to 1.0. Its `equals` and `hashCode` methods use both of these attributes in their calculation rather than the resolved value. | ||
|
||
If your class contains any `BigDecimal` fields, in order for comparably equal values to be considered the same, then the `equals` method must use `compareTo` for the check and the `hashCode` calculation must derive the same value for all `BigDecimal` instances that are equal using `compareTo` (taking care if it is a nullable field). | ||
|
||
EqualsVerifier will check this by default. If you do not have this requirement the check can be disabled by suppressing `Warning.BIGDECIMAL_EQUALITY`. | ||
|
||
{% highlight java %} | ||
EqualsVerifier.forClass(FooWithoutComparablyEqualBigDecimalFields.class) | ||
.suppress(Warning.BIGDECIMAL_EQUALITY) | ||
.verify(); | ||
{% endhighlight %} | ||
|
||
There is more information on `compareTo` and `equals` in the `Comparable` Javadoc and Effective Java's chapter on implementing `Comparable`. | ||
There is more information on `BigDecimal` in its Javadoc (and its representation can be seen by printing `unscaledValue()` and `scale()`). |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
87 changes: 87 additions & 0 deletions
87
src/main/java/nl/jqno/equalsverifier/internal/checkers/fieldchecks/BigDecimalFieldCheck.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,87 @@ | ||
package nl.jqno.equalsverifier.internal.checkers.fieldchecks; | ||
|
||
import static nl.jqno.equalsverifier.internal.util.Assert.assertEquals; | ||
|
||
import java.lang.reflect.Field; | ||
import java.math.BigDecimal; | ||
import java.math.RoundingMode; | ||
import nl.jqno.equalsverifier.Warning; | ||
import nl.jqno.equalsverifier.internal.reflection.FieldAccessor; | ||
import nl.jqno.equalsverifier.internal.reflection.ObjectAccessor; | ||
import nl.jqno.equalsverifier.internal.util.CachedHashCodeInitializer; | ||
import nl.jqno.equalsverifier.internal.util.Formatter; | ||
|
||
public class BigDecimalFieldCheck<T> implements FieldCheck<T> { | ||
|
||
private final CachedHashCodeInitializer<T> cachedHashCodeInitializer; | ||
|
||
public BigDecimalFieldCheck(CachedHashCodeInitializer<T> cachedHashCodeInitializer) { | ||
this.cachedHashCodeInitializer = cachedHashCodeInitializer; | ||
} | ||
|
||
@Override | ||
public void execute( | ||
ObjectAccessor<T> referenceAccessor, | ||
ObjectAccessor<T> copyAccessor, | ||
FieldAccessor fieldAccessor | ||
) { | ||
if (BigDecimal.class.equals(fieldAccessor.getFieldType())) { | ||
Field field = fieldAccessor.getField(); | ||
BigDecimal referenceField = (BigDecimal) referenceAccessor.getField(field); | ||
BigDecimal changedField = referenceField.setScale( | ||
referenceField.scale() + 1, | ||
RoundingMode.UNNECESSARY | ||
); | ||
ObjectAccessor<T> changed = copyAccessor.withFieldSetTo(field, changedField); | ||
|
||
T left = referenceAccessor.get(); | ||
T right = changed.get(); | ||
|
||
checkEquals(field, referenceField, changedField, left, right); | ||
checkHashCode(field, referenceField, changedField, left, right); | ||
} | ||
} | ||
|
||
private void checkEquals( | ||
Field field, | ||
BigDecimal referenceField, | ||
BigDecimal changedField, | ||
T left, | ||
T right | ||
) { | ||
Formatter f = Formatter.of( | ||
"BigDecimal equality by comparison: object does not equal a copy of itself where BigDecimal field %%" + | ||
" has a value that is equal using compareTo: %% compared to %%" + | ||
"\nIf these values should be considered equal then use compareTo rather than equals for this field." + | ||
"\nIf these values should not be considered equal, suppress Warning.%% to disable this check.", | ||
field.getName(), | ||
referenceField, | ||
changedField, | ||
Warning.BIGDECIMAL_EQUALITY | ||
); | ||
assertEquals(f, left, right); | ||
} | ||
|
||
private void checkHashCode( | ||
Field field, | ||
BigDecimal referenceField, | ||
BigDecimal changedField, | ||
T left, | ||
T right | ||
) { | ||
Formatter f = Formatter.of( | ||
"BigDecimal equality by comparison: hashCode of object does not equal hashCode of a copy of itself" + | ||
" where BigDecimal field %%" + | ||
" has a value that is equal using compareTo: %% compared to %%" + | ||
"\nIf these values should be considered equal then make sure to derive the same constituent hashCode from this field." + | ||
"\nIf these values should not be considered equal, suppress Warning.%% to disable this check.", | ||
field.getName(), | ||
referenceField, | ||
changedField, | ||
Warning.BIGDECIMAL_EQUALITY | ||
); | ||
int leftHashCode = cachedHashCodeInitializer.getInitializedHashCode(left); | ||
int rightHashCode = cachedHashCodeInitializer.getInitializedHashCode(right); | ||
assertEquals(f, leftHashCode, rightHashCode); | ||
} | ||
} |
151 changes: 151 additions & 0 deletions
151
src/test/java/nl/jqno/equalsverifier/integration/extended_contract/BigDecimalTest.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,151 @@ | ||
package nl.jqno.equalsverifier.integration.extended_contract; | ||
|
||
import static nl.jqno.equalsverifier.testhelpers.Util.defaultHashCode; | ||
|
||
import java.math.BigDecimal; | ||
import java.util.Objects; | ||
import nl.jqno.equalsverifier.EqualsVerifier; | ||
import nl.jqno.equalsverifier.Warning; | ||
import nl.jqno.equalsverifier.testhelpers.ExpectedException; | ||
import org.junit.jupiter.api.Test; | ||
|
||
public class BigDecimalTest { | ||
|
||
@Test | ||
public void fail_whenBigDecimalsComparedUsingEquals() { | ||
ExpectedException | ||
.when(() -> EqualsVerifier.forClass(BigDecimalEquals.class).verify()) | ||
.assertFailure() | ||
.assertMessageContains( | ||
"BigDecimal", | ||
"equals", | ||
"compareTo", | ||
"bd", | ||
Warning.BIGDECIMAL_EQUALITY.toString() | ||
); | ||
} | ||
|
||
@Test | ||
public void succeed_whenBigDecimalsComparedUsingEquals_givenBigDecimalEqualsWarningIsSuppressed() { | ||
EqualsVerifier | ||
.forClass(BigDecimalEquals.class) | ||
.suppress(Warning.BIGDECIMAL_EQUALITY) | ||
.verify(); | ||
} | ||
|
||
@Test | ||
public void succeed_whenBigDecimalsComparedUsingCompareTo() { | ||
EqualsVerifier.forClass(BigDecimalCompareTo.class).verify(); | ||
} | ||
|
||
@Test | ||
public void fail_whenBigDecimalsComparedUsingCompareToWithInconsistentHashCode() { | ||
ExpectedException | ||
.when(() -> EqualsVerifier.forClass(BigDecimalInconsistentHashCode.class).verify()) | ||
.assertFailure() | ||
.assertMessageContains( | ||
"BigDecimal", | ||
"hashCode", | ||
"compareTo", | ||
"bd", | ||
Warning.BIGDECIMAL_EQUALITY.toString() | ||
); | ||
} | ||
|
||
/** | ||
* Uses standard equals and hashCode for objects. | ||
* 0 and 0.0 are not equal. | ||
*/ | ||
private static final class BigDecimalEquals { | ||
|
||
private final BigDecimal bd; | ||
|
||
public BigDecimalEquals(BigDecimal bd) { | ||
this.bd = bd; | ||
} | ||
|
||
@Override | ||
public boolean equals(Object obj) { | ||
if (!(obj instanceof BigDecimalEquals)) { | ||
return false; | ||
} | ||
BigDecimalEquals other = (BigDecimalEquals) obj; | ||
return Objects.equals(bd, other.bd); | ||
} | ||
|
||
@Override | ||
public int hashCode() { | ||
return defaultHashCode(this); | ||
} | ||
} | ||
|
||
/** | ||
* Uses compareTo for BigDecimal equality and ensures hashCode is equal for equal BigDecimal instances. | ||
* 0 and 0.0 are equal and produce the same hashCode. | ||
*/ | ||
private static final class BigDecimalCompareTo { | ||
|
||
private final BigDecimal bd; | ||
|
||
public BigDecimalCompareTo(BigDecimal bd) { | ||
this.bd = bd; | ||
} | ||
|
||
@Override | ||
public boolean equals(Object obj) { | ||
if (!(obj instanceof BigDecimalCompareTo)) { | ||
return false; | ||
} | ||
BigDecimalCompareTo other = (BigDecimalCompareTo) obj; | ||
return comparablyEquals(bd, other.bd); | ||
} | ||
|
||
@Override | ||
public int hashCode() { | ||
return Objects.hashCode(comparablyHashed(bd)); | ||
} | ||
} | ||
|
||
/** | ||
* Uses compareTo for BigDecimal equality but has hashCode that may be inconsistent. | ||
* 0 and 0.0 are equal but may produce a different hashCode. | ||
*/ | ||
private static final class BigDecimalInconsistentHashCode { | ||
|
||
private final BigDecimal bd; | ||
|
||
public BigDecimalInconsistentHashCode(BigDecimal bd) { | ||
this.bd = bd; | ||
} | ||
|
||
@Override | ||
public boolean equals(Object obj) { | ||
if (!(obj instanceof BigDecimalInconsistentHashCode)) { | ||
return false; | ||
} | ||
BigDecimalInconsistentHashCode other = (BigDecimalInconsistentHashCode) obj; | ||
return comparablyEquals(bd, other.bd); | ||
} | ||
|
||
@Override | ||
public int hashCode() { | ||
return defaultHashCode(this); | ||
} | ||
} | ||
|
||
/** | ||
* Checks equality using compareTo rather than equals. | ||
*/ | ||
private static boolean comparablyEquals(BigDecimal left, BigDecimal right) { | ||
boolean bothNull = left == null && right == null; | ||
boolean bothNonNullAndEqual = left != null && right != null && left.compareTo(right) == 0; | ||
return bothNull || bothNonNullAndEqual; | ||
} | ||
|
||
/** | ||
* Returns a instance (or null) that produces the same hashCode as any other instance that is equal using compareTo. | ||
*/ | ||
private static BigDecimal comparablyHashed(BigDecimal bd) { | ||
return bd != null ? bd.stripTrailingZeros() : null; | ||
} | ||
} |