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

Обобщенные типы (generic types)

В Java есть так называемые дженерики (generics) — «обобщённые» или «параметризованные» типы. Например, множество чисел будет записываться как Set<Integer>, а множество строк как Set<String>. Здесь Integer — это что-то вроде базового типа.

Часто бывает полезно оперировать множествами значений одного и того же абстрактного типа T, обозначим это Set<T>. Во многих случаях мы можем писать код, не задумываясь о том, что именно представляет из себя T.

Это позволяет писать меньше кода за счёт того, что он теперь более универсальный: сортировка чисел и сортировка строк будут делаться одной и той же функцией sort(Set<T>), которая работает с множеством объектов любого типа.

Мы должны были бы дополнительно указать, что объекты должны быть сравниваемыми (т.е. тип T должен быть Comparable), но опустим эту деталь.

Ковариантное и контравариантное наследование типов

С дженериками тесно связано понятие вариантности. Она бывает трёх видов: ковариантность, контравариантность и инвариантность. Вариантность описывает, как наследуются обобщённые типы.

Например, у нас есть два типа: тексты (Text) и стихи (Poem).
Очевидно, стихотворение является текстом (мы здесь упростим реальность и опустим тот факт, что стихотворение может быть и не текстом). Обозначим такое отношение типов стрелочкой: Poem → Text и будем говорить, что тип Poem унаследован от типа Text.
В таких случаях ещё говорят, что Poem — подтип, а Text — родительский тип или надтип.

Теперь заведём два обобщённых типа: автор и читатель (или скорее чтец). Более общо они называются производитель (producer) и потребитель (consumer).

type Author<T> {
  // Функция без аргументов, при вызове возращает
  // художественное произведение типа T
  T create_masterpiece();
}

type Reader<T> {
  // Функция принимает текст типа T и
  // громко и с выражением его зачитывает
  read_out(T some_text)
}

И автор, и чтец у нас на зарплате («Власти оказались хитрей — они наняли зрителей»), так что если автор заявляет, что умеет писать стихи, то должен суметь по заказу написать стихотворение. А читатель текстов точно так же по заказу должен уметь прочитать любой текст, какой его попросят.

Автор — это обобщённый производитель. Он может производить стихотворения, а может — романы, комиксы, ноты или просто какие-то обобщённые тексты.

Обратите внимание, что автор стихотворений является автором текстов, а вот в обратную сторону это неверно: не любой создатель текстов — поэт. Таким образом, Author<Poem> → Author<Text>.

Чтецы тоже бывают привередливыми: некоторые из них читают только стихи. А бывают и всеядными, которым всё равно, что читать (они могут читать даже новости, хоть и через слёзы).

Теперь следите за руками! Читатель поэзии НЕ является читателем текстов, потому что ему нельзя «скормить» произвольный текст. А вот читатель произвольных текстов является читателем и поэзии тоже: Reader<Text> → Reader<Poem>.

Заметили разницу? Эти два разных типа наследований называются ковариантным и контравариантным. Давайте выпишу рядом, как отношение наследования типов T и S переносится на производные типы Reader<T>, Reader<S>, Author<T>, Author<S>.

       Poem  →        Text   базовый тип
Author<Poem> → Author<Text>  ковариантное наследование
Reader<Poem> ← Reader<Text>  контравариантное наследование

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

Давайте теперь посмотрим, что может пойти не так.

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

Представьте, что вы хотите собрать список всех произведений Set<Text> и список всех поэтических текстов Set<Poem>. В какой-то момент вам надоедает делать разные обработчики для двух разных списков и вы задаётесь вопросом, а нельзя ли и это место как-то унифицировать?

Кажется очевидным, что множество стихотворений является множеством текстов.
Но это не совсем так!

Подтип должен уметь делать всё то же самое, что умеет делать родительский тип (это называется «принцип подстановки Барбары Лисков»).
Это ограничение позволяет использовать использовать подтип везде, где можно использовать надтип.

То есть, если у вас есть функция печати текста print_text(Text t), то и стихотворения она распечатывать тоже умеет. Такое применение вполне корректно: print_text(some_poem).

