Системный подход к Agile: исследование совместимостей Java библиотек. agile.. agile. binary compatibility.. agile. binary compatibility. compatibility.. agile. binary compatibility. compatibility. Java.. agile. binary compatibility. compatibility. Java. JavaScript.. agile. binary compatibility. compatibility. Java. JavaScript. библиотеки.. agile. binary compatibility. compatibility. Java. JavaScript. библиотеки. Инженерные системы.. agile. binary compatibility. compatibility. Java. JavaScript. библиотеки. Инженерные системы. совместимость.. agile. binary compatibility. compatibility. Java. JavaScript. библиотеки. Инженерные системы. совместимость. Управление разработкой.. agile. binary compatibility. compatibility. Java. JavaScript. библиотеки. Инженерные системы. совместимость. Управление разработкой. экосистема.

Java называют языком программирования. С формальной точки зрения это может быть и так. На практике картина более широкая. Я утверждаю, что Java — это технология: целая программная система для гибкой (agile) разработки. В ней можно выделить четыре ключевых подсистемы, которые вместе образуют Java платформу:

  1. Система ведения онтологии, то есть модели мира из набора абстрактных концептов, которые каждый разработчик переносит к себя в голову через когнитивные ассоциации.

  2. Система формальной верификации типов со строгими контрактами, дженериками и всей этой математикой про ковариантность и правила подстановки.

  3. Система среды исполнения динамического кода с линковщиком внутри JVM, который связывает символические ссылки между загруженными библиотеками.

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

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

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

Гибкий подход (Agile / Агиль) — это не отсутствие жестких рамок, а наоборот, предельная дисциплина в управлении границами зон ответственности и наличие мостов (bridges), связывающих их, через которые инженер может ловко перемещаться, чтобы свободно подстраивать модель в самых труднодоступных местах. 

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

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

Настоящий Агиль — это не просто переход от “водопада” к “водовороту”, а возможность свободно перемещаться над хаосом смешанных стадий разработки, сохраняя баланс и ловкость.

Для доказательства возьму конкретную тему совместимостей библиотек. Этот вопрос с технической стороны хорошо изучен, и разделяют три вида совместимостей: исходную (по компилятору), бинарную (по линковщику) и поведенческую (по семантике). Хотя все эти моменты задокументированы и существуют готовые решения ⤴ по учету совместимостей, все равно будет полезным изучить их еще раз, но уже разбив не просто по этим трём категориям, а по зонам ответственности, в которых они применяются. Надеюсь, это будет полезно, ведь согласно опросам ⤴ , даже эксперты могут допускать ошибки в этом вопросе.

Формат

Это — не только техническая статья: я хочу доказать, что инженерия гораздо шире программирования. В Java 27 вводятся всё новые улучшения производительности, но опять же, это все программирование. С приходом ИИ все как будто бы и забыли про методологии. Однако это волшебная пилюля сегодня может привести к очень плачевным последствиям через несколько лет, когда накопится масса немодульного спагетти кода, который нужно как-то поддерживать, а цены на промты из-за возможных энергетических и климатических кризисов вырастут в десятки, если не сотни раз.

Восхваляя агентов, тех. индустрия с их бездонным венчурным капиталом на самом деле запустила бомбу замедленного действия, но выход есть, если всё-таки немного образумится и дать возможность настоящим специалистам индустрии ПО тоже сказать своё слово пока не поздно. Это первая часть технического документа (whitepaper) о новом стеке на базе GraalVM.

Начнем с того, что я быстро дам контекст того, откуда идет методология гибкой разработки и как это связано с Явой. Затем, основной текст будет разделен по этапам вроде дизайн/реализация, где четко показано, как на каждом может возникнуть несовместимость. Чтобы выстроить доводы для тезиса, в разделе про верификацию, будут также изложены:

  • принцип подстановки Лисковой;

  • мнемоники по определению ковариантности и контравариантности;

  • разница между структуральным и номинальным типированием;

  • как вообще понять формальную верификацию и её лимиты;

  • чем интенсионал отличается от экстенсионала;

  • почему нужно программировать к интерфейсам, а не реализации;

  • в углубленном виде описаны правила вариативности в 3х группах дженериков;

  • и, между делом, сформулирована критика функционального программирования;

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

От водопада к водовороту

Классическая каскадная ⤴ модель (или водопад, Waterfall) делила разработку на строгие последовательные этапы: анализ, проектирование, реализацию, верификацию и сопровождение. Первый кризис ⤴ ПО вскрыл главную проблему такого подхода — реальность отказывалась укладываться в линейную схему. Итерации между стадиями растягивались на месяцы, а главное — разрыв между проектированием и кодом приводил к тому, что модель существовала отдельно от реализации, и они неизбежно расходились со временем.

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

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

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

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

Так Ява становится уже не просто языком, а целой инженерной системой. Ступени перерастают из линейных фаз в параллельные зоны ответственности, которые участвуют в едином круговороте гибкой разработки с мгновенной петлёй обратной связи.

В отличие от водопада, в водовороте границы между стадиями стёрты. Но Агиль ли это?

В отличие от водопада, в водовороте границы между стадиями стёрты. Но Агиль ли это?

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

Совместимость на всех стадиях

В свете вышесказанного важно понимать: любое изменение кодовой базы в процессе эволюции проекта может бить сразу по нескольким зонам ответственности. Далее рассмотрим каждую по отдельности с примерами.

1) Анализ

Сегодня мы работаем на экономику знаний, где основным фактором производства является информация. Мы перестали быть программистами в узком смысле и стали инженерами знания, которые управляют высоко-абстрактными концептами. Ява в этом свете, это не только виртуальная машина, а ещё и инструмент, где мы моделируем онтологии.

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

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

Расширение мира

Как это сказывается на совместимости? Давайте приведу пример. Допустим, мы делаем ПО для компании, занимающейся грузоперевозками, в том числе и животных.

class CargoService {
  transportAnimal(Animal animal) {
    if(animal instanceof Dog) {
      transportWithoutPermit(animal); // разрешение не нужно
    } else if (animal instanceof Parrot) {
      var permit = getPermit(animal);
      transportWithPermit(animal, permit); // с разрешением
    }    
  }
}

Сервис использует стороннюю библиотеку, которая предоставляет список животных. Метод transportAnimal различает между животными, которых можно транспортировать без разрешения (таких как Dog), и для которых нужно получить разрешение (Parrot).

interface Animal {}
class Dog implements Animal {}
class Parrot implements Animal {}

При разработке, зная, что животных всего два, мы хард-кодим проверку по самому классу животного. Затем, выходит новая версия библиотеки animals2j, где вводится новый зверь:

class Wolf extends Dog {}

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

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

Да, такая ситуация немного надумана, но в эпоху вайб-кодинга, когда человек не контролирует, что делает код или агент, вполне возможно в том или ином виде. Именно поэтому реальная ценность инженера ПО не в том, чтобы закинуть промт в чат и получить алгоритм, а в том, чтобы поддерживать актуальную модель корпоративной онтологии и предвидеть проблемы. Агентский код будет работать в 99% случаях, но кто будет отвечать за 1% критических ошибок?

Системный подход к Agile: исследование совместимостей Java библиотек - 3

Внутренняя логика

Чисто с технической точки зрения, изменение семантического ядра через внедрение интерфейса тоже может повлиять на логику клиента, если если тот использует instanceof:

if (obj instanceof Serializable) {
    saveToDisk(obj);
} else if (obj instanceof Service) {
    closeRequest(obj.requ); // ожидаемая ветка для сервиса
}

Если фреймворк предоставляет Service, в голове разработчика может сложиться такая картина, Serializable и Service являются ортогональными классами, и можно применить else if. Немного дурной код, но вполне возможно. Теперь же, при включении автором Serializable в список интерфейсов сервиса, будет исполнена другая ветка логики, и какие-то важные операции перестанут исполняться.

Смена стратегии сериализации

Похожая ситуация может сложиться из-за того, что многие JSON-сериализаторы меняют логику, если класс внезапно становится “коллекцией” или “картой”.

  • Сценарий: Автор решает, что класс DataContainer теперь должен вести себя как Iterable и добавляет implements Iterable<T>.

  • Логика клиента: Клиент отправляет этот объект по REST API.

  • F-up: Раньше Jackson сериализовал объект как обычный POJO: {"field1": "val"}. Теперь он видит Iterable и сериализует его как массив: ["item1", "item2"].

  • Результат: На стороне фронтенда или другого микросервиса всё падает, так как они ожидали объект {...}, а получили список [...].

Инверсия управления

Inversion of Control (IoC) является одним из основных принципов дизайна, использующихся во фреймворках. На нем, например, основан паттерн посетителя (visitor pattern), где, при передаче объекта нашей библиотеке, мы выполняем в нем определенный метод. Считается ⤴, что добавление метода в интерфейс бинарно-совместимо. Так ли это?

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

public class Framework { 
  public static interface IEntity {
    void hello();
  }
  
  void lifeCycle(IEntity entity) {
    entity.hello();
  }
}

// клиент создает компонент для фреймворка
class Entity implements Framework.IEntity {
  public void hello() {
      System.out.println("привет от кода клиента");
  }
}

Однако представим, что фреймворк с такой инверсией контроля обновляется:

public class Framework { 
  public static interface IEntity {
    void hello();
    void world(); // добавляется новый метод
  }
  
  void lifeCycle(IEntity entity) {
    entity.hello();
    entity.world(); // вызывается новый метод
  }
}

// клиент остался тем же

Из-за того, что зависимость клиента обновилась, но сами компоненты — нет, то во время исполнения возникнет ошибка:

Exception in thread "main"java.lang.AbstractMethodError: Receiver class Entity does not define or inherit an implementation of the resolved method 'abstract void world()' of interface Framework.IEntity.

Реальные примеры, когда это может случится:

  1. JDBC-драйверы: Когда в JDK добавляют новые методы в интерфейс java.sql.Connection (например, метод getSchema()), старые JDBC-драйверы, которые не обновлялись годами, вылетают с ошибкой AbstractMethodError.

  2. Servlet-контейнеры: Если Tomcat или Jetty начинают вызывать новый метод в интерфейсе HttpServletRequest, которого нет в старом фильтре или сервлете (скомпилированном под старую версию API), вся цепочка обработки запроса схлопывается с критической ошибкой.

Фактически, это причина, по которой в Java 8 были введены default методы. Чтобы избежать IoC-краха, считается, что авторы фреймворков должны предоставить реализацию по умолчанию, хотя бы пустую или с логированием.

public class Framework { 
  public static interface IEntity {
    void hello();
    default void world() {
      System.out.println("Метод world не реализован");
    }
  }
}

Реальный случай

Допустим, нам не сложно добавить модификатор default, чтобы обеспечить обратную совместимость. Безопасно ли это? Стюарт Маркс из Oracle описывает случай ⤴ с библиотекой Eclipse Collections, который наглядно показал, как подсистема онтологии JDK (добавление метода в интерфейс) вошла в прямой конфликт с подсистемой динамической линковки JVM у клиента.

Суть конфликта: в библиотеке Eclipse Collections был класс CharAdapter, который наследовал два независимых мира (онтологии):

  1. Мир библиотеки: интерфейс PrimitiveIterable, где был свой default метод isEmpty().

  2. Мир JDK: интерфейс CharSequence (стандартный интерфейс Java для строк).

interface PrimitiveIterable {
    default boolean isEmpty() { ... }
}
 
class CharAdapter implements PrimitiveIterable, CharSequence {
    ...
}

В JDK 15 разработчики Java решили “улучшить” API и добавили default метод isEmpty() в стандартный интерфейс CharSequence. Но теперь, когда старый, неизмененный бинарник CharAdapter.class запускается на JDK 15, линковщик JVM при вызове isEmpty() видит два разных default-метода из двух разных веток наследования.

Согласно спецификации JVM, это патовая ситуация (ambiguity). Линковщик не имеет права выбирать сам, и вместо того, чтобы гадать, он выбрасывает:

java.lang.IncompatibleClassChangeError: Conflicting default methods

Чтобы починить это, разработчикам Eclipse Collections пришлось явно переопределить метод isEmpty() в самом классе CharAdapter, чтобы устранить двусмысленность для линковщика. Фикс в одну строку, но сколько таких ситуаций вообще возможно?

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

А вся загвоздка в том, что интерфейсы, которые изначально задумывались только в качестве заголовок (headers), стали контейнерами поведения. Эта статья хоть и описывает технические детали несовместимостей библиотек, её настоящий посыл в том, что сами подсистемы Явы (онтология ↔ расширяемость ↔ рантайм в данном случае), вообще могут быть несовместимы, и это нужно как-то фундаментально решать, а не городить всё новые огороды.

Как добавить метод в интерфейс [⚠️ Графический контент]
© Cartoon Network, Inc

© Cartoon Network, Inc

Эти примеры показывает, насколько тесно становятся интегрированы зависимости в наш код, и наш код в зависимости. Используя чужие библиотеки, мы объединяем их “мир” с нашим и поэтому несём полную ответственность за каждого участника и их союз. В этом и есть главная обязанность инженера, знать и следить, а не строчить код и добавлять всё что попало в pom.

