В некоторых ситуациях, особенно при реализации шаблона компоновщика или создании других свободно распространяемых API, методы возвращают this
. Тип возвращаемого значения метода, вероятно, будет таким же, как класс, в котором определен метод, но иногда это не сокращает его! Если мы хотим наследовать методы и их возвращаемый тип должен быть наследующим типом (а не объявленным типом), то нам не повезло. Нам нужно, чтобы возвращаемый тип был чем-то вроде «типа этого», часто называемого собственным типом, но в Java такого нет.
Или есть?
Несколько примеров
Прежде чем мы пойдем дальше, давайте рассмотрим некоторые ситуации, в которых типы самодеятельности пригодятся.
Object::clone
Очень хороший пример — скрытие на виду: Object::clone
. С некоторыми заклинаниями черной магии он создает копию объекта, на котором он вызывается. За исключением исключения, которое мы можем игнорировать здесь, оно имеет следующую подпись:
protected Object clone();
Обратите внимание на тип возвращаемого значения: Object
. Почему так вообще? Допустим, у нас есть Person
и мы хотели бы выставить clone
:
public class Person { // ... @Override public Person clone() { return (Person) super.clone(); } }
Мы должны переопределить метод, чтобы сделать его публично видимым. Но мы также должны привести результат super.clone()
, который является Object
, к Person
. Мысленно освобождаясь от системы типов Java на мгновение, мы можем понять, почему это странно. Что еще может вернуть clone
кроме экземпляра того же класса, для которого вызывается метод?
строитель
Другой пример возникает при использовании шаблона построителя . Допустим, нашему Person
нужен PersonBuilder
:
public class PersonBuilder { private String name; public PersonBuilder withName(String name) { this.name = name; return this; } public Person build() { return new Person(name); } }
Теперь мы можем создать человека следующим образом:
Person doe = new PersonBuilder() .withName("John Doe") .build();
Все идет нормально.
Теперь, скажем, у нас не только есть люди в нашей системе — у нас также есть сотрудники и подрядчики. Конечно, они также являются лицами (верно?), Поэтому Employee extends Person
а Contractor extends Person
. И поскольку с Person
все прошло хорошо, мы решили создать для них и строителей.
И тут начинается наше путешествие. Как мы устанавливаем имя в нашем EmployeeBuilder
?
Мы могли бы просто реализовать метод с тем же именем и кодом, что и в PersonBuilder
, дублируя его, за исключением того, что он возвращает EmployeeBuilder
вместо PersonBuilder
. И тогда мы делаем то же самое для ContractorBuilder
? И потом, когда мы добавляем поле в Person
мы добавляем три поля и три метода для наших сборщиков? Это не звучит правильно.
Давайте попробуем другой подход. Какой наш любимый инструмент для повторного использования кода? Право, наследство . (Хорошо, это была плохая шутка. Но в этом случае я бы сказал, что с наследованием все в порядке.) Итак, у EmployeeBuilder extend PersonBuilder
есть EmployeeBuilder extend PersonBuilder
и мы получаем withName
бесплатно.
Но хотя унаследованное withName
действительно возвращает EmployeeBuilder
, компилятор этого не знает — тип возвращаемого значения унаследованного метода объявлен как PersonBuilder
. Это тоже не хорошо! Предполагая, что EmployeeBuilder
выполняет некоторые специфические для сотрудника вещи (например, withHiringDate
), мы не можем получить доступ к этим методам, если мы вызываем в неправильном порядке:
Employee doe = new EmployeeBuilder() // now we have an EmployeeBuilder .withName("John Doe") // now we have a PersonBuilder .withHiringDate(LocalDateTime.now()) // compile error 🙁 .build();
Мы можем переопределить withName
в EmployeeBuilder
:
public class EmployeeBuilder { public EmployeeBuilder withName(String name) { return (EmployeeBuilder) super.withName(name); } }
Но для этого требуется почти столько же строк, сколько в исходной реализации. Повторение таких фрагментов в каждом подтипе PersonBuilder
для каждого унаследованного метода явно не идеально.
Сделав шаг назад, давайте посмотрим, как мы оказались здесь. Проблема в том, что возвращаемый тип withName
явно фиксирован для класса, который объявляет метод: PersonBuilder
.
public class PersonBuilder { public PersonBuilder withName(String name) { this.name = name; return this; } }
Таким образом, если метод наследуется, например, EmployeeBuilder
, тип возвращаемого значения остается прежним. Но это не должно! Это должен быть тип, для которого был вызван метод, а не тот, который его объявляет.
Эта проблема быстро возникает в свободно распространяемых API, например в шаблоне компоновщика, где тип возвращаемого значения имеет решающее значение для работы всего API.
Рекурсивные контейнеры
Наконец, скажем, мы хотим построить график:
public class Node { private final List<Node> children; public Stream<? extends Node> children() { return children.stream(); } }
В дальнейшем мы понимаем, что нам нужны разные виды узлов и что деревья могут содержать только узлы одного вида. Как только мы используем наследование для моделирования различных типов узлов, мы оказываемся в очень похожей ситуации:
public class SpecialNode extends Node { @Override public Stream<? extends SpecialNode> children() { return super.stream(); // compile error 🙁 } }
Stream<? extends Node>
Stream<? extends Node>
нет Stream<? extends SpecialNode>
Stream<? extends SpecialNode>
поэтому он даже не компилируется. Опять же, у нас есть проблема, которую мы хотели бы сказать: «мы возвращаем поток узлов того типа, для которого был вызван этот метод».
Self Типы на помощь
В некоторых языках есть концепция самоподобных типов:
Тип self относится к типу, для которого вызывается метод (более формально называется получателем ).
Если собственный тип используется в унаследованном методе, он представляет отдельный тип в каждом классе, который объявляет или наследует этот метод, а именно этот конкретный класс , независимо от того, объявил ли он или унаследовал метод. Случайно говоря, это эквивалент времени компиляции this.getClass()
или «тип этого».
В оставшейся части этого поста я отмечу это как [this]
(тоже было бы хорошо [self]
, но с [this]
мы получаем некоторую подсветку синтаксиса).
Примеры с Self-типами
Чтобы быть совершенно ясным: Java не имеет собственных типов . Но что, если бы это было? Как будут выглядеть наши примеры?
Object::clone
Object::clone
должен был вернуть копию экземпляра, к которому он был вызван. Этот экземпляр, конечно, должен быть того же типа, поэтому подпись может выглядеть следующим образом:
protected [this] clone();
Подклассы могут затем напрямую получить экземпляр своего собственного типа:
public class Person { // ... @Override public [this] clone() { // no cast required // because in this class [this] means Person Person clone = super.clone(); return clone; } }
строитель
Для строителей, о которых мы говорили выше, решение также очевидно. Мы просто объявляем все with...
методами, чтобы вернуть тип [this]
:
public class PersonBuilder { public [this] withName(String name) { this.name = name; return this; } }
Как и раньше EmployeeBuilder::withName
возвращает экземпляр EmployeeBuilder
но на этот раз компилятор знает это и то, что мы хотели раньше, теперь работает:
Employee doe = new EmployeeBuilder() // now we have an EmployeeBuilder .withName("John Doe") // still an EmployeeBuilder thanks to [this] .withHiringDate(LocalDateTime.now()) // works! 🙂 .build();
Рекурсивные контейнеры
Последнее, но не менее важное, давайте посмотрим, как работает наш график:
public class Node { private final List<[this]> children; public Stream<? extends [this]> children() { return children.stream(); } }
Теперь SpecialNode::children
возвращает Stream<? extends SpecialNode>
Stream<? extends SpecialNode>
, что именно то, что мы хотели.
Эмуляция Я Типов с Дженериками
Хотя у Java нет собственных типов, существует способ эмулировать их с помощью обобщений. Это ограничено и немного запутано, хотя.
Если бы у нас был параметр общего типа, относящийся к этому классу, скажем, THIS
, мы могли бы просто использовать его везде, где мы использовали [this]
выше. Но как мы THIS
получим? Просто (почти), мы просто добавляем THIS
в качестве параметра типа, и классы наследования определяют свой собственный тип как THIS
.
Чего ждать? Я думаю, что пример проясняет это.
public class Object<THIS> { protected THIS clone(); } public class Person extends Object<Person> { @Override public Person clone() { // no cast required because // in this class THIS was specified as Person Person clone = super.clone(); return clone; } }
Если это выглядит подозрительно (что вообще означает Object<Person>
?), Вы уже наткнулись на одну из слабых сторон этого подхода, но мы рассмотрим это через минуту. Во-первых, давайте рассмотрим это немного подробнее и проверим другие наши примеры.
public class PersonBuilder<THIS> { private String name; public THIS withName(String name) { this.name = name; return (THIS) this; // if we do this more often, '(THIS) this' // should become its own method } } public class EmployeeBuilder extends PersonBuilder<EmployeeBuilder> { }
Теперь EmployeeBuilder::withName
возвращает EmployeeBuilder
без необходимости что-либо делать.
Точно так же наши проблемы с Node
исчезают:
public class Node<THIS> { private final List<THIS> children; public Stream<? extends THIS> children() { return children.stream(); } } public class SpecialNode extends Node<SpecialNode> { }
Как и в случае с [this]
, нам не нужен дополнительный код в SpecialNode
.
Ограничения и недостатки
Это выглядит не так уж плохо, верно? Но есть некоторые ограничения и недостатки, которые мы должны сгладить.
Запутанная абстракция
Как я уже говорил выше, что вообще означает Object<Person>
? Это объект, который держит, создает, обрабатывает или иным образом имеет дело с человеком? Потому что именно так мы обычно понимаем аргумент общего типа. Но это не так — это просто «объект личности», что довольно странно.
Запутанные типы
Также неясно, как объявлять супертипы сейчас. Это Node
, Node<Node>
или даже больше Node
s? Учтите следующее:
Node<Node> node = new Node<>(); Stream<Node> children = node.children(); Stream grandchildren = children .flatMap(child -> child.children());
Вызвав children
дважды, мы «израсходовали» общие типы, которые мы объявили для node
и теперь мы получаем необработанный поток. Обратите внимание, что благодаря рекурсивному объявлению SpecialNode extends Node<SpecialNode>
у нас нет такой проблемы:
SpecialNode node = new SpecialNode(); Stream<SpecialNode> children = node.children(); Stream<SpecialNode> grandchildren = children .flatMap(child -> child.children());
Все это запутает и в конечном итоге оттолкнет пользователей таких типов.
Наследование на одном уровне
Хитрость в том, чтобы заставить SpecialNode
вести себя так, как ожидалось при повторных вызовах для children
означало, что у него не было своего собственного параметра типа. Теперь тип, расширяющий его, нигде не может указать себя, поэтому его методы возвращают специальные узлы:
public class VerySpecialNode extends SpecialNode { } VerySpecialNode node = new VerySpecialNode(); // we a want Stream<VerySpecialNode> Stream<SpecialNode> children = node.children(); // damn 🙁
Это слишком универсально
В таком виде THIS
может быть любого типа, что означает, что мы должны рассматривать его как Object
. Но что, если мы хотим сделать что-то с нашими экземплярами этого, что является специфическим для нашего текущего класса?
public class Node<THIS> { // as before, especially `children` public Stream<THIS> grandchildren() { // doesn't compile because `child` is no `Node` // and hence has no `children` method return children.flatMap(child -> child.children()); } }
Ну, это просто глупо.
Уточнение подхода
Давайте посмотрим, сможем ли мы сделать немного лучше, чем раньше, и разобраться с некоторыми из этих слабостей. И мы действительно можем что-то сделать, чтобы решить все проблемы, которые мы только что обсудили.
Рекурсивные Дженерики
Прежде всего, давайте посмотрим на THIS
слишком общий. Это можно легко исправить с помощью рекурсивных обобщений:
public class Node<THIS extends Node<THIS>> { // as before, especially `children` public Stream<THIS> grandChildren() { // hah, now `child` is a `Node` return children.flatMap(child -> child.children()); } }
(Я сказал «легко», а не «просто».)
Но это значительно усугубляет проблему запутанных типов. Теперь даже невозможно объявить Node
потому что компилятор всегда ожидает другой параметр типа:
// doesn't compile // the fourth `Node` is not // within its type bounds of `Node<Node>` Node<Node<Node<Node>>> node = new Node<>();
Частная Иерархия
Мы можем исправить это, хотя и оставшиеся проблемы с другим обходом.
Мы можем создать иерархию абстрактных классов, которые содержат весь код, о котором мы говорили до сих пор. Конкретные реализации, которые будут использовать наши клиенты, являются потомками этой иерархии. Поскольку никто никогда не будет напрямую использовать абстрактные классы, мы можем использовать THIS
на каждом уровне наследования — реализации будут его указывать.
В идеале абстрактные классы являются видимыми в пакете, поэтому их отвратительность скрыта от внешнего мира.
abstract class NodeScaffold<THIS extends NodeScaffold<THIS>> { private final List<THIS> children; public Stream<THIS> children() { return children.stream(); } public Stream<THIS> grandChildren() { return children.stream() .flatMap(child -> child.children()); } } abstract class SpecialNodeScaffold<THIS extends SpecialNodeScaffold<THIS>> extends NodeScaffold<THIS> { // special methods } abstract class VerySpecialNodeScaffold<THIS extends VerySpecialNodeScaffold<THIS>> extends SpecialNodeScaffold<THIS> { // more special methods } public class Node extends NodeScaffold<Node> { } public class SpecialNode extends SpecialNodeScaffold<SpecialNode> { } public class VerySpecialNode extends VerySpecialNodeScaffold<VerySpecialNode> { }
Небольшая деталь: общедоступные классы не наследуются друг от друга, поэтому SpecialNode
является Node
. Если бы мы сделали то же самое со сборщиками, это могло бы быть в порядке, но это неудобно с узлами. Чтобы исправить это, нам нужен еще один уровень абстракции, а именно некоторые интерфейсы, которые расширяют друг друга и реализуются общедоступными классами.
Теперь пользователи открытых классов видят четкие абстракции и никаких запутанных типов. Подход работает на произвольном множестве уровней наследования, и THIS
всегда относится к наиболее конкретному типу.
THIS
как тип аргумента
Вы могли заметить, что все примеры используют [this]
и THIS
в качестве возвращаемого типа. Разве мы не можем использовать его как тип для аргументов? К сожалению, не потому, что, хотя возвращаемые типы являются ковариантными, типы аргументов являются контравариантными — и [this]
/ THIS
по своей природе ковариантны. (Проверьте этот вопрос StackOverflow для краткого объяснения этих терминов.)
Если вы попытаетесь это сделать (например, добавив void addChild(THIS node)
), следуя описанному выше подходу, то, похоже, все будет работать, пока вы не попытаетесь создать интерфейсы, которые приводят Node
, SpecialNode
и VerySpecialNode
в отношения наследования. Тогда тип node
не может стать более конкретным при переходе по дереву наследования.
Этот пост уже достаточно длинный, поэтому я оставлю подробности в качестве упражнения любопытному читателю.
Резюме
Мы поняли, почему нам иногда нужно ссылаться на «тип этого» и как это может выглядеть для языковой функции. Но у Java нет такой возможности, поэтому нам пришлось придумать несколько хитростей, чтобы сделать это самим:
- мы определили методы, которые мы хотим наследовать в абстрактных классах (
A
) - мы дали им рекурсивный параметр универсального типа (
A<THIS extends A<THIS>>
) - мы создали публично видимые конкретные реализации, которые определяют себя как этот тип (
C extends A<C>
) - при необходимости мы создаем дерево наследования интерфейса, которое реализуют наши конкретные классы
Стоило ли это всех усилий и хитрости, решать вам и зависит от варианта использования, который у вас есть. Вообще говоря, чем больше методов вы можете унаследовать таким образом, тем больше вы почувствуете преимущества (посмотрите на реализацию AssertJ API, чтобы узнать, сколько это может быть). И наоборот, трения от понимания этого шаблона уменьшаются, когда классы, которые вы создаете таким образом, являются фундаментальными для вашей кодовой базы — в соответствии с «если это больно, делайте это чаще». Если оно останется второстепенным решением, разработчики не будут знать, что оно есть, и случайно и неожиданно столкнутся с ним, будучи более легко запутанными.
Как вы думаете? Вы видите вариант использования в своей кодовой базе? Мне интересно услышать об этом, поэтому оставьте комментарий.