В моей предыдущей статье о статической компиляции Groovy описана идея объединения выразительности Groovy со статической проверкой типов для достижения улучшенной производительности, необходимой для написания параллельных приложений, а также для выявления большего количества ошибок во время компиляции. До настоящего времени эксперимент по реализации этого подхода проделал хороший прогресс. Очень скоро мы надеемся выпустить версию, доступную для экспериментов в сообществе.
Сегодня я хочу поговорить о том, как Groovy помогает избавиться от большого количества стандартного кода, навязанного традиционным API java.util.concurrent.
В качестве витрины я выберу очень стандартный сценарий, который, я уверен, многие люди встречали на практике.
Задача:
У нас есть функция с одним параметром, которая время от времени должна передаваться для одновременного выполнения с использованием java.util.concurrent.Executo r
S ometimes мы хотим ждать результата исполнения ( java.util.concurrent.FutureTask является идеальным API для этого и других задач)
Иногда мы хотим просто получить уведомление, когда результат будет готов (или вычисление отменено или сбой). Мы не имеем непосредственно из FutureTask , но может легко достигнуть его overiding в FutureTask.done () метод
Хорошо, давайте начнем с кода Java, который решает проблему. Мы определяем абстрактный класс, представляющий нашу функцию и метод в будущем, возвращая FutureTask с 3 параметрами — аргумент, исполнитель и продолжение. Поскольку мы ленивы и хотим иметь возможность опускать нерелевантные параметры, мы также определяем две удобные перегрузки будущего
public abstract class F<T,R> {
public abstract R apply (T arg);
public FutureTask<R> future (T arg) {
return future(arg, null, null);
}
public FutureTask<R> future (T arg, Executor executor) {
return future(arg, executor, null);
}
public FutureTask<R> future (final T arg, Executor executor, final F<FutureTask<R>,Object> continuation) {
final FutureTask<R> futureTask = new FutureTask<R> ( new Callable<R> () {
public R call() throws Exception {
return apply(arg);
}
}) {
protected void done() {
if (continuation != null)
continuation.apply(this);
}
};
if (executor != null)
executor.execute(futureTask);
return futureTask;
}
}
По правде говоря, это совсем не плохо. В 28 строках кода мы создали метод с параметрами по умолчанию, который делает именно то, что нам нужно.
Давайте теперь попробуем добиться того же с помощью статически типизированного Groovy.
abstract class F<T,R> {
abstract R apply (T param)
FutureTask<R> future (T arg, Executor executor = null, F<FutureTask<R>,Object> continuation = null) {
FutureTask<R> futureTask = [ 'super': { -> apply(arg) }, done: { continuation?.apply(this) } ]
executor?.execute(futureTask)
futureTask
}
}
И вот мы — 9 строк Groovy делают свою работу.
Я могу представить, что кто-то начнет спорить, что я могу использовать другое форматирование в коде Java и сжимать код в меньшее количество строк. Вы знаете, что — я буду использовать мои настройки форматирования Java от IntelliJ. Мы можем сжать эти 28 строк до 18 строк ниже, заплатив цену за то, чтобы сделать код полностью нечитаемым.
public abstract class F<T,R> {
public abstract R apply (T arg);
public FutureTask<R> future (T arg) { return future(arg, null, null); }
public FutureTask<R> future (T arg, Executor executor) { return future(arg, executor, null); }
public FutureTask<R> future (final T arg, Executor executor, final F<FutureTask<R>,Object> continuation) {
final FutureTask<R> futureTask = new FutureTask<R> ( new Callable<R> () {
public R call() throws Exception { return apply(arg); }
}) {
protected void done() { if (continuation != null) continuation.apply(this); }
};
if (executor != null) executor.execute(futureTask);
return futureTask;
}
}
Во всяком случае, я считаю, что количество строк не самый важный параметр. Что действительно важно, так это то, что мы заменили огромное количество стандартного кода сосредоточенными умами — я имею в виду код, который говорит именно то, что мы хотим, и ничего более.
Но позвольте мне попытаться объяснить, что здесь происходит. Я предполагаю, что значение параметров по умолчанию очевидно, поэтому я сосредоточусь на следующих 3 строках кода Groovy
FutureTask<R> futureTask = [ 'super': { -> apply(arg) }, done: { continuation?.apply(this) } ]
executor?.execute(futureTask)
futureTask
Последний метод является очень стандартным в Groovy — вам не нужно указывать ключевое слово return в последнем утверждении метода.
Линия как раз перед ней тоже абсолютно стандартная — это знаменитый Groovy безопасный вызов. Очень грубо говоря, объект? .Method (args) является ярлыком для объекта! = Null? object.method (args): null
Вышеприведенное утверждение не на 100% верно. В действительности это включает в себя так называемую Groovy Truth, которая намного больше, чем просто ! = Null И для статически типизируемого значения Groovy по умолчанию необязательно null, но в случае примитивных типов это ноль соответствующего типа. Например, если метод возвращает значение double, значение по умолчанию будет 0.0d
. Первая строка метода future является наиболее интересной.
FutureTask<R> futureTask = [ 'super': { -> apply(arg) }, done: { continuation?.apply(this) } ]
Это вывод в действии. Мы определяем локальную переменную и инициализируем ее синтаксисом карты Groovy. Давайте попробуем следить за преобразованиями, выполняемыми компилятором, шаг за шагом и увидим, что в этом нет магии
-
Поскольку переменная типизирована, компилятор принимает выражение приведения impilicit (FutureTask <R>) перед выражением карты [..: ..]
Если наоборот, у нас есть нетипизированная переменная, инициализированная типизированным выражением, и компилятор определит тип переменной. Мы увидим пример этого позже.
- Теперь компилятор должен создать новый экземпляр FutureTask в соответствии с определением, данным в выражении карты. Есть несколько решений (и действительно впечатляет, что синтаксис карты позволяет нам охватить все возможности ниже)
- Должен ли быть создан FutureTask или какой-то новый его подкласс (например, анонимный внутренний класс)?
- Какие методы переопределяются, какие новые свойства или методы определены и какой конструктор суперкласса использовать в случае подкласса?
- Какие значения должны быть установлены для старых и / или новых свойств (если таковые имеются) в обоих случаях?
- Ключ ‘ super’ немедленно сообщает компилятору, что мы хотим создать подкласс и вызвать суперконструктор с аргументами, указанными выражением, соответствующим super
Мы используем небольшой синтаксический сахар здесь. Более формальный способ указать параметры суперконструктора —
«super»: [{-> apply (arg)}] — обратите внимание на дополнительный [] . Но так как есть только один параметр, мы можем опустить скобки.
Теперь компилятор должен определить, что мы подразумеваем под выражением замыкания {-> apply (arg)}- Поскольку существует только один конструктор FutureTask с одним параметром (типа java.util.concurrent.Callable ), компилятор сразу понимает, что выражение закрытия должно быть скомпилировано в Callable.
Обратите внимание, что метод apply используется внутри нашего Callable. То же самое происходит с анонимными внутренними классами. Нам просто не нужно объявлять его как final (компилятор знает это сам) — снова немного меньше шума в коде.
- Callable — это интерфейс «одного метода», а параметры замыкания (без параметров) соответствуют неопределенному методу, поэтому нет ничего проще, чем создать анонимный внутренний класс, представляющий Callable.
- Теперь, после завершения super , компилятору приходится иметь дело с готовой парой ключ / значение. Есть важные решения, которые должны быть приняты
- Определяет ли это свойство или метод?
- Свойство или метод являются новыми или они уже существуют?
- Логика очень естественная, так как не существует существующего свойства с именем done, мы имеем дело с чем-то новым. Это дополнительная подсказка компилятору, что мы создаем экземпляр подкласса, а не сам FutureTask
Если бы у FutureTask было свойство с именем done , компилятор неявно приводил бы выражение значения к типу свойства и устанавливал это свойство после создания экземпляра.
- Теперь, когда компилятор знает, что имеет дело с новым свойством или новым методом, он принимает очень простое решение — выражение замыкания определяет метод, а все остальное определяет новое свойство. Так что в нашем случае готово определяет метод.
Представьте, что мы хотим иметь дополнительное свойство с именем uuid типа UUID в качестве члена нашего подкласса. Все, что нам нужно сделать, это добавить uuid: UUID.randomUUID () внутри нашей карты — компилятор сделает все остальное. uuid: (UUID) null создаст неинициализированное свойство (обратите внимание, что приведение необходимо для определения типа свойства)
Теперь у компилятора есть последний, но чрезвычайно важный вопрос: хотим ли мы новый метод или мы хотим переопределить существующий? Конечно, мы хотим перевесить существующий. И у компилятора есть простой способ понять, что с помощью параметров выражения замыкания {continueation.apply (this)} , которые соответствуют существующему методу FutureTask- И теперь, поскольку это переопределенный метод, он должен иметь тот же тип возврата void, что и метод суперкласса.
Интересно отметить, что неофициально {continueation.apply (this)} означает {def it = null -> continueation.apply (this)}, если бы в FutureTask был выполнен другой метод void (SomeType x), у нас была бы потенциальная коллизия (но мы всегда можно явно указать параметры выражения замыкания). К счастью, в нашем случае все работает хорошо.
Итак, мы закончили с нашим основным примером.
Прежде чем закончить статью, я хочу привести еще несколько примеров
Представьте, что мы решили упростить нашу задачу и нам не нужно иметь дело с Исполнителем. Тогда код становится еще более элегантным (благодаря неявному возвращению и автоматическому приведению возвращаемого значения к типу возврата метода)
FutureTask<R> future (T arg,compiler F<FutureTask<R>,Object> continuation = null) {
[ 'super': { -> apply(arg) }, done: { continuation?.apply(this) } ]
}
Еще один интересный способ использования вывода типа Groovy заключается в следующем
void method (def param) {
if (param instanceof List && param.size() > 0 ) {
def sum = 0
for (element in (List<Integer>)param)
sum += element
}
else
return 0;
}
Здесь происходят три вещи:
- Переменная сумма является нетипизированной но мы относим его к Int компилятор знает , что это ИНТ , когда он используется в следующий раз
- Условие param instanceof List позволяет нам использовать && param.size ()> 0, не выполняя явного приведения param к List, как мы это делали бы в Java
- Нам не нужно определять тип цикла for для элемента переменной, потому что компилятор может вычесть его из типа List
Спасибо за уделенное время. В следующей статье я планирую поговорить о том, как смешивание статического и динамического кода может помочь в разработке как производительного, так и элегантного кода.