Я создал опросы в 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()
Pair
, Bag
и 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.SELECTED
, Filter.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 должны быть типы Partition
, Bag
и Multimap
. Partition
уже существует в виде реализации. Он просто должен перестать притворяться Map
и стать публичным или представленным более конкретным и ограниченным интерфейсом. К сожалению, поскольку partitioningBy
уже возвращает Map
, этот метод, скорее всего, никогда не будет изменен. Тем не менее его можно объявить устаревшим и заменить альтернативой с лучшим возвращаемым типом.
Надеюсь, эта статья заставила вас задуматься о соотношении затрат и выгод от использования Map
в качестве универсального возвращаемого типа. Моя рекомендация — не используйте его! Применяйте Map
в качестве возвращаемого типа только тогда, когда это оптимальный вариант, доступный для метода. Если в качестве возвращаемого типа лучше использовать другой тип, то создайте его или используйте, если он уже существует.
Читайте также:
- 6 рекомендаций по устранению типичных проблем производительности Java
- Как написать на Java функцию, подобную sizeof в C
- Как отобразить индикатор выполнения на стандартной консоли с помощью Java
Читайте нас в Telegram, VK и Дзен
Перевод статьи Donald Raab: Map-Oriented Programming in Java