2) Проектировка

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

Тут совместимость можно определить по таблице ниже:

Что меняется

Пример

Совместимость

Приватизация (public/protectedprivate)

public void run() {}
private void run() {}

🔴 Несовместимо: код снаружи больше не видит метод

Защита (publicprotected)

public void run() {}
protected void run() {}

🔴 Несовместимо: метод остаётся виден только подклассам

Усиление доступа (privateprotected/public)

private void run() {}public void run() {}

🟢 Совместимо: метод становится доступнее

➕ Добавление final

void run() {}
final void run() {}

🔴 Несовместимо: классы-наследники, переопределявшие метод, перестанут компилироваться

➖ Удаление final

final void run() {}
void run() {}

🟢 Совместимо: теперь метод можно переопределять

➕ Добавление abstract

void run() {}
abstract void run() {}

🔴 Несовместимо: класс теряет реализацию, наследники ломаются

➖ Удаление abstract

abstract void run();
void run() {}

🟢 Совместимо: реализация появилась

➕ Добавление static

void run() {}
static void run() {}

🔴 Несовместимо: вызов через экземпляр перестаёт работать

➖ Удаление static

static void run() {}
void run() {}

🔴 Несовместимо: вызов через класс перестаёт работать

Для классов тоже самое:

Что меняется

Пример

Совместимость

Приватизация (public/protectedprivate)

public class Dog {}private class Dog {}

🔴 Несовместимо: код снаружи больше не видит класс

Защита (publicprotected)

public class Dog {}protected class Dog {}

🔴 Несовместимо: класс виден только подклассам

Усиление доступа (privateprotected/public)

private class Dog {}public class Dog {}

🟢 Совместимо: класс становится доступнее

➕ Добавление final

class Dog {}
final class Dog {}

🔴 Несовместимо: классы-наследники перестанут компилироваться

➖ Удаление final

final class Dog {}
class Dog {}

🟢 Совместимо: теперь класс можно наследовать

➕ Добавление abstract

class Dog {}
abstract class Dog {}

🔴 Несовместимо: создание экземпляра new Dog() перестаёт работать

➖ Удаление abstract

abstract class Dog {}
class Dog {}

🟢 Совместимо: теперь можно создавать экземпляры

➕ Добавление static

class Inner {}
static class Inner {}

🔴 Несовместимо: создание new Outer().new Inner() перестаёт работать

➖ Удаление static

static class Inner {}
class Inner {}

🔴 Несовместимо: создание new Outer.Inner() перестаёт работать

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

Добавление методов

Казалось бы, ввод новых методов в классы безвреден и лишь улучшает API. Но может получится так, что образуется конфликт:

interface Interface {} // либа

class Client implements Interface {
  private method() {}
}

// ======== v2
interface Interface {
  public method() {} // в либе добавили method
}

class Client implements Interface {
  //❗️ Cannot reduce the visibility of the inherited method from Interface
  private method() {}
}

Из-за того, что все методы в интерфейсах являются открытыми (public), если класс в прошлом объявлял свой личный (private) метод с тем же именем, то теперь при компиляции появится ошибка.

Похожая ситуация наблюдается в случае несоответствия выводов метода:

interface Interface {} // либа

class Client implements Interface {
  public String getValue() {
    return "";
  }
}

// ======== v2
interface Interface {
  public Number getValue(); // в либе добавили method
}

class Client implements Interface {
  //❗️ The return type is incompatible with Interface
  public String getValue() {
    return "";
  }
}

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

Пример с конфликтом по static:

class Parent {} // либа

class Client extends Parent {
  public void method() {}
}

// ======== v2
class Parent {
  public static void method() {} // в либе добавили static 
}

class Client extends Parent {
  //❗️ This instance method cannot override the static method from Parent
  public void method() {} 
}

Пример с конфликтом по final:

class Parent {} // либа

class Client extends Parent {
  public void method() {}
}

// ======== v2
class Parent {
  public final void method() {} // в либе добавили final method 
}

class Client extends Parent {
  // ❗️ Cannot override the final method from Parent
  public void method() {} 
}

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

Error: LinkageError occurred while loading main class Client
java.lang.IncompatibleClassChangeError: class Client overrides final method Parent.method()V

Встраивание констант

Помним, что компилятор встраивает примитивные константы и строки прямо в код клиента.

Что меняется

Пример

Совместимость

Изменение значения static final поля

public static final int TIMEOUT = 5; → 10;

public static final String VERSION ="1.2.3";"1.3.0"

🟡 Совместимо, но старый клиентский код продолжит использовать старые значения пока не будет перекомпилирован. Константа вшита “намертво” и не поддается эволюции из вне.

Технически, это совместимо, но если в библиотеке поменяется константа вроде BUFFER_SIZE, то функционал не будет изменен, пока клиент не перекомпилирует свой код. Не инлайнятся:

  • public static final Integer TIMEOUT = 5,

  • public static final int TIMEOUT = getValue().

Статические экспорты

Любой статический метод можно импортировать с помощью import static com.example.*. Нюанс состоит в том, что если класс начал публиковать новую статику, она может вступить в конфликт с неймспейсом import static org.example.*.


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

3) Реализация

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

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

Что меняется

Пример

Совместимость

Добавление/удаление native

любое изменение

🟢 Совместимо, при условии наличия динамических библиотек для каждой из архитектур

Добавление/удаление strictfp

любое изменение

🟡 Совместимо, минимальные численные отличия при вычислениях с плавающей точкой

➕ Добавление synchronized

void run() {}synchronized void run() {}

🟡 Cовместимо, но код начать работать медленнее без необходимости

➖ Удаление synchronized

synchronized void run() {}void run() {}

🔴 Несовместимо: гонки данных, неконсистентное состояние

➕ Добавление volatile

int age; → volatile int age;

🟡 Совместимо, операции могут быть медленнее

➖ Удаление volatile

volatile int age; → int age;

🔴 Несовместимо: поток может никогда не увидеть обновление

➕ Добавление transient

int age; → transient int age;

🟡 Совместимо, но старые сохранённые данные перестанут восстанавливаться в это поле

➖ Удаление transient

transient int age; → int age;

🟠 Плохосовместимо: старые сериализованные объекты могут не содержать этого поля; InvalidClassException ошибки при строгой проверке serialVersionUID

Исключения

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

Для начинающих быстро опишу, в Яве есть проверяемые (checked) и непроверямые (unchecked) исключения. Первые соответственно проверяются статическим анализом, вторые (наследуемые от RuntimeException вроде NullPointerException) лишь описываются для удобства программиста, поэтому они тоже будут семантическими.

Добавление новых контролируемых исключений после сигнатуры метода нарушает исходную совместимость. Пример:

import java.io.IOException;

public class Service 
  public void run() {                    // версия 1
    System.out.println("running");
  }

  public void run() throws IOException { // версия 2, с исключением
    System.out.println("running");
  }
}

class Client {
  void useService() { // теперь нужно throws
    Service s = new Service();
    // Unreported exception IOException; must be caught or declared to be thrown
    s.run();
  }  
}

Что меняется

Пример

Совместимость

➕ Добавление новых checked

void run()throws IOException

Нельзя добавлять новые: Компилятор требует обработки

➕ Добавление новых unchecked

void run()throws NullPointerException

🟡 Совместимо: сама throws аннотация не влияет на сборку, но при исполнении кода могут начать выскакивать ошибки.

➕ Удаление любых

throws NullPointerExceptionvoid run()

Можно убирать: Старый код обрабатывал исключение, а теперь его нет — лишний catch не мешает


В этом разделе кроме как о модификаторах и исключениях, говорить особо не о чем. Хотя стоит отметить, что Java — это классический ООП язык, а значит каждый метод получает входные данные не только из аргументов, но и из контекста this. Из-за этого очень сложно обозначить границы метода, что является главным аргументом приверженцев функциональной парадигмы. Тогда как аннотация исключений ещё служит какой-то поддержкой в этом деле, то понять, какие эффекты производит код (напр., модификация полей в элементах списка), мы можем только изучив сам код или по комментариям разработчика.

4) Верификация

Когда мы пишем программу, то составляем доказательства того, что код в базе выполняет требования. Делается это 1) через покрытием тестами и 2) через проверку логики статическим анализом. Во втором случае, присвоение переменной Type t = method(x), это не просто запись ячейки памяти, а ещё логическое утверждение. Статический анализ проверяет, все ли такие утверждения убедительны (sound) ещё до запуска, помогая избежать ошибок.

Принцип подстановки Лисковой (для методов)

Теория типов здесь играет главную роль, ведь в Яве задействован полиморфизм, главный принцип ООП, придуманный для того, чтобы хоть как-то улучшить переиспользование. Основное правило, на которое мы опираемся, это принцип подстановки Лисковой ⤴* (ППЛ):

Если B <: A (B — подтип A), то везде, где ожидается A, можно передать B.

* я всегда думал, что Лисков — это мужчина, поэтому чтобы отдать дань женщинам в науке и ИТ, буду называть этот принцип именно Лисковой, хоть она и Барбара Лисков; так даже более по-русски.

Это правило лежит в основе статической проверки корректности при наследовании. Когда класс-потомок переопределяет метод родителя, система типов должна гарантировать, что потомка можно использовать вместо родителя. Для этого работают общие правила вариантности, в зависимости от того, где употреблен тип (в аргументах IN или на выходе OUT):

Позиция в сигнатуре

Требуемое отношение

Правило

Почему

Параметры метода (IN — входящие данные)

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

Потомок может принимать более общие типы (супертипы), но не может принимать более конкретные (подтипы).

Код, написанный под родителя, передает аргументы типа, который ожидал родитель. Если потомок сузит параметр, этот код сломается. Если расширит — будет работать.

Пример

Родитель: void process(Dog d)
Потомок:  void process(Animal a) — можно ✅ (контравариантность параметра)
Потомок:  void process(Bulldog b) — нельзя ❌

Возвращаемый тип (OUT —исходящие данные)

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

Потомок может возвращать более конкретные типы (подтипы).

Вызывающий код ожидает получить тип родителя. Если потомок вернет подтип, это все равно соответствует ожиданиям.

Пример

Родитель: Dog get()
Потомок: Bulldog get() — можно ✅ (ковариантный возврат)
Потомок: Animal get() — нельзя

Исключения (throws*) (OUT — исходящие данные)

Ковариантность (в Java только для проверяемых)

Потомок может выбрасывать более конкретные исключения или не выбрасывать их вообще

Вызывающий код готов к исключениям родителя. Более конкретные или их отсутствие — безопасны.

Пример

Родитель: read() throws IOException
Потомок: read() throws FileNotFoundException — можно ✅ (ковариантный возврат)
Потомок: read() — можно
Потомок: read() throws Exception — нельзя ❌

* принцип Лисковой ничего не говорит про сами исключения, речь лишь идет о совместимости типов исключений. Исключения считаются особым типов выхода, так как чисто с математической точки зрения, функция возвращает тип Return<Output, Error>.

Как запомнить ковариантность и контравариантность? Если наследственность идет сверху вниз, то ковариантность идёт в том же направлении (копо направлению), а контравариантность — в обратную сторону (контра, противостояние):

Наследственность

Восходящее расширение

Нисходящее сужение

Animal

Animal

Animal

Dog extends Animal

Dog

Dog

Bulldog extends Dog

Bulldog

Bulldog

Контравариантность
(для параметров ☑️)

Ковариантность
(для вывода ☑️)

По таблице смотрим: чтобы ничего не сломать, в параметрах нужно двигаться в противоположном направлении к иерархии наследия: чтобы добиться контравариантности, можно заменить Bulldog на Dog или Animal (более конкретный тип на более общий), и Animal на Dog или Bulldog (более общий на более конкретный) на выходе для ковариантности .

© 2017 ТНТ-Телесеть, "Реальные пацаны". Цитирование для учебных и научных целей.

© 2017 ТНТ-Телесеть, “Реальные пацаны”. Цитирование для учебных и научных целей.

Нужно отметить, что вариативность бывает только позитивной, то есть если подтип Dog в сигнатуре поменяется на более общий супертип Animal в аргументах (цепочка AnimalDogBulldog), по проявляется контравариантность, но если в том же месте Dog меняется на более конкретный Bulldog, то это не называется ковариантностью, потому что наследник просто перестает быть подтипом и связь вообще ломается!

Тут умные люди сразу заметят, что Ява не следует этому принципу слово-в-слово, потому что если в наследника добивать метод method(Integer) на method(Number), то это будет не полиморфизмом, а перегрузкой — т.е. просто введётся дополнительный метод, и их теперь будет два. Но мы определяем совместимость с реальной подменой, а не наследственность.

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

Позиция

Что происходит

Пример

Совместимость

Парам-ы

IN

Сужение (параметр стал более конкретным — спустились вниз по направлению наследственности)

void set(Animal a)

void set(Dog a)

🔴 Несовместимо 
Старый код мог вызвать set(new Cat()). Новый метод принимает только Dog.

Парам-ы

IN

Расширение (параметр стал более общим — поднялись вверх, против наследственности)

КОНТР-ВАРИАНТНОСТЬ

void set(Dog a)

void set(Animal a)