Если говорить точнее, принцип формулируется так: «Любое утверждение верное для родительского типа должно быть так же верно и для подтипа» (см. комментарий).

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

Значит нам нужно выбрать действия, которые мы разрешаем делать с нашим контейнером Set<T>. И от того, какие операции мы введём, будет зависеть и то, как связаны типы.

Скажем, что множество имеет две операции (знатокам джавы просьба не волноваться: это ненастоящий Set, ненастоящая Java, а слова «множество» и «список» я использую взаимозаменяемо):

  • T get() — операция, которая выбирает какой-то объект из списка и возвращает его вам. Выданный вам объект имеет тип T.
  • put(T) — операция, которая добавляет объект типа T в список.

Давайте поймём, как при таком наборе действий соотносятся между собой множество текстов и множество стихотворений.




Предположим, как мы сказали ранее, что множество стихотворений является множеством текстов: Set<Poem> → Set<Text>.

Наш список стихотворений Set<Poem> имеет метод get, возвращающий Poem. А если мы будем трактовать список стихотворений как список текстов (мы только что предположили, что делать так разрешено), то метод get вернёт просто Text. Вроде нормально: стихотворение является текстом.

Кроме этого Set<Poem> имеет метод put, добавляющий Poem в список. Но! Если мы снова трактуем наш объект как список текстов, то метод put имеет возможность принять любой Text и добавить его в список. Остановитесь на секунду и осознайте проблему.

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

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

Я приведу псевдокод, который иллюстрирует проблему.
Если вы рыбка, и вам такое сложновато, то feel free to skip.
Но там есть стих из статьи. Я старался!

Set<Poem> set_of_poems; // список стихов

// Переинтерпретируем его как список просто текстов.
// Это не другой список, а тот же самый, на который
//   мы смотрим под другими углом
// Мы имеем право так делать, потому что список
//   стихотворений считаем списком текстов
Set<Text> set_interpreted_as_texts = set_of_poems;

// Добавили статью в переинтерпретированный список стихов.
// Теперь у нас в списке стихов есть непоэтический текст.
// Проблема!!!
set_interpreted_as_texts.put(article_1);

// На самом деле, благодаря принципу подстановки Лисков,
// мы могли бы добавить её и напрямую.
// Результат был бы тот же — ошибка
set_of_poems.put(article_2);

// Методом get мы получаем научную статью.
// Имеем право: set_interpreted_as_texts — список текстов
Text text_1 = set_interpreted_as_texts.get();

Reader<Text> universal_reader;  // чтец-универсал
// Он может зачитать написанное в статье
reader.read_out(text_1);

    // H2 + 2⋅O2 => 2H2O + Energy

// Теперь вызываем get у исходного списка (поэтического).
// Из множества может быть выбрана научная статья,
// хотя set_of_poems гарантировал, что операция get
//   нам выдаст стихотворение
//
// Мы попытались интерпретировать статью как стих,
//   но вообще-то это некорректная операция!!!
Poem poem_2 = set_of_poems.get();

// И наконец прочтём поэму о химии.
Reader<Poem> poem_reader;   // чтец поэзии
// Пытается зачитать статью по химии
poem_reader.read_out(poem_2);

    // В реторту засыпь пару моль водорода,
    // Кислорода добавь соразмерно,
    // Чиркни спичкой,
    // Adios.

Шутки шутками, но вообще-то мы получили нестыкуемые типы. Мы не имеем права так делать. Получается, что Set<Poem> не является Set<Text>.




Но может быть всё наоборот, и это Set<Text> является Set<Poem>?

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

// Заведём набор текстов
Set<Text> texts;

// переинтерпретируем его как набор стихов
Set<Poem> reinterpreted_set = texts;

// Функция get для типа Set<Poem> должна выдавать
//   объекты типа «стихотворение».
// Но вообще-то там хранятся тексты общего вида.
// Снова проблема!
Poem poem_1 = reinterpreted_set.get();

Снова нет! В обратную сторону это тоже не работает и Set<Text> не является Set<Poem>.




