Контравариантность: почему Consumer ‹Animal› является подтипом Consumer ‹Cat›?

Этот пост завершает серию сообщений в блоге о трех типах дисперсии (in / co / contra). Java предлагает нам механизм различения этих трех случаев отклонения. Вы можете проверить инвариантность и ковариантность в наших предыдущих блогах: Часть 1 и Часть 2 соответственно. В этой третьей и заключительной части обсуждается контравариантность.

TL; DR:
1) Полезно знать, что влечет за собой инвариантность, ковариантность и контравариантность (Часть 1 и Часть 2 )
2) Определение подтипа : Если объект class.Animal можно легко заменить на объект class.Cat, то Cat является (или, по крайней мере, должен быть) подклассом Animal.
3) Точка № 2 становится более нечеткой, когда на сцену выходят универсальные шаблоны. Вся разница в том, что происходит, когда мы имеем дело со сложными типами (типами, которые зависят от некоторых других типов, в основном в дженериках). Иногда GenericType ‹Animal› является супертипом GenericType ‹Cat›. Это называется ковариация.
Иногда они не имеют ничего общего друг с другом. Это называется инвариантностью.
И, наконец, иногда GenericType ‹Animal› является подтипом GenericType ‹Cat›, как бы это ни звучало в обратном порядке. Это кажется действительно нелогичным, поскольку Animal - это супертип Cat, а некоторые GenericType ‹Animal› являются подтипами GenericType ‹Cat›. Странно, правда? Что ж, это контравариантность, и теперь вы можете перейти к другим статьям о среде, если это кажется вам явно очевидным. Если вам нужны подробности, продолжайте ниже.

Контравариантность:

Рассмотрим следующий взгляд на кошек, собак и животных в приведенном ниже примере вечнозеленого подтипа:
Все животные имеют имя и могут говорить. Имя представляет собой строку, хранящуюся в базовом классе. Animal, а String Speak () - абстрактный метод, который переопределяется как в Cat, так и в Dog:

Контравариантность - вот где начинается самое интересное с наследованием в Java. Дженерики в Java по умолчанию инвариантны. Они могут быть и обычно являются логически контравариантными. Давайте представим обратный мир, где некий GenericType ‹Animal› должен быть подтипом GenericType ‹Cat›, несмотря на то, что Animal является супертипом Cat.
В java GenericType ‹Cat› и GenericType ‹Animal› не имеют абсолютно ничего общего с друг друга за счет инвариантности. Чтобы универсальные типы в Java можно было назначать друг другу на основе универсального параметра, нам нужно использовать подстановочный знак. Но потерпите меня и действительно попробуйте представить перевернутый мир, в котором GenericType ‹Animal› является подтипом GenericType ‹Cat›.
Чтобы это произошло в Java, нам нужно использовать общий оператор контравариантности: ‹? супер Кот ›. А чтобы упростить задачу, давайте воспользуемся List ‹› в качестве фиктивного универсального типа для выбора.
Теперь: ссылка на Список ‹? super Cat ›может указывать на Список ‹Cat›, Список ‹Animal› или Список ‹Object›. Действительно наоборот. Особенно если учесть определение подклассов:
Если ссылку на Animal можно легко заменить ссылкой на Dog, тогда Dog является подклассом Animal, а Animal должен быть (и является) суперкласс Dog.

Вот код:

Обратите внимание, как List ‹? super Cat ›baseList [строка 2] может иметь ссылку на List ‹Cat› [строка 11], но также и на List ‹Animal› [строка 7].
Это немного странно, но, поскольку это компилируемое, по определению оно следует через этот List ‹? super Cat ›является базовым классом List ‹Animal›. Безумно верно ??

