Является ли List <Dog> подклассом List <Animal>? Почему дженерики Java не являются неявно полиморфными?


771

Я немного озадачен тем, как дженерики Java обрабатывают наследование / полиморфизм.

Предположим следующую иерархию -

Животное (родитель)

Собака - Кот (Дети)

Итак, предположим, у меня есть метод doSomething(List<Animal> animals). По всем правилам наследования и полиморфизма я бы предположил, что a List<Dog> есть a, List<Animal>а a List<Cat> есть a, List<Animal>и поэтому любой из них может быть передан этому методу. Не так. Если я хочу , чтобы добиться такого поведения, я должен явно указать метод , чтобы принять список любого подкласса животных говоря doSomething(List<? extends Animal> animals).

Я понимаю, что это поведение Java. У меня вопрос почему ? Почему полиморфизм обычно неявный, но когда дело доходит до дженериков, он должен быть указан?


17
И совершенно не связанный с этим вопрос грамматики, который беспокоит меня сейчас - должен ли мой заголовок быть «почему не дженерики Java» или «почему не дженерики Java» ?? Является ли «дженерики» множественного числа из-за s или единственного числа, потому что это одна сущность?
froadie

25
Обобщения, как это сделано в Java, являются очень плохой формой параметрического полиморфизма. Не вкладывайте в них слишком много (как я привык), потому что однажды вы сильно поразите их жалкие ограничения: хирург расширяет Handable <Scalpel>, Handable <Sponge> KABOOM! Имеет ли не вычислять [TM]. Есть ограничение Java-дженериков. Любой OOA / OOD может быть прекрасно переведен на Java (и MI может быть очень хорошо сделан с использованием интерфейсов Java), но генерики просто не сокращают его. Они хороши для «коллекций» и процедурного программирования, которое говорит (что так или иначе делают большинство Java-программистов ...).
СинтаксисT3rr0r

8
Суперкласс List <Dog> - это не List <Animal>, а List <?> (Т.е. список неизвестного типа). Обобщения стирают информацию о типе в скомпилированном коде. Это сделано для того, чтобы код, использующий дженерики (java 5 и выше), был совместим с более ранними версиями java без дженериков.
rai.skumar


9
@froadie, так как никто, казалось, не отвечал ... это определенно должно быть "почему не дженерики Java ...". Другая проблема заключается в том, что «generic» на самом деле является прилагательным, и поэтому «generic» относится к существительному падежному множественному числу, измененному как «generic». Вы могли бы сказать «эта функция является общей», но это было бы более громоздким, чем сказать «эта функция является общей». Однако немного громоздко говорить «у Java есть универсальные функции и классы», а не просто «у Java есть дженерики». Как человек, который написал магистерскую диссертацию по прилагательным, я думаю, что вы наткнулись на очень интересный вопрос!
Дантистон

Ответы:


918

Нет, List<Dog>это неList<Animal> . Подумайте, что вы можете сделать с List<Animal>- вы можете добавить к нему любое животное ... включая кошку. Теперь, вы можете логически добавить кошку в помет щенков? Точно нет.

// Illegal code - because otherwise life would be Bad
List<Dog> dogs = new ArrayList<Dog>(); // ArrayList implements List
List<Animal> animals = dogs; // Awooga awooga
animals.add(new Cat());
Dog dog = dogs.get(0); // This should be safe, right?

Внезапно у вас очень запутанная кошка.

Теперь вы не можете добавить Catк, List<? extends Animal>потому что вы не знаете, что это List<Cat>. Вы можете получить значение и знать, что оно будет Animal, но вы не можете добавлять произвольных животных. Обратное верно для List<? super Animal>- в этом случае вы можете безопасно добавить Animalк нему, но вы ничего не знаете о том, что можно извлечь из него, потому что это может быть List<Object>.


50
Интересно, что каждый список собак - это действительно список животных, как подсказывает нам интуиция. Дело в том, что не каждый список животных - это список собак, поэтому проблема с мутацией в списке при добавлении кошки.
Инго

66
@ Инго: Нет, не совсем: вы можете добавить кошку в список животных, но вы не можете добавить кошку в список собак. Список собак - это только список животных, если рассматривать его только для чтения.
Джон Скит