Итак, наследование не ковариантно и не контравариантно по типу параметра. Типы «набор текстов» и «набор стихов» независимы и никак друг с другом не соотносятся. Set<Text> и Set<Poem> нельзя использовать взаимозаменяемо. Такое наследование называется инвариантным.

В сухом остатке

Мы теперь можем расширить нашу таблицу:

       Poem  →        Text   базовый тип
Author<Poem> → Author<Text>  ковариантное наследование
Reader<Poem> ← Reader<Text>  контравариантное наследование
   Set<Poem> ≠    Set<Text>  инвариантное наследование

Как вариантность представляется в языке

Теперь вернёмся к вопросу про нотацию и именование сущностей.

Мы разобрали три типа наследования параметризованных типов:

  • ковариантное (Author<Poem> → Author<Text>),
  • контравариантное (Reader<Text> → Reader<Poem>),
  • инвариантное (Set<Poem> ≠ Set<Text>)

Но теперь языку программирования необходимо объяснить, как именно должен вести себя ваш класс: как Author, Reader или Set?

И в этом месте языки поступают по-разному. В языке Java программист описывает отношения наследования между базовыми типами: — Set<Poem> и Set<Text> — два инвариантных типа. Всё так просто: по-умолчанию все дженерики инвариантны. Если мы ничего не говорим про наследование типов, то язык не пытается делать догадок за нас. — Author<T extends Text> — тип T расширяет тип Text (то есть является подтипом) — Reader<T super Poem> — тип T это родительский тип для Poem (как его иногда называют, супер-тип, отсюда и синтаксис).

type Author<T extends Text> {
  T create_masterpiece();
}

type Reader<T super Text> {
  read_out(T some_text)
}

Когда у нас есть такие типы, мы можем их безопасно использовать:

// Пушкин пишет стихи
Author<Poem> pushkin;
//   и даже если б он не писал
//   ничего кроме, всё равно
//   был бы писателем
Author<Text> writer = pushkin;

// Книжный червь читает всё
Reader<Text> bookworm;
//   и стихи тоже
Reader<Poem> poetry_fan = bookworm;

А вот наша табличка ко-ко-контравариантности:

         ковариантное наследование

Author<T extends Text> → Author<Text>
       T extends Text  →        Text

        например Poem
         или Article


——————————————————————————————————————————————
        контравариантное наследование

Reader<Poem>           ← Reader<T super Poem>
       Poem            →        T super Poem

                                например Text
                                  или Object

Вариантность done right

Если вы к этому месту окончательно запутались, вы в хорошей компании. В этих super и extend я тоже запутался. И неоднократно!

Поэтому мне очень понравилось, как то же самое элегантно решено в Kotlin (и, говорят, ещё раньше в C#)!

По умолчанию обобщённые типы всё так же инвариантны. А для ко-/контра-вариантности там сделано по-уму: у параметра типа можно поставить один из двух модификаторов: in или out.

Модификатор in говорит, что объекты типа T употребляются только в качестве аргумента функций. То есть приходят в наши функции на вход. Функции являются потребителями (читателями) объектов типа T.

Модификатор out обозначает, что объекты типа T употребляется только в качестве возвращаемого значения функций. Иными словами, функции являются производителями (авторами) объектов типа T.

То есть in идёт функции на вход, out — на выход:

out         in
 ↓          ↓
 T   action(T argument, ...)

Давайте на примере, там всё становится ещё проще.

type Author<out T: Text> {
  T create_masterpiece();
}

type Reader<in T> {
  read_out(T some_text)
}

Есть ли разница, писать in/out или super/extends? На мой взгляд, отличия весьма существенные.

Слова super/extends описывают отношение между типами. А мы видели, что типы ведут себя не слишком-то интуитивно: множество стихотворений не является множеством текстов.

В действительности вам нужно не отношение между типами; вам требуется знать, можно или нельзя использовать объект типа T в указанной роли. И ключевые слова in/out говорят ровно об этом.

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

Возможность меньше задумываться о деталях и больше о существенном — огромное достоинство хорошей нотации.



Исходно этот текст опубликован в моём телеграм-канале «Шутки Шрёдингера».