`

СПЕЦІАЛЬНІ
ПАРТНЕРИ
ПРОЕКТУ

Чи використовує ваша компанія ChatGPT в роботі?

BEST CIO

Определение наиболее профессиональных ИТ-управленцев, лидеров и экспертов в своих отраслях

Человек года

Кто внес наибольший вклад в развитие украинского ИТ-рынка.

Продукт года

Награды «Продукт года» еженедельника «Компьютерное обозрение» за наиболее выдающиеся ИТ-товары

 

Стрелка, которая никуда не указывает

Статья опубликована в №24 (735) от 6 июля

+22
голоса

Если в разговоре со сторонником JavaScript вы упрекнете его любимый язык в том, что тот не обеспечивает инкапсуляцию данных, то наверняка услышите в ответ, что она может быть с успехом реализована с помощью механизма, имя которому – замыкание. Этот термин прозвучал для вас впервые? Тогда есть смысл разобраться с ним подробнее, тем более что он присутствует далеко не в одном языке.

Замыкание (closure) – инструмент не новый. Он базируется на аппарате лямбда-исчисления, разработанном А. Черчем. Учитывая панический ужас, охватывающий отдельных читателей при слове «математика», откажемся от рассмотрения математических основ, а вместе с ними – и от строгого определения данного понятия. Ограничимся «почти точным» описанием, согласно которому замыкание – это функция, объявленная в теле другой функции и имеющая доступ к ее локальным переменным.

Впервые замыкания были реализованы еще в середине 60-х годов в Scheme. Сейчас это средство поддерживается в Python, Ruby, JavaScript и многих других языках. А заговорили о них мы постольку, поскольку уже в ближайшее время они появятся в Java.

Не стоит доверять интуиции

Для определения замыкания в Java используется хоть и непривычная, но достаточно простая языковая конструкция:

 

{ формальные_параметры => выражения возвращаемое_значение }

 

Тех, кто попытается провести аналогию с уже известными им синтаксическими правилами, вероятнее всего, ждет разочарование. Ассоциации могут быть самые разные. Оператор => разделяет содержимое фигурных скобок на правую и левую части, если переставить местами символы, получаем привычный оператор «больше или равно», однако ни с прямым присвоением, ни со сравнением он не имеет ничего общего. Не стоит рассматривать => и как стрелку, отображающую левую часть в правую, потому что этого не происходит. На самом деле внутри фигурных скобок заключено только определение функции, выраженное несколько непривычным образом. Слева от => расположены формальные параметры, которые ничем не отличаются от таковых для метода, справа – обычные выражения, что встречаются в теле метода, последнее определяет возвращаемое значение. Оператор return в замыкании не предусмотрен, а возвращаемое значение отличается лишь отсутствием точки с запятой после него. Каждый из элементов замыкания может быть опущен. Так, конструкция { => } синтаксически правильна, хотя и бессмысленна, { => "String" } возвращает строковое значение, а { int x, int y => System.out.println(x+y); }ничего не возвращает, но производит побочный эффект: выводит на консоль сумму целочисленных параметров.

Интересным является вопрос, как правильно называть замыкание: функцией или методом? С одной стороны, процедуры в терминологии Java принято именовать методами. С другой, замыкание – не совсем метод, а скорее его часть. Воспользовавшись тем, что терминология еще не устоялась, будем применять слово «функция»: это поможет отличить ее от метода, в котором она определена.

«Где же здесь что-то принципиально новое? – спросит читатель. – Давно известны неименованные анонимные классы. Если их определение включить внутрь метода, то станут доступны и их методы». Действительно, приведенная ниже конструкция позволяет определить в составе метода другой метод и вызвать его, передать параметры, получить возвращаемое значение, одним словом, сделать все то, о чем шла речь выше. Достаточно лишь заменить многоточия формальными параметрами, реальными командами, обрабатывающими данные, возвращаемым значением и фактическими параметрами:

 

(new MySuperclass() {
myMethod(...) {... ; return ...}
} ). myMethod(...);

 

Что здесь можно возразить? Получив положительный ответ на вопрос «возможно ли?», не следует пренебрегать вопросом «удобно ли?». Это совсем немаловажная деталь. Для того чтобы новое средство стало широко распространенным, оно должно быть понятным, а его запись – краткой. Сказанное в полной мере относится к замыканиям в языке Java. Неименованные внутренние классы в роли замыканий не удовлетворяют данным требованиям. Такие конструкции громоздки, а накладные расходы поистине огромны, ведь чтобы метод мог получить управление, необходимо создать класс, а подчас и интерфейс. Кроме того, неименованный внутренний класс имеет чрезвычайно важное ограничение, не позволяющее ему стать полноценным замыканием: ни один метод, входящий в его состав, не может модифицировать локальную переменную метода, в котором он определен. Да и чтобы получить возможность только прочитать значение переменной, надо объявить последнюю как final, в результате чего она превратится в константу. Для замыканий такого ограничения нет, что демонстрирует следующий простой фрагмент кода:

 

...
@Shared
int x = 1;
{ => x++; }.invoke();
...

 

Как видно из примера, чтобы функция замыкания получила доступ к локальной переменной, последняя должна быть лишь помечена аннотацией @Shared, но вряд ли это можно считать серьезным препятствием для применения нового средства.

Функция как параметр

В обзорную статью не принято включать большой объем кода, но рассмотреть хотя бы вкратце один из вариантов использования замыканий необходимо. Представим себе разработчика, который занимается клеточными автоматами и выбрал предметом своих исследований две задачи: Майхилла и победы большинства (см. врезку). А в качестве инструмента использует язык Java.

Проанализировав задачи, он решил, что для обеих модель автомата может быть построена на базе одномерного целочисленного массива: тип int более чем достаточен для хранения возможных состояний элемента (клетки автомата). Поскольку для всех элементов используется один и тот же алгоритм, неотъемлемой частью модели является метод forEach(), который в полном соответствии с его именем перебирает элементы массива и выполняет над каждым из них определенную последовательность действий. Включив в тело метода цикл for (благо его вариант, впервые появившийся в JDK 1.5, идеально подходит для данной задачи), разработчик задумался о целесообразности своих действий.

Дело в том, что отдельные решения, которые приходится реализовывать в коде программы, применимы для обеих задач. В каждом случае формируется массив, представляющий автомат, его элементы перебираются в цикле, разница лишь в действиях, выполняемых над каждой клеткой. Вывод очевиден: разделить перебор элементов и действия над ними, и тут на помощь приходит механизм замыканий. Он позволяет указать в качестве одного из параметров метода forEach() функцию, реализующую алгоритм работы клетки автомата, в результате чего код приобретает вид, как в листинге 1.

Соответственно, вызов forEach() для задачи Майхилла будет выглядеть, как в листинге 2. А для задачи победы большинства – как в листинге 3. (Подразумевается, что Fire и Majority – это классы для задач Майхилла и победы большинства, а метод execute(x), присутствующий в каждом из них, реализует алгоритм работы клетки автомата.)

Однако еще не все проблемы решены. Клеточные автоматы для задач Майхилла и победы большинства организованы по-разному. В первом случае у самого правого стрелка нет правого соседа, а у самого левого – левого. Во втором же, напротив, элементы закольцованы: правым соседом самого правого элемента является самый левый, а левым соседом самого левого – самый правый. Поскольку состояние клетки на очередном шаге зависит от предыдущего состояния не только ее самой, но и ближайших соседей, необходимо правильно организовать получение информации о них. Это также позволяют сделать замыкания. В итоге заголовок метода forEach() приобретает вид, как в листинге 4.

Три целочисленных параметра функций замыканий left и right означают соответственно индекс текущего элемента, смещение до требуемого соседа и длину массива, хранящего состояния; целочисленное возвращаемое значение – это индекс найденного соседа в массиве, представляющем автомат. Вызов forEach() для задачи победы большинства будет выглядеть, как в листинге 5.

Можно ли сделать то же самое без применения замыканий? Конечно же, да. Но также очевидно, что это приведет к написанию намного более громоздкого кода. Программисту потребуется создать как минимум один интерфейс, объявив в нем методы execute(), left() и right(), а затем разработать два реализующих его класса, экземпляры которых будут передаваться методу forEach(). Если же классы, обеспечивающие поведение элемента в разных клеточных автоматах, уже созданы другими разработчиками и их исходные коды недоступны, придется прибегнуть к дополнительным ухищрениям, например написать классы-оболочки. Одним словом, объем работы впечатляет. Замыкания же позволяют упростить решение целого ряда задач, сэкономить время и усилия, что, в общем-то, и является основной целью всех технологий программирования.

Научитесь плавать – нальем в бассейн воду

К счастью, фраза из популярного анекдота к Java никак не относится. Еще на страницах журналов и в Интернете ведутся споры о целесообразности включения того или иного выразительного средства в состав языка, а разработчикам уже становится доступным инструмент, поддерживающий это средство хотя бы частично. Не стали исключением и замыкания. Несмотря на то что их поддержку специалисты компании Sun Microsystems (вернее, уже Oracle) обещают реализовать только в JDK 1.7, сейчас через сайт javac.info можно найти все необходимое: архив closure.jar и сценарии командной строки, полностью автоматизирующие использование данного архива. Последние позволяют компилировать java-файлы (javac), запускать полученные классы на выполнение (java) и формировать документацию на основании комментариев (javadoc).

На сегодняшний день поддержка замыканий реализована следующим образом. Встретив уже знакомую нам конструкцию { ... => ... }, компилятор в первую очередь создает интерфейс с именем, определяемым типами возвращаемого значения и формальных параметров функции замыкания. Например, для { => 10 } будет создан интерфейс с именем I (целочисленное возвращаемое значение, параметров нет), а для { int x => a += x; } – VI ( возвращаемое значение отсутствует, но предусмотрен один целочисленный параметр). В составе создаваемого интерфейса объявляется единственный метод invoke(), параметры и возвращаемое значение которого опять же определяются исходной конструкцией. Наконец, само замыкание заменяется неименованным внутренним классом, реализующим данный интерфейс.

«Тут что-то не так, – скажет внимательный читатель. – Ведь выше было ясно написано, что неименованный внутренний класс не способен модифицировать локальную переменную. Как же может сделать это замыкание, реализованное посредством внутреннего класса?». Все дело в аннотации @Shared. Переменная, помеченная ею, перестает быть локальной, вместо нее создается неименованный внутренний класс, являющийся подклассом Object. А, как известно, переменные экземпляра, пусть даже принадлежащие другому объекту, вполне доступны неименованному внутреннему классу. Необходимо лишь обеспечить соответствующую область видимости, но об этом способен позаботиться сам компилятор.

Как именно будут реализованы замыкания в JDK 1.7 – сказать трудно; официальные документы об этом умалчивают. Скорее всего, использование неименованных внутренних классов – временная мера, а в будущем компилятор станет обрабатывать замыкания «напрямую». Останется ли требование, касающееся использования аннотации @Shared, в силе? На этот счет могут быть разные мнения. С одной стороны, оно явно противоречит определению, согласно которому переменные включающего метода должны быть доступны замыканию. С другой, дополнительный контроль всегда полезен, так как позволяет обнаружить ошибку на стадии компиляции, впрочем, это уже совершенно отдельная тема.

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

• Клеточные автоматы

Клеточный автомат – это упорядоченный набор элементов, каждый из которых может находиться в одном конкретном состоянии из конечного их набора. Как правило, состояние элемента (клетки) в следующий момент времени определяется текущим состоянием самого элемента и его ближайших соседей.

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

Задача Майхилла

Другое ее название – задача о цепи стрелков. Формулируется она следующим образом. Имеется цепь, состоящая из конечного числа стрелков, каждый из которых может общаться только с двумя своими соседями (очевидно, что у двух крайних стрелков – только по одному соседу). Количество слов, которыми могут обмениваться стрелки, и объем памяти каждого из них ограничены и не зависят от длины цепи. Один из крайних стрелков получает коман-ду «огонь», после чего стрелки должны одновременно произвести выстрел.

Задача победы большинства

Имеется одномерный клеточный автомат, состоящий из нечетного числа элементов (оно традиционно принимается равным 149). В начале работы отдельные элементы пребывают в состоянии 0, остальные – 1, других вариантов нет. Через конечное количество итераций все элементы должны перейти в состояние, в котором изначально находилось большинство клеток.

Многие исследователи считают, что существующее на сегодняшний день решение задачи Майхилла не оптимально. Задача победы большинства до сих пор не решена.

Ready, set, buy! Посібник для початківців - як придбати Copilot для Microsoft 365

+22
голоса

Напечатать Отправить другу

Читайте также

"Научно-популярная" статья )
Покоробила фраза "Впервые замыкания были реализованы еще в середине 60-х годов в Scheme. Сейчас это средство поддерживается в Python, Ruby, JavaScript и многих других языках."
Могу уже и ошибиться, за давностью лет, но функциональное замыкание - это один из столпов Lisp. Попытка притащить "за уши" в структурные языки методологию функциональных языков программирования, на мой взгляд, не приведет к чему-то полезному, кроме усложнения компилятора.

В C# замыкание пришло с лямбда выражениями. Никаких усложнений это не вызвало, а уместное использование делает код гораздо проще.

 

Ukraine

 

  •  Home  •  Ринок  •  IТ-директор  •  CloudComputing  •  Hard  •  Soft  •  Мережі  •  Безпека  •  Наука  •  IoT