Статьи

Самостоятельные типы с обобщением Java

В некоторых ситуациях, особенно при реализации шаблона компоновщика или создании других свободно распространяемых 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, чтобы узнать, сколько это может быть). И наоборот, трения от понимания этого шаблона уменьшаются, когда классы, которые вы создаете таким образом, являются фундаментальными для вашей кодовой базы — в соответствии с «если это больно, делайте это чаще». Если оно останется второстепенным решением, разработчики не будут знать, что оно есть, и случайно и неожиданно столкнутся с ним, будучи более легко запутанными.

Как вы думаете? Вы видите вариант использования в своей кодовой базе? Мне интересно услышать об этом, поэтому оставьте комментарий.