W3docs

Обобщённые интерфейсы в Java

Создание обобщённых интерфейсов в Java, параметризующих сигнатуры методов по типу.

Обобщённый интерфейс — это интерфейс, в объявлении которого используется один или несколько параметров типа, точно так же, как в обобщённом классе. Это третье место в Java, где могут находиться параметры типов, наряду с обобщёнными классами и обобщёнными методами, — и самое значимое, потому что почти каждый повторно используемый контракт в стандартной библиотеке является обобщённым интерфейсом. List<E>, Map<K, V>, Comparator<T>, Function<T, R>, Supplier<T>, Iterable<T>, Iterator<T> — это основа современного Java.

Синтаксис

Список параметров типов располагается между именем интерфейса и телом:

public interface Container<T> {
  void add(T item);
  T   get(int index);
  int size();
}

Читайте это как «Container, параметризованный по некоторому типу элемента T.» Внутри интерфейса T может появляться в параметрах методов, возвращаемых типах и в любом другом месте, где допустим тип. Методы по умолчанию (Java 8+) и приватные методы интерфейса (Java 9+) тоже могут использовать T.

Когда вы реализуете интерфейс, вам нужно сделать выбор — и этот выбор является ключевым архитектурным решением:

// 1. Pick a concrete type — the implementation is specialised.
public class StringContainer implements Container<String> {
  private final List<String> items = new ArrayList<>();
  public void   add(String s)  { items.add(s); }
  public String get(int i)     { return items.get(i); }
  public int    size()         { return items.size(); }
}

// 2. Stay generic — pass the parameter through to the class.
public class ListContainer<E> implements Container<E> {
  private final List<E> items = new ArrayList<>();
  public void add(E e)    { items.add(e); }
  public E    get(int i)  { return items.get(i); }
  public int  size()      { return items.size(); }
}

Оба варианта допустимы. Первый — это «Container, который хранит строки, конкретно.» Второй — «Container, параметризованный по тому E, который выбирает вызывающий код.» Большинство повторно используемых контейнеров — второй вариант; специализированные (например, JsonObject — «контейнер JsonValue и ничего больше») — первый.

Несколько параметров типов

Схема напрямую обобщается на два и более параметра. Посмотрим на java.util.Map:

public interface Map<K, V> {
  V    put(K key, V value);
  V    get(Object key);          // Object on purpose — see below
  Set<K> keySet();
  Collection<V> values();
  ...
}

Объявление Map<K, V> означает «два параметра: K для ключей, V для значений.» Реализации фиксируют их или передают дальше:

public class StringIntMap implements Map<String, Integer> { ... }   // pinned
public class HashMap<K, V> implements Map<K, V>           { ... }   // passed through

get(Object key) в сигнатуре Map — намеренное решение при проектировании API: он принимает любой объект в качестве ключа поиска по историческим причинам. Мы вернёмся к этому в разделе Collections; это не правило обобщений, а лишь компромисс, специфичный для Map.

Функциональные интерфейсы — это обобщённые интерфейсы

Интерфейсы из java.util.functionFunction, Predicate, Consumer, Supplier, BiFunction и другие — все являются обобщёнными интерфейсами с единственным абстрактным методом, что делает их целью для лямбда-выражений:

public interface Function<T, R> {
  R apply(T t);
}

public interface Predicate<T> {
  boolean test(T t);
}

public interface Comparator<T> {
  int compare(T a, T b);
}

Когда вы пишете s -> s.length(), компилятор выводит Function<String, Integer> из контекста. Два параметра типа Function<T, R> заполняются окружающим кодом — обычно это потоковая операция или параметр метода:

List<String> names = List.of("Ada", "Grace", "Linus");
List<Integer> lengths = names.stream()
    .map(s -> s.length())          // Function<String, Integer> — both inferred
    .toList();

Здесь обобщённый интерфейс и обобщённый метод (Stream.map) работают совместно. Сигнатура метода примерно такова: <R> Stream<R> map(Function<? super T, ? extends R> mapper) — метасимволы, с которыми мы познакомимся в Wildcards, и параметр типа, определяющий R на основе переданной функции.

