Периодически на хабре можно встретить статьи о том, какие невероятные вещи можно сделать на шаблонах C++: конечные автоматы, лямбда-исчисление, машина Тьюринга и многое другое. Параметризованные типы в Java традиционно считаются лишь пародией на шаблоны C++ (несмотря на то, что их даже сравнивать как-то некорректно), и причины этого несложно понять. Тем не менее не всё так плохо, и компилятор Java можно заставить производить во время проверки типов любые вычисления, лишь бы хватило оперативной памяти. Конкретный способ это сделать был описан в ноябре 2016-го года в этой прекрасной публикации. Его я и хотел бы объяснить. Для затравки приведу следующий код. Корректен ли он? Предлагаю скомпилировать и проверить, угадали ли вы результат. Компилятор выбросит java.lang.StackOverflowError независимо от размера стэка. Разберёмся, почему компилятор ведёт себя именно так (я бы не назвал это багом), как понимание данных причин может быть полезно и причём тут машина Тьюринга. В первую очередь поговорим об основах. Самый простой способ использования параметризованных типов выглядит примерно так: Обычно подобного хватает, но есть проблема — тип элементов жёстко зафиксирован. У нас нет возможности использовать объект integerList там, где нужен List<Number>, поскольку вставка в такой список элемента типа Double сломала бы его целостность. И наоборот, нельзя использовать numberList там, где ожидается List<Integer>, поскольку это приведёт к ClassCastException при чтении элементов списка в случае, если в нём попадутся не целые числа. Конечно, для решения второй проблемы можно использовать такую маленькую хитрость: Я называю это хитростью, потому что такой подход требует введения дополнительного типа T. Более того, проблему вставки подобным способом не решить в принципе. Альтернативный и, на мой взгляд, более правильный вариант этого метода должен выглядеть так: Говорят, что такие типы ковариантны своим параметрам. Это значит, что если A является подтипом B, то List<A> (как и List<? extends A>) является подтипом List<? extends B>, а значит между ними разрешено присваивание. Побочный эффект — такой список будет доступен только для чтения и целостность никаких объектов мы нарушить не сможем.
Для случая записи есть другая конструкция: List<? super Integer> описывает совокупность списков, в которые можно добавлять целые числа, то есть его подтипами являются List<Integer>, List<Number> и List<Object> (плюс List<? super Integer>, List<? super Number> и List<? super Object> соответственно). Подобные классы контравариантны своим параметрам, то есть если A является супертипом B, то List<A> является подтипом List<? super B>. Ситуация прямо противоположная ковариантному случаю. Сосредоточимся на контравариантных типах. Итак, определим, какие именно типы в Java нас интересуют. Введём понятие индуктивно: Более сложные типы будут записываться как , что соответствует такому коду:
C1<? super C2<? super ...Cn<? super t>...>> Определим отношение наследования. Итак, пусть есть тип
interface C<T> extends C1<C2<? super ...Cn<? super t>...>>, в таком случае будем говорить, что для любого типа или класса без параметров X выполнено следующее отношение наследования: . Символом  обозначим транзитивное замыкание отношения  (то есть, помимо прямого, ещё и косвенное наследование через цепочку классов).
У отношения  есть одно полезное свойство — ацикличность. Так как в Java нет множественного наследования, то мы можем заявлять, что: Теперь можно наконец ввести отношение является подтипом "". В этом месте появляется существенное ограничение. Чтобы отношение  совпадало со стандартным способом определения подтипов в Java, необходимо, чтобы в пункте 2 значение n было нечётным. Полного формального доказательства я приводить не буду, оно было бы неуместно длинным. Правильнее будет рассмотреть ситуацию на примерах. n=1:
interface S<T> extends C<T> {}
В данном случае не вызывает вопросов то, что S<? super X> является подтипом C<? super X>, поскольку это является прямой подстановкой параметра T. n=3:
interface S<T> extends C1<C2<C3<? super T>>> {}
Докажем, что верно : n=2:
interface S<T> extends C1<C2<? super T>> {}
Предположим, что : Случаи n>2 рассматриваются аналогичным образом. Теперь можно сформулировать следующую теорему, которая позже будет доказана: Другими словами, в общем случае в Java невозможно определить, является ли один тип подтипом другого. Машина Тьюринга  — это пятёрка , где  — это конечное множество состояний,  — это начальное состояние,  — это конечное состояние,  — это конечный алфавит и  — это функция перехода.  — это дополнительный символ, не содержащийся в . Конфигурация машины Тьюринга — это четвёрка , в которой  — это текущее состояние,  — это левая часть ленты,  — это текущий символ и  — это правая часть ленты.  Шаг выполнения машины  отображает множество конфигураций само в себя следующим образом: Так же учитываются граничные случаи (символ  здесь означает пустую строку). Они показывают, что по достижении конца строки (слева или справа) к ней автоматически дописывается символ : Запуск машины на входе  — это последовательность шагов выполнения, начинающаяся с конфигурации . Если  достигает , то мы говорим, что машина  завершается (halts) на входе . Если же функция перехода не позволяет сделать следующий шаг выполнения, будем считать, что машина  ломается на входе . Начнём связывать воедино имеющиеся у нас понятия. Цель — сопоставить шаги выполнения машины Тьюринга с процессом проверки типов в Java. Положим, что у нас есть такой запрос:       — это специальная конфигурация, называемая завершающей (halting).
Отметим, что имея правило наследования  и подставляя тип , мы получаем следующее правило выполнения:        Рассмотрим следующую таблицу классов:       Данный запрос тоже приводит к зацикливанию проверки типов, но теперь это уже нетривиальный цикл. Мы реализовали полноценное блуждание "треугольника" в обе стороны по нашей импровизированной ленте. Убедиться, что проверка типов зацикливается, можно на следующем коде: Имея машину Тьюринга  и входную ленту , построим типы  и  таким образом, что  тогда и только тогда, когда  останавливается на входе .
Для каждого состояния  введём 6 классов: , , , ,  и ; для каждого символа  построим класс . Символ  из определения машины Тьюринга для удобства будем записывать как , ему будет соответствовать класс . Так же дополнительно введём 4 класса: , ,  и . Выглядит немного похоже на последний пример. Дальше будет примерное описание их смысла и конкретный способ эмуляции функции перехода соответствующей машины Тьюринга.  и  — это по сути типы  и  из последнего примера. Большую часть времени они блуждают (wander) вдоль ленты. Смысл типов  и  тоже сохраняется: они нужны для разворота на конце ленты. Единственное исключение — момент, когда  ( это  или ) встречается с соответствующим ему : он при этом "превращается" в .  — класс для завершающего состояния, обрабатываемый особым образом.
Полное описание выглядит так:    и  указывают машине, что пришло время для очередного шага выполнения. Соответственно, для каждого правила  в машине Тьюринга  мы строим специальное отношение наследования. При этом не забываем об особых правилах работы с классом :     Данные отношения выглядят довольно-таки хитро, тем не менее в них можно проследить ряд закономерностей. Механику работы каждого я разбирать не буду, рассмотрим лишь часть.
:   :   :   Вернёмся в реальный мир. Мы же про Java говорим, а значит наши рассуждения должны в итоге привести к написанию какого-либо кода, желательно полезного.
В мире ООП есть такое понятие, как текучий интерфейс. Вы, возможно, никогда не встречали этого названия, но наверняка видели его реализацию в коде. По сути это просто каскадный вызов методов, использование которого нацелено на повышение читаемости, ну или ещё чего нибудь. Выглядит вот так: На подобную цепочку вызовов можно взглянуть новым образом — как на слово. В данном примере мы имеем алфавит {append, toString} и слово append append toString.
Предположим, что мы хотим попросить компилятор Java валидировать нашу цепочку вызовов, проверяя, принадлежит ли соответствующее слово заранее определённой грамматике. Например, не допускать слова длиной более 10 символов. Понятно, что для класса StringBuilder ничего подобного сделать уже не выйдет, зато можно попробовать для своих собственных интерфейсов. Пусть, для примера, мы хотим валидировать цепочки вызовов методов a, b и c. Так же пусть у нас есть машина Тьюринга, которая умеет валидировать слова соответствующего языка (subtyping machine со всеми её интерфейсами прилагается).
Наша цель — сделать так, чтобы для цепочки вызовов a().b().c() компилятор запускал такой запрос: . Для этого напишем следующий класс: Стоит понимать, что тут я описываю только сигнатуру, а не реализации соответствующих методов. О реализации будет позже. Если приглядеться, то можно заметить, что тип выражения B.start.a().b().c().stop, записываемый таким образом: полностью повторяет левую часть требуемого запроса. То есть, следующая строка кода на самом деле запустит требуемую машину Тьюринга во время работы алгоритма проверки типов: Добрались! Имея такой богатый инструментарий, пришло время создать что-нибудь внятное.
Помнится, прочитав эту статью, я подумал: "Интересная идея!". Так вот, сейчас моя очередь делать безопасный Builder. Внешний вид класса B из предыдущей части диктует некоторые ограничение, поэтому мой Builder будет чуть-чуть многословный. Итак, я хочу иметь следующий код, который мог бы валидироваться компилятором: Требования будут самыми простыми — чтобы оба поля были инициализированы, причём именно в таком порядке и не больше одного раза (я прекрасно понимаю, что такую простую задачу можно решить более подходящими методами; пример с грамматикой посложнее будет позже). Машина Тьюринга для такого случая будет весьма простой. В ней всего 5 состояний: Start, First, Last, Finish и Halt. Алфавит будет содержать 3 символа: FirstName, LastName и Hash.
Функция перехода выглядит следующим образом: 


 Довольно-таки прямолинейно. Машина остановится только на входе . Классы User и UserBuilder реализованы следующим образом (осталось только заполнить многоточия): сперва заполним метод build.
