До недавнего времени первый и последний раз, когда я использовал Java, был в 2000 году. Тогда это был относительно молодой язык, простой и неэффективный, выглядевший как какой-то сильно уродливый C++ (независимо от того, что его авторы говорили о предмет). Я пожал плечами и отошел от Java.

Перенесемся в 2019 год: я снова пишу на Java, и, конечно же, теперь он выглядит по-другому, не простой и не медленный. Почему-то меня особенно очаровали его возможности метапрограммирования или их отсутствие. Вот история того, что я нашел, но сначала немного личной истории.

[Оказывается, фрагменты кода выглядят не очень хорошо на Medium. Если вам нравится красивый код с подсветкой синтаксиса, прочтите эту статью в другом моем блоге Мысли о программном обеспечении.]

Делать это вручную

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

Обеспокоенный отсутствием стандартного решения этой простой проблемы и наличием аналогичных проблем с дублированием кода в других частях проекта, я в итоге реализовал простую систему шаблонов и сценарий Perl, который генерировал C-файлы из шаблонов.

Поначалу я думал, что это довольно деспотичный и отчаянный подход, но он сработал на удивление хорошо, а позже он меня зацепил. Все, что для этого требовалось, — это дополнительная строка в make-файле; промежуточный файл C был легко доступен для проверки и отладки; и, конечно, мощь Perl была намного больше, чем у любого другого доступного решения.

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

Давайте на секунду поговорим о других вариантах, доступных мне тогда. Конечно, в C есть встроенное решение для генерации кода, которое я «должен» использовать: препроцессор. Увы, препроцессор C — это грустная шутка (помимо нескольких простых задач вроде условной компиляции); это даже не Тьюринг-полный. Что немного разочаровывает, с исторической точки зрения, поскольку до C существовали промышленные языки с значительно превосходящими возможностями предварительной обработки.

Возьмем, к примеру, PL/I, разработанный IBM в 1960-х годах. Я очень хорошо помню, как учился программировать в школе. У него был полнофункциональный препроцессор с типизированными переменными, циклами и подпрограммами, не говоря уже о том, что синтаксис был подмножеством самого языка PL/I.

С появлением миникомпьютеров и ПК обо всем этом как-то забыли. История вычислительной техники сделала один из своих многочисленных перезапусков от сложного к простому. Можно легко утверждать, что даже в 2019 году современные основные языки все еще не догнали уровень техники 1960-х годов.

Если бы я писал на C++ вместо C, я бы, конечно, легко решил проблему с хэш-таблицей с помощью шаблонов. Однако шаблоны C++ являются плохим общим решением для метапрограммирования, даже несмотря на то, что они были продемонстрированы как полные по Тьюрингу. Я до сих пор помню, как читал знаменитую книгу Андрея Александреску Современный дизайн C++ и ненадолго задумался о том, чтобы отправить себя в психиатрическую лечебницу после ее прочтения. Конечно, это был тот самый Александреску, который написал многостраничную статью, исследуя различные варианты реализации max(a,b) в шаблонном C++ и придя к выводу, что удовлетворительного решения не существует (или, по крайней мере, не существовало в винтажном C++ 2001 года). ).

Все, что золото, не блестит

После счастливых лет, проведенных на C++, мне посчастливилось работать в компании, которая занималась разработкой корпоративного программного обеспечения на Lisp — действительно вымирающем виде!

Мое время там было откровением. Лисп, второй старейший язык программирования высокого уровня (1958 г.; ФОРТРАН был выпущен в 1957 г.), на протяжении десятилетий опережал гораздо более поздние языки, которые только недавно догнали его — в основном.

Одной из областей, в которых Лисп намного опередил свое время, были его непревзойденные возможности предварительной обработки (явно не существовавшие в первоначальной версии Лиспа, но появившиеся вскоре после этого, в начале 1960-х). В системе макросов Лиспа препроцессор — это не просто подмножество исходного языка, как в PL/I; это является исходным языком, просто примененным к исходному коду во время компиляции. Вот очень короткий пример определения среды модульного тестирования в Common Lisp (из Practical Common Lisp Питера Сейбеля):