12
@JonSkeet - Конечно, но кто обязывает, что создание нового списка из кошки и списка собак фактически меняет список собак? Это произвольное решение о реализации в Java. Тот, который идет вразрез с логикой и интуицией.
Инго

7
@Ingo: я бы не использовал это «конечно» для начала. Если у вас есть список с надписью «Отели, в которые мы могли бы пойти», а затем кто-то добавил к нему бассейн, сочтете ли вы это правильным? Нет - это список отелей, а не список зданий. И я даже не сказал, что «список собак - это не список животных» - я обозначил это кодовыми терминами , кодовым шрифтом. Я действительно не думаю, что здесь есть какая-то двусмысленность. Использование подкласса в любом случае было бы некорректным - речь идет о совместимости присваиваний, а не о подклассах.
Джон Скит

14
@ruakh: проблема в том, что вы потом пытаетесь сделать что-то, что может быть заблокировано во время компиляции. И я бы сказал, что ковариация массива была ошибкой проектирования с самого начала.
Джон Скит

84

То, что вы ищете, называется параметрами ковариантного типа . Это означает, что если один тип объекта может быть заменен другим в методе (например, Animalможет быть заменен на Dog), то же самое относится к выражениям, использующим эти объекты (поэтому List<Animal>может быть заменено на List<Dog>). Проблема в том, что ковариация не является безопасной для изменяемых списков в целом. Предположим, у вас есть List<Dog>, и он используется как List<Animal>. Что происходит, когда вы пытаетесь добавить к этому кота, List<Animal>который на самом деле List<Dog>? Автоматическое разрешение ковариантности параметров типа нарушает систему типов.

Было бы полезно добавить синтаксис, позволяющий указывать параметры типа как ковариантные, что исключает ? extends Fooобъявления методов in, но добавляет дополнительную сложность.


44

Причина, по которой a List<Dog>не является a List<Animal>, заключается в том, что, например, вы можете вставить a Catв a List<Animal>, но не в List<Dog>... вы можете использовать подстановочные знаки, чтобы сделать дженерики более расширяемыми, где это возможно; например, чтение из a List<Dog>похоже на чтение из List<Animal>- но не запись.

В Обобщения в языке Java и секции по Дженерики из Java Tutorials имеют очень хорошее, глубокое объяснение, почему некоторые вещи являются или не являются полиморфными или разрешаться с обобщениями.


36

Я думаю, что к тому, что упоминают другие ответы, следует добавить

List<Dog>не List<Animal> в Яве

это также правда, что

Список собак - это список животных на английском языке (ну при разумной интерпретации)

То, как работает интуиция ОП - что, разумеется, совершенно верно, - это последнее предложение. Однако, если мы применим эту интуицию, мы получим язык, который не является Java-esque в своей системе типов: предположим, что наш язык позволяет добавлять кошку в наш список собак. Что бы это значило? Это означало бы, что список перестает быть списком собак и остается просто списком животных. И список млекопитающих, и список четвероногих.

Другими словами: A List<Dog>на Java не означает «список собак» на английском языке, это означает «список, в котором могут быть собаки и ничего больше».

В более общем смысле, интуиция OP ориентирована на язык, в котором операции над объектами могут изменять свой тип , или, скорее, тип (ы) объекта является (динамической) функцией его значения.


Да, человеческий язык более размытый. Но, тем не менее, когда вы добавляете другое животное в список собак, это все равно список животных, но больше не список собак. Разница в том, что человек с нечеткой логикой, как правило, не испытывает проблем с осознанием этого.
Власек

Как кто-то, кто находит постоянные сравнения с массивами еще более запутанными, этот ответ прибил его для меня. Моей проблемой была языковая интуиция.
FLonLon

32

Я бы сказал, что весь смысл Generics в том, что он этого не допускает. Рассмотрим ситуацию с массивами, которые допускают этот тип ковариации:

  Object[] objects = new String[10];
  objects[0] = Boolean.FALSE;

