Статьи

Эволюция интерфейса с методами по умолчанию — Часть II: Интерфейсы

Методы по умолчанию были введены для обеспечения развития интерфейса. Если обратная совместимость является священной, это ограничивается добавлением новых методов в интерфейсы (что является их исключительным использованием в JDK). Но если ожидается, что клиенты будут обновлять свой код, можно использовать методы по умолчанию для постепенного развития интерфейсов, не вызывая ошибок компиляции, что дает клиентам время для обновления своего кода до новой версии интерфейса.

В первой части этого мини-сериала объяснялось, как реализации по умолчанию позволяют добавлять, заменять и удалять методы, не нарушая клиентский код. Я по глупости объявил, что «в будущем посте будут рассмотрены способы замены целых интерфейсов» — также без нарушения клиентского кода.

Ну, вы читаете этот пост сейчас, и вот печальное резюме:

Я не мог заставить это работать.

Почему? Обобщения.

Почему именно? Вы действительно хотите знать? Что ж, читайте дальше, но остальная часть поста — всего лишь описание того, как я оказался на контрольно-пропускном пункте, поэтому не ожидайте слишком многого от него. (Отличный стимул, а?)

обзор

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

Постановка проблемы

Вот что мы хотим сделать:

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

Теперь вы хотите существенно изменить интерфейс: переименовать его, переместить или изменить его так, чтобы это нельзя было выразить изменениями отдельных методов. (Но оба интерфейса все равно эквивалентны в том смысле, что могут быть предоставлены адаптеры для перехода с одной версии на другую.)

Вы можете просто сделать это, выпустить новую версию с изменениями и сказать своим клиентам, чтобы они исправили их ошибки компиляции. Если их код тесно связан с вашим, им, возможно, придется сделать это в отдельной ветке, чтобы потратить некоторое время на это, но это жизнь, верно? Вы действительно хороший парень / гал, однако, вместо того, чтобы требовать день флага, вы хотели бы дать им возможность постепенно менять свой код с течением времени (например, до следующего выпуска) без каких-либо ошибок компиляции.

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

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

Идея

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

Развивающиеся методы интерфейса

Использование методов по умолчанию для добавления, замены или удаления отдельных методов интерфейса довольно просто и обычно состоит из трех этапов (в некоторых случаях меньше):

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

Если вас интересует более подробное описание этих шагов, вы можете прочитать мой предыдущий пост .

Эволюция интерфейса

Этот подход, похоже, имел большой смысл и для этого случая, поэтому я сел, чтобы обыграть его.

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

  • внутреннее использование, где вы владеете реализацией и кодом, используя интерфейс
  • опубликованное использование, где вы владеете реализацией, но клиент выполняет вызовы кода
  • внешнее использование, где клиент владеет реализацией и кодом, используя интерфейс

Часть, которая работает, следует тому же подходу, что и развивающиеся методы:

  • Новая версия: выпуск новой версии с новым интерфейсом, который расширяет старый. Пусть весь внутренний код реализует и использует новый интерфейс. Весь опубликованный код будет использовать старый интерфейс для объявления типов аргументов и новый интерфейс для возвращаемых типов. Если необходимо преобразовать экземпляры, это можно сделать с помощью адаптера. Пока игнорируем параметризованные типы, это изменение не приведет к ошибкам компиляции в клиентском коде.
  • Переход: после выпуска клиенты меняют свой код. Начиная с реализаций старого интерфейса (которые были изменены для реализации нового) и экземпляров, возвращаемых опубликованным кодом, они могут начать объявлять экземпляры нового типа, обновлять типы аргументов методов, которым они передают их, и т. Д. на. При необходимости адаптер можно временно использовать для взаимодействия со старыми экземплярами через новый интерфейс.
  • Новая версия: выпуск версии, которая удаляет старый интерфейс.

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

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

Контрольно-пропускной пункт