Его параметр — это объект типа E<? super E<? super User>>, а у интерфейса E своих методов нет. Значит для того, чтобы достать из e собранный объект типа User, нам понадобится что-то дополнительное. К счастью, подсказка спрятана в самом типе, достаточно только вспомнить правило PECS (Producer Extends, Consumer Super). Поэтому изменим описание интерфейса E: Теперь у объекта e есть метод void accept(E<? super User>), принимающий другой объект E. И это уже будет Consumer для желанного нами объекта User. Если исходить из предположения, что вызов e.accept(callback) приведёт внутри к срабатыванию колбэка, то реализация метода build станет такой: Осталось только обеспечить требуемое предположение; Целиком пример можно найти на гитхабе. Чтобы не быть голословным, далее я привожу пример машины Тьюринга посложнее. Такая машина, грубо говоря, проверяет парность скобок, т.е. выражение ()(()()) является валидным, а выражение ()())(() — невалидным. Только открывающая и закрывающая скобки заменены символами A и B (x — дополнительный символ, означающий, что скобка на этом месте уже проверена).
Функция перехода будет следующей:  


 

 
 Поиграться с данной машиной, или погенерировать свои, можно всё там же на гитхабе.
Да, генератор кода по описанию машины Тьюринга присутствует. Для данной грамматики следующий код спокойно скомпилируется: а вот этот уже нет: Содержательная часть на этом закончена. Мы узнали, как можно заставить компилятор валидировать цепочки вызовов согласно произвольному заранее заданному алгоритму. Но можно — не значит нужно.
Во-первых, слишком много дополнительно объявленных классов, да и сигнатуры методов класса Builder выглядят очень сложно.
Во-вторых, компилятору понадобится гораздо больше памяти, чтобы не падать на сложных выражениях. В первую очередь — гораздо более глубокий стэк.
В-третьих, сложная диагностика ошибок. Типичный вывод компилятора выглядит как-то так (для краткости стёр названия пакетов): Уровень информативности нулевой. Если хочется найти ошибку, то остаётся только два варианта: Тогда зачем это всё? Ответов, опять же, несколько: Разработчики Kotlin, к примеру, знали, что в Java легко составить типы, проверка которых приведёт к экспоненциально долгому времени компиляции, и ввели т.н. Non-Expansive Inheritance Restriction. Поясню на минимальном примере: Это код на Kotlin, который не скомпилируется. А всё из-за того, что в параметрах суперинтерфейса для класса B присутствует тот же класс B, но там в параметрах уже не просто T, а более сложный тип. Определение не совсем точное и уж тем более не исчерпывающее, но идея должная быть понятна. Цель проста — избежать "слишком сложных" типов, тем самым гарантируя высокую скорость компиляции. Из-за данного ограничения описанный способ запуска машин Тьюринга на Kotlin реализовать невозможно. В таком случае есть шанс, что проверка типов в Kotlin гарантированно завершается за конечное время. С другой стороны, если все интерфейсы для машины Тьюринга объявить в Java-файлах, то компилятор Kotlin сделает ровно то же, что и компилятор Java — запустит соответствующую машину. Так что если учесть, что Kotlin используется не в изоляции от Java, то можно заключить, что и его система типов полна по Тьюрингу. Математик, программист