Так почему это важно?
Что ж, в контравариантных списках мы можем добавлять все, что захотим, если это Cat (или супертип Cat), в любой список, который может содержать Это. «Худшее», что может случиться, - это то, что наш базовый список типа List ‹? super Cat ›указывает на Список ‹Object›, но это не проблема, потому что мы можем добавить Кошек или Животных в Список ‹Object›. Это может быть полезно, если мы хотим, скажем, добавить кошек во все типы различных списков, некоторые из которых могут быть специфичными для кошек, некоторые из которых могут быть специфичными для животных, а некоторые из них могут быть специфичными для объектов.

Мы можем обобщить код для добавления кошек в любой список с помощью List ‹? супер Кот ›[строка 1].

Резюме списков: если мы хотим иметь базовый указатель для списков иерархии типов, мы должны рассмотреть 2 сценария:

a) (Ковариация) Мы хотим читать / удалять / отсортировать элементы, но не добавлять новые. В каком случае нам нужно использовать указатель базового списка типа List ‹? расширяет Animal ›. Об этом подробно рассказывается в предыдущем посте. Это означает, что в худшем случае этот список содержит животных. Обратите внимание, как теперь мы можем быть уверены, что получаем животное, но мы не можем быть уверены, что это экземпляр Animal.class, Cat.class или Dog.class. Более того, мы не можем быть уверены, что это исходит из списка ‹Cat›, List ‹Dog› или List ‹Animal›. Поэтому добавлять элементы немного сложно.

б) (Контравариантность). Мы хотим добавить элементы определенного типа (Cat в наших примерах), но не хотим читать элементы. В сценарии контравариантности мы знаем только, что они принадлежат к типу где-то между Object и Cat. Следовательно, каждый раз, когда нам нужно использовать объект из этого списка, нам нужно будет повышать не только от объекта, но и от типа. Не очень полезно, и, действительно, легче сказать, чем сделать.

Недостатки как a), так и b) можно обойти, если мы каким-то образом сохраняем информацию о том, какие элементы к какому типу относятся в списках, но это лишает смысла имея как базовый указатель, так и весь полиморфизм, который мы, программисты, объектно-ориентированные разработки все лелеем и любим. Более того, контравариантные списки почти не имеют смысла. Намного более полезный и естественный пример контравариантности - это интерфейс Consumer ‹T›.

Так почему же это важно (т. 2) с непринужденным примером?

Быстрый возврат: помните определение отношения подтип / супертип? Если ссылку на Animal можно легко заменить ссылкой на Dog, тогда Dog является подклассом Animal, а Animal должен быть (и является) суперклассом Dog.
Давайте переведем это определение на Потребитель ‹T› (действие). С точки зрения непрофессионала, мы можем сопоставить Consumer ‹T› Java, наиболее близкое к выражению «что-то делается с T».
Кошка - это животное, и все, что делается с животным, может быть сделано и с кошкой *. Если у нас есть какое-то действие, которое нужно сделать с Животным (Потребитель ‹Animal›), мы наверняка можем сделать то же действие с Кошкой (Потребитель ‹Cat›), верно?
Это будет означать, что Consumer ‹Animal› может заменить любого Consumer ‹Cat›.
Попробуйте сами: все, что сделали с животным, можно сделать с кошкой? Помните, что «сделано для / X» можно заменить на Consumer ‹X›. Итак, согласно определению, что «Потребитель ‹Animal› может заменить любого Consumer ‹Cat›», тогда Consumer ‹Cat› является (и должен быть) суперклассом Consumer ‹Animal›. Наследование естественно переворачивается. Сумасшедший.
Вернемся к некоторым примерам.
Если мы хотим напечатать имена всех кошек в списке, нам действительно не нужен Consumer ‹Cat›. Подойдет Consumer ‹Animal›, поскольку имя животного хранится в базовом классе.