🟢 Совместимо
Старый код вызывал set только с Dog. Новый метод принимает Animal — значит, Dog туда по-прежнему можно передавать.

Возврат

OUT →

Сужение (вывод стал более конкретным, спустились вниз по на направлению древа наследственности)

КОВАРИАНТНОСТЬ

Animal get()

Dog get()

🟢 Совместимо: Старый код ожидает Animal, а получает DogDog — это Animal

Возврат

OUT

Расширение (вывод стал более общим — поднялись вверх, против направления наследственности)

Dog get()

Animal get()

🔴 Несовместимо: Старый код рассчитывает на методы класса Dog, а в Animal их может и не быть

Искл-ия

OUT

Сужение (вывод стал более конкретным, спустились вниз по на направлению наследственности)

КОВАРИАНТНОСТЬ

throws IOException
throws FileNotFoundException

🟢 Совместимо: FileNotFoundException ловится как IOException

Искл-ия

OUT

Расширение (вывод стал более общим — поднялись вверх, против направления наследственности)

throws FileNotFoundException

throws IOException

🔴 Несовместимо: IOException шире, старый код, ожидающий FileNotFoundException, может быть не готов к другим IOException

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

Позиция

Что происходит

Пример

Совместимость

Парам-ы

IN

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

void set(Dog d)

➕ 

void set(Animal a)

✅ При перегрузке, оба метода существуют, предыдущий код работает как прежде.

Парам-ы

IN

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

void set(Animal a)

void set(Dog d)

🟠 Совместимо, но старый скомпилированный код был привязан к set(Animal). При перекомпиляции он начнёт привязываться к set(Dog). Ожидаемое поведение может измениться.

Совместимость полей

Из-за того, что полиморфизм полей вообще не поддерживается (так как они работают и на чтение OUT →, и на запись → IN одновременно), изменение их типа как на более узкий так и на более широкий запрещено (наверное поэтому в Яве они так редко и используются).

Сужение/Расширение

Пример

Совместимость

Комментарий

Сужение

Animal aDog a

🔴 Несовместимо

box.a = new Cat() стал невозможен, хоть и Animal a = box.a остался бы в порядке.

Расширение

Dog aAnimal a

🔴 Несовместимо

box.a.bark() больше недоступен, хотя box.a = new Dog() мог бы продолжить работать.

Классовая совместимость

С методами понятно, теперь представим, что мы обновляем класс, добавляя родителя:

class C {}           // v1
class C extends P {} // v2 - начали наследовать P

// клиент:
class Client {
  void method(C child)
}

Из-за того, что добавление родителя P к классу C накладывает на него дополнительные ограничения (ведь теперь класс обязан соответствовать контракту P), может показаться, что это сужает тип, делая его более специфичным. Это противоречило бы принципу Лисковой при использовании класса в качестве параметра.

Однако в теории типов всё работает наоборот: добавление родителя — это расширение возможностей типа. Когда мы пишем class Dog extends Animal, мы не сужаем диапазон объектов, а расширяем их классификацию. Теперь объект типа Dog совместим не только сам с собой, но и с типом Animal. С точки зрения ППЛ:

  • В любом методе, где Dog используется как параметр, его входной диапазон не сокращается — старый код по-прежнему может передавать туда Dog.

  • Сам объект внутри метода становится шире, так как приобретает методы родителя.

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

Номинальное распространение

С другой стороны, нам запрещено расширять вывод, значит getDog() должно было бы должно было бы вылиться в повсеместные ошибки, если бы Dog начал наследовать от Animal. Это было бы так, если бы Java не имела номинальную типизацию.

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

Dog dog = getDog(); // до обновления
// Animal dog = getDog(); // такого раньше быть просто не могло!

Dog dog = getDog(); // после обновления Dog [+ extends Animal]
                    // вывод стал шире, но и сам слот dog тоже

Даже если иерархия расширилась, номинальное равенство типов (C == C) гарантирует, что вывод метода и принимающий его слот остаются полностью совместимыми. Таким образом, расширение типа не создает конфликта “ожидание vs реальность”, поскольку обновляется само определение того, чем является этот тип в системе. Это как прибавить +10 с обоих сторон уравнения.

Чтобы прочувствовать этот момент, посмотрим что будет со структурной типизацией (или утиной, duck-typing) в TypeScript:

class Dog {
  eat() { console.log("Едим..."); }
}

class Bulldog {
  eat() { console.log("Много едим..."); }
}

function test(a: Dog) { 
  a.eat(); 
}

const d = new Bulldog();
test(d); // ✅ OK: Bulldog подходит по структуре Dog

В TypeScript, классу вообще не надо наследовать от родителя, чтобы подходить по требованиям сигнатуры метода. Достаточно того, что у него есть все методы и поля того типа, который указан в параметрах.

class Animal {
  sleep() { console.log("Спим..."); }
}

class Dog extends Animal { // теперь наследуем от Animal
  eat() { console.log("Едим..."); }
}

class Bulldog {
  eat() { console.log("Много едим..."); }
}

function test(a: Dog) { 
  a.eat(); 
}

const d = new Bulldog();
test(d); // ❌ OK: Bulldog больше не подходит по структуре, нету sleep

В отличие от Явы, если мы добавим родителя, это может привести к несовместимости, так как параметр функции test расширился с Dog до Dog & Animal, в то время как Bulldog, который не наследовал от Dog (но при этом подходил в качестве аргумента по структуре раньше), автоматически не расширился.

В теории: Расширение типа после добавление родителя в класс расширяет вывод сигнатур и является нарушением принципа Лисковой, который требует ковариантности (сужения) вывода.

В номинальных системах: Из-за атомического номинального распространения, классы расширяются в одно и то же время вместе с выводом методов, где они упомянуты.

Экстенсиональная и интенсиональная модели

Можно поспорить, что добавление родителя P к типу C в номинальной системе типов не является изменением самого типа C (его множества значений). Это изменение структуры отношений подтипизации в графе типов.

Да, поскольку само множество значений |C| остается неизменным, а новые операции родителя лишь переходят из состояния “скрытых” в “видимые”, с точки зрения экстенсиональной теории типов никакого “расширения” самого типа не происходит. Весь эффект расширения, о котором говорят в контексте ППЛ, относится к ослаблению требований в местах использования типа, то есть интенсиональной теории.

Экстенсиональная модель (Тип = Множество)

  • Фокус: На объектах (значениях).

  • Суть: Тип — это просто мешок, в котором лежат все возможные экземпляры Dog.

  • Вывод: Если в мешке те же самые собаки, что и вчера, то тип не изменился. Добавление родителя — это просто наклеивание на мешок Dog дополнительной этикетки Animal.

Интенсиональная модель (Тип = Свойства / Сигнатура / Правила)

  • Фокус: На определении и контракте.

  • Суть: Тип — это список условий, которым должен удовлетворять объект, чтобы считаться “своим”.

  • Вывод: Когда мы добавляем родителя, мы меняем определение типа. Теперь в него встроены методы и правила родителя.

  • Зона применения: Именно здесь рождается понятие “расширения”. Тип расширяется не потому, что собак стало больше, а потому, что возможностей пристроить (передать в методы) эти объекты стало больше.

В Яве класс выполняет две роли одновременно, что и создает путаницу:

  1. Класс как конструктор множества (экстенсионал): Он порождает объекты в памяти.

  2. Класс как декларация типа (интенсионал): Он задает правила для компилятора.

Принцип Лисковой работает на втором уровне: ей не интересно, сколько у нас собак в памяти; ей интересно, насколько широким стал входной шлюз (параметр метода) и насколько узким осталось обещание (возврат метода).

Хотя множество самих объектов остается неизменным (экстенсиональный аспект), их социальный статус в системе растет: они приобретают право участвовать во всех контрактах, где ожидается P. В этом ключе, расширение типа — это не рост количества его экземпляров, а рост его прав и возможностей внутри системы сигнатур (топологии), что и делает его “шире” в контексте ППЛ.

Почему в ООП программировать нужно к интерфейсам

В идеале, идея ООП, если интерпретировать математически, следующая: есть некий мир со множество объектов (термов), которое делится на типы интерфейсами, то есть набором логических утверждений (предикатов). Например, interface T { X proposition(Y y); X2 proposition2(Y2 y2) } утверждает, что для объектов типа T, одновременно существуют две операции: одна, которая получает Y и выдает X, и вторая, которая получает Y2 и выдает X2.

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

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

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

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

Сегодня мало кто реально соблюдает золотое правило дизайна по разделению классов и интерфейсов, мол, если реализация одна, то пусть будет сразу класс. Язык фактически построен и эволюционировал на костылях, поощряя смешение ролей: будь то абстрактные классы (интенсионал в зоне ответственности экстенсионала) или default реализации в интерфейсах (экстенсионал в интенсионале). Это вовсе не то, что имелось ввиду под agile 🤦🏼‍♀️

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

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

Лимиты типовой верификации

Добавление родителя (class C extends P) не вызывает появления новых объектов типа C (конструктор не меняется) и не удаляет старые. Однако множество может сузится, если внести определенные ограничения внутри типа. Например, код до:

class Dog {
  int height;
  Dog(int _height) {
    height = _height;
  }
}

Код после обновления политики компании:

Системный подход к Agile: исследование совместимостей Java библиотек - 6
class Dog {
  int height;
  Dog(int _height) {
    if (_height > 35) { // добавляем ограничение высоты
      throw new IllegalArgumentException("Используйте Big Dog!")
    }
    height = _height;
  }
}
class BigDog extends Dog {} // конструктор без ограничений высоты

Теперь тип D' = { (height ∈ Z) | height ≤ 35 }

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

Какой в этом всем смысл? Допустим, мы ввели ограничения на вход собак по планке, т.е., доказали, что внутри магазина не может быть собаки выше 35 см. Теперь уборщица, при виде собаки, не должна подбегать к ней и измерять снова, потому что “собака”, это априори низкая собака, “доказательство” уже встроено в сам тип и не нужно тратить ресурсы на проверки после конструктора, мы провели своеобразную оптимизацию модели сразу везде.

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

Совместимость наследований

При этом в отличие от класса, в который можно безболезненно добавить интерфейс и доказать его реализацией, безболезненно расширить интерфейс через extends другим интерфейсом нельзя. Может получится так, что один из классов клиента задекларировал его через implements, следовательно ему потребуется самостоятельно ввести реализации (только если это не default методы).

Позиция

Изменение

Пример

Совместимость

КЛАСС КЛАСС

Удаление родителя, уточнение родителя класса

Сужение

class A extends Animalclass A extends Dog
class A

🔴 Несовместимо

Нарушит ППЛ в позициях IN

Добавление родителя, обобщение родителя класса

Расширение

class A
class A extends Dogclass A extends Animal

🟢 Совместимо

сработает номинальное распространение методов класса

КЛАСС ИНТЕРФЕЙСЫ

Убираем интерфейс из списка implements

Сужение

class A implements X,Y
class A implements X

🔴 Несовместимо

Нарушит ППЛ в позициях OUT

Добавляем интерфейс в список implements

Расширение

class A implements X
class A implements X,Y

🟢 Совместимо

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

ИНТЕРФЕЙСИНТЕРФЕЙСЫ

Убираем интерфейс из списка extends

Сужение

interface X extends C,D
interface X extends D

🔴 Несовместимо

Нарушит ППЛ в позициях OUT

Добавляем интерфейс в список extends

Расширение

interface X extends D
interface X extends C,D

🔴 Несовместимо

Требует ввода новых доказательств (реализаций) в классах

Добавляем интерфейс с default методами в список extends

Расширение

interface X extends D
interface X extends C_DEFAULTS,D

🟢 Совместимо

вместе с расширением требований, номинально расширятся доказательства

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

class Lib {
  interface Animal {
    void eat();
  }
  interface Pet {
    void play();
  }
  static class Dog implements Pet {
    public void play() {
    }
  }
}

class Client {
  void interact(Pet p) {
  }
  void interact(Animal a) {
  }
  void test() {
   interact(new Dog()); // выбирается interact(Pet)
  }
}

В примере выше, в библиотеке есть два интерфейса, Pet и Animal. В клиентском коде есть два метода: interact Pet и interact Animal. Во второй версии библиотеки, автор понял, что Dog это все-таки тоже Animal, и решил добавить implements Pet, Animal, и даже предоставил реализацию.

class Lib {
  interface Animal {
   void eat();
  }
  interface Pet {
   void play();
  }
  class Dog implements Pet, Animal {
   public void play() {
   }
   public void eat() {
   }
  }
 }

 class Client {
  void interact(Pet p) {
  }
  void interact(Animal a) {
  }
  void test() {
   // The method interact(Pet) is ambiguous for the type Client
   interact(new Dog());
  }
 }

Хотя на уровне реализации, где классам был запрещена множественная наследственность, чтобы не возникла ромбовая проблема (diamond problem), она всё же случилась на уровне интерфейсов: так как у Dog теперь указаны два интерфейса одинаковой специфичности, то компилятор не может понять, какой interact для объекта dog выбрать — у него фактически случается когнитивный диссонанс 🤯

Хотя дизайнеры Явы и ограничили множественную наследственность в классах, они прикрыли, а не решили архитектурную проблему; в случаях с проверкой типов можно видеть, как сильно компилятор её не любит.

