Я создал опросы в Twitter (X) и LinkedIn, чтобы выяснить, используют ли разработчики типы Bag/Multiset или только Map Java. Неудивительно, что в обоих опросах доминирует java.util.Map.

В опросе я не ставил цель определить, какой тип лучше — Bag или Map. У этих интерфейсов разное назначение и поведение. Map связывает ключи со значениями. Bag является неупорядоченной неуникальной коллекцией, которая позволяет легко отслеживать количество элементов и обычно поддерживается Map для ускорения поиска. Использовать Map для отслеживания количества все равно что пробивать молотком дыру в бетоне вместо того, чтобы сделать это бурильным молотком. Никто не станет спорить, что обычный молоток — лучший инструмент для пробивания дыр в бетоне, если нет под рукой бурильного молотка. В противном случае придется бить по бетону тем молотком, который у вас есть.

Когда ваш единственный инструмент — Map, все остальное — пара ключ-значение.

Вернемся к опросу и главному вопросу. В опросе названы три библиотеки, предоставляющие тип Bag/Multiset в Java —  Google Guava, Apache Commons Collections и Eclipse Collections. Затем упоминается Map от Java. Вопрос в том, готовы ли вы использовать сторонние зависимости в своем приложении ради типа Bag/Multiset или предпочитаете использовать MOP (map-oriented programming — Map-ориентированное программирование)? Обычно разработчики стараются ограничить количество сторонних зависимостей по целому ряду причин (размер бинарных файлов, разрешение конфликтов версий, потенциальные уязвимости и т. д.). Это ставит специалистов перед необходимостью использовать альтернативы MOP, иногда предоставляемые JDK, или же создавать собственное решение для Bag/Multiset. Большинство разработчиков, которых я знаю, выбирают MOP.

Следующая цитата передает суть MOP:

“Мы должны сделать это немедленно! У нас есть Map, и мы не боимся его использовать!”

Проблема с MOP заключается в том, что, хотя  Map очень гибок в отношении данных, которые он может содержать в слотах ключей и значений, его поведение остается неизменным. Это просто Map. Вы можете помещать в него элементы и получать из него элементы, включая null или любой другой случайный тип. С годами в интерфейс Java Map были добавлены новые специфические для Map  варианты поведения, которые повышают гибкость, например возможность объединять элементы, вычислять их или получать значение по умолчанию, если ключ отсутствует. Более специализированное поведение, такое как подсчет или добавление в коллекцию/извлечение из нее в одном из слотов значений, которое не является частью контракта Map, просачивается в код или в алгоритмы, добавляемые к Stream и Collector. Вы теряете возможность располагать типами и структурами, которые обеспечивают расширенное поведение Map.

Я пользовался преимуществами применения как систем динамических типов, так и систем статических типов, профессионально работая как в Smalltalk, так и в Java. Структуры данных, подобные Map, иногда дают ощущение преимуществ системы динамических типов, но не дают преимуществ системы статических типов. Мне нравится применять систему статических типов по многим причинам, даже несмотря на то что она иногда замедляет процесс разработки, выполняемой мной самостоятельно. Я не поклонник Map-ориентированного программирования, но признаюсь, что иногда использовал его, когда это давало кратковременное удобство, а добавление новых типов было хлопотным делом. Обнаруживая необходимость в новом типе, я обычно добавляю его. Иногда это трудный путь, но чаще всего правильный. Каждый новый тип, добавляемый в приложении, обходится разработчику недешево, но есть и преимущества: связь, ясность, инкапсуляция, уменьшение дублирования кода, повышение безопасности и производительности.

Разве ярлыки не являются таковыми?

В JDK не хватает трех типов, которые были представлены с помощью Map в качестве альтернативы. Map позволяет использовать существующую гибкость типов, чтобы избежать затрат (в данном случае разработчикам фреймворков). Все понесенные затраты вместо этого перекладываются на разработчиков приложений, которые используют Map в качестве возвращаемого типа. Следующие возвращаемые типы Map, используемые в Collectors, не могут быть изменены. Эти возвращаемые типы Map были введены в Collectors с выходом Java 8.

// Map<Boolean, List<T>> -> Pair<T, T>
Collectors.partitioningBy()

// Map<T, Long> -> Bag<T>
Collectors.groupingBy(Collectors.counting)

// Map<K, Collection<V>> -> Multimap<K, V>
Collectors.groupingBy()

PairBag и Multimap — лишь некоторые из типов, отсутствующих в JDK. Можно назвать Pair чем-то более конкретным в случае с partitioningBy, например Partition, но это все равно Pair (пара) двух элементов одного типа.