Примечание. Я придерживаюсь очень либерального употребления IS, когда говорю о том, что что-то является подклассом. Пример: Если некоторые условия GenericType ‹Something› ДОЛЖНЫ БЫТЬ / ЕСТЬ суперклассом / подклассом.
Что ж, это зависит от языковой реализации. Некоторые вещи, которые теоретически являются суперклассом / подклассом, могут не разделять отношения наследования на конкретном языке.
По умолчанию универсальные шаблоны в Java являются инвариантными; Consumer ‹Dog› не имеет ничего общего с Consumer ‹Animal›. Однако Consumer ‹? super Dog ›имеет прямое отношение как к Consumer ‹Animal›, так и к Consumer ‹Dog›, и соответствует противоречащей интуиции ситуации контравариантности, которую мы имеем.
Хотя логично, Consumer ‹Dog› ДОЛЖЕН БЫТЬ базовым классом Consumer ‹Animal›, они инвариантны.
Вместо этого в Java:
Consumer ‹? супертип Dog ›ЯВЛЯЕТСЯ базовым классом Consumer ‹Animal›, и это именно то, что следует использовать при работе с Consumer‹ ›.

Так почему это важно (том 3.) на примере из жизни omni: us?

Здесь, в omni: us, мы имеем дело с большим количеством файлов. Один из способов сделать это - использовать потоки Java. Иногда мы получаем эти потоки из файловой системы, иногда из корзины хранилища, иногда из большого двоичного объекта базы данных. И когда, скажем, мы регистрируем количество обработанных файлов или просто копируем их из одного места в другое, нам все равно, откуда они. Но когда мы пытаемся подсчитать байты в нем, декодировать его или зарегистрировать производительность, зависящую от реализации, нам действительно нужно знать базовую реализацию. В (очень, очень упрощенном) коде концепция выглядит как эти две реализации:

Неуниверсальный:

Общий:

Допустим, нам нужно обработать файлы с помощью следующих 3 шагов (метод: void ingest (String) в двух реализациях):
1) Получить файл из некоторого хранилища [строка 17] и сохранить копию [строка 19]
2) Запустите темную магию AI [строка 21]
3) Выполните пост-обработку. До звонящего [строка 22].

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

Поэтому, если мы будем придерживаться ClassImplementationLossIngester, мы потеряем возможность вести журнал, зависящий от реализации, на этапе постобработки ([строка 11] не компилируется).
Однако, если мы выберем второй способ с GenericIngester, а также силу контравариантности, мы откроем возможность постобработки конкретных реализаций [строка 16], а также общих [строка 21] .

Это знаменует собой вторую реализацию GenericIngester как явного победителя в том, как мы должны это делать. Понимание контравариантности и того, где ее использовать, позволяет нам, как технической команде, создавать общие модули, которые могут взаимодействовать друг с другом на разных уровнях абстракции, при этом сохраняя типобезопасность. Кто бы ни создавал потребителя или поставщика, может решить, для какой конкретной реализации интерфейса InputStream они его пишут (это имело место еще до контравариантности). ОДНАКО, когда кто-то использует свою работу (при создании экземпляра загрузчика), он / она имеет такую ​​же свободу. Он / она может решить использовать конкретного поставщика и конкретного потребителя, или конкретного поставщика, и потребителя-генерика, или поставщика-дженерик, и потребителя-дженерика… вы меня поняли. Во всех этих случаях безопасность типов сохраняется. Никаких апкастов не требуется.

Вывод:
Обобщения в Java по умолчанию инвариантны по уважительной причине, чтобы оставаться типобезопасными. Вы можете заставить дженерики быть либо контравариантными, либо ковариантными, используя подстановочный знак. ‹? extends T ›означает ковариантный,‹? super T ›означает контравариантность.
Как правило, параметры выходного типа часто ковариантны, а параметры входного типа - контравариантны. Ярким примером является Consumer ‹T›. Практически во всех сценариях работы с универсальными потребителями Java's Consumer ‹T› следует использовать следующим образом: Consumer ‹? супер Т ›.

Используйте входные параметры с учетом контравариантности и держите своих собак и кошек в отдельных списках, чтобы не нарушать правила.