Objects.hash 为相等的对象返回不同的 hashCodes



给定以下类:

package software.visionary.identifr;
import software.visionary.identifr.api.Authenticatable;
import software.visionary.identifr.api.Credentials;
import javax.crypto.*;
import javax.crypto.spec.PBEKeySpec;
import javax.crypto.spec.PBEParameterSpec;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.security.InvalidAlgorithmParameterException;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.security.spec.InvalidKeySpecException;
import java.util.Arrays;
import java.util.Objects;
public final class PasswordCredentials implements Credentials {
private final Authenticatable owner;
private final byte[] value;
private final SecretKey key;
public PasswordCredentials(final Authenticatable human, final String password) {
if (Objects.requireNonNull(password).trim().isEmpty()) {
throw new IllegalArgumentException("Invalid password");
}
this.owner = Objects.requireNonNull(human);
this.key = asSecretKey(password);
this.value = this.key.getEncoded();
}
private SecretKey asSecretKey(final String password) {
try {
final PBEKeySpec pbeKeySpec = new PBEKeySpec(password.toCharArray());
final SecretKeyFactory secretKeyFactory = SecretKeyFactory.getInstance("PBEWithMD5AndTripleDES");
return secretKeyFactory.generateSecret(pbeKeySpec);
} catch (NoSuchAlgorithmException | InvalidKeySpecException e) {
throw new RuntimeException(e);
}
}
@Override
public boolean equals(final Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) {
return false;
}
final PasswordCredentials that = (PasswordCredentials) o;
return owner.equals(that.owner) &&
Arrays.equals(value, that.value);
}
@Override
public int hashCode() {
return Objects.hash(owner, value);
}  
}

以及以下测试:

package software.visionary.identifr;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import software.visionary.Randomizr;
import software.visionary.identifr.api.Authenticatable;
import software.visionary.identifr.api.Credentials;
import java.util.UUID;
final class PasswordCredentialsTest {
@Test
void rejectsNullOwner() {
final Authenticatable owner = null;
final String password = Randomizr.INSTANCE.createRandomPassword();
Assertions.assertThrows(NullPointerException.class, () -> new PasswordCredentials(owner, password));
}
@Test
void rejectsNullPassword() {
final Authenticatable owner = new Authenticatable() {
@Override
public Credentials getCredentials() {
return null;
}
@Override
public UUID getID() {
return null;
}
};
final String password = null;
Assertions.assertThrows(NullPointerException.class, () -> new PasswordCredentials(owner, password));
}
@Test
void rejectsEmptyPassword() {
final Authenticatable owner = new Authenticatable() {
@Override
public Credentials getCredentials() {
return null;
}
@Override
public UUID getID() {
return null;
}
};
final String password = "";
Assertions.assertThrows(IllegalArgumentException.class, () -> new PasswordCredentials(owner, password));
}
@Test
void rejectsWhitespacePassword() {
final Authenticatable owner = new Authenticatable() {
@Override
public Credentials getCredentials() {
return null;
}
@Override
public UUID getID() {
return null;
}
};
final String password = "ttnnn";
Assertions.assertThrows(IllegalArgumentException.class, () -> new PasswordCredentials(owner, password));
}
@Test
void hashCodeIsImplementedCorrectly() {
final Authenticatable owner = Fixtures.randomAuthenticatable();
final String password = Randomizr.INSTANCE.createRandomPassword();
final PasswordCredentials creds = new PasswordCredentials(owner, password);
final int firstHash = creds.hashCode();
final int secondHash = creds.hashCode();
Assertions.assertEquals(firstHash, secondHash);
final PasswordCredentials same = new PasswordCredentials(owner, password);
Assertions.assertEquals(creds.hashCode(), same.hashCode());
final PasswordCredentials different = new PasswordCredentials(owner, Randomizr.INSTANCE.createRandomPassword());
Assertions.assertNotEquals(firstHash, different.hashCode());
}
@Test
void equalsIsImplementedCorrectly() {
final Authenticatable owner = Fixtures.randomAuthenticatable();
final String password = Randomizr.INSTANCE.createRandomPassword();
final PasswordCredentials creds = new PasswordCredentials(owner, password);
Assertions.assertTrue(creds.equals(creds));
final PasswordCredentials same = new PasswordCredentials(owner, password);
Assertions.assertTrue(creds.equals(same));
Assertions.assertTrue(same.equals(creds));
final PasswordCredentials different = new PasswordCredentials(owner, Randomizr.INSTANCE.createRandomPassword());
Assertions.assertFalse(creds.equals(different));
Assertions.assertFalse(different.equals(creds));
}
}

hashCodeIsImplementedCorrectly()以一种我意想不到的方式失败了:满足equals协定的两个对象返回不同的哈希码。这似乎直接违反了JavaDoc:

如果根据 equals(Object( 方法将两个对象相等,则对两个对象中的每一个调用 hashCode 方法必须产生相同的整数结果。

我只是以推荐的 IDE 自动生成方式使用Objects.hash...

此方法对于在包含多个字段的对象上实现 Object.hashCode(( 很有用。例如,如果一个对象有三个字段 x、y 和 z,则可以这样写:

@Override public int hashCode() {
return Objects.hash(x, y, z);
}

我错过了一些明显的东西吗?我以前没有遇到过这个问题,并为equals((/hashCode((编写了很多单元测试。

想想就不寒而栗,但万一是相关的......

java --version
openjdk 11.0.5 2019-10-15
OpenJDK Runtime Environment (build 11.0.5+10-post-Ubuntu-0ubuntu1.119.04)
OpenJDK 64-Bit Server VM (build 11.0.5+10-post-Ubuntu-0ubuntu1.119.04, mixed mode, sharing)

正如你所指出的,如果对象AB相等(在A.equals(B)返回true的意义上(,它们应该具有相同的哈希代码。推而广之,如果通过检查一系列字段的相等性来实现equals方法,则使用Objects.hash应提供正确的哈希码。

但这不是您在这里要做的 - 您正在使用Arrays.equals来比较两个数组 - 这是您应该做的。具有相同内容的数组不相等,因此可能(并且可能会(具有不同的哈希码。相反,您可以使用Arrays#hashCode来获取value的哈希代码:

@Override
public int hashCode() {
return Objects.hash(owner, Arrays.hashCode(value));
// Here -------------------^
} 

更仔细地查看生成的equals方法会发现:这是因为value是一个byte[]。当使用数组作为字段时,Objects.hash需要使用Arrays.hashCode(value)

这工作正常:

@Override
public int hashCode() {
return Objects.hash(owner, Arrays.hashCode(value));
}