Интерфейс Collection в Java: методы и контракт
Вызов List.of("a", "b").remove("z") бросает UnsupportedOperationException, хотя удалять нечего, а Arrays.asList("a", "b").remove("z") спокойно возвращает false. Оба списка реализуют один и тот же интерфейс Collection — и оба делают это правильно. Разница в том, как реализация трактует его необязательные операции.
Интерфейс Collection (java.util.Collection<E>) — корневой интерфейс ветки списков, множеств и очередей в Collection Framework. Он расширяет Iterable<E> и объявляет операции, общие для List, Set и Queue: добавление и удаление элементов, проверку наличия, размер, перебор, преобразование в массив и в поток. Map интерфейс Collection не наследует. Место Collection в общей схеме показано в уроке Иерархия коллекций Java.
Методы интерфейса Collection
Методы интерфейса Collection делятся на три группы: изменение коллекции (add, remove, addAll, removeAll, retainAll, removeIf, clear), проверки (contains, containsAll, size, isEmpty) и перебор с преобразованием (iterator, toArray, stream). Методы, которые меняют коллекцию, возвращают true, если коллекция изменилась.
Знаки вопроса в сигнатурах вроде Collection<? extends E> — это wildcard; зачем они нужны, объясняет урок принцип PECS.
Изменение коллекции
| Метод | Что делает | Возвращает | Граничный случай |
|---|---|---|---|
boolean add(E e) | Добавляет элемент | true, если коллекция изменилась | Set с таким элементом вернёт false; ArrayDeque.add(null) — NullPointerException |
boolean addAll(Collection<? extends E> c) | Добавляет все элементы c | true, если добавлен хотя бы один элемент | Для HashSet, где часть элементов уже есть, всё равно true |
boolean remove(Object o) | Удаляет одно вхождение элемента, равного o по equals() | true, если элемент найден и удалён | Удаляется только первое вхождение; элемента нет — false |
boolean removeAll(Collection<?> c) | Удаляет все элементы, которые есть в c, включая повторы | true, если что-то удалено | removeAll(List.of()) ничего не меняет — false |
boolean retainAll(Collection<?> c) | Оставляет только элементы, которые есть в c | true, если что-то удалено | retainAll(List.of()) очищает коллекцию |
default boolean removeIf(Predicate<? super E> filter) | Удаляет элементы, для которых условие истинно (Java 8) | true, если удалён хотя бы один | filter == null — NullPointerException |
void clear() | Удаляет все элементы | — | List.of().clear() — UnsupportedOperationException даже для пустого списка |
Проверки и размер
| Метод | Что делает | Граничный случай |
|---|---|---|
boolean contains(Object o) | Проверяет, есть ли элемент, равный o по equals() | List.of(1).contains(null) — NullPointerException |
boolean containsAll(Collection<?> c) | Проверяет, есть ли все элементы c | Для пустой c всегда true; повторы в c не учитываются |
int size() | Возвращает число элементов | Больше Integer.MAX_VALUE элементов — вернёт Integer.MAX_VALUE |
boolean isEmpty() | Возвращает true, если элементов нет | Читается лучше, чем size() == 0, и в некоторых коллекциях быстрее |
Перебор, массивы и потоки
| Метод | Что делает | Граничный случай |
|---|---|---|
Iterator<E> iterator() | Возвращает итератор для обхода | Порядок обхода задаёт реализация: у HashSet он не гарантирован |
default void forEach(Consumer<? super T> action) | Выполняет действие для каждого элемента (метод Iterable, Java 8) | Изменение коллекции внутри действия — ConcurrentModificationException |
Object[] toArray() | Создаёт новый массив Object[] | Приведение к String[] — ClassCastException |
<T> T[] toArray(T[] a) | Создаёт массив нужного типа (или заполняет a, если он достаточного размера) | Несовместимый тип элементов — ArrayStoreException |
default <T> T[] toArray(IntFunction<T[]> generator) | Создаёт массив через генератор, например String[]::new (Java 11) | Тот же ArrayStoreException при несовместимом типе |
default Stream<E> stream() | Возвращает последовательный поток (Java 8) | Поток не меняет исходную коллекцию |
default Stream<E> parallelStream() | Возвращает поток, который может выполняться параллельно (Java 8) | На маленьких коллекциях обычно медленнее stream() |
default Spliterator<E> spliterator() | Возвращает «разделяемый итератор», на котором построены потоки (Java 8) | Напрямую вызывается редко |
Ещё два метода объявлены в Collection заново, чтобы уточнить их контракт: boolean equals(Object o) и int hashCode(), который возвращает хеш-код коллекции. Как они работают, разобрано в разделе Как Collection сравнивает элементы.
Пример: add, remove, removeAll, retainAll
Чтобы понять методы Collection, проще всего следить за их возвращаемым значением: true означает, что коллекция изменилась, false — что осталась прежней. Переменная объявлена типом Collection, поэтому в примере доступны только методы этого интерфейса, без индексов.
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
public class CollectionMethodsExample {
public static void main(String[] args) {
Collection<String> names = new ArrayList<>(List.of("Анна", "Олег", "Анна", "Ира"));
System.out.println(names.add("Олег")); // true
System.out.println(names.remove("Анна")); // true
System.out.println(names); // [Олег, Анна, Ира, Олег]
System.out.println(names.removeAll(List.of("Олег", "Пётр"))); // true
System.out.println(names); // [Анна, Ира]
System.out.println(names.retainAll(List.of("Анна", "Ира"))); // false
System.out.println(names.containsAll(List.of("Ира", "Анна"))); // true
System.out.println(names.addAll(List.of())); // false
names.clear();
System.out.println(names.isEmpty() + " " + names.size()); // true 0
Collection<String> tags = new HashSet<>(List.of("java"));
System.out.println(tags.add("java")); // false
System.out.println(tags.addAll(List.of("java", "sql"))); // true
}
} Вывод:
true
true
[Олег, Анна, Ира, Олег]
true
[Анна, Ира]
false
true
false
true 0
false
true Обратите внимание на три момента. remove("Анна") удалил только первую «Анну», а removeAll() убрал оба «Олега». Отсутствующий «Пётр» не помешал removeAll() вернуть true. addAll() для множества вернул true, хотя добавилась только строка «sql»: метод сообщает, изменилась ли коллекция, а не то, добавились ли все элементы. Те же методы на ArrayList, а также методы с индексами разобраны в уроке Интерфейс List и класс ArrayList.
Метод removeIf: удаление по условию
Метод removeIf(Predicate) удаляет из коллекции все элементы, для которых условие возвращает true, и возвращает true, если удалён хотя бы один. Он появился в Java 8 как default-метод и заменяет ручной цикл с итератором: одна строка вместо пяти и без риска получить ConcurrentModificationException.
Collection<Integer> numbers = new ArrayList<>(List.of(5, 12, 7, 20, 3));
System.out.println(numbers.removeIf(n -> n > 10)); // true
System.out.println(numbers); // [5, 7, 3]
System.out.println(numbers.removeIf(n -> n > 100)); // false — ничего не удалено Если удалять элементы вызовом numbers.remove(n) внутри цикла for-each, следующий шаг цикла обычно падает с ConcurrentModificationException. Почему так происходит и как удалять через Iterator.remove(), показано в уроке Iterator.
Метод toArray: три способа получить массив
Метод toArray() копирует элементы коллекции в новый массив. Вариантов три: toArray() возвращает Object[], toArray(T[] a) — массив нужного типа, а toArray(IntFunction) из Java 11 создаёт массив по ссылке на конструктор, например String[]::new.
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
public class ToArrayExample {
public static void main(String[] args) {
Collection<String> langs = new ArrayList<>(List.of("Java", "Kotlin"));
Object[] objects = langs.toArray();
String[] strings = langs.toArray(new String[0]);
String[] strings11 = langs.toArray(String[]::new); // Java 11+
System.out.println(Arrays.toString(strings11)); // [Java, Kotlin]
String[] wrong = (String[]) langs.toArray(); // ClassCastException
}
} Вывод:
[Java, Kotlin]
Exception in thread "main" java.lang.ClassCastException: class [Ljava.lang.Object; cannot be cast to class [Ljava.lang.String; ([Ljava.lang.Object; and [Ljava.lang.String; are in module java.base of loader 'bootstrap') toArray() у ArrayList создаёт именно Object[], и привести его к String[] нельзя, даже если внутри только строки. Если тип элементов не подходит под тип массива, toArray(new String[0]) бросит ArrayStoreException. Пример с массивом заранее заданного размера — в уроке Получение массива из ArrayList.
Массив новый, объекты те же
Массив из toArray() не связан с коллекцией: замена array[0] = ... коллекцию не изменит. Но элементы не копируются — в массив попадают ссылки на те же объекты. Если положить в список new StringBuilder("a"), вызвать ((StringBuilder) list.toArray()[0]).append("!") и вывести список, получится [a!]. Для неизменяемых String и Integer разница незаметна, для изменяемых объектов — важна.
Перебор: iterator, forEach и stream
Любую коллекцию можно перебрать тремя способами: циклом for-each (он работает, потому что Collection расширяет Iterable), методом forEach() с лямбдой и через поток stream(). Итератор iterator() нужен явно, когда при обходе элементы удаляются.
Collection<String> words = List.of("set", "list", "queue");
words.forEach(System.out::println);
long longWords = words.stream()
.filter(w -> w.length() > 3)
.count();
System.out.println("Длиннее трёх букв: " + longWords); Вывод:
set
list
queue
Длиннее трёх букв: 2 stream() не меняет исходную коллекцию: filter() отбирает элементы только для дальнейшей обработки. Чтобы удалить элементы из самой коллекции, нужен removeIf(). Методы hasNext(), next(), remove() и ListIterator разобраны в уроке Интерфейс Iterator.
Необязательные операции и UnsupportedOperationException
Методы, которые меняют коллекцию (add, addAll, remove, removeAll, retainAll, removeIf, clear), в документации Collection помечены как необязательные (optional operations). Реализация вправе их не поддерживать и бросать UnsupportedOperationException. Так устроены неизменяемые коллекции: List.of(), Collections.unmodifiableCollection(), а для операций изменения размера — Arrays.asList().
Вернёмся к примеру из начала урока:
import java.util.Arrays;
import java.util.List;
public class OptionalOperationsExample {
public static void main(String[] args) {
List<String> fixed = Arrays.asList("a", "b");
List<String> immutable = List.of("a", "b");
System.out.println(fixed.remove("z")); // false
System.out.println(immutable.remove("z")); // исключение
}
} Вывод:
false
Exception in thread "main" java.lang.UnsupportedOperationException
at java.base/java.util.ImmutableCollections.uoe(ImmutableCollections.java:142) Документация разрешает обе реакции: если вызов ничего не изменил бы, необязательная операция может бросить исключение, но не обязана. Список Arrays.asList() ищет элемент и бросает исключение, только когда действительно нужно удалить: fixed.remove("a") закончится UnsupportedOperationException. Коллекции из List.of() отказывают сразу, не глядя на аргумент. То же различие видно у removeIf(): на Arrays.asList(1, 2, 3) условие n -> n > 5 вернёт false, а n -> n > 1 бросит исключение.
Тип переменной не гарантирует изменяемость
По объявлению Collection<String> c нельзя понять, поддерживает ли объект add(). Если метод получает коллекцию извне и собирается её менять, надёжнее скопировать её: new ArrayList<>(c). Код, который «работает» на тестах с ArrayList, упадёт в продакшене, как только ему передадут List.of(...).
Сравнение способов создать неизменяемую коллекцию (List.of, List.copyOf, Collections.unmodifiableList, stream().toList()) — в разделе Неизменяемые коллекции, а почему Arrays.asList() связан с исходным массивом — в уроке Backed Collections.
Как Collection сравнивает элементы: equals и hashCode
Методы contains(), remove(), containsAll(), removeAll() и retainAll() ищут элементы через equals(): элемент найден, если o.equals(e) возвращает true (для null — если e == null). Поэтому для собственных классов, которые хранятся в коллекциях, нужно переопределять метод equals(), иначе сравниваются ссылки.
class PointClass {
int x, y;
PointClass(int x, int y) { this.x = x; this.y = y; }
}
record Point(int x, int y) {} // equals() и hashCode() генерируются автоматически
Collection<PointClass> a = new ArrayList<>(List.of(new PointClass(1, 2)));
Collection<Point> b = new ArrayList<>(List.of(new Point(1, 2)));
System.out.println(a.contains(new PointClass(1, 2))); // false — разные объекты
System.out.println(b.contains(new Point(1, 2))); // true Хеш-коллекции (HashSet, ключи HashMap) сначала сравнивают hashCode(), и только потом equals(), поэтому там методы переопределяют в паре. Контракт hashCode() и equals() разобран в уроке Интерфейс Set и HashSet.
Когда две коллекции равны
Сам Collection не задаёт правило равенства коллекций — его уточняют подинтерфейсы. Два List равны, если содержат равные элементы в том же порядке, независимо от класса. Два Set равны, если содержат одни и те же элементы. Список никогда не равен множеству. Что выведет этот код?
List<Integer> arrayList = new ArrayList<>(List.of(1, 2, 3));
List<Integer> linkedList = new LinkedList<>(List.of(1, 2, 3));
Set<Integer> hashSet = new HashSet<>(List.of(1, 2, 3));
Set<Integer> treeSet = new TreeSet<>(List.of(3, 2, 1));
Deque<Integer> deque1 = new ArrayDeque<>(List.of(1, 2, 3));
Deque<Integer> deque2 = new ArrayDeque<>(List.of(1, 2, 3));
System.out.println(arrayList.equals(linkedList));
System.out.println(hashSet.equals(treeSet));
System.out.println(arrayList.equals(hashSet));
System.out.println(deque1.equals(deque2)); Вывод:
true
true
false
false Последняя строка удивляет чаще всего. ArrayDeque не является ни List, ни Set и не переопределяет equals(), поэтому две очереди с одинаковыми элементами сравниваются по ссылке. То же относится к PriorityQueue и к коллекции map.values(). Хеш-код тоже зависит от подинтерфейса: у List он вычисляется с учётом порядка элементов, у Set равен сумме хеш-кодов элементов.
Как менялся Collection по версиям Java
Интерфейс Collection появился в Java 1.2 вместе со всем Collection Framework. С тех пор его расширяли default-методами: они имеют реализацию прямо в интерфейсе, поэтому новые методы добавились без поломки старых классов, которые реализуют Collection.
| Версия | Что изменилось |
|---|---|
| Java 1.2 | Интерфейс Collection и абстрактные методы: add, remove, contains, size, iterator, toArray и другие |
| Java 5 | Интерфейс стал обобщённым: Collection<E>; родителем стал Iterable, отсюда цикл for-each |
| Java 8 | Default-методы removeIf(), stream(), parallelStream(), spliterator(); forEach() в Iterable |
| Java 11 | Default-метод toArray(IntFunction<T[]>) |
| Java 21 | Подинтерфейс SequencedCollection с getFirst(), getLast(), reversed() — см. урок Иерархия коллекций |
Методы конкретных ветвей иерархии разобраны в отдельных уроках: List (доступ по индексу), Set (уникальные элементы), Queue и Deque (очереди и стек). Что такое список, стек, очередь и множество как структуры данных, объясняет урок Структуры данных в Java.
Часто задаваемые вопросы
Почему contains() и remove() принимают Object, а не E?
Потому что элемент ищется через equals(), а по контракту равными могут быть объекты разных классов. Обратная сторона — компилятор не поймает ошибку типа: для List<Long> ids = List.of(1L, 2L) вызов ids.contains(1) скомпилируется и вернёт false, потому что 1 упаковывается в Integer, а Integer не равен Long. Правильно — ids.contains(1L).
Почему в Collection нет метода get(int index)?
У множества и очереди нет позиций элементов, поэтому индекс есть только в подинтерфейсе List. Чтобы взять «первый» элемент произвольной коллекции, используют iterator().next() или stream().findFirst(); для HashSet «первый» при этом не определён заранее. Методы с индексами — в уроке Интерфейс List.
Как написать свою коллекцию, не реализуя все методы Collection?
Унаследоваться от абстрактного класса AbstractCollection и реализовать два метода: iterator() и size(). Остальные методы (contains, toArray, containsAll, toString) уже написаны на их основе. Для изменяемой коллекции нужно ещё переопределить add() и метод remove() у итератора, иначе они бросают UnsupportedOperationException.
Что вернёт addAll(), если добавились не все элементы?
true, если добавился хотя бы один элемент: метод сообщает, изменилась ли коллекция. Для HashSet, где часть элементов уже была, результат тоже true, а false будет, только когда не добавилось ничего. Пример с выводом — в разделе Пример: add, remove, removeAll, retainAll.
Видео объяснение
Предпочитаете видеоформат? Посмотрите этот урок с примерами и объяснениями.
Комментарии