Нам не нужен тип Pair!

Было принято обоснованное решение не добавлять общий тип Pair или поддержку общих кортежей в Java. Вместо этого с момента выхода Java 16 разработчикам Java рекомендуется использовать именованные типы, созданные с помощью Java Records. Это обоснованное решение, которое я полностью поддерживаю, даже если в созданном мной фреймворке с открытым исходным кодом (Eclipse Collections) есть типы Pair и Triple. Я ценю создание специализированных типов: возможность применять их с минимальными формальностями с помощью Java Records — это здорово.

А теперь пристегните ремни безопасности — наше погружение в тему набирает обороты.

У нас есть Map!

Использование Map в качестве общего типа Pair, пожалуй, хуже, чем добавление общего типа Pair. Как использовать Map в качестве Pair? В коде Stream и Collectors в JDK есть пример с partitioningBy.

Рассмотрим следующий пример использования partitioningBy, в котором одним проходом отфильтруем поток Integer на отдельные экземпляры List для четных и нечетных значений.

@Test
public void partitioningBy()
{
    Map<Boolean, List<Integer>> map =
            IntStream.rangeClosed(1, 10)
                    .boxed()
                    .collect(Collectors.partitioningBy(each -> each % 2 == 0));

    List<Integer> evens = map.get(true);
    List<Integer> odds = map.get(false);
    List<Integer> ummm = map.get(null);
    List<Integer> ohno = map.get(new Object());

    Assertions.assertEquals(List.of(2, 4, 6, 8, 10), evens);
    Assertions.assertEquals(List.of(1, 3, 5, 7, 9), odds);
    Assertions.assertNull(ummm);
    Assertions.assertNull(ohno);

    ummm = map.getOrDefault(null, evens);
    Assertions.assertEquals(List.of(2, 4, 6, 8, 10), ummm);

    ohno = map.getOrDefault(new Object(), odds);
    Assertions.assertEquals(List.of(1, 3, 5, 7, 9), ohno);
}

Этот код берет поток Integer от 1  до 10 и фильтрует четные значения в один List, а нечетные — в другой, используя  partitioningBy. В результате получается Map<Boolean, List<Integer>>. Значения true в Map — те, которые фильтруются инклюзивно. Значения false в Map — те, которые фильтруются эксклюзивно. Значения null в Map — те, которые… подождите, почему в Map есть значения null? Почему в Map<Boolean, List<Integer>> происходит поиск нового объекта new Object()? Что здесь происходит? Вспомните, что Map существовал до Java 5, когда в Java были добавлены дженерики, а метод get в контексте Map не является дженериком и принимает любой Object.

Если вы никогда раньше не углублялись в результаты работы метода partitioningBy, то учтите: он возвращает экземпляр типа Partition, который является внутренним классом в Collectors. Я знал, что метод partitioningBy возвращает Map<Boolean, List<Type>>, но до сегодняшнего дня не знал о фактической реализации. Тип Partition является неизменяемым, но все равно ведет себя как Map, что я проиллюстрировал выше. Метод get для Map не является дженериком, поэтому принимает любой объект любого типа. Класс Partition не выбрасывает исключение при небулевом доступе через get, а вместо этого возвращает null. Поиск с потенциально любым типом приведет к null. Метод getOrDefault или любой другой метод Map, предназначенный только для чтения, ведет себя согласованно с другими типами Map. Изменяемые методы, такие как put, выбрасывают исключения.

Как насчет использования примитивного BooleanObjectMap?

Думаете, я предлагаю использовать примитивную версию BooleanObjectMap из Eclipse Collections для решения проблемы дженерика get и Map? Нет, не предлагаю — это невозможно. Тип BooleanObjectMap не существует в Eclipse Collections. Когда мы разрабатывали иерархию примитивных Map, приняли сознательное решение удалить все комбинации примитивных Map с boolean в качестве ключа. В Eclipse Collections нет Map-boolean для всего. Почему?

Наличие связки Map-boolean для всего сродни использованию Map в качестве молотка. Если вам нужно два значения, одно для true и одно для false, то используйте две переменные для хранения значений и поместите эти значения в определенный тип. Переменные в этом новом типе могут иметь имена, раскрывающие их смысл (например, selected и rejected в коллекции Eclipse PartitionIterable), а не менее осмысленные имена, такие как ifTrue и ifFalse, или значения Boolean в Map. Если вы должны передавать эти значения вместе в одном экземпляре-дженерике чего-либо, потому что не можете или не хотите добавлять новый тип, то используйте дженерик Pair. Но будьте готовы к тому, что с Pair вы получите менее осмысленные имена для содержащихся значений (one и two или left и right).

