Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

In Groovy, why does the behaviour of '==' change for interfaces extending Comparable?

I'm trying to develop a project in Groovy and I've found some of my tests failing in an odd way: I have an interface Version extends Comparable<Version> with two concrete subclasses. Both override equals(Object) and compareTo(Version) - however, if I try to compare two instances of Version that are of different concrete types using ==, the equality check fails even though explicit equals and compareTo checks pass.

If I remove the extends Comparable<Version> part of Version, I get the expected behaviour - == gives the same result as equals would.

I've read elsewhere that Groovy delegates == to equals() unless the class implements Comparable, in which case it delegates to compareTo. However, I'm finding cases where both declare two instances of Version to be equal and yet the == checks fail.

I've created an SSCCE that demonstrates this behaviour here.

The full code is also provided below:

// Interface extending Comparable
interface Super extends Comparable<Super> {
    int getValue()
}

class SubA implements Super {
    int getValue() { 1 }
    int compareTo(Super that) { this.value <=> that.value }
    boolean equals(Object o) {
        if (o == null) return false
        if (!(o instanceof Super)) return false
        this.value == o.value
    }
}

class SubB implements Super {
    int getValue() { 1 }
    int compareTo(Super that) { this.value <=> that.value }
    boolean equals(Object o) {
        if (o == null) return false
        if (!(o instanceof Super)) return false
        this.value == o.value
    }
}

// Interface not extending Comparable
interface AnotherSuper {
    int getValue()
}

class AnotherSubA implements AnotherSuper {
    int getValue() { 1 }
    boolean equals(Object o) {
        if (o == null) return false
        if (!(o instanceof AnotherSuper)) return false
        this.value == o.value
    }
}

class AnotherSubB implements AnotherSuper {
    int getValue() { 1 }
    boolean equals(Object o) {
        if (o == null) return false
        if (!(o instanceof AnotherSuper)) return false
        this.value == o.value
    }
}


// Check with comparable versions
def a = new SubA()
def b = new SubB()

println "Comparable versions equality check: ${a == b}"
println "Explicit comparable equals check: ${a.equals(b)}"
println "Explicit comparable compareTo check: ${a.compareTo(b)}"

// Check with non-comparable versions
def anotherA = new AnotherSubA()
def anotherB = new AnotherSubB()

println "Non-comparable versions equality check: ${anotherA == anotherB}"
println "Explicit non-comparable equals check: ${anotherA.equals(anotherB)}"

What I'm getting back is:

Comparable versions equality check: false
Explicit comparable equals check: true
Explicit comparable compareTo check: 0
Non-comparable versions equality check: true
Explicit non-comparable equals check: true

EDIT
I think I understand why this happens now, thanks to the JIRA discussion that Poundex linked to below.

From Groovy's DefaultTypeTransformation class, which is used to handle equality/comparison checks, I assume that the compareEqual method is first called when a statement of the form x == y is being evaluated:

public static boolean compareEqual(Object left, Object right) {
    if (left == right) return true;
    if (left == null || right == null) return false;
    if (left instanceof Comparable) {
        return compareToWithEqualityCheck(left, right, true) == 0;
    }
    // handle arrays on both sides as special case for efficiency
    Class leftClass = left.getClass();
    Class rightClass = right.getClass();
    if (leftClass.isArray() && rightClass.isArray()) {
        return compareArrayEqual(left, right);
    }
    if (leftClass.isArray() && leftClass.getComponentType().isPrimitive()) {
        left = primitiveArrayToList(left);
    }
    if (rightClass.isArray() && rightClass.getComponentType().isPrimitive()) {
        right = primitiveArrayToList(right);
    }
    if (left instanceof Object[] && right instanceof List) {
        return DefaultGroovyMethods.equals((Object[]) left, (List) right);
    }
    if (left instanceof List && right instanceof Object[]) {
        return DefaultGroovyMethods.equals((List) left, (Object[]) right);
    }
    if (left instanceof List && right instanceof List) {
        return DefaultGroovyMethods.equals((List) left, (List) right);
    }
    if (left instanceof Map.Entry && right instanceof Map.Entry) {
        Object k1 = ((Map.Entry)left).getKey();
        Object k2 = ((Map.Entry)right).getKey();
        if (k1 == k2 || (k1 != null && k1.equals(k2))) {
            Object v1 = ((Map.Entry)left).getValue();
            Object v2 = ((Map.Entry)right).getValue();
            if (v1 == v2 || (v1 != null && DefaultTypeTransformation.compareEqual(v1, v2)))
                return true;
        }
        return false;
    }
    return ((Boolean) InvokerHelper.invokeMethod(left, "equals", right)).booleanValue();
}