Хотя дизайнеры Явы и ограничили множественную наследственность в классах, они прикрыли, а не решили архитектурную проблему; в случаях с проверкой типов можно видеть, как сильно компилятор её не любит.

Дженерики

Отдельно рассмотрим дженерики. Выделим три разных группы.

1) Параметизированный тип

void method(List<Dog> list)

Такой метод принимает только List<Dog>. List<Animal> или List<Bulldog> не подойдут, потому что дженерики в Java инвариантны — List<Integer> не является подтипом List<Number>. Даже если по ППЛ контравариантная подстановка в параметрах разрешена, конкретные дженерики инвариантны по дизайну языка из-за безопасности типов. Ниже пример того, почему вариативность отключили:

List<Bulldog> bulldogs = new ArrayList<>();
List<Dog> dogs = bulldogs;   // допустим разрешили вариативность
dogs.add(new Poodle());      // логично для List<Dog>

Теперь в List<Bulldog> лежит Poodle. Список сломан. Чтобы не допустить такого сценария, сделали инвариантность типовых параметров в конкретных типах.

2) Параметизированный тип с квантором ?

Из-за того, что жесткая инвариантность параметров, описанная выше, значительно ограничивает возможности полиморфизма, были введены кванторы (wildcard), позволяющие повысить гибкость сигнатур методов через ослабление этого ограничения. Кванторы бывают двух видов: ? extends X и ? super X. Теорию PECS об их использовании на чтение / запись здесь повторять не буду, лишь рассмотрю как это влияет на совместимость.

В примерах используется иерархия Animal → Dog → Bulldog | Poodle
 class Animal {
 }

 class Dog extends Animal {
 }

 class Poodle extends Dog {
 }

 class Bulldog extends Dog {
 }
     Animal (самый общий)
        ↑
       Dog (средний)
       /  
      ↓    ↓
Bulldog    Poodle (самые конкретные)

? extends Xквантор с верхней границей (upper bound wildcard), понимаем как: “любой тип, который является X или его подтипом”. Что можно передать:

public void processDogs(List<? extends Dog> dogs) { // [P]roducer [e]xtends
    for (Dog d : dogs) {           // ✅ читаем как Dog 
        System.out.println(d);
    }
}

List<Dog> dogs = new ArrayList<>();
processDogs(dogs);        // ✅ Dog — Dog

List<Bulldog> bulldogs = new ArrayList<>();
processDogs(bulldogs);    // ✅ Bulldog — подтип Dog

List<Animal> animals = new ArrayList<>();
processDogs(animals);     // ❌ Animal — супертип Dog (не подходит)

? super Xквантор с нижней границей (lower bound wildcard), понимаем как: “любой тип, который является X или его супертипом”. Что можно передать:

public void addDogToList(List<? super Dog> list) { // [C]onsumer [s]uper
    list.add(new Dog());      // ✅ можно добавить Dog 
    list.add(new Bulldog());  // ✅ можно добавить Bulldog (подтип Dog)
    // list.add(new Animal()); // ❌ НЕЛЬЗЯ! Animal может быть несовместим с реальным типом списка
}

List<Dog> dogs = new ArrayList<>();
addDogToList(dogs);        // ✅ OK (Dog - Dog)

List<Animal> animals = new ArrayList<>();
addDogToList(animals);     // ✅ OK (Animal — супертип Dog)

List<Bulldog> bulldogs = new ArrayList<>();
addDogToList(bulldogs);    // ❌ ОШИБКА: Bulldog — подтип, не подходит

Теперь составим матрицы extends / super по позициям → IN (параметры) и OUT → (вывод). Помним, что для совместимости, нам нужно достичь контравариантности метода по параметрам, то есть чтобы он принимал не меньше типов, чем раньше, и ковариантность на выводе (не больше, чем раньше). Чтобы проверить на расширение/сужение, типы можно прямо брать и считать: {Dog} ⇄ {Animal, Dog, Bulldog}.

2.0) Кванторы с инвариантами

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

Изменение

Пример

Совместимость

Комментарий

Квантор ? extends инвариантный

List<? extends String>
List<? extends Number>

🔴 Несовместимо

Типы не связаны иерархически, подстановка невозможна.

Квантор ? super инвариантный

List<? super String>
List<? super Number>

🔴 Несовместимо

Типы не связаны, подстановка невозможна.

2.1) Ковариантный квантор ? extends Type

Возможные интервалы при использовании квантора с верхней границей

Возможные интервалы при использовании квантора с верхней границей

Изменение (до отмечено жирным)

Описание

List<Dog>
List<? extends Dog>

A) Ввод квантора с верхней границей до типа, расширение вниз

Интервал до: List<Dog>
Интервал после: List<Dog>, List<Bulldog>, List<Poodle>

IN: 🟢 Совместимо
КОНТРАВАРИАНТНОСТЬ

☑️ method((List<Dog>) list)

OUT: 🔴 Несовместимо

× List<Dog> dogs = method()

Проверить тесты Extends_A 🧪
 /**
  * Dog -> ? extends Dog
  */
 class A {
  void test_param_before(List<Dog> dogs) {
  }
  void test_param_after(List<? extends Dog> dogs) {
  }
  void test_param() {
   test_param_before((List<Dog>) null);
   test_param_after((List<Dog>) null);
  }
  List<Dog> test_ret_before() {
   return null;
  }
  List<? extends Dog> test_ret_after() {
   return null;
  }
  void test_ret() {
   List<Dog> dogs = test_ret_before();
   // ❗️ Type mismatch: cannot convert from List<capture#1-of ? extends Hierarchy.Dog> to List<Hierarchy.Dog>
   List<Dog> dogs_ = test_ret_after();
  }
 }

List<? extends Animal>
List<Dog>

B) Ввод квантора с верхней границей выше типа, расширение вверх

Интервал до: List<Dog>
Интервал после: List<Animal>, List<Dog>, List<Bulldog>, List<Poodle>

IN: 🟢 Совместимо
КОНТРАВАРИАНТНОСТЬ

☑️ method((List<Dog>) list)

OUT: 🔴 Несовместимо

× List<Dog> dogs = method()

Проверить тесты Extends_B 🧪
 /**
  * Dog -> ? extends Animal
  */
 class B {
  void test_param_before(List<Dog> dogs) {
  }
  void test_param_after(List<? extends Animal> dogs) {
  }
  void test_param() {
   test_param_before((List<Dog>) null);
   test_param_after((List<Dog>) null);
  }
  List<Dog> test_ret_before() {
   return null;
  }
  List<? extends Animal> test_ret_after() {
   return null;
  }
  void test_ret() {
   List<Dog> dogs = test_ret_before();
   // ❗️ Type mismatch: cannot convert from List<capture#2-of ? extends Hierarchy.Animal> to List<Hierarchy.Dog>
   List<Dog> dogs_ = test_ret_after();
  }
 }

List<Dog>
List<? extends Bulldog>

C) Ввод квантора с верхней границей, расширение вниз ниже текущего типа

Интервал до: List<Dog>
Интервал после: List<Bulldog>

IN: 🔴 Несовместимо
ИНВАРИАНТНОСТЬ

× method((List<Dog>) list)

OUT →: 🔴 Несовместимо
ИНВАРИАНТНОСТЬ

× List<Dog> list = method()

Проверить тесты Extends_C 🧪
 
 /**
  * Dog -> ? extends Bulldog
  */
 class C {
  void test_param_before(List<Dog> dogs) {
  }
  void test_param_after(List<? extends Bulldog> dogs) {
  }
  void test_param() {
   test_param_before((List<Dog>) null);
   // The method test_param_after(List<? extends Hierarchy.Bulldog>) in the type Hierarchy.C is not applicable for the arguments (List<Hierarchy.Dog>)
   test_param_after((List<Dog>) null);
  }
  List<Dog> test_ret_before() {
   return null;
  }
  List<? extends Bulldog> test_ret_after() {
   return null;
  }
  void test_ret() {
   List<Dog> dogs = new ArrayList<>();
   dogs = test_ret_before();
   // ❗️ Type mismatch: cannot convert from List<capture#3-of ? extends Hierarchy.Bulldog> to List<Hierarchy.Dog>
   dogs = test_ret_after();
  }
 }

List<? extends Animal>
List<? extends Dog>

D) Сдвиг границы квантора вниз, сужение интервала

Интервал до: List<Animal>, List<Dog>, List<Bulldog>, List<Poodle>
Интервал после: List<Dog>, List<Bulldog>, List<Poodle>

IN: 🔴 Несовместимо

☑️ method((List<? extends Dog>) list)
☑️
method((List<Dog>) list)

× method((List<? extends Animal>) list)
× method((List<Animal>) list)

OUT →: 🟢 Совместимо
КОВАРИАНТНОСТЬ

☑️ List<? extends Animal> list = method()

Проверить тесты Extends_D 🧪
 /**
  * ? extends Animal -> ? extends Dog
  */
 class D {
  void test_param_before(List<? extends Animal> dogs) {
  }
  void test_param_after(List<? extends Dog> dogs) {
  }
  void test_param() {
   test_param_before((List<Animal>) null);
   test_param_before((List<Dog>) null);
   test_param_before((List<? extends Animal>) null);
   test_param_before((List<? extends Dog>) null);

    // ❗️ The method test_param_after(List<? extends Hierarchy.Dog>) in the type Hierarchy.D is not applicable for the arguments (List<Hierarchy.Animal>)
   test_param_after((List<Animal>) null);
   test_param_after((List<Dog>) null);
   // ❗️ The method test_param_after(List<? extends Hierarchy.Dog>) in the type Hierarchy.D is not applicable for the arguments (List<capture#6-of ? extends Hierarchy.Animal>)
   test_param_after((List<? extends Animal>) null);
   test_param_after((List<? extends Dog>) null);
  }
  List<? extends Animal> test_ret_before() {
   return null;
  }
  List<? extends Dog> test_ret_after() {
   return null;
  }
  void test_ret() {
   List<? extends Animal> animals = new ArrayList<>();
   animals = test_ret_before();
   animals = test_ret_after();
  }
 }

List<? extends Animal>
List<? extends Dog>

E) Сдвиг границы квантора вверх, расширение интервала

Интервал до: List<Dog>, List<Bulldog>, List<Poodle>
Интервал после: List<Animal>, List<Dog>, List<Bulldog>, List<Poodle>

IN: 🟢 Совместимо
КОНТРАВАРИАНТНОСТЬ

☑️ method((List<Dog>) list)
☑️ method((List<? extends Dog>) list)
☑️ method((List<? extends Bulldog>) list)

OUT →: 🔴 Несовместимо

× List<? extends Dog> dogs = method()

Проверить тесты Extends_E 🧪
 /**
  * ? extends Dog -> ? extends Animal
  */
 class E {
  void test_param_before(List<? extends Dog> dogs) {
  }
  void test_param_after(List<? extends Animal> dogs) {
  }
  void test_param() {
   test_param_before((List<Dog>) null);
   test_param_before((List<? extends Dog>) null);
   test_param_before((List<? extends Bulldog>) null);

   test_param_after((List<Dog>) null);
   test_param_after((List<? extends Dog>) null);
   test_param_after((List<? extends Bulldog>) null);
  }
  List<? extends Dog> test_ret_before() {
   return null;
  }
  List<? extends Animal> test_ret_after() {
   return null;
  }
  void test_ret() {
   List<? extends Dog> dogs = new ArrayList<>();
   dogs = test_ret_before();
   // ❗️ Type mismatch: cannot convert from List<capture#19-of ? extends Hierarchy.Animal> to List<? extends Hierarchy.Dog>
   dogs = test_ret_after();
  }
 }

List<? extends Dog>
List<Dog>

F) Удаление квантора, сужение интервала до одиночного типа

Интервал до: List<Dog>, List<Bulldog>, List<Poodle>
Интервал после: List<Dog>

IN: 🔴 Несовместимо

☑️ method((List<Dog>) list)
× method((List<? extends Dog>) list)
× method((List<? extends Bulldog>) list)

OUT →: 🟢 Совместимо
КОВАРИАНТНОСТЬ

☑️ List<? extends Dog> dogs = method()

Проверить тесты Extends_F 🧪
 /**
  * ? extends Dog -> Dog
  */
 class F {
  void test_param_before(List<? extends Dog> dogs) {
  }
  void test_param_after(List<Dog> dogs) {
  }
  void test_param() {
   test_param_before((List<Dog>) null);
   test_param_before((List<? extends Dog>) null);
   test_param_before((List<? extends Bulldog>) null);

   test_param_after((List<Dog>) null);
   // ❗️ The method test_param_after(List<Hierarchy.Dog>) in the type Hierarchy.F is not applicable for the arguments (List<capture#20-of ? extends Hierarchy.Dog>)
   test_param_after((List<? extends Dog>) null);
   // ❗️ The method test_param_after(List<Hierarchy.Dog>) in the type Hierarchy.F is not applicable for the arguments (List<capture#21-of ? extends Hierarchy.Bulldog>)
   test_param_after((List<? extends Bulldog>) null);
  }
  List<? extends Dog> test_ret_before() {
   return null;
  }
  List<Dog> test_ret_after() {
   return null;
  }
  void test_ret() {
   List<? extends Dog> dogs = test_ret_before();
   List<? extends Dog> dogs_ = test_ret_after();
  }
 }