Этот код прекрасно компилируется, но выдает ошибку времени выполнения ( java.lang.ArrayStoreException: java.lang.Booleanво второй строке). Это небезопасно. Задача Generics состоит в том, чтобы добавить безопасность типов времени компиляции, в противном случае вы можете просто придерживаться простого класса без обобщений.

Сейчас бывают моменты, когда вам нужно быть более гибким, и для этого ? super Classи ? extends Classнужны. Первый - это когда вам нужно вставить в тип Collection(например), а второй - когда вам нужно читать из него безопасным для типа способом. Но единственный способ сделать оба одновременно - это иметь определенный тип.


13
Возможно, ковариация массива является ошибкой проектирования языка. Обратите внимание, что из-за стирания типа такое же поведение технически невозможно для универсального сбора.
Майкл Боргвардт

« Я бы сказал, что весь смысл Generics в том, что он этого не позволяет ». Вы никогда не можете быть уверены: системы типов Java и Scala не работают: экзистенциальный кризис нулевых указателей (представлен на OOPSLA 2016) (кажется, исправлено)
Дэвид Тонхофер,

15

Чтобы понять проблему, полезно сравнить с массивами.

List<Dog>не является подклассом List<Animal>.
Но Dog[] это подкласс Animal[].

Массивы являются переопределимыми и ковариантными .
Reifiable означает, что информация о их типе полностью доступна во время выполнения.
Поэтому массивы обеспечивают безопасность типа времени выполнения, но не безопасность типа времени компиляции.

    // All compiles but throws ArrayStoreException at runtime at last line
    Dog[] dogs = new Dog[10];
    Animal[] animals = dogs; // compiles
    animals[0] = new Cat(); // throws ArrayStoreException at runtime

Для дженериков все наоборот: дженерики стираются и
остаются неизменными .
Поэтому дженерики не могут обеспечить безопасность типов во время выполнения, но они обеспечивают безопасность типов во время компиляции.
В приведенном ниже коде, если генерики были ковариантными, в строке 3 будет возможно загрязнение кучи .

    List<Dog> dogs = new ArrayList<>();
    List<Animal> animals = dogs; // compile-time error, otherwise heap pollution
    animals.add(new Cat());

2
Можно утверждать, что именно из-за этого массивы в Java повреждены ,
leonbloy

Ковариантность массивов - это «особенность» компилятора.
Cristik

6

Ответы, данные здесь, не полностью убедили меня. Поэтому вместо этого я приведу другой пример.

public void passOn(Consumer<Animal> consumer, Supplier<Animal> supplier) {
    consumer.accept(supplier.get());
}

звучит нормально, не так ли? Но вы можете передать только Consumers и Suppliers для Animals. Если у вас есть Mammalпотребитель, но Duckпоставщик, они не должны подходить, хотя оба являются животными. Чтобы запретить это, были добавлены дополнительные ограничения.

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

Например,

public <A extends Animal> void passOn(Consumer<A> consumer, Supplier<? extends A> supplier) {
    consumer.accept(supplier.get());
}

гарантирует, что мы можем использовать только поставщика, который предоставляет нам правильный тип объекта для потребителя.

Ото, мы могли бы также сделать

public <A extends Animal> void passOn(Consumer<? super A> consumer, Supplier<A> supplier) {
    consumer.accept(supplier.get());
}

куда мы идем другим путем: мы определяем тип Supplierи ограничиваем его использование в Consumer.

Мы даже можем сделать

public <A extends Animal> void passOn(Consumer<? super A> consumer, Supplier<? extends A> supplier) {
    consumer.accept(supplier.get());
}

где, имея интуитивные отношения Life-> Animal-> Mammal-> Dogи Catт. д., мы могли бы даже поместить Mammalв Lifeпотребителя, но не Stringв Lifeпотребителя.


1
Среди 4 версий # 2, вероятно, неверно. например, мы не можем назвать его, (Consumer<Runnable>, Supplier<Dog>)пока DogподтипAnimal & Runnable
ZhongYu

5

Основой логики такого поведения является то, что Generics следуют механизму стирания типа. Таким образом, во время выполнения у вас нет никакого способа, если вы идентифицируете тип в collectionотличие от того, arraysгде нет такого процесса стирания. Итак, возвращаясь к вашему вопросу ...