Важнейшим элементом представленного подхода является опубликованный код. Он вызывается вашими клиентами, поэтому первый выпуск должен изменить его совместимым образом. И поскольку весь внутренний код требует нового интерфейса, он должен сделать шаг от Old к New .

Без дженериков это может выглядеть так:

Преобразование старого в новое в опубликованном коде

01
02
03
04
05
06
07
08
09
10
11
12
13
14
// in version 0
public Old doSomething(Old o) {
    // 'callToInternalCode' requires an 'Old'
    callToInternalCode(o);
    return o;
}
 
// in version 1 the method still accepts 'Old' but returns 'New'
public New doSomething(Old o) {
    // 'callToInternalCode' now requires a 'New'
    New n = o.asNew();
    callToInternalCode(n);
    return n;
}

Хорошо, пока все хорошо. Теперь давайте посмотрим, как это может выглядеть с генериками.

Преобразование «старого» в «новое» в опубликованном коде — Generics

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
// in version 0
public Container<Old> doSomething(Container<Old> o) {
    // 'callToInternalCode' requires a 'Container<Old>'
    callToInternalCode(o);
    return o;
}
 
// in version 1
// doesn't work because it breaks assignments of the return value
public Container<New> doSomething(Container<Old> o) {
    // 'callToInternalCode' requires a 'Container<New>'
    // but we can not hand an adapted version to 'callToInternalCode'
    // instead we must create a new container
    New nInstance = o.get().asNew();
    Container<New> n = Container.of(nInstance);
    callToInternalCode(n);
    return n;
}

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

  • Из-за неизменности обобщений в Java все присваивания возвращаемого значения будут нарушены:

    Распределение Инвариантности

    1
    2
    3
    Container<Old> old = // ...
    // works in version 0; breaks in version 1
    Container<Old> o = published.doSomething(old);
  • Тот же экземпляр Container не может быть передан из опубликованного во внутренний код. Это приводит к двум проблемам:
    • Создание нового контейнера может быть трудным или невозможным.
    • Изменения, вносимые внутренним кодом в новый контейнер, не распространяются на контейнер, переданный внешним кодом.

Черт…

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

Возможные обходы

После того, как я ударился головой о стену какое-то время, я все еще не вижу общего способа решить эту проблему. Но я пришел с некоторыми идеями, которые могут помочь в решении особых случаев.

Wildcards

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

В зависимости от ситуации это может привести к решению.

Специализированные интерфейсы, классы, экземпляры

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

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

Адаптеры для контейнеров

Вы можете предоставить адаптеры для контейнеров, которые используются со старым интерфейсом в опубликованном коде. По сути, это позволит вам вызывать asNew() для этих контейнеров.

(По несвязанной причине в настоящее время я работаю над такими преобразованиями для некоторых коллекций JDK. Следующая версия LibFX будет содержать их; если вам интересно, вы уже можете посмотреть демонстрацию на GitHub .)

Винт это!

Все это и для чего? Чтобы клиент не создал ветку, потратил некоторое время на исправление ситуации, прежде чем объединить все обратно с master? Винт это!

На данный момент, это мое мнение по этому вопросу. Хотя эволюция интерфейса проходит гладко до тех пор, пока вы работаете только с отдельными методами, становится все труднее, если вы хотите заменить целые интерфейсы. Поэтому, если нет достаточно веских причин для введения всей этой сложности, я бы просто сделал это сложным путем и позволил бы клиенту разобраться. Или не делать это вообще.

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

отражение

Мы повторили, как методы по умолчанию могут использоваться для эволюции интерфейса с последовательностью из трех частей Release, Transition, Release. Хотя это работает для отдельных методов, мы увидели, что он не может заменить целые интерфейсы. Основная проблема заключается в том, что неизменность параметрических типов не позволяет нам использовать опубликованный код в качестве адаптивного слоя.

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

Я что-то упустил? Или вся идея просто глупая? Почему бы не оставить комментарий!