List<? extends Dog>
List<? super Dog>

G) Замена квантора extends на super, разворот

Интервал до: List<Dog>, List<Bulldog>, List<Poodle>
Интервал после: List<Dog>, List<Animal>

IN: 🔴 Несовместимо
ИНВАРИАНТНОСТЬ

☑️ method((List<Dog>) list)
× method((List<? extends Dog>) list)
× method((List<? extends Bulldog>) list)

OUT →: 🔴 Несовместимо
ИНВАРИАНТНОСТЬ

× List<? extends Dog> dogs = method()

Проверить тесты Extends_G 🧪
 /**
  * ? extends Dog -> ? super Dog
  */
 class G {
  void test_param_before(List<? extends Dog> dogs) {
  }
  void test_param_after(List<? super Dog> dogs) {
  }
  void test_param() {
   test_param_before((List<Dog>) null);
   test_param_before((List<? extends Dog>) null);
   test_param_before((List<? extends Bulldog>) null);

   test_param_after((List<Dog>) null);
   // ❗️ The method test_param_after(List<Hierarchy.Dog>) in the type Hierarchy.F is not applicable for the arguments (List<capture#20-of ? extends Hierarchy.Dog>)
   test_param_after((List<? extends Dog>) null);
   // ❗️ The method test_param_after(List<Hierarchy.Dog>) in the type Hierarchy.F is not applicable for the arguments (List<capture#21-of ? extends Hierarchy.Bulldog>)
   test_param_after((List<? extends Bulldog>) null);
  }
  List<? extends Dog> test_ret_before() {
   return null;
  }
  List<? super Dog> test_ret_after() {
   return null;
  }
  void test_ret() {
   List<? extends Dog> dogs = test_ret_before();
   // ❗️ Type mismatch: cannot convert from List<capture#28-of ? super Hierarchy.Dog> to List<? extends Hierarchy.Dog>
   List<? extends Dog> dogs_ = test_ret_after();
  }
 }

List<?>
List<? extends Dog>

H) Ввод верхней границы квантора, сужение

Интервал до: List<T> (любой)
Интервал после: List<Dog>, List<Bulldog>, List<Poodle>

IN: 🔴 Несовместимо

☑️ method((List<Dog>) list)
☑️ method((List<? extends Dog>) list)
☑️ method((List<? extends Bulldog>) list)
× method((List<Animal>) list)
× method((List<? extends Animal>) list)

OUT →: 🟢 Cовместимо
ВАРИАНТНОСТЬ

☑️ List<?> dogs = method()

Проверить тесты Extends_H 🧪
 /**
  * ? -> ? extends Dog
  */
 class H {
  void test_param_before(List<?> list) {
  }
  void test_param_after(List<? extends Dog> dogs) {
  }
  void test_param() {
   test_param_before((List<? extends Animal>) null);
   test_param_before((List<Animal>) null);
   test_param_before((List<Dog>) null);
   test_param_before((List<? extends Dog>) null);
   test_param_before((List<? extends Bulldog>) null);

   // ❗️ The method test_param_after(List<? extends Hierarchy.Dog>) in the type Hierarchy.H is not applicable for the arguments (List<Hierarchy.Animal>)
   test_param_after((List<Animal>) null);
   // ❗️ The method test_param_after(List<? extends Hierarchy.Dog>) in the type Hierarchy.H is not applicable for the arguments (List<capture#32-of ? extends Hierarchy.Animal>)
   test_param_after((List<? extends Animal>) null);
   test_param_after((List<Dog>) null);
   test_param_after((List<? extends Dog>) null);
   test_param_after((List<? extends Bulldog>) null);
  }
  List<?> test_ret_before() {
   return null;
  }
  List<? extends Dog> test_ret_after() {
   return null;
  }
  void test_ret() {
   List<?> dogs1 = test_ret_before();
   List<?> dogs1_ = test_ret_after();
  }
 }

List<?>
List<? extends Dog>

I) Удаление верхней границы квантора, расширение до любого типа

Интервал до: List<Dog>, List<Bulldog>, List<Poodle>
Интервал после: List<T> (любой)

IN: 🟢 Cовместимо
КОВАРИАНТНОСТЬ

☑️ method((List<Dog>) list)
☑️ method((List<? extends Dog>) list)
☑️ method((List<? extends Bulldog>) list)

OUT →: 🔴 Несовместимо

× List<? extends Dog> dogs = method()

Проверить тесты Extends_I 🧪
 /**
  * ? extends Dog -> ?
  */
 class I {
  void test_param_before(List<? extends Dog> dogs) {
  }
  void test_param_after(List<?> list) {
  }
  void test_param() {
   test_param_before((List<Dog>) null);
   test_param_before((List<? extends Dog>) null);
   test_param_before((List<? extends Bulldog>) null);

   test_param_after((List<Dog>) null);
   test_param_after((List<? extends Dog>) null);
   test_param_after((List<? extends Bulldog>) null);
  }
  List<? extends Dog> test_ret_before() {
   return null;
  }
  List<?> test_ret_after() {
   return null;
  }
  void test_ret() {
   List<? extends Dog> dogs1 = test_ret_before();
   List<? extends Dog> dogs1_ = test_ret_after();
  }
 }

Чтобы не запутаться, нужно помнить, что когда говорят, что тип List<? extends Animal> ковариантен имеют ввиду то, что если взять подтип Animal вроде Dog, то и List<Dog> будет подтипом List<? extends Animal> — вариативность типа списка движется в одном направлении с вариативностью его параметра. Поэтому ? extends называется ковариантным квантором (covariant wildcard).

⚠️ Но к вариантности класса, внутри которого находится метод с типом, это не относится!

Критика чистого (функционального) программирования *

* Критика чистого разума — философский труд Иммануила Канта

Выше я объяснил, что в то время как метод с параметром List<Dog> не поддаётся эволюции из-за запрета Явы на вариантность дженериков, параметр типа List<? extends Dog> уже стал более гибким: в будущем мы сможем расширить его тип на List<? extends Animal>. Но если всё, что <? extends Dog>, это и есть Dog, почему нельзя просто поставить List<Dog>, который в будущем просто может стать List<Animal>? Если мы начнем принимать больше типов животных, то это никак не навредит старому коду?

Тут “прикол” вот в чём: если мы вспомним, что имеем дело с ООП, и о том, что поля работают как на запись так и на чтение, то можно осознать следующую вещь. Фиксируя List<Dog> как тип параметра в сигнатуре, мы не просто утверждаем, что готовы принять список собак, а ещё и то, что возможно где-то внутри код может записать в этот список. Иными словами, это не чистый → IN чистой функции, он может служить и ее OUT → каналом, хоть и не через return. Получается всё то же самое, что и с полями — работа идет как на чтение так и на запись.

Главной концепцией функционального программирования является “чистая” (pure) функция, то есть та, у который каналы IN/OUT жестко зафиксированы А) параметром для принятия и Б) return для возврата значения. В ООП, код внутри метода может обращается к другим методам и полям, и они служат ему боковыми каналами, через которые “утекают” побочные эффекты, поэтому методы не считаются чистыми.

Знающий человек заметит, что добавить ? extends (Producer Extends), это не единственный способ запретить запись и сделать список read-only. Мы вполне себе могли бы вместо List передавать Iterable, у которого нет API на запись элементов. Но так как синтаксически в языке нет никакой возможности прямо определить, работает ли тип на чтение или запись, дизайнеры Явы ввели такое глобальное ограничение на дженерики, дабы предотвратить ошибки.

Отношение к List<T> — лучший пример, как не согласованы подсистемы Java.

Отношение к List<T> — лучший пример, как не согласованы подсистемы Java.

Интересно то, что если рассматривать функцию с математического ракурса, а не с позиции моделирования поведения актеров, становится понятно, что сигнатура служит лишь условной границей для кода внутри метода. Если честно, сигнатура вообще ничего не значит: внутри любого метода нам нужны лишь другие 1-2-3 метода/поля объектов, но в ООП, мы передаем целые классы, чтобы можно было легко вызывать их методы при реализации. При указании класса, мы фактически встраиваем в домен нашей функции все методы внутри этого класса.

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

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

Выделяют четыре основных стиля обучения: визуальный, аудиальный, читательский/письменный и кинестетический. При этом сам интеллект, это вообще не способ обучения! Чтобы обучиться библиотекам, и дают UML диаграммы. Визуализируют ли чистые методы на графике x/y? Не видел. Исследования показали ⤴, что 81% инженеров понимают мир через кинестетику, поэтому находить баланс — вот задача, с который мы так отлично справляемся:

Считается ⤴, что люди с телесно-кинестетическим интеллектом ловкихорошо контролируют движения и имеют отличную зрительно-моторную координацию. Именно поэтому ⤴ при обучении ими используются такие методы, как ролевые игры. А что такое ООП, если не одна большая ролевая игра разработчиков?

Работа с дебагером — вот наша главная ловкость рук. Мы включаемся в процесс, а не доказываем теоремы. Математики же, которые выбирают функциональное программирование, решили, что раз они такие шибко-умные, значит это 100% правильно, но это лишь очередная ловушка бессистемного подхода: может они и добились идеала верификации, но при этом перекрыли все пути подхода к эволюции кодовой базы, отвергли значимость онтологических ролей и, как следствие, важность обучаемости инженеров и их разработческого опыта (Dev-X).

Для душнил энтузиастов строгости, статический анализ — это всё, ради чего вообще стоит жить. Комментарий иронизирует над такой философией: "Полиция безопасности типов! Мы закрываем вас на проведение полного стат. анализа * Разворачивает кордон и оцепляет периметр."

Для душнил энтузиастов строгости, статический анализ — это всё, ради чего вообще стоит жить. Комментарий иронизирует над такой философией: “Полиция безопасности типов! Мы закрываем вас на проведение полного стат. анализа * Разворачивает кордон и оцепляет периметр.”

2.2) Контравариантный квантор ? super Type

Вернемся к нашим баранам собакам.

Возможные интервалы при использовании квантора с нижней границей

Возможные интервалы при использовании квантора с нижней границей
  • 2.2.1) Ввод контравариантного квантора

Изменение

Описание

List<Dog>
List<? super Dog>

A) Ввод квантора с нижней границей после текущего типа, расширение

Визуал до:

Визуал после:

List<Animal>
List<Dog>

List<Bulldog> | List<Poodle>

List<Animal>
List<Dog>
━━━━━━━━━
List<Bulldog> | List<Poodle>

Интервал до изменения: List<Dog>
Интервал после изменения: List<Animal>, List<Dog>, List<Dog>

IN: 🟢 Совместимо
КОНТРАВАРИАНТНОСТЬ

☑️ method((List<Dog>) list)

OUT: 🔴 Несовместимо

× List<Dog> dogs = method()

Проверить тесты Super_A 🧪
 /**
  * Dog -> ? super Dog
  */
 class Super_A {
  void test_param_before(List<Dog> dogs) {
  }
  void test_param_after(List<? super Dog> dogs) {
  }
  void test_param() {
   List<Dog> dogs = new ArrayList<>();
   test_param_before(dogs);
   test_param_after(dogs);
  }
  List<Dog> test_ret_before() {
   return null;
  }
  List<? super Dog> test_ret_after() {
   return null;
  }
  void test_ret() {
   List<Dog> dogs = new ArrayList<>();
   dogs = test_ret_before();
   // ❗️ Type mismatch: cannot convert from List<capture#1-of ? super Hierarchy.Dog> to List<Hierarchy.Dog>
   dogs = test_ret_after();
  }
 }

List<Dog>
List<? super Animal>

B) Ввод квантора с нижней границей перед текущим типов, инвариантное расширение

List<Animal>

List<Dog>
List<Bulldog> | List<Poodle>

List<Animal>
━━━━━━━━━
List<Dog>
List<Bulldog> | List<Poodle>

Интервал до: List<Dog>
Интервал после: List<Animal>

IN: 🔴 Несовместимо
ИНВАРИАНТНОСТЬ

× method((List<Dog>) list)

OUT: 🔴 Несовместимо
ИНВАРИАНТНОСТЬ

× List<Dog> dogs = method()

Проверить тесты Super_B 🧪

 /**
  * Dog -> ? super Animal
  */
 class Super_B {
  void test_param_before(List<Dog> dogs) {
  }
  void test_param_after(List<? super Animal> dogs) {
  }
  void test_param() {
   test_param_before((List<Dog>) null);
   // ❗️ The method test_param_after(List<? super Hierarchy.Animal>) in the type Consumer_Super.Super_B is not applicable for the arguments (List<Hierarchy.Dog>)
   test_param_after((List<Dog>) null);
  }
  List<Dog> test_ret_before() {
   return null;
  }
  List<? super Animal> test_ret_after() {
   return null;
  }
  void test_ret() {
   List<Dog> dogs = test_ret_before();
   // ❗️ Type mismatch: cannot convert from List<capture#2-of ? super Hierarchy.Animal> to List<Hierarchy.Dog>
   List<Dog> dogs_ = test_ret_after();
  }
 }

