Статьи

Статический Groovy и Concurrency: вывод типа в действии

В моей предыдущей статье о статической компиляции 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 в соответствии с определением, данным в выражении карты. Есть несколько решений (и действительно впечатляет, что синтаксис карты позволяет нам охватить все возможности ниже)
  1. Должен ли быть создан FutureTask или какой-то новый его подкласс (например, анонимный внутренний класс)?
  2. Какие методы переопределяются, какие новые свойства или методы определены и какой конструктор суперкласса использовать в случае подкласса?
  3. Какие значения должны быть установлены для старых и / или новых свойств (если таковые имеются) в обоих случаях?
  • Ключ super’ немедленно сообщает компилятору, что мы хотим создать подкласс и вызвать суперконструктор с аргументами, указанными выражением, соответствующим super

Мы используем небольшой синтаксический сахар здесь. Более формальный способ указать параметры суперконструктора —
«super»: [{-> apply (arg)}] — обратите внимание на дополнительный [] . Но так как есть только один параметр, мы можем опустить скобки.


  • Теперь компилятор должен определить, что мы подразумеваем под выражением замыкания 
    {-> apply (arg)}
  • Поскольку существует только один конструктор FutureTask с одним параметром (типа java.util.concurrent.Callable ), компилятор сразу понимает, что выражение закрытия должно быть скомпилировано в Callable.

Обратите внимание, что метод apply используется внутри нашего Callable. То же самое происходит с анонимными внутренними классами. Нам просто не нужно объявлять его как final (компилятор знает это сам) — снова немного меньше шума в коде.

  • Callable — это интерфейс «одного метода», а параметры замыкания (без параметров) соответствуют неопределенному методу, поэтому нет ничего проще, чем создать анонимный внутренний класс, представляющий Callable.
  • Теперь, после завершения super , компилятору приходится иметь дело с готовой парой ключ / значение. Есть важные решения, которые должны быть приняты
  1. Определяет ли это свойство или метод?
  2. Свойство или метод являются новыми или они уже существуют?
  • Логика очень естественная, так как не существует существующего свойства с именем 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;
}

 Здесь происходят три вещи:

  1. Переменная сумма является нетипизированной но мы относим его к Int компилятор знает , что это ИНТ , когда он используется в следующий раз
  2. Условие param instanceof List позволяет нам использовать && param.size ()> 0, не выполняя явного приведения param к List, как мы это делали бы в Java
  3. Нам не нужно определять тип цикла for для элемента переменной, потому что компилятор может вычесть его из типа List

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