Notice that if the LHS of the expression is an instance of Comparable, as it is in the example I provide, the comparison is delegated to compareToWithEqualityCheck:

private static int compareToWithEqualityCheck(Object left, Object right, boolean equalityCheckOnly) {
    if (left == right) {
        return 0;
    }
    if (left == null) {
        return -1;
    }
    else if (right == null) {
        return 1;
    }
    if (left instanceof Comparable) {
        if (left instanceof Number) {
            if (right instanceof Character || right instanceof Number) {
                return DefaultGroovyMethods.compareTo((Number) left, castToNumber(right));
            }
            if (isValidCharacterString(right)) {
                return DefaultGroovyMethods.compareTo((Number) left, ShortTypeHandling.castToChar(right));
            }
        }
        else if (left instanceof Character) {
            if (isValidCharacterString(right)) {
                return DefaultGroovyMethods.compareTo((Character)left, ShortTypeHandling.castToChar(right));
            }
            if (right instanceof Number) {
                return DefaultGroovyMethods.compareTo((Character)left,(Number)right);
            }
        }
        else if (right instanceof Number) {
            if (isValidCharacterString(left)) {
                return DefaultGroovyMethods.compareTo(ShortTypeHandling.castToChar(left),(Number) right);
            }
        }
        else if (left instanceof String && right instanceof Character) {
            return ((String) left).compareTo(right.toString());
        }
        else if (left instanceof String && right instanceof GString) {
            return ((String) left).compareTo(right.toString());
        }
        if (!equalityCheckOnly || left.getClass().isAssignableFrom(right.getClass())
                || (right.getClass() != Object.class && right.getClass().isAssignableFrom(left.getClass())) //GROOVY-4046
                || (left instanceof GString && right instanceof String)) {
            Comparable comparable = (Comparable) left;
            return comparable.compareTo(right);
        }
    }

    if (equalityCheckOnly) {
        return -1; // anything other than 0
    }
    throw new GroovyRuntimeException(
            MessageFormat.format("Cannot compare {0} with value ''{1}'' and {2} with value ''{3}''",
                    left.getClass().getName(),
                    left,
                    right.getClass().getName(),
                    right));
}

Down near the bottom, the method has a block that delegates the comparison to the compareTo method, but only if certain conditions are satisfied. In the example I provide, none of these conditions are satisfied, including the isAssignableFrom check, since the example classes I provide (and the code in my project that's giving me the problem) are siblings, and therefore not assignable to one another.

I suppose I understand why the checks are failing now, but I'm still puzzled over the following things:

  1. How do I get around this?
  2. What's the rationale behind this? Is this a bug or a design feature? Is there any reason why two subclasses of a common super class should not be comparable with one another?
like image 839
Tagc Avatar asked Feb 05 '15 23:02

Tagc


1 Answers

The answer to why Comparable is used for == if existing is easy. It is because of BigDecimal. If you make a BigDecimal out of "1.0" and "1.00" (use Strings not doubles!) you get two BigDecimal that are not equal according to equals, because they don't have the same scale. Value-wise they are equal though, which is why compareTo will see them as equal.

Then of course there is also GROOVY-4046, which shows a case in which just directly calling compareTo will lead to a ClassCastException. Since this exception is unexpected here we decided to add an check for assignability.

To get around this you can use <=> instead which you already found. Yes, they still go through DefaultTypeTransformation so you can compare for example an int and an long. If you don't want that either, then directly calling compareTo is the way to go. If I misunderstood you and you want to actually have equals, well, then you should call equals of course instead.

like image 78
blackdrag Avatar answered Oct 07 '22 01:10

blackdrag