List<Dog>
List<? super Bulldog>

C) Ввод квантора с нижней границей под текущим типом, расширение

List<Animal>
List<Dog>
List<Bulldog> | List<Poodle>

List<Animal>
List<Dog>
List<Bulldog> | List<Poodle>
━━━━━━━━━━

Интервал до: List<Dog>
Интервал после: List<Animal>, List<Dog>, List<Bulldog>

IN: 🟢 Совместимо
КОНТРАВАРИАНТНОСТЬ

☑️ method((List<Dog>) list)

OUT →: 🔴 Несовместимо

× List<Dog> list = method()

Проверить тесты Super_C 🧪
 /**
  * Dog -> ? super Bulldog
  */
 class Super_C {
  void test_param_before(List<Dog> dogs) {
  }
  void test_param_after(List<? super Bulldog> dogs) {
  }
  void test_param() {
   test_param_before((List<Dog>) null);
   test_param_after((List<Dog>) null);
  }
  List<Dog> test_ret_before() {
   return null;
  }
  List<? super Bulldog> test_ret_after() {
   return null;
  }
  void test_ret() {
   List<Dog> t1 = test_ret_before();
   // ❗️ Type mismatch: cannot convert from List<capture#3-of ? super Hierarchy.Bulldog> to List<Hierarchy.Dog>
   List<Dog> t1_ = test_ret_after();
  }
 }
  • 2.2.2) Изменение границ контравариантного квантора

List<? super Animal>
List<? super Dog>

D) Сдвиг границы квантора вниз, расширение

List<Animal>
━━━━━━━━━━
List<Dog>
List<Bulldog> | List<Poodle>

List<Animal>
List<Dog>
━━━━━━━━━━
List<Bulldog> | List<Poodle>

Интервал до: List<Animal>
Интервал после: List<Animal>, List<Dog>

IN: 🟢 Совместимо
КОНТРАВАРИАНТНОСТЬ

☑️ method((List<Animal>) list)
☑️ method((List<? super Animal>) list)

OUT →: 🔴 Несовместимо

× List<? extends Animal> list = method()

Проверить тесты Super_D 🧪
 /**
  * ? super Animal -> ? super Dog
  */
 class Super_D {
  Object t;
  void test_param_before(List<? super Animal> dogs) {
  }
  void test_param_after(List<? super Dog> dogs) {
  }
  void test_param() {
   test_param_before((List<Animal>) null);
   test_param_before((List<? super Animal>) null);

   test_param_after((List<Animal>) null);
   test_param_after((List<? super Animal>) null);

   // дополнительно доступны:
   test_param_after((List<Dog>) null);
   test_param_after((List<? super Dog>) null);
  }
  List<? super Animal> test_ret_before() {
   return null;
  }
  List<? super Dog> test_ret_after() {
   return null;
  }
  void test_ret() {
   List<? super Animal> t1 = test_ret_before();
   List<? super Animal> t1_ = test_ret_after();
  }
 }

List<? super Dog>
List<? super Animal>

E) Сдвиг границы квантора вверх, сужение

List<Animal>
List<Dog>
━━━━━━━━━━
List<Bulldog> | List<Poodle>

List<Animal>
━━━━━━━━━━
List<Dog>
List<Bulldog> | List<Poodle>

Интервал до: List<Animal>, List<Dog>
Интервал после: List<Animal>

IN: 🔴 Несовместимо

× method((List<Dog>) list)
× method((List<? super Dog>) list)

OUT →: 🟢 Совместимо
КОВАРИАНТНОСТЬ

☑️ List<? super Dog> dogs = method()

Проверить тесты Super_E 🧪
 /**
  * ? super Dog -> ? super Animal
  */
 class Super_E {
  void test_param_before(List<? super Dog> dogs) {
  }
  void test_param_after(List<? super Animal> dogs) {
  }
  void test_param() {
   test_param_before((List<Dog>) null);
   test_param_before((List<? super Dog>) null);

   // ❗️ The method test_param_after(List<? super Hierarchy.Animal>) in the type Consumer_Super.Super_E is not applicable for the arguments (List<Hierarchy.Dog>)
   test_param_after((List<Dog>) null);
   // ❗️ The method test_param_after(List<? super Hierarchy.Animal>) in the type Consumer_Super.Super_E is not applicable for the arguments (List<Hierarchy.Dog>)
   test_param_after((List<? super Dog>) null);
  }
  List<? super Dog> test_ret_before() {
   return null;
  }
  List<? super Animal> test_ret_after() {
   return null;
  }
  void test_ret() {
   List<? super Dog> dogs = test_ret_before();
   List<? super Dog> dogs_ = test_ret_after();
  }
 }
  • 2.2.3) Удаление контравариантного квантора

List<? super Dog>
List<Dog>

F) Удаление квантора, сужение интервала до одиночного типа

List<Animal>
List<Dog>
━━━━━━━━━━
List<Bulldog> | List<Poodle>

List<Animal>
List<Dog>

List<Bulldog> | List<Poodle>

Интервал до: List<Animal>, List<Dog>
Интервал после: List<Dog>

IN: 🔴 Несовместимо

☑️ method((List<Dog>) list)
× method((List<? super Dog>) list)

OUT →: 🟢 Совместимо
КОВАРИАНТНОСТЬ

☑️ List<? extends Dog> dogs = method()

Проверить тесты Super_F 🧪
 /**
  * ? super Dog -> Dog
  * @todo: можно обсудить инвариантный ? super Animal -> Dog
  */
 class Super_F {
  void test_param_before(List<? super Dog> dogs) {
  }
  void test_param_after(List<Dog> dogs) {
  }
  void test_param() {
   test_param_before((List<Dog>) null);
   test_param_before((List<? super Dog>) null);

   test_param_after((List<Dog>) null);
   // ❗️ The method test_param_after(List<Hierarchy.Dog>) in the type Hierarchy.F is not applicable for the arguments (List<capture#20-of ? super Hierarchy.Dog>)
   test_param_after((List<? super Dog>) null);
  }
  List<? super Dog> test_ret_before() {
   return null;
  }
  List<Dog> test_ret_after() {
   return null;
  }
  void test_ret() {
   List<? super Dog> dogs = test_ret_before();
   List<? super Dog> dogs_ = test_ret_after();
  }
 }
  • 2.2.4) Замена контравариантного квантора на ковариантный

List<? super Dog>
List<? extends Dog>

G) Замена квантора, разворот

List<Animal>

List<Dog>
━━━━━━━━━━
List<Bulldog> | List<Poodle>

List<Animal>
━━━━━━━━━━
List<Dog>

List<Bulldog> | List<Poodle>

Интервал до: List<Animal>, List<Dog>
Интервал после: List<Dog>, List<Bulldog>, List<Poodle>

IN: 🔴 Несовместимо
ИНВАРИАНТНОСТЬ

☑️ method((List<Dog>) list)
× method((List<? super Dog>) list)
× method((List<Animal>) list)
× method((List<? super Bulldog>) list)

OUT →: 🔴 Несовместимо
ИНВАРИАНТНОСТЬ

× List<? super Dog> dogs = method()

Проверить тесты Super_G 🧪
 
 /**
  * ? super Dog -> ? extends Dog
  */
 class Super_G {
  void test_param_before(List<? super Dog> dogs) {
  }
  void test_param_after(List<? extends Dog> dogs) {
  }
  void test_param() {
   test_param_before((List<Animal>) null);
   test_param_before((List<? super Animal>) null);
   test_param_before((List<Dog>) null);
   test_param_before((List<? super Dog>) null);

   test_param_after((List<Dog>) null);
   // ❗️ The method test_param_after(List<? extends Hierarchy.Dog>) in the type Consumer_Super.Super_G is not applicable for the arguments (List<capture#18-of ? super Hierarchy.Dog>)
   test_param_after((List<? super Dog>) null);
   // ❗️ The method test_param_after(List<? extends Hierarchy.Dog>) in the type Consumer_Super.Super_G is not applicable for the arguments (List<Hierarchy.Animal>)
   test_param_after((List<Animal>) null);
   // ❗️ The method test_param_after(List<? extends Hierarchy.Dog>) in the type Consumer_Super.Super_G is not applicable for the arguments (List<capture#19-of ? super Hierarchy.Animal>)
   test_param_after((List<? super Animal>) null);
  }
  List<? super Dog> test_ret_before() {
   return null;
  }
  List<? extends Dog> test_ret_after() {
   return null;
  }
  void test_ret() {
   List<? super Dog> dogs = test_ret_before();
   // ❗️ Type mismatch: cannot convert from List<capture#28-of ? super Hierarchy.Dog> to List<? super Hierarchy.Dog>
   List<? super Dog> dogs_ = test_ret_after();
  }
 }
  • 2.2.5) Управление контравариантностью квантора

List<?>
List<? super Dog>

H) Ввод верхней границы квантора, сужение до интервала

List<Animal>
List<Dog>

List<Bulldog> | List<Poodle>

List<Animal>
List<Dog>
━━━━━━━━━━
List<Bulldog> | List<Poodle>

Интервал до: List<T> (любой)
Интервал после: List<Animal>, List<Dog>

IN: 🔴 Несовместимо

☑️ method((List<Animal>) list)
☑️ method((List<? super Animal>) list)
☑️ method((List<Dog>) list)
☑️ method((List<? super Dog>) list)
× method((List<Bulldog>) list)
× method((List<? super Bulldog>) list)

OUT →: 🟢 Cовместимо

☑️ List<?> dogs = method()

Проверить тесты Super_H 🧪
 /**
  * ? -> ? super Dog
  */
 class Super_H {
  void test_param_before(List<?> list) {
  }
  void test_param_after(List<? super Dog> dogs) {
  }
  void test_param() {
   test_param_before((List<Animal>) null);
   test_param_before((List<? super Animal>) null);
   test_param_before((List<Dog>) null);
   test_param_before((List<? super Dog>) null);
   test_param_before((List<Bulldog>) null);
   test_param_before((List<? super Bulldog>) null);

   test_param_after((List<Animal>) null);
   test_param_after((List<? super Animal>) null);
   test_param_after((List<Dog>) null);
   test_param_after((List<? super Dog>) null);
   // ❗️ The method test_param_after(List<? super Hierarchy.Dog>) in the type Consumer_Super.Super_H is not applicable for the arguments (List<Hierarchy.Bulldog>)
   test_param_after((List<Bulldog>) null);
   // ❗️ The method test_param_after(List<? super Hierarchy.Dog>) in the type Consumer_Super.Super_H is not applicable for the arguments (List<capture#27-of ? super Hierarchy.Bulldog>)
   test_param_after((List<? super Bulldog>) null);
  }
  List<?> test_ret_before() {
   return null;
  }
  List<? super Dog> test_ret_after() {
   return null;
  }
  void test_ret() {
   List<?> dogs1 = test_ret_before();
   List<?> dogs1_ = test_ret_after();
  }
 }

List<? super Dog>
List<?>

I) Удаление верхней границы квантора, расширение до любого типа

List<Animal>
List<Dog>
━━━━━━━━
List<Bulldog> | List<Poodle>

List<Animal>
List<Dog>

List<Bulldog> | List<Poodle>

Интервал до: List<Animal>, List<Dog>
Интервал после: List<T> (любой)

IN: 🟢 Cовместимо
КОНТРАВАРИАНТНОСТЬ

☑️ method((List<Dog>) list)
☑️ method((List<? super Dog>) list)
☑️ method((List<Animal>) list)
☑️ method((List<? super Animal>) list)

OUT →: 🔴 Несовместимо

× List<? super Dog> dogs = method()

Проверить тесты Super_I 🧪
 /**
  * ? super Dog -> ?
  */
 class Super_I {
  void test_param_before(List<? super Dog> dogs) {
  }
  void test_param_after(List<?> list) {
  }
  void test_param() {
   test_param_before((List<Animal>) null);
   test_param_before((List<? super Animal>) null);
   test_param_before((List<Dog>) null);
   test_param_before((List<? super Dog>) null);

   test_param_before((List<Animal>) null);
   test_param_before((List<? super Animal>) null);
   test_param_after((List<Dog>) null);
   test_param_after((List<? super Dog>) null);
  }
  List<? super Dog> test_ret_before() {
   return null;
  }
  List<?> test_ret_after() {
   return null;
  }
  void test_ret() {
   List<? super Dog> dogs1 = test_ret_before();
   List<? super Dog> dogs1_ = test_ret_after();
  }
 }

3) типовой параметр метода или класса

Типовой параметр отличается от конкретного типа из пункта 1 тем, что он используется в декларации класса или метода, а не при его использовании. Он может быть либо неограничен, либо иметь верхнюю extends границу (но не нижнюю). В дополнение, может быть представлено несколько границ перечисленных через &.

<T> void method(List<T> list)
<T extends Dog> void method(List<T> list)
<T extends Dog & Serializable<T>> void method(List<T> list)

Как и с квантором подстановки, вариантность границы не запрещена: её изменение будет либо сужением (например, NumberInteger или ObjectNumber при добавлении границы), либо расширением (например, IntegerNumber или NumberObject при удалении границы).