Итак, предположим, что есть метод, описанный ниже:

add(List<Animal>){
    //You can add List<Dog or List<Cat> and this will compile as per rules of polymorphism
}

Теперь, если java позволяет вызывающей стороне добавлять Список типа Animal к этому методу, вы можете добавить в коллекцию неправильную вещь, и во время выполнения она также будет работать из-за удаления типа. Хотя в случае массивов вы получите исключение времени выполнения для таких сценариев ...

Таким образом, по сути, это поведение реализовано так, что нельзя добавить в коллекцию не то, что нужно. Теперь я считаю, что стирание типов существует, чтобы обеспечить совместимость с унаследованной Java без использования обобщений ....


4

Подтипирование является инвариантным для параметризованных типов. Даже если класс Dogявляется подтипом Animal, параметризованный тип List<Dog>не является подтипом List<Animal>. Напротив, ковариантныйDog[] подтип используется массивами, поэтому тип массива является подтипомAnimal[] .

Инвариантный подтип гарантирует, что ограничения типов, навязанные Java, не будут нарушены. Рассмотрим следующий код, данный @Jon Skeet:

List<Dog> dogs = new ArrayList<Dog>(1);
List<Animal> animals = dogs;
animals.add(new Cat()); // compile-time error
Dog dog = dogs.get(0);

Как утверждает @Jon Skeet, этот код недопустим, потому что в противном случае он нарушил бы ограничения типа, возвращая кошку, когда собака ожидала.

Поучительно сравнить приведенное выше с аналогичным кодом для массивов.

Dog[] dogs = new Dog[1];
Object[] animals = dogs;
animals[0] = new Cat(); // run-time error
Dog dog = dogs[0];

Код является законным. Однако выдает исключение хранилища массива . Массив несет свой тип во время выполнения, так что JVM может обеспечить безопасность типов ковариантного подтипирования.

Чтобы понять это далее, давайте посмотрим на байт-код, сгенерированный javapклассом ниже:

import java.util.ArrayList;
import java.util.List;

public class Demonstration {
    public void normal() {
        List normal = new ArrayList(1);
        normal.add("lorem ipsum");
    }

    public void parameterized() {
        List<String> parameterized = new ArrayList<>(1);
        parameterized.add("lorem ipsum");
    }
}

Используя команду javap -c Demonstration, это показывает следующий байт-код Java:

Compiled from "Demonstration.java"
public class Demonstration {
  public Demonstration();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public void normal();
    Code:
       0: new           #2                  // class java/util/ArrayList
       3: dup
       4: iconst_1
       5: invokespecial #3                  // Method java/util/ArrayList."<init>":(I)V
       8: astore_1
       9: aload_1
      10: ldc           #4                  // String lorem ipsum
      12: invokeinterface #5,  2            // InterfaceMethod java/util/List.add:(Ljava/lang/Object;)Z
      17: pop
      18: return

  public void parameterized();
    Code:
       0: new           #2                  // class java/util/ArrayList
       3: dup
       4: iconst_1
       5: invokespecial #3                  // Method java/util/ArrayList."<init>":(I)V
       8: astore_1
       9: aload_1
      10: ldc           #4                  // String lorem ipsum
      12: invokeinterface #5,  2            // InterfaceMethod java/util/List.add:(Ljava/lang/Object;)Z
      17: pop
      18: return
}

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

В заключение, безопасность во время выполнения невозможна для параметризованных типов, поскольку компилятор заменяет каждый параметризованный тип его стиранием. Это делает параметризованные типы не более чем синтаксическим сахаром.


3

На самом деле вы можете использовать интерфейс для достижения того, что вы хотите.

public interface Animal {
    String getName();
    String getVoice();
}
public class Dog implements Animal{
    @Override 
    String getName(){return "Dog";}
    @Override
    String getVoice(){return "woof!";}

}

Затем вы можете использовать коллекции с помощью

List <Animal> animalGroup = new ArrayList<Animal>();
animalGroup.add(new Dog());

1

