Антон Мальцев
24 февраля 2020 • 4 минуты чтения

О концепции ковариантности возвращаемых типов

Хорошо известно, что Java – объектно-ориентированный язык. Однако многие программисты, которые начинают работать с Java, пишут этот код не в объектно-ориентированном стиле. В программах новичков часто присутствует сильная зависимость между классами, которую можно избежать при более тщательном продумывании иерархии сущностей.

 

Ещё одной «детской» болезнью является чрезмерное увлечение наследованием в ущерб обычной композиции. Эти и многие другие негативные явления связаны с недостаточным пониманием некоторых аспектов объектно-ориентированного программирования в целом и их реализации на языке Java в частности. Одним из таких аспектов является концепция ковариантности возвращаемых типов (появилась в JAVA SE5), речь о которой пойдёт ниже.

 

Поставим вопрос: «Как может изменяться тип возвращаемого значения при переопределении метода?»

 

Если речь идёт о простых типах, то ответ знают почти все: тип возвращаемого значения переопределённого метода должен быть в точности таким же, как тип возвращаемого значения переопределяемого метода. Даже попытка заменить тип возвращаемого значения типом с более узким диапазоном значений приведёт к ошибке компиляции.

 

Пример 1

 

Совсем по-другому обстоят дела, когда тип, возвращаемый переопределяемым методом не является простым. Рассмотрим следующую иерархию классов.

 

Пример 2

 

Прежде всего, введём в рассмотрение иерархию фабрик, каждая из которых имеет единственный метод produce() для производства продукции соответствующего типа.

 

Пример 3

 

Очевидно, что для всех методов produce() в предложенной иерархии фабрик можно было бы выбрать типом возвращаемого значения Product: тип, который возвращает фабрика, находящаяся на вершине иерархии. Однако не является ли более логичной ситуация, когда Молочная фабрика (MilkFactory) производит именно Молоко (Milk), а не некий абстрактный продукт Product? Аналогично, вполне естественно ожидать, что Фабрика сладостей (SweetsFaсtory) производит сладости (Sweets), а Шоколадная фабрика (ChocolateFactory) производит Шоколад (Chocolate).

 

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

 

Чтобы понять описанную конструкцию, разберём несколько простых вопросов

 

ВОПРОС:  Может ли метод produce() класса ChocolateFactory использовать Product в качестве типа возвращаемого значения? (код других классов ПРИМЕРА 3 предполагается неизменным)

 

ОТВЕТ: Нет, компилятор такого не допустит. Дело в том, что родительским классом для ChocolateFactory является класс SweetsFactory. Метод produce() этого класса возвращает Sweets, а класс Product не является подклассом Sweets. Однако метод produce() класса ChocolateFactory вполне может использовать класс Chocolate в качестве типа возвращаемого значения (как это, собственно, и сделано в ПРИМЕРЕ3). Это допустимо, так как класс Chocolate является подклассом Sweets. Также для указанного метода есть возможность возвращать непосредственно  Sweets.

 

ВОПРОС: Может ли метод produce() класса MilkFactory использовать класс Chocolate в качестве типа возвращаемого значения? (код других классов ПРИМЕРА 3 предполагается неизменным)

 

ОТВЕТ: Да, синтаксически это вполне допустимо (хотя и не вполне логично с точки зрения наименований). Дело в том, что класс Chocolate является наследником класса Product, пусть и не прямым. Поэтому срабатывает концепция ковариантности возвращаемых типов.

 

Что в итоге?

 

Подытожив все вышенаписанное, делаем следующий вывод. Мы доказали, что переопределённый метод класса-наследника возвращает конкретное значение. Тип этого значения — наследник другого типа, который в свою очередь возвращает переопределяемый метод класса-родителя. На простых примерах мы продемонстрировали, как именно функционирует вышеизложенная схема.