Код написания кода: введение в теорию и практику современного метапрограммирования
Опубликовано: 2022-07-22Всякий раз, когда я думаю о том, как лучше всего объяснить макросы, я вспоминаю программу на Python, которую написал, когда впервые начал программировать. Я не мог организовать это так, как хотел. Пришлось вызывать несколько немного отличающихся функций, и код стал громоздким. То, что я искал — хотя тогда я этого не знал, — было метапрограммированием .
метапрограммирование (существительное)
Любой метод, с помощью которого программа может обрабатывать код как данные.
Мы можем построить пример, который демонстрирует те же проблемы, с которыми я столкнулся в своем проекте Python, представляя, что мы создаем серверную часть приложения для владельцев домашних животных. Используя инструменты из библиотеки pet_sdk
, мы пишем Python, чтобы помочь владельцам домашних животных покупать корм для кошек:
Убедившись, что код работает, мы переходим к реализации той же логики еще для двух видов домашних животных (птиц и собак). Мы также добавили функцию записи на прием к ветеринару:
Было бы неплохо объединить повторяющуюся логику Фрагмента 2 в цикл, поэтому мы решили переписать код. Мы быстро понимаем, что поскольку каждая функция называется по-разному, мы не можем определить, какую из них (например, book_bird_appointment
, book_cat_appointment
) вызывать в нашем цикле:
Давайте представим версию Python с турбонаддувом, в которой мы можем писать программы, автоматически генерирующие окончательный код, который нам нужен, — такую, в которой мы можем гибко, легко и плавно манипулировать нашей программой, как если бы это был список, данные в файле или что-то еще. другой общий тип данных или ввод программы:
Это пример макроса , доступного в таких языках, как Rust, Julia или C, но не в Python.
Этот сценарий — отличный пример того, как может быть полезно написать программу, способную изменять и манипулировать своим собственным кодом. В этом и состоит прелесть макросов, и это один из многих ответов на более важный вопрос: как мы можем заставить программу анализировать собственный код, рассматривая его как данные, а затем действовать в соответствии с этим самоанализом?
В широком смысле все методы, которые могут привести к такому самоанализу, подпадают под общий термин «метапрограммирование». Метапрограммирование — обширная область разработки языков программирования, и ее можно проследить до одной важной концепции: код как данные.
Отражение: в защиту Python
Вы могли бы указать, что, хотя Python не поддерживает макросы, он предлагает множество других способов написания этого кода. Например, здесь мы используем метод isinstance()
, чтобы идентифицировать класс, экземпляром которого является наша animal
переменная, и вызывать соответствующую функцию:
Мы называем этот тип рефлексии метапрограммирования и вернемся к нему позже. Код фрагмента 5 по-прежнему немного громоздкий, но для программиста его легче написать, чем код фрагмента 2, в котором мы повторили логику для каждого перечисленного животного.
Вызов
Используя метод getattr
, измените предыдущий код, чтобы он вызывал соответствующие функции order_*_food
и book_*_appointment
динамически. Возможно, это делает код менее читаемым, но если вы хорошо знаете Python, стоит подумать о том, как вы могли бы использовать getattr
вместо функции isinstance
и упростить код.
Гомоиконность: важность Лиспа
Некоторые языки программирования, такие как Lisp, выводят концепцию метапрограммирования на другой уровень с помощью гомоиконичности .
гомоиконичность (существительное)
Свойство языка программирования, при котором нет различия между кодом и данными, с которыми работает программа.
Lisp, созданный в 1958 году, является старейшим гомоиконичным языком и вторым по возрасту языком программирования высокого уровня. Получив свое название от процессора LISt, Lisp стал революцией в вычислительной технике, которая глубоко повлияла на то, как используются и программируются компьютеры. Трудно переоценить то, насколько фундаментально и характерно влияние Lisp на программирование.
Emacs написан на Лиспе, единственном красивом компьютерном языке. Нил Стивенсон
Лисп был создан всего через год после Фортрана, в эпоху перфокарт и военных компьютеров, заполнявших комнату. Тем не менее, программисты до сих пор используют Лисп для написания новых современных приложений. Первый создатель Lisp, Джон Маккарти, был пионером в области искусственного интеллекта. В течение многих лет Lisp был языком ИИ, и исследователи ценили возможность динамически переписывать собственный код. Сегодняшние исследования ИИ сосредоточены вокруг нейронных сетей и сложных статистических моделей, а не такого типа кода генерации логики. Однако исследования, проведенные в области ИИ с использованием Lisp, особенно исследования, проведенные в 60-х и 70-х годах в Массачусетском технологическом институте и Стэнфорде, создали область, которую мы знаем, и ее огромное влияние продолжается.
Появление Lisp впервые открыло ранним программистам практические вычислительные возможности таких вещей, как рекурсия, функции высшего порядка и связанные списки. Он также продемонстрировал мощь языка программирования, построенного на идеях лямбда-исчисления.
Эти идеи вызвали взрыв в разработке языков программирования и, как выразился Эдсгер Дейкстра, одно из величайших имен в компьютерной науке, « … помогли ряду наших самых одаренных собратьев придумать ранее невозможные мысли».
В этом примере показана простая программа на Лиспе (и ее эквивалент в более знакомом синтаксисе Python), которая определяет функцию «факториал», которая рекурсивно вычисляет факториал своих входных данных и вызывает эту функцию с входными данными «7»:
Лисп | питон |
---|---|
( defun factorial ( n ) ( if ( = n 1 ) 1 ( * n ( factorial ( - n 1 ))))) ( print ( factorial 7 )) | |
Код как данные
Несмотря на то, что это одно из самых впечатляющих и последовательных нововведений Лиспа, гомоиконичность, в отличие от рекурсии и многих других концепций, впервые появившихся в Лиспе, не вошла в большинство современных языков программирования.
В следующей таблице сравниваются гомоиконные функции, которые возвращают код как в Julia, так и в Lisp. Julia — это гомоиконичный язык, который во многих отношениях напоминает языки высокого уровня, с которыми вы, возможно, знакомы (например, Python, Ruby).
Ключевой частью синтаксиса в каждом примере является символ кавычек . Джулия использует :
(двоеточие) для цитирования, а Лисп использует '
(одинарную кавычку):
Юлия | Лисп |
---|---|
function function_that_returns_code() return :(x + 1 ) end | |
В обоих примерах кавычка рядом с основным выражением ( (x + 1)
или (+ x 1)
) преобразует его из кода, который должен был быть вычислен напрямую, в абстрактное выражение, которым мы можем манипулировать. Функция возвращает код, а не строку или данные. Если бы мы вызвали нашу функцию и написали бы print(function_that_returns_code())
, Джулия напечатала бы код, представленный в виде строки x+1
(и эквивалент верен для Lisp). И наоборот, без :
(или '
в Лиспе) мы получили бы ошибку, что x
не определен.
Вернемся к нашему примеру с Джулией и расширим его:
Функцию eval
можно использовать для запуска кода, который мы генерируем из другого места в программе. Обратите внимание, что распечатываемое значение основано на определении переменной x
. Если бы мы попытались eval
наш сгенерированный код в контексте, где x
не был определен, мы бы получили ошибку.
Гомоиконичность — это мощный вид метапрограммирования, способный открыть новые и сложные парадигмы программирования, в которых программы могут адаптироваться на лету, генерируя код, соответствующий проблемам, специфичным для предметной области, или новым встречающимся форматам данных.
Возьмем, к примеру, WolframAlpha, где одноименный язык Wolfram Language может генерировать код, адаптирующийся к невероятному кругу задач. Вы можете спросить WolframAlpha: «Каков ВВП Нью-Йорка, разделенный на население Андорры?» и, что примечательно, получить логичный ответ.
Кажется маловероятным, что кому-то когда-либо придет в голову включить эти неясные и бессмысленные вычисления в базу данных, но Wolfram использует метапрограммирование и онтологический граф знаний для написания кода «на лету», чтобы ответить на этот вопрос.
Важно понимать гибкость и мощь, которые обеспечивают Lisp и другие гомоиконичные языки. Прежде чем мы углубимся дальше, давайте рассмотрим некоторые из имеющихся в вашем распоряжении вариантов метапрограммирования:
Определение | Примеры | Заметки | |
---|---|---|---|
гомоиконичность | Характеристика языка, в которой код является данными «первого класса». Поскольку между кодом и данными нет разделения, их можно использовать взаимозаменяемо. |
| Здесь Lisp включает в себя другие языки семейства Lisp, такие как Scheme, Racket и Clojure. |
Макросы | Оператор, функция или выражение, которые принимают код в качестве входных данных и возвращают код в качестве выходных данных. |
| (См. следующее примечание о макросах C.) |
Директивы препроцессора (или прекомпилятора) | Система, которая принимает программу в качестве входных данных и на основе утверждений, включенных в код, возвращает измененную версию программы в качестве выходных данных. |
| Макросы C реализованы с использованием системы препроцессора C, но это два разных понятия. Ключевое концептуальное различие между макросами C (в которых мы используем директиву препроцессора #define ) и другими формами директив препроцессора C (например, #if и #ifndef ) заключается в том, что мы используем макросы для генерации кода, используя другие, не относящиеся к #define , директивы. директивы препроцессора для условной компиляции другого кода. Они тесно связаны в C и в некоторых других языках, но это разные типы метапрограммирования. |
Отражение | Способность программы исследовать, модифицировать и анализировать собственный код. |
| Отражение может происходить во время компиляции или во время выполнения. |
Дженерики | Возможность писать код, который действителен для ряда различных типов или может использоваться в нескольких контекстах, но храниться в одном месте. Мы можем определить контексты, в которых код действителен явно или неявно. | Дженерики в стиле шаблона:
Параметрический полиморфизм:
| Общее программирование — более широкая тема, чем общее метапрограммирование, и грань между ними четко не определена. С точки зрения этого автора, система параметрических типов считается метапрограммированием только в том случае, если она написана на языке со статической типизацией. |
Давайте рассмотрим несколько практических примеров гомоиконичности, макросов, директив препроцессора, рефлексии и дженериков, написанных на разных языках программирования:
Макросы (например, во фрагменте 11) снова становятся популярными в языках программирования нового поколения. Чтобы успешно развивать их, мы должны рассмотреть ключевую тему: гигиена.
Гигиенические и негигиеничные макросы
Что значит для кода быть «гигиеничным» или «негигиеничным»? Чтобы прояснить, давайте посмотрим на макрос Rust, созданный с помощью macro_rules!
функция. Как следует из названия, macro_rules!
генерирует код на основе правил, которые мы определяем. В этом случае мы назвали наш макрос my_macro
, а правило — «Создать строку кода, let x = $n
», где n
— наш ввод:
Когда мы расширяем наш макрос (запуская макрос для замены его вызова кодом, который он генерирует), мы ожидаем получить следующее:
По-видимому, наш макрос переопределил переменную x
так, чтобы она была равна 3, поэтому мы можем разумно ожидать, что программа напечатает 3
. Фактически, он печатает 5
! Удивлен? В Rust macro_rules!
является гигиеничным по отношению к идентификаторам, поэтому он не будет «захватывать» идентификаторы за пределами своей области. В данном случае идентификатор был x
. Если бы он был захвачен макросом, он был бы равен 3.
гигиена (существительное)
Свойство, гарантирующее, что раскрытие макроса не захватит идентификаторы или другие состояния вне области действия макроса. Макросы и макросистемы, не обеспечивающие этого свойства, называются негигиеничными .
Гигиена в макросах — довольно спорная тема среди разработчиков. Сторонники настаивают на том, что без гигиены слишком легко случайно незаметно изменить поведение вашего кода. Представьте себе макрос, значительно более сложный, чем фрагмент 13, используемый в сложном коде с множеством переменных и других идентификаторов. Что, если бы этот макрос использовал одну из тех же переменных, что и ваш код, а вы этого не заметили?
Разработчик нередко использует макрос из внешней библиотеки, не прочитав исходный код. Это особенно распространено в новых языках, которые предлагают поддержку макросов (например, Rust и Julia):
Этот негигиеничный макрос на языке C захватывает идентификатор веб- website
и меняет его значение. Конечно, захват идентификатора не является вредоносным. Это просто случайное следствие использования макросов.
Итак, гигиеничные макросы — это хорошо, а негигиеничные — плохо, верно? К сожалению, это не так просто. Есть веские основания утверждать, что гигиенические макросы ограничивают нас. Иногда полезно использовать захват идентификатора. Давайте вернемся к фрагменту 2, где мы используем pet_sdk
для предоставления услуг трем видам домашних животных. Наш исходный код начинался так:
Вы помните, что Фрагмент 3 был попыткой объединить повторяющуюся логику Фрагмента 2 во всеобъемлющий цикл. Но что, если наш код зависит от идентификаторов cats
и dogs
, а мы хотели написать что-то вроде следующего:
Фрагмент 16, конечно, немного прост, но представьте себе случай, когда мы хотели бы, чтобы макрос написал 100% данной части кода. Гигиенические макросы могут быть ограничивающими в таком случае.
Хотя дебаты о гигиеничности и негигиеничности макроэкономических показателей могут быть сложными, хорошая новость заключается в том, что вам не нужно занимать определенную позицию. Язык, который вы используете, определяет, будут ли ваши макросы гигиеничными или негигиеничными, поэтому имейте это в виду при использовании макросов.
Современные макросы
У макросов сейчас немного времени. Долгое время акцент современных императивных языков программирования смещался от макросов как основной части их функциональности, избегая их в пользу других типов метапрограммирования.
Языки, которым обучали новых программистов в школах (например, Python и Java), говорили им, что все, что им нужно, — это рефлексия и дженерики.
Со временем, когда эти современные языки стали популярными, макросы стали ассоциироваться с устрашающим синтаксисом препроцессоров C и C++ — если программисты вообще знали о них.
Однако с появлением Rust и Julia тенденция вернулась к макросам. Rust и Julia — это два современных, доступных и широко используемых языка, которые переопределили и популяризировали концепцию макросов с некоторыми новыми и инновационными идеями. Это особенно интересно в Julia, который, похоже, готов занять место Python и R в качестве простого в использовании универсального языка «в комплекте с батарейками».
Когда мы впервые посмотрели на pet_sdk
через наши очки «TurboPython», нам действительно хотелось чего-то вроде Джулии. Давайте перепишем фрагмент 2 на языке Julia, используя его гомоиконичность и некоторые другие инструменты метапрограммирования, которые он предлагает:
Давайте разберем фрагмент 17:
- Мы перебираем три кортежа. Первым из них является
("cat", :clean_litterbox)
, поэтому переменнаяpet
присваивается"cat"
, а переменнаяcare_fn
присваивается символу в кавычках:clean_litterbox
. - Мы используем функцию
Meta.parse
для преобразования строки вExpression
, чтобы мы могли оценить ее как код. В этом случае мы хотим использовать силу интерполяции строк, когда мы можем поместить одну строку в другую, чтобы определить, какую функцию вызывать. - Мы используем функцию
eval
для запуска кода, который мы генерируем.@eval begin… end
— это еще один способ написанияeval(...)
, позволяющий избежать повторного ввода кода. Внутри блока@eval
находится код, который мы динамически генерируем и выполняем.
Система метапрограммирования Джулии действительно позволяет нам выражать то, что мы хотим, так, как мы этого хотим. Мы могли бы использовать несколько других подходов, включая отражение (например, Python во фрагменте 5). Мы также могли бы написать макрофункцию, которая явно генерирует код для конкретного животного, или мы могли бы сгенерировать весь код в виде строки и использовать Meta.parse
или любую комбинацию этих методов.
Помимо Джулии: другие современные системы метапрограммирования
Джулия, пожалуй, один из самых интересных и убедительных примеров современной макросистемы, но ни в коем случае не единственный. Rust также сыграл важную роль в том, что макросы снова стали доступны программистам.
В Rust макросы имеют гораздо более централизованное значение, чем в Julia, хотя мы не будем подробно исследовать это здесь. По ряду причин вы не можете писать идиоматический Rust без использования макросов. Однако в Julia вы можете полностью игнорировать гомоиконичность и макросистему.
Как прямое следствие этой центральности, экосистема Rust действительно включает макросы. Члены сообщества создали несколько невероятно крутых библиотек, доказательств концепции и функций с помощью макросов, включая инструменты, которые могут сериализовать и десериализовать данные, автоматически генерировать SQL или даже преобразовывать аннотации, оставшиеся в коде, в другой язык программирования. время компиляции.
В то время как метапрограммирование Джулии может быть более выразительным и свободным, Rust, вероятно, является лучшим примером современного языка, который поднимает метапрограммирование на новый уровень, поскольку он широко используется во всем языке.
Взгляд в будущее
Сейчас невероятное время, чтобы интересоваться языками программирования. Сегодня я могу написать приложение на C++ и запустить его в веб-браузере или написать приложение на JavaScript для запуска на компьютере или телефоне. Барьеры для входа никогда не были ниже, и новые программисты имеют доступ к информации, как никогда раньше.
В этом мире выбора и свободы программиста у нас все чаще появляется возможность использовать богатые современные языки, в которых тщательно отобраны функции и концепции из истории компьютерных наук и более ранних языков программирования. Приятно видеть, как макросы поднимаются и стираются на этой волне разработки. Мне не терпится увидеть, что сделают разработчики нового поколения, когда Rust и Julia познакомят их с макросами. Помните, что «код как данные» — это больше, чем просто крылатая фраза. Это основная идеология, о которой следует помнить при обсуждении метапрограммирования в любом онлайн-сообществе или академической среде.
«Код как данные» — это больше, чем просто крылатая фраза.
64-летняя история метапрограммирования стала неотъемлемой частью развития программирования в том виде, в каком мы его знаем сегодня. Хотя инновации и история, которые мы исследовали, являются лишь частью саги о метапрограммировании, они иллюстрируют мощную силу и полезность современного метапрограммирования.