Java 8 - equals和hashcode的默认方法

3
我已经在接口中创建了默认方法,用于以可预测的方式实现equals(Object)hashCode()。我使用反射来迭代类型(类)中的所有字段,以提取值并进行比较。该代码依赖于Apache Commons Lang及其HashCodeBuilderEqualsBuilder
问题是我的测试结果显示,第一次调用这些方法时,第一次调用需要更长时间。计时器使用System.nanoTime()。以下是日志示例:
Time spent hashCode: 192444
Time spent hashCode: 45453
Time spent hashCode: 48386
Time spent hashCode: 50951

实际代码为:
public interface HashAndEquals {

    default <T> int getHashCode(final T type) {
        final List<Field> fields = Arrays.asList(type.getClass().getDeclaredFields());
        final HashCodeBuilder builder = new HashCodeBuilder(31, 7);
        fields.forEach( f -> {
            try {
                f.setAccessible(true);
                builder.append(f.get(type));
            } catch (IllegalAccessException e) {
                throw new GenericException(e.toString(), 500);
            }
        });
        return builder.toHashCode();
    }

    default <T, K> boolean isEqual(final T current, final K other) {
        if(current == null || other == null) {
            return false;
        }
        final List<Field> currentFields = Arrays.asList(current.getClass().getDeclaredFields());
        final List<Field> otherFields = Arrays.asList(other.getClass().getDeclaredFields());
        final IsEqual isEqual = new IsEqual();
        isEqual.setValue(true);
        currentFields.forEach(c -> otherFields.forEach(o -> {
            c.setAccessible(true);
            o.setAccessible(true);
            try {
                if (o.getName().equals(c.getName())) {
                    if (!o.get(other).equals(c.get(current))) {
                        isEqual.setValue(false);
                    }
                }
            } catch (IllegalAccessException e) {
                isEqual.setValue(false);
            }
        }));
        return isEqual.getValue();
    }
}

这些方法如何用于实现 hashCodeequals
@Override
public int hashCode() {
    return getHashCode(this);
}

@Override
public boolean equals(Object obj) {
    return obj instanceof Step && isEqual(this, obj);
}

一个测试的例子:

    @Test
public void testEqualsAndHashCode() throws Exception {
    Step step1 = new Step(1, Type.DISPLAY, "header 1", "description");
    Step step2 = new Step(1, Type.DISPLAY, "header 1", "description");
    Step step3 = new Step(2, Type.DISPLAY, "header 2", "description");
    int times = 1000;
    long total = 0;

    for(int i = 0; i < times; i++) {
        long start = System.nanoTime();
        boolean equalsTrue = step1.equals(step2);
        long time = System.nanoTime() - start;
        total += time;
        System.out.println("Time spent: " + time);
        assertTrue( equalsTrue );
    }
    System.out.println("Average time: " + total / times);

    for(int i = 0; i < times; i++) {
        assertEquals( step1.hashCode(), step2.hashCode() );
        long start = System.nanoTime();
        System.out.println(step1.hashCode() + " = " + step2.hashCode());
        System.out.println("Time spent hashCode: " + (System.nanoTime() - start));
    }
    assertFalse( step1.equals(step3) );
} 

将这些方法放在接口中的原因是尽可能灵活。我的一些类可能需要继承。
我的测试表明,我可以相信hashcode和equals始终为具有相同内部状态的对象返回相同的值。
我想知道的是是否我遗漏了什么。这些方法的行为是否可靠?(我知道项目LombokAutoValue提供了一些帮助来实现这些方法,但我的客户不太喜欢这些库)。
任何关于为什么第一次执行方法调用总是需要大约5倍长的时间的见解也将非常有帮助。
1个回答

9
这里的default方法并没有什么特殊之处。第一次在以前未使用的类上调用方法时,调用将触发类的加载、验证和初始化,并且方法的执行将在解释模式下开始,然后JIT编译器/hotspot优化器将启动。对于一个interface,当实现它的类被初始化时,它将被加载并进行一些验证步骤,但其他步骤仍将被推迟,直到它实际被使用,在你的情况下,当第一次调用interfacedefault方法时。

在Java中,第一次执行比后续执行需要更多的时间是一种正常的现象。在你的情况下,你正在使用lambda表达式,当函数式接口实现在运行时生成时,会有额外的第一次开销。

请注意,你的代码是一个存在比default方法更长时间的常见反模式。 HashAndEquals与“实现”它的类之间不存在is-a关系。相反,你可以(也应该)将这两个实用方法作为static方法提供给专门的类,并使用import static如果你想调用这些方法而不必添加声明类。

interface继承这些方法没有任何好处。毕竟,每个类都必须重写Object.hashCodeObject.equals并可以选择有意地使用这些实用程序方法或不使用。


感谢您提供出色的答案。同时,感谢您指出缺失的“is-a”关系。我也尝试过在继承和组合之间选择组合,但在这种情况下我失败了:)。由于lambda表达式生成了一个函数接口,您是否建议使用普通的for循环来提高性能? - thomas77
在这种特定情况下,我不会称之为反模式,因为它并未应用于域概念:equalshashCode不是类“域”的一部分,而是技术要求。通过使用default方法,可以使用语法糖(混入)将纯技术要求添加到这些类中。编写implements HashAndEquals比在每个类中复制和粘贴覆盖equalshashCode要短得多(即DRY)。 - Alexander Langer
1
@Alexander Langer:看起来您忽略了一个重要方面,即“接口”“默认”方法无法覆盖从“java.lang.Object”继承的方法。这就是为什么我写“每个类都必须覆盖Object.hashCodeObject.equals”。实际上,预期使用方式正是将equalshashCode方法(调用default方法的实现)复制并粘贴到每个单独的类中。从“接口”继承的方法不是用于从外部调用,而只是通过“接口”导出的实现工件,这是一种反模式。 - Holger
1
@Alexander Langer:让我这样说吧:如果您可以在基类中提供一个可工作的equals/hashCode,那么它可能被称为ValueType,而关系,例如ComplexNumber 是一个 ValueType将证明继承的合理性。尽管如此,我仍然不建议使用默认的反射实现,但这并不是我所指的反模式。过去,interface被滥用来声明常量,这些常量只有通过实现特定的interface才能导入,以便在实现内部使用这些常量,就像这个问题的这些default方法一样。 - Holger
1
@thomas77:关于你的性能问题,我不会怪罪lambda表达式。首先,反射的开销将占主导地位。其次,在你的isEqual方法中,你有两个嵌套迭代,这意味着你正在执行n×m个操作。我建议坚持只有相同类的对象才能相等,因此,你只需要迭代一个Field数组并比较两个实例在相同字段上的实际值即可。 - Holger
显示剩余2条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接