Если вы уверены, что элементы списка являются подклассами этого заданного супертипа, вы можете привести список, используя этот подход:

(List<Animal>) (List<?>) dogs

Это полезно, когда вы хотите передать список в конструкторе или перебрать его


2
Это создаст больше проблем, чем решит на самом деле
Ferrybig

Если вы попытаетесь добавить Кошку в список, конечно, это создаст проблемы, но для целей цикла я думаю, что это единственный не многословный ответ.
Сагитс

1

ответ , а также другие правильные ответы. Я собираюсь добавить к этим ответам решение, которое, я думаю, будет полезным. Я думаю, что это часто встречается в программировании. Следует отметить, что для коллекций (списков, наборов и т. Д.) Основной проблемой является добавление в коллекцию. Вот где все рушится. Даже удаление в порядке.

В большинстве случаев мы можем использовать именно Collection<? extends T>тогда, Collection<T>и это должно быть первым выбором. Однако я нахожу случаи, когда это нелегко сделать. Это спор о том, всегда ли это лучше всего делать. Я представляю здесь класс DownCastCollection, который может преобразовать a Collection<? extends T>в a Collection<T>(мы можем определить аналогичные классы для List, Set, NavigableSet, ..), которые будут использоваться при использовании стандартного подхода, очень неудобно. Ниже приведен пример того, как его использовать (мы могли бы также использовать Collection<? extends Object>в этом случае, но я оставляю это простым для иллюстрации, используя DownCastCollection.

/**Could use Collection<? extends Object> and that is the better choice. 
* But I am doing this to illustrate how to use DownCastCollection. **/

public static void print(Collection<Object> col){  
    for(Object obj : col){
    System.out.println(obj);
    }
}
public static void main(String[] args){
  ArrayList<String> list = new ArrayList<>();
  list.addAll(Arrays.asList("a","b","c"));
  print(new DownCastCollection<Object>(list));
}

Теперь класс:

import java.util.AbstractCollection;
import java.util.Collection;
import java.util.Iterator;
import java.util.NoSuchElementException;

public class DownCastCollection<E> extends AbstractCollection<E> implements Collection<E> {
private Collection<? extends E> delegate;

public DownCastCollection(Collection<? extends E> delegate) {
    super();
    this.delegate = delegate;
}

@Override
public int size() {
    return delegate ==null ? 0 : delegate.size();
}

@Override
public boolean isEmpty() {
    return delegate==null || delegate.isEmpty();
}

@Override
public boolean contains(Object o) {
    if(isEmpty()) return false;
    return delegate.contains(o);
}
private class MyIterator implements Iterator<E>{
    Iterator<? extends E> delegateIterator;

    protected MyIterator() {
        super();
        this.delegateIterator = delegate == null ? null :delegate.iterator();
    }

    @Override
    public boolean hasNext() {
        return delegateIterator != null && delegateIterator.hasNext();
    }

    @Override
    public  E next() {
        if(!hasNext()) throw new NoSuchElementException("The iterator is empty");
        return delegateIterator.next();
    }

    @Override
    public void remove() {
        delegateIterator.remove();

    }

}
@Override
public Iterator<E> iterator() {
    return new MyIterator();
}



@Override
public boolean add(E e) {
    throw new UnsupportedOperationException();
}

@Override
public boolean remove(Object o) {
    if(delegate == null) return false;
    return delegate.remove(o);
}

@Override
public boolean containsAll(Collection<?> c) {
    if(delegate==null) return false;
    return delegate.containsAll(c);
}

@Override
public boolean addAll(Collection<? extends E> c) {
    throw new UnsupportedOperationException();
}

@Override
public boolean removeAll(Collection<?> c) {
    if(delegate == null) return false;
    return delegate.removeAll(c);
}

@Override
public boolean retainAll(Collection<?> c) {
    if(delegate == null) return false;
    return delegate.retainAll(c);
}

@Override
public void clear() {
    if(delegate == null) return;
        delegate.clear();

}

}


Это хорошая идея, настолько, что она уже существует в Java SE. ; )Collections.unmodifiableCollection
Radiodef

1
Правильно, но коллекцию, которую я определяю, можно изменить.
Дан б