А что, если вместо Boolean использовать Enum для ключевого типа?

Другим вариантом использования Map для представления пары одного типа было бы использование Enum для ключа, где имена в Enum имеют раскрывающие смысл названия (например, Filter.SELECTEDFilter.REJECTED). Тогда можно было бы написать map.get(Filter.SELECTED) вместо map.get(true). Почему нет?

Такое решение требует создания нового типа Enum для хранения этих имен ключей. Если уж приходится добавлять новый тип, то лучше просто определить нужный тип с помощью именованных переменных и типов (например, тип Partition с переменными selected и rejected). Более удачные имена в Enum  также не решат общих проблем с методом get в Map. На самом деле вы все равно напишете map.get(true) и получите null.

Остановите время для молотка!

Думаю, что для JDK лучше использовать преимущества статической типизации и возвращать конкретные типы вместо возврата Map, когда это возможно. По моему мнению, возвращать тип Partition для partitioningBy было бы более разумно вместо Map. Это означало бы открытие нового публичного типа. Тип Partition является приватным статическим типом. Новый публичный тип не обязательно должен быть абсолютным дженериком, как Pair. Метод partition из Eclipse Collections, применяемый к RichIterable, возвращает тип PartitionIterable. Добавление/поддержание этого типа и всех его подтипов обходится разработчикам Eclipse Collections недешево. Зато специалисты, использующие библиотеку, получают наиболее безопасную и специфическую альтернативу на различных уровнях иерархии типов.

@Test
public void partition()
{
    PartitionMutableList<Integer> partition =
            Interval.oneTo(10)
                    .partition(each -> each % 2 == 0);

    MutableList<Integer> selected = partition.getSelected();
    MutableList<Integer> rejected = partition.getRejected();

    Assertions.assertEquals(List.of(2, 4, 6, 8, 10), selected);
    Assertions.assertEquals(List.of(1, 3, 5, 7, 9), rejected);
}

Есть еще два места, где Collectors возвращает Map как тип, который лучше было бы использовать как более конкретный тип. Проблема заключается в удобстве и стоимости. В выпуске Java 8 было удобнее возвращать Map, а не Bag или Multimap, потому что это означало бы введение типов и реализаций Bag или Multimap, что потенциально значительно задержало бы выпуск Java 8. Видя, как эти типы создавались в Eclipse Collections много лет назад, могу подтвердить, что они дороги как в создании, так и в тестировании. К сожалению, мы навсегда застряли с решением об удобстве и возврате типа Map в Collectors.

Куда ведет Map-ориентированное программирование?

Map — молоток. Это очень полезный и удобный инструмент, но мы слишком часто используем Map в качестве универсального инструмента и гибкого возвращаемого типа. Java Records дает новый уровень удобства с преимуществом статической типизации. Дополнительные типы Collection, такие как Bag и Multimap, расширяют возможности Map, предоставляя разработчикам различные специализированные возможности.

В области программирования, ориентированного на данные, я предпочитаю такие решения, как библиотеки Dataframe, которые гораздо более конкретны по своим возможностям и назначению, чем коллекции Map, основанные на строках. Думаю, что Java Record представляет собой прекрасную низкозатратную альтернативу для создания Collection типов Record, которые могут быть статично проверены. Это помогает обеспечить безопасность типов, эффективность использования памяти и производительность.

Надеюсь, что в JDK появятся дополнительные типы Collection, которыми можно будет воспользоваться, вместо того чтобы продолжать применять Map в качестве удобной, но запутанной альтернативы. Считаю, что в JDK должны быть типы PartitionBag и Multimap. Partition уже существует в виде реализации. Он просто должен перестать притворяться Map и стать публичным или представленным более конкретным и ограниченным интерфейсом. К сожалению, поскольку partitioningBy уже возвращает Map, этот метод, скорее всего, никогда не будет изменен. Тем не менее его можно объявить устаревшим и заменить альтернативой с лучшим возвращаемым типом.

Надеюсь, эта статья заставила вас задуматься о соотношении затрат и выгод от использования Map в качестве универсального возвращаемого типа. Моя рекомендация — не используйте его! Применяйте Map в качестве возвращаемого типа только тогда, когда это оптимальный вариант, доступный для метода. Если в качестве возвращаемого типа лучше использовать другой тип, то создайте его или используйте, если он уже существует.

Читайте также:

Читайте нас в Telegram, VK и Дзен


Перевод статьи Donald Raab: Map-Oriented Programming in Java

Предыдущая статьяОбнаружение банковских троянов на устройствах Android
Следующая статьяИИ поможет создавать Dockerfile