НО❕ говорить о правилах ППЛ больше нельзя: T extends Type это вообще не тип и не квантор, это скорее нечто вроде макро, который клиентский код “вызывает” для создания методов “на лету” (или перегрузка по требованию). Так что о подстановке здесь речь не идет, потому что это два принципиально разных механизма:

<T> — это макрос. Компилятор каждый раз подставляет конкретный тип (PoodleDog), создавая виртуальную перегрузку метода во всех местах, где он использован.

? — это квантор. Это не макрос, а полноценное подтипирование. Компилятор не выбирает тип, а просто фиксирует: “тут какая-то собака, но какая именно — неизвестно”.

Макро <T extends Dog> может сгенерировать до 3х виртуальных методов по месту использования "на лету".

Макро <T extends Dog> может сгенерировать до 3х виртуальных методов по месту использования “на лету”.

Иными словами, дженерики в Яве — это виртуальное метапрограммирование. Виртуальное потому, что JVM в итоге все равно сотрёт дженерик и выставит первую границу в сигнатуре. Но для статической верификации, будет создано нечто вроде сквозного “туннеля”, где параметрический аргумент может участвовать в формациях типов как в параметрах, так и на выходе. Это зона вывода типов ⤴, у которой свои правила, отличные от подстановочных.

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

Изменение

Направление

Совместимость

<Dog>
<T extends Animal>

Расширение (A)

🟢 Совместимо

Всё работает как раньше, появляется возможность конвертации вывода.

Раньше: Dog
Теперь: +Animal, Dog, +Bulldog, +Poodle

<T extends Dog>
<Dog>

Сужение (A)

🔴 Несовместимо

Пропали варианты метода с подтипами Dog, делая конвертацию недоступной.

Раньше: Dog, -Bulldog, -Poodle
Теперь: Dog

Тесты (A) показывают, что несмотря на то, что изменение никак не повлияло на параметры, после параметизирования появляется возможность приводить вывод к конкретному типу (Poodle poodle = test()) .

Проверить тесты Macro_A 🧪
 /**
  * Dog -> T extends Dog
  */
 class Macro_A_Widen {
  void test_param_before(Dog dog) {
  }
  <T extends Dog> void test_param_after(T infer) {
  }
  void test_param() {
   test_param_before((Dog) null);
   test_param_before((Poodle) null);
   test_param_before((Bulldog) null);

   test_param_after((Dog) null);
   test_param_after((Poodle) null);
   test_param_after((Bulldog) null);
  }
  Dog test_ret_before() {
   return null;
  }
  <T extends Dog> T test_ret_after(T infer) {
   return null;
  }
  void test_ret() {
   Animal animal = test_ret_before();
   Animal animal_ = test_ret_after(null);
   Dog dog = test_ret_before();
   Dog dog_ = test_ret_after(null);
   // Bulldog bulldog = test_ret_before();
   Bulldog bulldog_ = test_ret_after(null); // ➕
  }
 }

 /**
  * T extends Dog -> Dog
  */
 class Macro_A_Narrow {
  <T extends Dog> void test_param_before(T dog) {
  }
  void test_param_after(Dog dog) {
  }
  void test_param() {
   test_param_before((Dog) null);
   test_param_before((Poodle) null);
   test_param_before((Bulldog) null);

   test_param_after((Dog) null);
   test_param_after((Poodle) null);
   test_param_after((Bulldog) null);
  }
  <T extends Dog> T test_ret_before(T example) {
   return null;
  }
  Dog test_ret_after() {
   return null;
  }
  void test_ret() {
   Animal animal = test_ret_before(null);
   Animal animal_ = test_ret_after();
   Dog dog = test_ret_before(null);
   Dog dog_ = test_ret_after();
   Poodle poodle = test_ret_before(null);
   Bulldog bulldog = test_ret_before(null);
   // ❗️ Type mismatch: cannot convert from Hierarchy.Dog to Hierarchy.Poodle
   Poodle poddle_ = test_ret_after();
   // ❗️ Type mismatch: cannot convert from Hierarchy.Dog to Hierarchy.Bulldog
   Bulldog buldog_ = test_ret_after();
  }
 }

<T extends Dog>
<T>

Расширение (B)

🟢 Совместимо

Удалена граница, можно создать виртуальный метод под любой тип.

Раньше: Dog, Bulldog, Poodle
Теперь: любой T

<T>
<T extends Dog>

Сужение (B)

🔴 Несовместимо

Варианты методов с любым параметром (напр., String) больше недопустимы.

Раньше: любой T
Теперь: Dog, Bulldog, Poodle

Снятие ограничения это всё равно что расширение границы до любого типа: <T> = <T extends Object>.

Проверить тесты Macro_B 🧪
 /**
  * T extends Dog -> T
  */
 class Macro_B_Widen {
  <T extends Dog> void test_param_before(T infer) {
  }
  <T> void test_param_after(T infer) {
  }
  void test_param() {
   test_param_before((Dog) null);
   test_param_before((Poodle) null);
   test_param_before((Bulldog) null);
   // test_param_before((String) null);

   test_param_after((Dog) null);
   test_param_after((Poodle) null);
   test_param_after((Bulldog) null);
   // test_param_after((String) null); // ➕
  }
  <T extends Dog> T test_ret_before() {
   return null;
  }
  <T> T test_ret_after(T infer) {
   return null;
  }
  void test_ret() {
   // String string = test_ret_before();
   // String string_ = test_ret_after(null);  // ➕
   Animal animal = test_ret_before();
   Animal animal_ = test_ret_after(null);
   Dog dog = test_ret_before();
   Dog dog_ = test_ret_after(null);
   Bulldog bulldog = test_ret_before();
   Bulldog bulldog_ = test_ret_after(null);
  }
 }

 /**
  * T -> T extends Dog
  */
 class Macro_B_Narrow {
  <T> void test_param_before(T infer) {
  }
  <T extends Dog> void test_param_after(T infer) {
  }
  void test_param() {
   test_param_before((Dog) null);
   test_param_before((Poodle) null);
   test_param_before((Bulldog) null);
   test_param_before((String) null);

   test_param_after((Dog) null);
   test_param_after((Poodle) null);
   test_param_after((Bulldog) null);
   // ❗️ The method test_param_after(T) in the type Macromorphic.Macro_B_Narrow is not applicable for the arguments (String)
   test_param_after((String) null);
  }
  <T> T test_ret_before(T infer) {
   return null;
  }
  <T extends Dog> T test_ret_after(T infer) {
   return null;
  }
  void test_ret() {
   String string = test_ret_before(null);
   // ❗️ Type mismatch: cannot convert from Hierarchy.Dog to String
   String string_ = test_ret_after(null);
   Animal animal = test_ret_before(null);
   Animal animal_ = test_ret_after(null);
   Dog dog = test_ret_before(null);
   Dog dog_ = test_ret_after(null);
   Bulldog bulldog = test_ret_before(null);
   Bulldog bulldog_ = test_ret_after(null);
  }
 }

<T extends Dog>
<T extends Animal>

Расширение (C)

🟢 Совместимо

Старый код остаётся корректным, возможных вариантов больше.

Раньше: Dog, Bulldog, Poodle
Теперь: +Animal, Dog, Bulldog, Poodle

<T extends Animal><T extends Dog>

Сужение (C)

🔴 Несовместимо

Варианты методов с Animal пропали из зоны видимости компилятора.

Раньше: -Animal, Dog, Bulldog, Poodle
Теперь: Dog, Bulldog, Poodle

Проверить тесты Macro_C 🧪
 /**
  * T extends Dog -> T extends Animal
  */
 class Macro_C_Widen {
  <T extends Dog> void test_param_before(T infer) {
  }
  <T extends Animal> void test_param_after(T infer) {
  }
  void test_param() {
   test_param_before((Dog) null);
   test_param_before((Poodle) null);
   test_param_before((Bulldog) null);
   // test_param_before((Animal) null);

   test_param_after((Dog) null);
   test_param_after((Poodle) null);
   test_param_after((Bulldog) null);
   test_param_after((Animal) null); // ➕
  }
  <T extends Dog> T test_ret_before(T infer) {
   return infer;
  }
  <T extends Animal> T test_ret_after(T infer) {
   return infer;
  }
  void test_ret() {
   Animal animal = test_ret_before(null);
   Animal animal_ = test_ret_after(null);
   Dog dog = test_ret_before(null);
   Dog dog_ = test_ret_after(null);
   Bulldog bulldog = test_ret_before(null);
   Bulldog bulldog_ = test_ret_after(null);
  }
 }

 /**
  * T extends Animal -> T extends Dog
  */
 class Macro_C_Narrow {
  <T extends Animal> void test_param_before(T infer) {
  }
  <T extends Dog> void test_param_after(T infer) {
  }
  void test_param() {
   test_param_before((Dog) null);
   test_param_before((Poodle) null);
   test_param_before((Bulldog) null);
   test_param_before((Animal) null);

   test_param_after((Dog) null);
   test_param_after((Poodle) null);
   test_param_after((Bulldog) null);
   // ❗️ The method test_param_after(T) in the type Macromorphic.Macro_C_Narrow is not applicable for the arguments (Hierarchy.Animal)
   test_param_after((Animal) null);
  }
  <T extends Animal> T test_ret_before(T infer) {
   return infer;
  }
  <T extends Dog> T test_ret_after(T infer) {
   return infer;
  }
  void test_ret() {
   Animal animal = test_ret_before(null);
   Animal animal_ = test_ret_after(null);
   Dog dog = test_ret_before(null);
   Dog dog_ = test_ret_after(null);
   Bulldog bulldog = test_ret_before(null);
   Bulldog bulldog_ = test_ret_after(null);
  }
 }

На тестах (C) видно, что при сужении параметров одной иерархии, все доступные прежде выходы продолжают работать как прежде, но становится невозможным передать параметры, типы которых отпали при сужении.

В завершение нужно отметить, что если присутствует несколько границ, то ни одна из них не должна сужаться для поддержания совместимости.

А теперь небольшая проверка знаний: соберётся ли этот код? Ответы пишите в комментариях.

import java.util.List;
 
public class Example {
 <T extends Number> T example() {
  return null;
 }
 void test_ret() {
  List<Number> list_number_ = example();
 }
}

Массивы

В отличие от инвариантных List<T>, массивы в Java являются ковариантными, что было сделано в JDK 1.0 для возможности создания общих методов (например, Arrays.sort(Object[])) еще до появления дженериков. Это означает, что если Dog наследуется от Animal, то Dog[] считается подтипом Animal[].

Однако за эту гибкость приходится платить отсутствием безопасности типов на этапе выполнения: JVM помнит реальный тип массива в куче и выбросит ArrayStoreException, при попытке положить кота в массив собак через ссылку на массив животных.


В этой секции было предоставлено много обсуждений ради понимания того, как понимать методы как математические функции. Мы сошлись на том, что также как и {1, 2, 3} составляют множество целых чисел, классы и интерфейсы являются номиналом всего универсума программы. В сигнатурах они задают рамки, сужая или расширяя которые мы проводим глобальную ребалансировку всей системы, изменяя гарантии среды коду и кода среде.

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

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

Статический анализ Явы основывается на принципах технологии 90х под названием Поведенческого Подтипирование ⤴. Проверка правил подстановки служит первый линией защиты от нарушения баланса гарантий, но анализ одной лишь сигнатуры метода не может гарантировать семантической совместимости — он служит лишь для обслуживания модульности и по сути является лишь урезанной теорией типов (об этом в след. раз).

5) Сопровождение

В дополнение к проверкам исходной совместимости выше, существует бинарная совместимость. Она проявляется в рантайме, когда уже скомпилированные классы загружаются в JVM и линкуются с новыми версиями .class файлов библиотеки. Я отношу такую совместимость к “сопровождению”, так как распространитель собранных классов берет на себя ответственность убедится, что его обновления не ломают существующих клиентов.

После сборки и при загрузке класса в JVM будут проведены дополнительные проверки, но это уже не входит в стадию “верификации” при разработке, о которой шла речь ранее. Для таких механических проверок, JVM используют анализ потока данных и убеждается в соответствии структур памяти. Это сделано потому, что статический анализ не даёт 100% гарантий на типовую безопасность программы (ведь компилятор можно обмануть через касты).

И тут правила уже совсем другие, не имеющие ничего общего с Лисковской математикой — в дело вступает чистая инженерия. JVM смотрит только на

  • Наличие класса/метода/поля с тем же именем

  • Совпадение сигнатуры (имя + параметры + возвращаемый тип)

  • Модификаторы доступа (public метод должен оставаться public)

Никакого “подтип подходит вместо супертипа” на уровне линковки нет. Если метод ожидал Animal, а в новой версии параметр стал Dog — бинарная совместимость сломается, потому что сигнатура set(Animal) и set(Object) — это разные сигнатуры для JVM. Старый вызов будет искать set(Animal) и не найдёт. Поэтому бинарная совместимость жёстче исходной потому что требует точного сохранения сигнатур, а не просто подтипизации.