Саморекурсивные интерфейсы — Comparable<T>

Один из наиболее полезных шаблонов в стандартной библиотеке — саморекурсивный обобщённый интерфейс, где аргумент типа является самим реализующим классом:

public interface Comparable<T> {
  int compareTo(T other);
}

public class Money implements Comparable<Money> {
  private final long cents;
  // ...
  @Override public int compareTo(Money other) {
    return Long.compare(this.cents, other.cents);
  }
}

Читайте class Money implements Comparable<Money> как «Money умеет сравнивать себя с другими Money.» Именно благодаря этому работает Collections.sort(List<Money> list) без Comparator — каждый элемент уже несёт compareTo(Money), унаследованный из контракта интерфейса, и система типов гарантирует, что аргумент имеет тот же тип, что и получатель.

Comparable<T> — канонический пример такого шаблона: каждый тип значения в JDK, имеющий естественный порядок, реализует его: Integer implements Comparable<Integer>, String implements Comparable<String>, LocalDate implements Comparable<LocalDate> и так далее.

Наследование от обобщённого интерфейса

Те же три варианта применимы к наследованию интерфейса от интерфейса — extends вместо implements, но правила те же:

// Pin the parameter.
public interface StringList extends List<String> { ... }

// Pass it through.
public interface MyList<E> extends List<E> { ... }

// Add new ones.
public interface IndexedList<E, I> extends List<E> { I indexOf(E e); }

Та же идея, что и для классов: параметр родителя должен быть указан (конкретным типом или переданным параметром), а дочерний интерфейс может добавлять собственные параметры.

Методы по умолчанию могут использовать параметр типа

В Java 8 в интерфейсы были добавлены методы по умолчанию. Они могут использовать параметр типа интерфейса точно так же, как любой абстрактный метод:

public interface Container<T> {
  void add(T item);
  T    get(int index);
  int  size();

  default boolean isEmpty()        { return size() == 0; }
  default void addAll(Iterable<T> items) {
    for (T item : items) add(item);
  }
}

Метод по умолчанию addAll работает для каждого реализатора независимо от того, какой T был выбран. Именно так Collection<E> предоставляет forEach, removeIf, stream и другие методы — одно тело по умолчанию, и каждая реализация получает его бесплатно.

Практический пример: обобщённый интерфейс Repository

Небольшая абстракция репозитория — интерфейс и две реализации. Первая реализация фиксирует тип сущности (UserRepo хранит только пользователей); вторая остаётся обобщённой (InMemoryRepo<E> хранит всё, что запросит вызывающий код). Обе удовлетворяют одному и тому же контракту с точки зрения вызывающего кода.

java— editable, runs on the server

InMemoryRepo<E> — повторно используемая форма: параметр типа передаётся от интерфейса к классу, поэтому одно и то же тело работает для User, String или чего угодно ещё. UserRepo — специализированная форма: она фиксирует E как User и добавляет методы, имеющие смысл только для пользователей. Оба соблюдают один и тот же контракт Repository<E>, и оба наследуют isEmpty() бесплатно из метода по умолчанию.

Что дальше

До сих пор каждый параметр типа был совершенно неограниченным — T мог быть чем угодно. На практике вам часто нужно сказать «T должен быть Number» или «T должен реализовывать Comparable», чтобы вызывать методы на нём внутри тела. Именно для этого предназначены ограниченные параметры типа, и они рассматриваются в следующей главе. Перейдите к Java Bounded Type Parameters.

Практика

Практика
У вас есть `interface Repository<E> { E find(int id); }` и `class UserRepo implements Repository<User>`. Вызывающий код пишет `Repository r = new UserRepo();` (без аргумента типа), а затем `User u = r.find(1);`. В чём проблема?
У вас есть `interface Repository<E> { E find(int id); }` и `class UserRepo implements Repository<User>`. Вызывающий код пишет `Repository r = new UserRepo();` (без аргумента типа), а затем `User u = r.find(1);`. В чём проблема?
Was this page helpful?