Как использовать HashMap с пользовательскими ключами (и как не выстрелить себе в ногу)

Как использовать HashMap с пользовательскими ключами (и как не выстрелить себе в ногу)

9 апреля 2022 г.

При написании сложных процессов обработки данных мы часто находим очень полезными хеш-таблицы. Но когда вы решите использовать свой объект в качестве ключа, его легко реализовать неправильно.


==Небольшое предупреждение==: эта статья требует определенных знаний в области разработки Java, и я не могу охватить все это в этой статье. Знакомство с ними высоко ценится:


  • Неизменяемость

  • HashMap.java

  • equals() / hashCode() лучшие практики

  • Модульное тестирование

Несколько слов о HashMap


Он состоит из записей key->val → которые находятся в сегментах → которые находятся в массиве. Одно ведро включает много записей. Вот пример того, как это выглядит:


HashMap<Объект, Объект>


Когда алгоритм ищет подходящий ключ:


  1. Во-первых, он ищет ведро. Он использует hashCode(). Грубо говоря, берет хэш, упрощает его, чтобы не выходить за границы массива бакетов, а потом пытается взять бакет по этому индексу - bucket = table[hash & length],

  1. затем ищет нужный ключ, сравнивая записи с помощью key.equals()

Поиск записи


Эта проблема


Если у вас есть «Число» или «Строка» в качестве ключа, вам не о чем беспокоиться. Однако иногда вам нужен ваш объект в качестве ключа, например, для дальнейшей обработки или упрощения логики, или для того, чтобы назвать его.


Со сложными ключами, когда дело доходит до кода, первое, что мы делаем, это переопределяем Equals и HashCode, потому что это необходимо, чтобы эта структура данных работала.


Но являются ли ваши ключи [неизменяемыми] (https://en.wikipedia.org/wiki/Immutable_object)?


Что произойдет с этим алгоритмом поиска, если хэш ключа будет изменен через некоторое время после того, как он был использован в карте? Мы можем попрощаться с такой записью. Скорее всего, мы его больше никогда не увидим.


Однажды я часами пытался исправить подобную ошибку, пока не отладил ее и не обнаружил, что поле моего ключа было изменено глубоко внутри бизнес-логики другим разработчиком.


Кажется, это обычная проблема: не изменяйте его, и все будет в порядке. Но это не так. Это о том, как мы пишем наши объекты.


По моему опыту, большинство объектов было создано с использованием «ломбока» или шаблона IDE. Он прост в использовании и экономит много времени. Одно простое действие генерирует «конструкторы», «геттеры/сеттеры», переопределяет «равные» и «хэш-код». Но эти объекты изменчивы.


Давайте посмотрим на пример ниже. У пользователя есть userName и куча ролей. У каждой роли есть имя и некоторые «разрешения», которые эта роль может делать.


Пользователь.java


```java


@Данные


@AllArgsConstructor


Пользователь открытого класса {


частная строка имя_пользователя;


частные роли Set;


Роль.java


```java


@Данные


@AllArgsConstructor


роль публичного класса {


частное строковое имя;


частные разрешения Set;


Кажется невозможным совершить эту ошибку - изменить ключ HashMap. Однако однажды это может случиться. Например, роль пользователя получает новое разрешение во всей бизнес-логике. После его добавления hashCode изменится, а данные пользователя исчезнут.


Южный парк, s13e3


Вот демонстрация этого:


```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 getPermissions() {


вернуть разрешения;


общедоступный статический конструктор классов {


частное строковое имя;


частные разрешения 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.



Оригинал
PREVIOUS ARTICLE
NEXT ARTICLE