Да, это может быть изменено. Collection<? extends E>уже обрабатывает это поведение правильно, если только вы не используете его способом, который не является типобезопасным (например, приведение его к чему-то другому). Единственное преимущество, которое я вижу, это когда вы вызываете addоперацию, она генерирует исключение, даже если вы ее произвели.
Власек

0

Давайте возьмем пример из учебника по JavaSE

public abstract class Shape {
    public abstract void draw(Canvas c);
}

public class Circle extends Shape {
    private int x, y, radius;
    public void draw(Canvas c) {
        ...
    }
}

public class Rectangle extends Shape {
    private int x, y, width, height;
    public void draw(Canvas c) {
        ...
    }
}

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

// drawAll method call
drawAll(circleList);


public void drawAll(List<Shape> shapes) {
   shapes.add(new Rectangle());    
}

Таким образом, у Java-архитекторов было два варианта решения этой проблемы:

  1. не считайте, что подтип неявно является его супертипом, и выдают ошибку компиляции, как это происходит сейчас

  2. Считайте, что подтип является его супертипом, и ограничивайте при компиляции метод «add» (поэтому в методе drawAll, если будет передан список окружностей, подтип формы, компилятор должен обнаружить это и ограничить вас ошибкой компиляции. который).

По понятным причинам, что выбрали первый путь.


0

Мы также должны принять во внимание, как компилятор угрожает универсальным классам: в «создает» другой тип всякий раз, когда мы заполняем универсальные аргументы.

Таким образом , мы имеем ListOfAnimal, ListOfDog, ListOfCatи т.д., которые являются различными классами , которые в конечном итоге «создал» компилятор , когда мы указуем общие аргументы. И это плоская иерархия (на самом деле это Listвообще не иерархия).

Другим аргументом, почему ковариация не имеет смысла в случае универсальных классов, является тот факт, что в основе все классы одинаковы - это Listэкземпляры. Специализация a Listпутем заполнения универсального аргумента не расширяет класс, а просто заставляет его работать с этим конкретным универсальным аргументом.


0

Проблема была хорошо определена. Но есть решение; сделайте что- нибудь общее:

<T extends Animal> void doSomething<List<T> animals) {
}

теперь вы можете вызывать doSomething с помощью List <Dog> или List <Cat> или List <Animal>.


0

Другое решение заключается в создании нового списка

List<Dog> dogs = new ArrayList<Dog>(); 
List<Animal> animals = new ArrayList<Animal>(dogs);
animals.add(new Cat());

0

В дополнение к ответу Джона Скита, который использует этот пример кода:

// Illegal code - because otherwise life would be Bad
List<Dog> dogs = new ArrayList<Dog>(); // ArrayList implements List
List<Animal> animals = dogs; // Awooga awooga
animals.add(new Cat());
Dog dog = dogs.get(0); // This should be safe, right?

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

// This code is fine
List<Dog> dogs = new ArrayList<Dog>();
dogs.add(new Dog());
List<Animal> animals = new ArrayList<>(dogs); // Copy list
animals.add(new Cat());
Dog dog = dogs.get(0);   // This is fine now, because it does not return the Cat

После вызова List<Animal> animals = new ArrayList<>(dogs);, вы не можете впоследствии напрямую назначить animalsлибо dogsили cats:

// These are both illegal
dogs = animals;
cats = animals;

поэтому вы не можете поместить неправильный подтип Animalв список, потому что нет неправильного подтипа - любой объект подтипа ? extends Animalможет быть добавлен animals.

Очевидно, это меняет семантику, поскольку списки animalsи dogsбольше не являются общими, поэтому добавление в один список не добавляет в другой (это именно то, что вы хотите, чтобы избежать проблемы, которую Catможно добавить в список, который является только должен содержать Dogобъекты). Кроме того, копирование всего списка может быть неэффективным. Однако это решает проблему эквивалентности типов, нарушая равенство ссылок.

Используя наш сайт, вы подтверждаете, что прочитали и поняли нашу Политику в отношении файлов cookie и Политику конфиденциальности.
Licensed under cc by-sa 3.0 with attribution required.