JEP draft: special notation for the receiver helper pattern
специальная запись для паттерна вспомогательных вызовов к получателю
| Ответственный | John Rose |
| Тип | Feature |
| Область | JDK |
| Статус | Draft |
| Компонент | specification / language |
| Создан | 2017/08/18 23:26 |
| Обновлён | 2020/10/26 19:08 |
| Задача | 8186473 |
Краткое изложение: разрешить новый синтаксис ссылки на член, подобный this.m и super.m, для обращения к получателю самого внутреннего объемлющего вызова метода. Таким образом, r.foo(__Receiver.bar()) — синтаксический сахар для r.foo(r.bar()). Рассматриваются различные альтернативы. Новая запись поддерживает fluent API.
Fluent API — всё более важный паттерн проектирования в Java, позволяющий просто представлять предметно-ориентированные языки. Для них характерны очень длинные цепочки вызовов методов вида head.a(*).b(*).c(*)..., где каждый вызов метода опирается на результаты предыдущих вызовов. StringBuilder — один из ранних таких API, а Stream — один из более новых.
Наглядность этой записи зависит от того, можно ли развернуть её на странице как последовательность инструкций или запросов, выраженную цепочкой вызовов методов (.a().b().c()...). Аргументы каждого вызова метода относятся только к этому вызову, а результат вызова метода — (часто) совершенно новый получатель, который определяет смысл следующего вызова.
У этой записи есть слабое место: каждый запрос приходится выражать одним вызовом метода. Это значит, что проектировщик API вынужден делать вызов метода единицей запроса. В API, не являющихся fluent, это не так: там запрос может состоять из нескольких вызовов методов одного и того же получателя. Например, так устроено извлечение групп совпадения из MatchResult, и поэтому его нельзя записать в fluent-стиле.
Это ограничение заставляет проектировщика fluent API очень тщательно продумывать, чтобы каждый вызов метода нёс ровно нужный объём информации. Тщательно продумывать — это хорошо. Не так хорошо то, что сбоку ждёт крутой обрыв — всякий раз, когда fluent-методу может понадобиться задать получателю короткий вопрос до того, как сделать следующий запрос в цепочке. Предположим, я нахожусь посреди цепочки a().b() и мне нужно попросить у следующего получателя помощи, прежде чем я смогу сформировать следующий вызов c(). Я хочу написать что-то вроде этого:
head.a().b().c(?.help()).d()
Но затем мне приходится переделать код и ввести временную переменную, чтобы можно было обратиться к следующему получателю:
var btem = head.a().b();
btem.c(btem.help()).d()
Это явно сводит на нет читаемость fluent API. Тем более если API поддерживает цепочки, вложенные в другие цепочки. (Пример — разрабатываемый сейчас fluent API для байт-кода наподобие ASM, в котором класс — это fluent-цепочка, содержащая подцепочки для каждого метода, которые, в свою очередь, содержат подцепочки для более мелких конструкций, например сложных констант.) Необходимость во временной переменной даёт не постепенный эффект: все объемлющие цепочки распадаются на отдельные операторы.
Например, fluent API для сбора констант пула констант (в последовательность таких констант) может понадобиться преобразовать пользовательский тип, например String, во внутренний тип, например int (для индекса), а пользователю может понадобиться получить такое значение int в ходе более сложного запроса к пулу констант, например «добавить этот тип метода». Преобразование компонента типа метода — побочный вопрос, который неизбежно прерывает fluent-цепочку запросов:
pool.startGroup()
.addMT(pool.addT(void.class), pool.addT(int.class))
.endGroup();
В этом случае команда addMT просит некоторого получателя (сам пул или временный построитель группы) добавить тип метода с двумя компонентами, но сначала должна попросить пул (или, в некоторых API, построитель) принять типы void (для типа возвращаемого значения) и int (для единственного типа параметра). Обратите внимание: чтобы выполнить запрос типа метода, нужны два побочных «вспомогательных» запроса, по одному на каждый компонент. Задача, которую решает это предложение, — найти подходящего адресата для этих вспомогательных запросов, не предполагая, что всегда есть доступная в окружении переменная (в данном случае pool), в которой хранится нужный получатель.
Если стремиться избежать временных переменных, но сохранить столь же эффективный байт-код, простейшее решение — разрешить специальный синтаксис для доступа к следующему получателю в цепочке изнутри вызова метода, который составляется для этого получателя.
Поэтому стоит рассмотреть, как мог бы выглядеть такой синтаксис. (И, конечно, рассмотреть альтернативы.) Чтобы не конкретизировать преждевременно, предположим, что есть новый токен __Receiver, который можно использовать в любом выражении, вложенном на любом уровне в выражение вызова метода. Тогда проблемные цепочки выше можно переписать так:
head.a().b().c(__Receiver.help()).d()
pool.startGroup()
.addMT(__Receiver.addT(void.class), __Receiver.addT(int.class))
.endGroup();
Или фрагмент цепочки генератора байт-кода мог бы выглядеть так:
.getfield(.classInfo(Math.class), "PI", .descriptor(double.class));
Паттерн вспомогательных вызовов к получателю активно проявляется, когда думаешь о передаче нотаций массивов из таких языков, как R, Octave, APL, Julia или даже Python и Ruby. В этих нотациях выражение внутри скобок индексирования массива интерпретируется относительно индексируемого массива. Обращение к последнему элементу в виде чего-то вроде [*-1] должно транслироваться в [L-1], где L — выражение, которое вычисляет длину индексируемого массива. Для многомерных массивов весь кортеж индексных выражений должен интерпретироваться относительно самого массива. Есть способы не привязываться к массиву до собственно операции индексирования, но (как и в случае других fluent API) они делают проектирование этих операций гораздо более сложным и ограниченным.
Паттерн вспомогательных вызовов к получателю, вероятно, проявится и в некоторых видах программируемых записей констант, если мы будем их проектировать. В таких случаях последовательность выражений может неявно транслироваться в выражение построителя, и возникнет та же потребность в эпизодическом вызове вспомогательных методов. Простой пример: упакованной константе-массиву с пропусками могут время от времени понадобиться вызовы вспомогательных методов, чтобы сбросить позицию упаковки, как в синтаксисе «назначенных инициализаторов» (designated initializer) в некоторых версиях C. Например: int a[3] = { [1] = 5 };. Указатель [1] можно было бы передать вызовом вспомогательного метода во fluent API на Java.
Если посмотреть с другой стороны, сложное выражение индекса массива отчасти похоже на часть программируемого константного выражения. В обоих случаях есть фрагмент кода, который имеет смысл только в объемлющем контексте, и возникает естественная потребность задавать вопросы этому контексту. Это ещё одна форма «this».
Споры о синтаксисе
Вариантов синтаксиса для этого много:
- Контекстное ключевое слово
receiverилиthat, которое не действует, если уже есть предшествующее связывание. - Унарный оператор «точка», так что
.fooна самом деле означает__Receiver.foo; возможность ограничивается ссылками на члены. - Именованная ссылка на объемлющий метод,
__ReceiverOf.addMT. - Имя, назначаемое самим API получателя; возможность ограничивается fluent API.
Имя, назначаемое API, могло бы выглядеть так:
interface Builder<T> {
Builder<T> (Builder<T> b = this) add(T item);
T methodType(Class<?> rt, Class<?> pt);
}
// ...add(b.methodType(p,q))...
Обходной путь: переменные шаблона
Fluent API живут или умирают в зависимости от того, что можно назвать хвостово-рекурсивным синтаксисом, — непрерывного присоединения новых вызовов методов к концу старых. Если бы существовал хвостово-рекурсивный синтаксис для связывания временных переменных, цепочку можно было бы продолжить, поместив связывание внутрь цепочки:
head.a().b()<<var btem>>.c(btem.help()).d()
Это может стать возможным как побочный эффект выражений Pattern Matching (сопоставление с образцом).
Обходной путь: вспомогательные лямбды
Проектировщик fluent API обычно может предвидеть потребность во вспомогательных запросах и предусмотреть их для отдельных точек API специальным образом, введя лямбда-аргумент, который овеществляет подходящего получателя в виде аргумента лямбды:
head.a().b().c(x->x.help()).d()
interface Builder<T> {
Builder<T> add(T item);
default <X> Builder<T> add(X item, BiFunction<Builder<T>,X,T> fn) {
add(fn.apply(this, item));
}
T methodType(MethodType mt);
T methodType(Class<?> rt, Class<?> pt);
}
// ...add(b->b.methodType(p,q))...
// ...add(MethodType.methodType(p,q), Builder::methodType)...
Это можно назвать «лямбдифицированной» версией паттерна вспомогательных вызовов к получателю. Она хороша тем, что работает в сегодняшнем языке. Она плоха тем, что для компиляции лямбд внутри неё нужно больше байт-кода, чем для простых в остальном запросов о помощи к доступным получателям.
Этот обходной путь строго мощнее паттерна связывания получателя, потому что значением x может быть что угодно, доступное получателю, а не только сам получатель. С другой стороны, именно поэтому менее понятно, какова роль x в этом коде, и в документации метода c приходится описывать, какое значение передаётся. Паттерн вспомогательных вызовов к получателю конкретнее и определённее; в итоге он примерно так же мощен, как обходной путь, поскольку вспомогательный метод (help выше) может получить любое другое нужное значение x, не заставляя пользователя в этом разбираться. Главная проблема паттерна вспомогательных вызовов к получателю в том, что вспомогательные методы должны быть доступны вперемешку с методами самого API получателя, а это не всегда желательно.
В конечном счёте это предложение можно рассматривать как синтаксический сахар для распространённого случая паттерна r.foo(r->r.bar()) — паттерна, для которого можно генерировать гораздо более эффективный код (без лямбды), если есть специальная синтаксическая поддержка для извлечения значения r из контекста.
Обходной путь: методы и лямбды constexpr
Лямбдифицированную версию паттерна вспомогательных вызовов к получателю можно улучшить, вычисляя лямбду и вызов метода во время компиляции, как при раскрытии макроса (но с сохранением всех типов, областей видимости и т. д.). В этом случае нужная временная переменная появилась бы внутри раскрытого тела метода и использовалась бы в раскрытом теле лямбды. Предположительно, абстракция API сохранялась бы за счёт дальнейших операций внутри раскрытия метода, например вызова «главного метода», который использует результаты вызова лямбды, передавая их через инструкцию invokeinterface.
interface Builder<T> {
Builder<T> add(T item);
__Inline <X> Builder<T> add(X item,
__Inline BiFunction<Builder<T>,X,T> fn) {
add(fn.apply(this, item));
}
T methodType(MethodType mt);
T methodType(Class<?> rt, Class<?> pt);
}
// ...add(b->b.methodType(p,q))...
// ...add(MethodType.methodType(p,q), Builder::methodType)...
Для такого приёма нужен некий квалификатор «константного выражения» — что-то среднее между возможностями constexpr и inline в C++. Тогда можно было бы выполнять необходимую сложную свёртку констант во время компиляции. Эта возможность должна действовать в двух местах. Во-первых, метод интерфейса должен статически свёртываться; это значит, что он должен разрешаться как статический метод и не выполнять дальнейший выбор метода во время выполнения. Во-вторых, сам лямбда-аргумент должен быть помечен и скомпилирован как константа, чтобы его тоже можно было раскрыть.