Как использовать HashMap с пользовательскими ключами (и как не выстрелить себе в ногу)
9 апреля 2022 г.При написании сложных процессов обработки данных мы часто находим очень полезными хеш-таблицы. Но когда вы решите использовать свой объект в качестве ключа, его легко реализовать неправильно.
==Небольшое предупреждение==: эта статья требует определенных знаний в области разработки Java, и я не могу охватить все это в этой статье. Знакомство с ними высоко ценится:
- Неизменяемость
HashMap.java
equals()
/hashCode()
лучшие практики
- Модульное тестирование
Несколько слов о HashMap
Он состоит из записей key->val
→ которые находятся в сегментах
→ которые находятся в массиве
. Одно ведро включает много записей. Вот пример того, как это выглядит:
Когда алгоритм ищет подходящий ключ:
- Во-первых, он ищет ведро. Он использует
hashCode()
. Грубо говоря, берет хэш, упрощает его, чтобы не выходить за границы массива бакетов, а потом пытается взять бакет по этому индексу -bucket = table[hash & length]
,
- затем ищет нужный ключ, сравнивая записи с помощью
key.equals()
Эта проблема
Если у вас есть «Число» или «Строка» в качестве ключа, вам не о чем беспокоиться. Однако иногда вам нужен ваш объект в качестве ключа, например, для дальнейшей обработки или упрощения логики, или для того, чтобы назвать его.
Со сложными ключами, когда дело доходит до кода, первое, что мы делаем, это переопределяем Equals и HashCode
, потому что это необходимо, чтобы эта структура данных работала.
Но являются ли ваши ключи [неизменяемыми] (https://en.wikipedia.org/wiki/Immutable_object)?
Что произойдет с этим алгоритмом поиска, если хэш ключа будет изменен через некоторое время после того, как он был использован в карте? Мы можем попрощаться с такой записью. Скорее всего, мы его больше никогда не увидим.
Однажды я часами пытался исправить подобную ошибку, пока не отладил ее и не обнаружил, что поле моего ключа было изменено глубоко внутри бизнес-логики другим разработчиком.
Кажется, это обычная проблема: не изменяйте его, и все будет в порядке. Но это не так. Это о том, как мы пишем наши объекты.
По моему опыту, большинство объектов было создано с использованием «ломбока» или шаблона IDE. Он прост в использовании и экономит много времени. Одно простое действие генерирует «конструкторы», «геттеры/сеттеры», переопределяет «равные» и «хэш-код». Но эти объекты изменчивы.
Давайте посмотрим на пример ниже. У пользователя есть userName
и куча ролей
. У каждой роли есть имя и некоторые «разрешения», которые эта роль может делать.
Пользователь.java
```java
@Данные
@AllArgsConstructor
Пользователь открытого класса {
частная строка имя_пользователя;
частные роли Set
Роль.java
```java
@Данные
@AllArgsConstructor
роль публичного класса {
частное строковое имя;
частные разрешения Set
Кажется невозможным совершить эту ошибку - изменить ключ HashMap. Однако однажды это может случиться. Например, роль пользователя получает новое разрешение во всей бизнес-логике. После его добавления hashCode
изменится, а данные пользователя исчезнут.
Вот демонстрация этого:
```java
@Контрольная работа
public void cantFindExistingDataByUser() {
// дано
окончательная карта var = new HashMap
final var role = new Role("admin", new HashSet<>());
окончательный ключ var = новый пользователь («имя», Set.of (роль));
map.put(ключ, "некоторые данные");
// когда
конечные переменные данные = map.get(key);
// Здесь мы изменяем разрешения роли, это может произойти где-то далеко, где мы не помним, что User является ключом в HashMap
role.getPermissions().add("новое разрешение");
окончательная переменная dataAfterSomeTime = map.get(key);
// потом
assertThat(data).isEqualTo("некоторые данные");
assertThat(dataAfterSomeTime).isNull();
Решение
Как видите, сломать нашу карту не составляет труда. Хорошая новость — мы можем избежать этого навсегда, если будем использовать неизменяемые объекты. Они не меняются со временем, свободны от побочных эффектов, и мы можем использовать их в многопоточной среде.
Существует множество способов записи неизменяемых объектов. Я покажу, возможно, хороший, который я предпочитаю. Мы будем использовать шаблон Builder и, конечно же, ключевое слово final в наших полях. ==Помните== что все классы полей также должны быть неизменяемыми. Если у нас есть коллекция, она должна быть неизменяемой, и класс каждого поля должен быть неизменяемым.
Пользователь.java
```java
Пользователь открытого класса {
закрытая конечная строка userName;
закрытые финальные роли Set
публичный пользователь (строитель) {
this.userName = builder.userName;
this.roles = builder.roles;
@Override
общедоступное логическое значение равно (объект o) {
если (это == o) вернуть true;
если (o == null || getClass() != o.getClass()) вернуть false;
Пользователь user = (Пользователь) o;
return Objects.equals(userName, user.userName) && Objects.equals(roles, user.roles);
@Override
общедоступный хэш-код () {
вернуть Objects.hash (имя пользователя, роли);
общедоступный статический конструктор классов {
частная строка имя_пользователя;
частные роли Set
публичный статический пользователь Builder () {
вернуть новый Builder();
общедоступная пользовательская сборка () {
вернуть нового пользователя (этот);
имя_пользователя_общего_строителя (имя_пользователя_строки) {
this.имя_пользователя = имя_пользователя;
вернуть это;
общедоступные роли Builder (роли Set
this.roles = Collections.unmodifiedSet(роли);
вернуть это;
Роль.java
```java
роль публичного класса {
закрытое конечное имя строки;
частные окончательные разрешения Set
публичная роль (строитель-строитель) {
this.name = builder.name;
this.permissions = builder.permissions;
@Override
общедоступное логическое значение равно (объект o) {
если (это == o) вернуть true;
если (o == null || getClass() != o.getClass()) вернуть false;
Ролевая роль = (Роль)о;
return Objects.equals(name, role.name) && Objects.equals(permissions, role.permissions);
@Override
общедоступный хэш-код () {
вернуть Objects.hash(имя, разрешения);
общественный Set
вернуть разрешения;
общедоступный статический конструктор классов {
частное строковое имя;
частные разрешения Set
общественная статическая роль строителя () {
вернуть новый Builder();
сборка общественной роли () {
вернуть новую роль (эту);
общественное имя Builder (строковое имя) {
это.имя = имя;
вернуть это;
общедоступные разрешения Builder (разрешения Set
this.permissions = Collections.unmodifiedSet(разрешения);
вернуть это;
А вот попытка сломать эту версию HashMap, как видите, мы не можем этого сделать, потому что она неизменяемая и никогда не будет изменена.
```java
@Контрольная работа
public void alwaysCanFindExistingDataByUser() {
// дано
окончательная карта var = new HashMap
final var role = role().name("admin").permissions(new HashSet<>()).build();
final var key = user().UserName("name").roles(Set.of(role)).build();
map.put(ключ, "некоторые данные");
// когда
конечные переменные данные = map.get(key);
// Мы никогда не сможем изменить неизменяемое поле
assertThatThrownBy(() -> role.getPermissions().add("новое разрешение")).isInstanceOf(UnsupportedOperationException.class);
окончательная переменная dataAfterSomeTime = map.get(key);
// потом
assertThat(data).isEqualTo("некоторые данные");
assertThat(dataAfterSomeTime).isEqualTo("некоторые данные");
Вывод
Если вы используете HashMap, не забудьте переопределить equals() и hashCode() и не используйте изменяемые объекты в качестве ключа. Вместо этого сделайте его неизменяемым, потому что неизменяемые объекты: не меняются со временем, свободны от побочных эффектов и хороши в многопоточной среде.
Вы можете найти полный рабочий пример [здесь] (https://github.com/sutulovai/immutability-hash-map) на GitHub.
Оригинал