(defun report-result (result form) 
    (format t "~:[FAIL~;pass~] ... ~a~%" result form)) 
(defmacro check (&body forms) 
    `(progn 
        ,@(loop for f in forms collect `(report-result ,f ',f))))

Учитывая это, теперь вы можете начать что-то тестировать, например, арифметические выражения:

> (check (= 3 (+ 1 2)) 
         (= 5 (* 2 2))) 
pass ... (= 3 (+ 1 2)) 
FAIL ... (= 5 (* 2 2))

Опять же, я не знаю ни одного широко используемого языка, который мог бы сделать то же самое элегантно и с таким приятным конечным результатом. И вполне возможно, что ни один такой язык не исчезнет: сила макросов Лиспа исходит из способности обращаться с исходным кодом программы как с данными, которые, в свою очередь, исходят из Лиспа, языка, практически не имеющего синтаксиса.

Очень немногие языки программирования имеют тривиальный синтаксис. Попробуйте программно манипулировать исходным кодом C++ или Perl!

Еще одно мощное применение макросов Лиспа — определение новых языков. Например, в CLSQL (библиотеке Common Lisp, обеспечивающей интерфейс к базам данных SQL) вы можете написать что-то вроде этого:

(select [email] :from [users] 
    :where [= ["lower(name)"] (string-downcase customer)])

— не только защитить себя от SQL-инъекций, но и получить проверку синтаксиса SQL во время компиляции!

Но, вероятно, еще более впечатляющим является использование макросов в самом Common Lisp для расширения базовых языковых конструкций, лежащих в основе Lisp. Вы могли заметить цикл for-each в коде выше: (loop for f in forms collect ...). Когда любой другой язык на планете хочет иметь цикл for-each, они должны добавить его в базовое определение языка (спасибо C++ и Java за то, что наконец сделали это после стольких лет боли). В Common Lisp этот цикл — всего лишь часть стандартной библиотеки: еще один макрос, определенный в терминах гораздо более базовых языковых конструкций.

Простота и естественный способ определения макросов в Лиспе имеют очень интересный практический эффект. В то время как написание макросов на C (по праву) осуждается, у программиста на Лиспе нет таких запретов. Насколько я мог судить, настоящие программисты на Лиспе писали макросы буквально каждый день; для каждой части функциональности, которую вам нужно реализовать, вы просто принимаете решение, лучше ли она выполняется во время выполнения (тогда это функция) или во время компиляции (тогда это макрос).

Динамичный на всем пути

В конце концов, мне пришлось идти в ногу со временем и перейти на динамический язык. Так случилось, что это был Python, потому что с практической точки зрения это лучший выбор в наши дни, намного лучше, чем мой любимый Perl. Я понятия не имею, почему язык, созданный в 1991 году, кажется современным — может быть, это просто функция моего возраста.

Метапрограммирование в Python отличается от всех других языков, которые мы до сих пор изучали, потому что оно происходит во время выполнения. А где еще? Безусловно, у Python есть компилятор, который даже создает артефакты на жестком диске (печально известные файлы *.pyc), но компиляция Python настолько быстрая и скучная, что никого это не волнует. Все интересное в мире Python происходит во время выполнения.

Допустим, мы хотим реализовать средство, которое будет добавлять методы получения и установки для переменных-членов класса (называемых в Python attributes). Это не распространенный шаблон Python, но если бы мы настаивали на этом, мы бы, вероятно, использовали декоратор Python. Вот один из способов сделать это:

def getter_setter(*args): 
    def getter(member): return lambda self: getattr(self, member)
    def setter(member): return lambda self, val: setattr(self, member, val) 
    def decorator_getter_setter(cls): 
        for member in args: 
            setattr (cls, 'get_' + member, getter(member)) 
            setattr (cls, 'set_' + member, setter(member))
       return cls 
    return decorator_getter_setter 
@getter_setter('x', 'y') 
class Point: 
    def __init__(self, x, y): 
        self.x = x 
        self.y = y

Теперь вы можете сделать:

p = Point(1, 2) 
p.set_x(11) 
print(p.get_x())

Кстати: только при попытке реализовать понял, что в отличие от C++ и Java постановка задачи неоднозначна. Откуда вы вообще знаете, какие атрибуты есть у вашего класса? Их можно добавлять и удалять динамически во время выполнения.

На самом деле это единственный способ их создания (в случае нашего Point — в конструкторе); в Python нет возможностей для предварительного определения атрибутов экземпляра. В этом примере мы решаем эту проблему, явно сообщая декоратору, для каких атрибутов определять геттеры и сеттеры; разумный выбор, так как он дает программисту полный контроль, сохраняя при этом код коротким и чистым.

Обратите внимание, что несмотря на то, что мутация кода происходит во время выполнения, она выполняется только один раз для данного запуска программы (во время импорта). Таким образом, последствия для производительности почти наверняка незначительны.

Конечно, декораторы можно использовать еще проще: для определения пред- и пост-действий для функций, например, если мы хотим их синхронизировать:

def timeit(func): 
    def wrapped(*args, **kwargs):
        start = time.time() 
        func(*args, **kwargs) 
        print("%s took %f s." % (func.__name__, time.time() - start)) 
    return wrapped 
@timeit 
def foo(...): 
    ...

Это очень похоже на аспектно-ориентированное программирование в Java, только намного проще. Здесь нет никакой магии; ведь никакой особой власти декораторы не дают; они просто синтаксический сахар для func = decorator(func).

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

С другой стороны, простое волей-неволей переназначение функций на лету противоположно: запутанно, чревато ошибками, трудно проверить и обдумать. Это похоже на (не)известный #define TRUE FALSE в C: веская причина, по которой использование препроцессора не одобряется в этом языке.

(С другой стороны, с макросами Лиспа все в порядке: их определения может быть немного сложнее читать, но их использование так же читабельно, как и обычные функции.)

Красивый остров Хаки

А теперь мы подошли к моему последнему набегу на Java. В последний раз я использовал его очень-очень давно: в 2000 году. Тогда это был молодой и простой язык, по существу C++ лишенный его кажущихся сложностей, включая его скудные инструменты метапрограммирования: предварительную обработку и шаблоны. Это была еще одна историческая перезагрузка от сложного к простому, мало чем отличающаяся от го в наши дни.

Итак, говоря о простом: данные в структурах данных Java превратились в тыкву Object ссылок, и не было абсолютно никакого способа обойти это, кроме как вернуться к генерации кода с помощью написанных от руки скриптов Perl.

Я называю это страусиной типизацией: закопайте голову в песок и притворитесь, что у вас строго статически типизированный язык.

Перенесемся на 19 лет вперед: Java уже не молода и не проста, и у нее есть правильные шаблоны (называемые «дженериками»).

Теперь, заново изучая Java, программируя на ней, я был очень удивлен, увидев такой код:

@Getter 
@Setter 
class Point { 
    int x; 
    int y; 
}

Он выглядел почти идентично коду Python, который мы написали выше, и достигал той же цели: программно генерировать функции получения и установки для всех атрибутов класса. Ясно, что Java прошла долгий путь с тех пор, как я прикоснулся к ней в прошлый раз!

Мне было интересно узнать, что это такое и как это работает. Вот что я узнал.

Модификаторы с префиксом @ — аннотации — были частью Java довольно давно. (Любопытно, хотя моей первой реакцией было какой милый, он выглядит почти как Python, на самом деле все было наоборот: именно синтаксис аннотаций Java вдохновил декораторов на Python.)

Аннотации широко используются в современной Java — как правило, для того, что предполагает их название: аннотировать или помечать определенные элементы кода для проверки во время выполнения посредством отражения. Например, среда JUnit перебирает методы тестового класса во время выполнения, находит те, которые отмечены @Test, и выполняет их как тестовые примеры. Это хорошо и явно полезно, но не особо впечатляет и вряд ли может быть классифицировано как код для написания кода. В более мощных языках, где функции/методы являются объектами первого класса, их атрибуты можно устанавливать напрямую, без необходимости в специальном синтаксисе:

def foo (): 
    ... 
foo.test = True

Интересно, что одной из причин необычного синтаксиса аннотаций была необходимость поддерживать обратную совместимость — в частности, не вводить никаких новых зарезервированных ключевых слов. (Здесь я не могу устоять перед искушением снова упомянуть старый добрый PL/I. Каким-то образом в нем не было зарезервированных ключевых слов: вы могли называть свои переменные «if» и «do» как душе угодно, а компилятор был достаточно компетентен, чтобы отличить операторы от идентификаторов на основе грамматики. Почему мы не можем сделать это больше пятидесяти лет спустя?)

В любом случае план разработчиков языка, кажется, сработал достаточно хорошо. Различные фреймворки Java используют аннотации для обогащения языка, по существу определяя более высокий язык поверх Java. Одним из хороших примеров является фреймворк Spring, по существу построенный вокруг аннотаций. Они кажутся настолько всепроникающими, что, естественно, вызывают некоторую задумчивую реакцию. В любом случае, аннотации Spring, как и многие другие, похоже, основаны на том же поведении отражения во время выполнения, которое мы только что обсуждали для JUnit.

Однако есть способ обрабатывать аннотации во время компиляции. Вы можете написать свой собственный обработчик аннотаций (класс, реализующий javax.annotation.processing.Processor — или, как правило, расширяющий AbstractProcessor) и подключить его к компилятору. Вы просто помещаете jar-файл с специальным описанием процессора в свой путь сборки, и компилятор автоматически подбирает его и вызывает ваш процессор аннотаций по мере необходимости.

Здесь все становится захватывающим. Во-первых, компилятор Java фактически запускает JVM и выполняет код Java во время процесса компиляции, а также запускает ваш код во время компиляции! Во-вторых, ваш обработчик аннотаций имеет доступ к AST (абстрактному синтаксическому дереву, анализируемому представлению компилируемого исходного кода) и может генерировать дополнительный исходный код, который также будет скомпилирован в ходе того же процесса компиляции. Это определенно привносит в язык совершенно новую, мощную возможность, не похожую ни на что из того, что было раньше.

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

Если процессор аннотаций не может изменить текущий скомпилированный класс, то как может работать этот бизнес @Getter/@Setter? Вы не найдете ответа в документации по языку Java, но это именно то, что делает Project Lombok.

Ломбок — красивый индонезийский остров недалеко от острова Ява (см. заглавное изображение). Project Lombok определяет набор аннотаций, которые генерируют так называемый шаблонный код: геттеры, сеттеры, конструкторы, equals() и hashCode() и тому подобное. Они делают это, делая следующий шаг в обработчике аннотаций: получив доступ к AST программы, они используют недокументированный внутренний API-интерфейс компилятора Sun для прямой модификации AST. К счастью, почти все используют Sun (теперь Oracle) javac, и на практике все работает довольно хорошо.

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

Хотя это, очевидно, не единственное применение написания кода, для меня это был Святой Грааль из всех методов, которые мы рассмотрели до сих пор: расширить язык, чтобы сделать его более мощным, простым в использовании, более адаптированным к предметной области.

И дело в том, что Project Lombok это удается. Это действительно код написания кода, более того, Java пишет Java: использование того же языка для метапрограммирования по праву кажется мощным. Я все еще взволнован этим.

В отличие от большинства других применений аннотаций, которые кажутся в контексте того или иного фреймворка, Lombok не является фреймворком. Его аннотации не добавляют новых функций к аннотированному коду; они просто делают его более кратким и удобным в сопровождении.

Например, рассмотрим @Data, одну из самых мощных аннотаций в Project Lombok.

@Data 
class Point { 
    int x; 
    int y; 
}

Как указывается в его документации, @Data генерирует весь шаблон, который обычно связан с простыми POJO (обычными старыми объектами Java) и bean-компонентами: геттеры для всех полей, сеттеры для всех полей, не являющихся конечными, и соответствующие реализации toString, equals и hashCode. которые включают поля класса, и конструктор, который инициализирует все конечные поля. Этот довольно механический код, который вы каким-то образом должны написать сами (если бы не Project Lombok), десятки строк даже для такого тривиального класса, как этот, не только полностью затмили бы значимые части Point и сделать его почти невозможным для чтения. Это также еще несколько функций для тестирования. А если вы их не протестируете, потому что они скучны и тривиальны, — ну, тогда не удивляйтесь, когда решите сделать свою Точку трехмерной и добавите координату z, которую забудете обновить hashCode() и получите какую-то странную поведение ваших точек в HashMap.

Каждый раз, когда вам приходится писать строки кода, не относящиеся к поставленной задаче, это признак низкоуровневого языка. Избавляя вас от необходимости писать, Project Lombok возвышает Java, делает его языком более высокого уровня и лучшим.

Однако есть и огромное «но», конечно, — даже несколько. Несмотря на то, что Project Lombok реализован на Java, фактический язык определения и спецификации аннотаций довольно слаб, с плохой системой типов, без циклов и условий, а также без возможности взаимодействия различных аннотаций.

Реализации аннотаций Lombok находятся в совершенно отдельном проекте, очень далеком от изменяемого исходного кода. И мальчик, разве они не сложны! Просто взгляните на этот пост в блоге, описывающий, как добавить аннотацию @HelloWorld в Lombok. Подсчитайте, сколько экранов занимает то, для чего в Lisp или Python потребовалось бы всего несколько строк.

Сложность частично обусловлена ​​хакерским характером проекта, но частично это также природа зверя: плагины компилятора сложны, а синтаксис Java и его AST тоже нетривиальны. Не говоря уже о необходимости взаимодействия Project Lombok с IDE (почему-то в наши дни программирование на Java немыслимо без IDE).

В любом случае вывод один и тот же: хотя аннотации Ломбока суперполезны (я твердо принадлежу к этому лагерю), их реализация не для слабонервных. Разработчики вряд ли будут добавлять их в повседневной жизни так, как они это делают в Lisp, Python — даже в препроцессоре C, если уж на то пошло. Ломбок останется прекрасным островом, отдельным от острова Ява.

Интересно, что хорошие идеи продолжают перекрестно опылять языки. Точно так же, как на синтаксис декоратора Python повлияла Java, на аннотации Lombok, похоже, повлияли декораторы Python. И вот, Python 3.7 (последняя версия Python) представил стандартное оформление @dataclass, очень похожее на аннотацию @Data Project Lombok, которую мы только что обсуждали.

Это еще раз доказывает широкую межъязыковую привлекательность использования кода для написания кода для чтения обыденного, «шаблонного» кода. Однако некоторые языки — и языковые культуры — явно производят гораздо больше этого, чем другие. Язык, на котором был создан Project Lombok, вероятно, является одним из самых опасных. Некоторые из них являются культурными, а некоторые вызваны отсутствием языковой силы. Например, возвращаясь к функциям получения и установки: я упомянул, что они не используются в правильном коде Pythonic, и причина в том, что язык более мощный. Он имеет концепцию свойств, которые позволяют полностью прозрачно определять геттеры и сеттеры, и только тогда, когда они делают что-то нетривиальное: то есть, когда они не являются шаблонными.

* * *

И здесь, на этой неудовлетворительной ноте, я должен закончить свой рассказ. Да, Java прошла долгий путь, но с точки зрения написания кода она не просто не догнала Lisp пятьдесят лет назад: она все еще отстает на световые годы. Что касается меня, я сохраню свой энтузиазм в отношении некоторых более молодых и более смелых языков, таких как Rust, которые, по-видимому, имеют хорошо развитые макро-возможности.

Какие ваши любимые примеры метапрограммирования? Пожалуйста, поделитесь в комментариях.

Как я уже сказал, мои C++ и Lisp на данный момент несколько заржавели, и я только начинаю заново изучать Java. Если у меня что-то не так с этими языками, дайте мне знать в комментариях.

Первоначально опубликовано на tos.gorelik.org 2 марта 2019 г.