Все пункты четко изложены в спецификации ⤴, переписывать их смысла не вижу. Но вот пара фактов о бинарной совместимости, которые идут врознь с другими подсистемами Явы.

  • (типовые аргументы) Дженерики стираются — в байт-коде сигнатура считается по сырым типам. Это значит, что set(List<String>) → set(List<Integer>) — бинарно совместимы, хотя типы внутри <> разные (List как был List, так и остался).

  • (типовые параметры) при компиляции, <T extends Dog> T method будет заменён на Dog method, что приведёт к тому, что после обновления метод <T extends Animal> T method не будет найден.

  • (онтология) при перемещении метода вверх по иерархии бинарная совместимость сохраняется, потому что сигнатура метода та же (bark()V) — JVM ищет метод сначала в классе Dog, не находит — идёт в родителя Animal, находит там.

  • (реализация) JVM линковка не учитывает throws, поэтому бинарно старый клиентский код продолжит работать.

Из-за того, что типовые параметры стираются, можно сохранить бинарную совместимость:

void method(String)               // method(Ljava/lang/String;)V
// →
<T extends String> void method(T) // method(Ljava/lang/String;)V 

При этом erasure высчитывается по первой верхней границе дженерика. Если указан просто параметр <T>, то его верхняя граница — Object.

Также интересно, что тип возвращаемого значения можно менять при переопределении на подтип:

class Base { Number get() { return 1; } }
class Derived extends Base { // класс существовал ранее, но
  Integer get() { return 2; }  // добавим новый метод
}

Хотя и новый метод get() перезаписал родительский get (а не создал перегрузку) из-за замены Number на Integer, уже скомпилированный клиент все равно сможет найти метод по старой сигнатуре get()Ljava/lang/Number;, потому что Ява создает синтетические мосты:

public Number get() { return this.get(); } // вызывает get()Ljava/lang/Integer;

Совместимая несовместимость

Может показаться, что если метод по-прежнему доступен по своей сигнатуре в таблице вызовов, то это “бинарно совместимо”, но это иллюзия. Проблемы выявляются не при загрузке классов, а в процессе исполнения. Даже если JVM успешно связала вызов метода, старый клиентский байт-код хранит жесткие инструкции checkcast, ожидающие конкретный тип. Если новая версия библиотеки подсунет другой объект, программа не упадет при старте, а “взорвется” в самый неподходящий момент.

Вот основные runtime ошибки, возникающие из-за таких скрытых несовместимостей:

  • NoSuchMethodError — Самая частая: метод вызывается, но его стертая сигнатура в новой версии изменилась (например, из-за смены границ <T extends Dog> на <T extends Animal>).

  • ClassCastException — Возникает, когда метод возвращает более широкий тип (или Object), а старый клиентский код пытается принудительно привести его к узкому типу, который был в API раньше.

  • AbstractMethodError — Случается, если добавить новый абстрактный метод в интерфейс, которые старые классы не реализовали.

  • IncompatibleClassChangeError — Общий тип ошибки, когда, например, класс внезапно стал интерфейсом или статическое поле стало нестатическим, что ломает логику обращения в байт-коде.

  • NoSuchFieldError — Если клиентский код пытается обратиться к полю, которое в новой версии было переименовано или удалено.

Ад зависимостей

Перекомпиляция нашего кода под новую версию библиотеки не гарантирует стабильность всего проекта. Во многих проектах могут присутствуют сторонние зависимости, которые будут использовать ту же библиотеку, но которые были скомпилированы ранее под её старую версию (v1.0). Поскольку в classpath в итоге попадает только одна копия библиотеки, “застывший” байт-код зависимостей загружается в среде с новыми методами или типами.

Ловушка classpath в том, что при линковки нашего кода к версии 2 зависимости B, другая зависимость без перекомпиляции может продолжить линкаться к версии 1.

Ловушка classpath в том, что при линковки нашего кода к версии 2 зависимости B, другая зависимость без перекомпиляции может продолжить линкаться к версии 1.

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

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

Итоги

В XX веке наука совершила фундаментальный переход от изучения изолированных вещей к исследованию системных связей. Ключевой фигурой этого движения стал Людвиг фон Берталанфи, создатель Общей теории систем ⤴. Он предложил революционный взгляд на мир не как на склад статичных объектов, а как на динамический поток энергии и материи, в котором системы являются лишь устойчивыми формами этого движения.

Высоко-системная энтерпрайз модель как набор комплексов. Любой бизнес это Сложная Адаптивная Система ⤴, где Ява — не язык программирования, а платформа для управления сложностью.

Высоко-системная энтерпрайз модель как набор комплексов. Любой бизнес это Сложная Адаптивная Система ⤴, где Ява — не язык программирования, а платформа для управления сложностью.

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

  • Онтология: описание модели с помощью интерфейсов и их методов и проектировки

  • Воплощение: реализация классов и райнтайм с линкингом и исполнением байт-кода

  • Модульность: переиспользование библиотек и контроль за их совместимостью

  • Сохранность: типовая статическая верификация на основе принципа Лисковой

Эти идеи во многом предвосхитил Александр Богданов в своей Тектологии ⤴. Он рассматривал мир как иерархию комплексов, которые изначально могут работать слаженно и гармонично. Однако Богданов доказывает, что по мере роста и развития в частях системы неизбежно накапливаются различия. Это ведет к системному расхождению: подсистемы начинают развиваться по своей логике и в них копятся противоречия.

Вместе с условием устойчивости — дополнительными связями, оно развивает также определённые условия неустойчивости: порождает «системные» противоречия. Противоречия эти на известном уровне их развития способны даже перевешивать значение дополнительных связей. […] Системное расхождение означает возрастание организационных различий между частями целого, увеличение тектологической разности. Богданов

При этом в Яве, каждая фича языка не вводилась как отдельный компонент одной из систем, а как раз как связь между двумя комплексами: онтологические интерфейсы служили “скелетом” для реализаций, классы собирались в бинарные библиотеки, совместимость библиотек проверялась статическим анализом, ну а сам принцип Лисковой из последнего оставлял возможность для быстрой адаптации под изменение бизнес среды, и так по кругу.

Собрав все эти процессы под одной эгидой Явы как языка не только программирования, но и моделирования, компоновки и проверки, изначально удалось добиться революционных результатов в продуктивности разработчика, и продать это всё как Агиль. Но к сожалению, как всегда и бывает, получилось нечто вроде чуда одного хита — когда после одного гигантского скачка, сразу начался застой, длящийся до сих пор.

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

Джейм Гослинг, дизайнер Явы, в интервью InfoWorld ⤴ рассказывает:

Инженерное проектирование — это как игра «Убей крота». У вас вот там вылезла проблема. Вы бьете по ней, и она исчезает. Но действительно ли вы её исправили, или она просто переместилась в другое место? Часто трудно понять, решили вы проблему или просто перегнали её дальше. И чаще всего, когда люди говорят, что решили проблему, они её просто передвинули.

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

  1. Возможность добавление реализаций в интерфейсы через default методы была введена для предотвращения несовместимостей при использовании принципа инверсии контроля с новыми методами, однако приводит к java.lang.AbstractMethodError в рантайме.

  2. Ещё до default методов, абстрактные классы были задуманы как основа для переиспользования кода; но при этом были также добавлены абстрактные методы для деклараций без реализации, но это — зона ответственности онтологии и интерфейсов.

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

  4. Разработчики вынуждены прибегать к наследственности, чтобы переиспользовать код, однако за этим кроется проблема хрупкого базового класса ⤴: добавление новых методов в базовые классы может привести к несовместимостям “вниз” по иерархии.

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

  6. Хоть множественная наследственность и отключена, разработчики всё равно прибегают к хакам в виде использования для этого default методов, что в итоге может привести к отказу в компиляции из-за неопределенности на типовом уровне, как было показано.

  7. Модификаторы контроля потока и видимости прописаны в декларациях самих методов, напрочь “впаивая” дизайн в реализацию и препятствуя возможности “пересобрать” класс под другие паттерны, которые могут более подходить для конкретных задач.

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

  9. При наследовании типа с помощью extends, должен создаваться изоморф, а не полиморф; extends не даёт реального подтипа. Принцип Лисковой перестает быть методом поведенческой верификации и становится простой проверкой совместимости.

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

Ява — это целая устоявшаяся парадигма, крепко пустившая корни в ключевую техническую архитектуру миллионов огромных компаний, на которых держится сам Капитализм; однако её базис как основного инструмента разработки ПО методом Агиля хрупок и падок; где-то он даёт слишком много свободы, а где-то не даёт её вообще. Сам Гослинг признает, что он “использует интерфейсы не так часто, как следовало бы”. Это гадание, когда нужен интерфейс, а когда нет, никуда не годится. Разработчикам нужны четкие инструкции и устойчивая философская база, закрепляющая основные принципы Агиля без свободы истолкования как и кем попало.

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

Будущее JavaScript

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

С 2015 года Oracle Labs разрабатывала новый продукт GraalVM — многие знают его как основа для компиляции бинарников из исходников Явы. Но кроме этого, он позволяет отделить сам синтаксис Java и заменить его на JavaScript, Python и др. Главной заслугой Java как языка, было совмещение онтологии и реализации. Моя же ставка на то, что именно скриптовые языки Грааля послужат тем каналом связи, который поможет “подвязать” и остальные комплексы.

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

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

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

Для того, чтобы понять моё видение будущего Явы, нужно четко понять, что под дуальным термином Java понимается и платформа, и язык. В прошлом, они вместе составляли Java среду, однако теперь, с приходом Грааля, образуется новая среда на платформе — JavaScript. Опять же, это не язык-имплементация EcmaScript, а “подплатформа”, на которой “лежат” скриптовые языки вроде JavaScript, Python и др. Главная задача сейчас — “вычесать” все имеющиеся архитектурные колтуны и установить гармонию между средами для их свободной конъюгации.

До сих пор, среда Java находилась в каком-никаком равновесии и даже депрессии, и для перехода к фазе оживления с огромным скачком эффективности от технологии GraalVM, нужно построить новой среде JavaScript мосты к текущим артефактам (библиотекам, JDK) и знаниям (интерфейсам, документации) экосистемы, чтобы взамен она могла открыть новые возможности для описания процессов и гарантий (модельных проверок), включая более интеграцию с ИИ ввиду их простого, максимально приближённому к натуральному, языку.

Одним из выделенных противоречий была подмена понятия “верификации” с настоящей и обширной проверки онтологической модели, на простое соответствие ППЛ, которое не дает никаких поведенческих гарантий. Для этого были придуманы такие языки как UML и JML ⤴, но на практике они используются редко: разработчики жалуются, что не хотят просто переводить код в диаграммы. Скриптовые языки могут стать как раз тем “затерянным” средством разработки, который предоставляет баланс между удобством, гибкостью и формальностью.

Системный подход к Agile: исследование совместимостей Java библиотек - 21

Дисклеймер

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

Заключение

Тектология Богданова не зря опубликована на гуманитарном портале: инженерия, вопреки заблуждению, это гуманитарная, а не физико-математическая наука. Гуманитарий происходит от слова Humanities, то есть Человечество. Работа над созданием инструментов ПО — это удел не программистов, которые должны использовать технологии, а инженеров, которые, прибегая к этике, эрудиции и абстрактному мышлению, могут задавать вектор движения человечества.

Затем, конечно, с увеличением числа частей возрастает сумма их «трений», то есть внутренних дезингрессий в их движениях. Следовательно, и здесь дифференциация бывает организационно-выгодной только до известного предела, за которым её противоречия получают перевес. Тогда машина отвергается из-за чрезмерной тонкости и сложности. Богданов

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

Да, пока всё работает, но пкризис такая штука, которая подкрадывается незаметно. “Основная причина кризиса программного обеспечения — резкий рост мощностей вычислительных машин!”, говорил Дейкстра. Сегодня, мы наблюдаем резкий рост не мощностей, а самого кода. ИИ открывает небывалые возможности, но вместе с ним возрастают и трения — и чтобы это чудо техники не отверглось, нужны современные и системные подходы и методологии.

Запланировано

  • На следующей неделе будет предоставлен фреймворк для создания отчетов совместимостей на вики-страницах ваших проектов “Аудитор“, который поможет в автоматическом режиме донести все требуемые сведения до ваших пользователей.

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

Превью Аудитора
Аудитор помогает разработчикам поддерживать актуальную модель API библиотек в голове.

Аудитор помогает разработчикам поддерживать актуальную модель API библиотек в голове.

❕ Если вы желаете получить доступ к превью-версиям инструментов и статей раньше других, вы можете скинуть донат (от 500р) и написать мне в ЛС для демо-доступа и поддержки в установке. + Ваш ник будет упомянут в публикациях в разделе Патронов.

Дорогой Хабр!

Знаете ли вы, что на написание одной статьи может уйти до полумесяца? Пока вы просто читаете, кто-то из кожи вон лезет, чтобы всё сделать красиво. Стримеры на твиче берут по 2к чтобы просто поставить фильм, а мне нужно собирать заказы в Купере, чтобы дотянуть до конца месяца, потому что новые технологии и инженеры никому и не нужны. Если ты сегодня узнал что-то новое, или тебе просто зашел мем, отправить донат 💳 будет честным. Спасибо!

Автор: artdeco

Источник

Rambler's Top100