Сделай сам себе язык

Несмотря на то, что статья написана так, будто посвящена решению одной конкретной задачи, на самом деле она на этом примере иллюстрирует идеи, на которых строятся языки «метапрограммирования» — то есть языки для написания программ, способных анализировать, преобразовывать и генерировать собственный код.

Аналогичное заодно является основой символьных вычислений, в том числе — построения систем, которые могут, например, решать уравнения или выводить производные в символьной форме.

При всех огромных возможностях языка Wolfram и приделанной к нему Mathematica меня все эти годы раздражало то, что в плане определения функций там совершенно адский синтаксис.

Когда функция записывается в одну строку, это выглядит так же хорошо, как в языках типа Scala.

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

Нельзя просто взять и поставить скобочки вокруг блока кода. Такой синтаксис есть — объединение выражений через точку с запятой, однако в нём предполагается, что все переменные, которые таким образом заведены, — глобальные. То есть если им внутри функции задать какое-то значение, то они поменяются и извне функции тоже. Таким образом, если у вас лежало что-то ценное в одноимённых переменных, то после вызова функции оно перестанет там лежать.

Если же всё-таки надо локализовать переменные, то извольте написать что-то вот такое:

Не, ну Ок. Дополнительное слово ещё можно стерпеть, однако тут кроме него ещё и дополнительный блок с фигурными скобками, в котором должны быть перечислены эти самые локальные переменные (в данном примере в eq лежит локальная функция, которая позволяет радикально сократить запись финального выражение и упростить его понимание). Прямо скажем, эти фигурные скобки и запятая красоты коду не добавляют.

Если вы на этом месте стиснули зубы и превозмогли, то тут же приходит следующая радостная новость: блок с переменными не может ссылаться на переменные, которые определены в этом блоке. Как тебе такое, Илон Маск?

Видите, «eq» тут синенькое? Это потому, что Module говорит нам: «я такой штуки не знаю».

Да, она лежит прямо в предыдущей строке, но, нет, всё равно «не знаю».

Поэтому надо вот так:

Здравствуй, Pascal, мы так по тебе скучали.

Правда, надо отметить, в таком стиле оно всё-таки чуть-чуть более удобочитаемое, чем в предыдущей версии, не говоря уже о том, что так оно хотя бы работает.

Но как же это раздражает — перечислять все локальные переменные в начале блока в фигурных скобках. Да ещё и в тех условиях, когда в процессе разработки оного можно забыть какую-то из них там упомянуть, о чём тебе никто не скажет.

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

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

Однако мышки продолжали колоться и плакать, но всё равно ели кактус. Поскольку хоть, например, Scala или Python по синтаксису лучше, но символьных вычислений в них нет. Точнее, нет встроенных символьных вычислений и нет синтаксической для них поддержки. Поэтому те библиотеки, которые всё-таки частично это реализуют, по синтаксису получается гораздо более сложные и неудобные в использовании, чем Wolfram с его этим вот единственным значительным просчётом.

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

Впрочем, в Вольфраме всё так: «типа оно, но не оно».

В частности, там, где вы видите «типа функцию» на самом деле не функция, а определение правила автоматической замены.

Например, если у вас вдруг где-то написано

то не смотрите, что оно почти идентично определению функции на любом языке программирования.

Это не функция, а как бы «псевдофункция» — базовое выражение языка, которое очень похоже на функции обычных языков, но на самом деле это новое правило автоматического преобразования выражений. То есть инструкция движку: если ты где угодно встретишь буковку «f», после которой стоит квадратная скобка, потом что угодно, а потом снова квадратная скобка, то это надо заменить на возведение того, что в квадратных скобках, в квадрат плюс один. При этом подчёркивание после «x» означает «какой угодно текст, который мы тут будем называть “x”».

В общем, это — такие регулярные выражения с человеческим лицом. Встроенные прямо сразу в язык, в котором, по сути, кроме них вообще ничего нет.

Может показаться, что, а какая вообще разница, функция это или какое-то там «правило автоматического преобразования? Однако разница в ряде случаев есть.

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

Во-вторых, с тем же именем «функции», но с другими условиями можно определить другие шаблоны, а потому применяться будет не обязательно этот, а наиболее «частный случай» из всех к данному моменту определённых, которые вообще подходят к фрагменту. Чем-то это похоже на перегрузку функций (то есть определение функций с одинаковыми именами, но разными типами аргументов и разным их количеством), но здесь оно гораздо более детальное. В частности, потому, что в Wolfram можно определить шаблон с любой глубиной вложенности, шаблон от конкретных значений и вообще от любого выражения, а не только для каких-то особых конструкций, подобных, например, case-классам в Scala.

Например, вот тут

определяется автоматическое преобразование, которое будет срабатывать только там, где в квадратных скобках forPoint стоит список с одним элементом, и этот элемент — Point, в скобках у которого стоит сначала единица, а на месте второго «аргумента» что угодно, что мы будем называть «y» и вот его как раз в этом случае и возвращать как результат этого автоматического преобразования.

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

А «настоящие» функции, которые быстрые, но без распознавания шаблонов, — это то, что было в примерах в начале статьи — то самое, что со странными стрелочками. Вот там как раз написано: «если вы где-то встретите f, то замените его на функцию, которая написана справа от знака „=“».

То есть и это тоже — правило замены. Но там встретившийся где-то символ f с квадратными скобками после него заменяется уже на «настоящую» функцию.

Которая, впрочем, тоже хранится в символьном виде, а потому и её тоже можно как-то модифицировать.

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

В общем, всё кругом — это правила замены и ни что иное. Любое законченное синтаксически корректное выражение можно запомнить и поменять. А при помощи некоторых «читов» и незаконченное или некорректное выражение тоже.

Даже то, что выглядит в точности как присвоение численного значения переменной, — тоже определение правило автоматической замены.

Точнее, только именно оно-то — «присваивание» — эти правила замены и определяет. То есть, везде, где написано «=» или «≔», на самом деле написано «отныне, если где-то встретим то, что слева от знака равенства, то это надо автоматически заменять на то, что там было справа». И якобы «переменная» — это не переменная, а просто символ, для которого при помощи «=» определена какая-то автоматическая замена.

На этом месте грань между «стандартным языком» и макросами полностью исчезает — всё оказывается совершенно одинаковым по устройству, как бы оно ни выглядело. Да, по умолчанию уже определены какие-то правила замены, поэтому ваша программа как бы «выполняется». Хотя на самом деле программа не выполняется, а модифицируется движком при помощи этих правил, пока не домодифицируется до каких-то результатов — чисел, формул, картинок и т.п., которые дальнейшему модифицированию не поддаются, поскольку для этого уже нет определённых правил автоматической замены.

В этом смысле любое «вычисление» на языке, подобном Wolfram, это именно что последовательность замен, во время которых, возможно, определяются ещё какие-то замены.

Даже вызов скомпилированных функций можно трактовать как «замену»: имя функции и подставленное на место её аргументов заменяются на то, что получилось при вызове этой скомпилированной функции.

В дальнейшем под «вычислением» будет пониматься именно этот процесс.

То есть Wolfram — это такой специальный язык про рефлексию программы над самой собой. А то, что оно иногда выглядит так, будто бы это программа на обычном языке, — частный случай. Который, впрочем, не случайное совпадение, а намеренное стремление: «сделать, чтобы хотя бы в частных случаях было похоже».

Ну а раз так, то и типа «макросы» — это не предварительная генерация выражений «настоящего языка» (как, например, в C++), а ровно те же средства, что и для вообще всего остального: по сути, модификация программы этой же программой. Ну или какой-то другой, однако всё равно точно тем же способом.

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

Соответственно, добавление конструкции к языку в этом смысле — не более чем описание ещё каких-то правил замены выражений, подходящих под некоторый шаблон.

И для определения «умных блоков», которые будут автоматически делать локальными все встретившиеся внутри них переменные, тоже надо определить какие-то правила. При этом какие-то фрагменты этого определения ввиду визуального сходства с другими языками программирования можно называть «переменные», «функции», «аргументы» и т.п., однако надо помнить, что это всё и всегда выражения, определяющие правила автоматической замены и трансформируемые другими правилами.

В общем, что, по сути, надо сделать для определения «умных блоков».

Мы хотим, чтобы все места вида

без нашего участия вычленяли внутри себя всё то, что должно быть объявлено как локальная переменная внутри квадратных скобок, относящихся к SmartModule (в данном примере это — y).

Для этого надо выделить из фрагмента текста, который вписан в квадратные скобки SmartModule, все те места, где что-то куда-то присваивается, потом извлечь из этого то, что стоит слева от «равно», и сгенерировать из этого список «переменных», кои подставить в генерируемый же Module на то место, где там как раз и должны перечисляться переменные.

То есть сам по себе Module останется во всех местах, где он нужен, — просто не надо будет заводить в нём переменные вручную.

Можно будет вместо

писать что-то вроде

и второе превращаться в первое без нашего участия. Причём сохраняться в t будет именно текст, который уже с Module и перечислением локальных переменных в фигурных скобках.

С этого места начинается особая уличная магия замены выражений, которую вы, вполне возможно, не поймёте с первого раза. Но я всё-таки попробую объяснить.

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

«With» — это такая же штука, как Module, с некоторыми нюансами, которые, впрочем, нам не понадобятся. Будем просто считать, что это такой как бы «Module», который работает быстрее, чем обычный Module, поскольку не делает целую кучу вещей, которые делает обычный Module. Здесь With нужен исключительно для удобочитаемости — чтобы отдельной строкой определить шаблон «pattern» для вычленения выражений с присваиванием.

В самом шаблоне pattern перечислены все конструкции, которые могут потребовать объявления локальной переменной. Их всего четыре, хотя, если вдруг вспомнится ещё какая-то, то её можно будет сюда добавить.

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

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

Каждое из этих выражений обёрнуто в HoldPattern, чтобы движок не попытался их вычислять — то есть проводить все ему на данный момент известные автоматические замены, а вместо этого считал их уже готовыми шаблонами.

Все шаблоны объединены через вертикальную черту «|», которая означает «или». То есть таким образом сообщается, что нас устроит соответствие любому из этих шаблонов.

Выше уже говорилось, как трактуется шаблон: знак подчёркивания означает «что угодно».

То есть, например, первый шаблон

означает «что угодно, что мы назовём “l”, потом в квадратных скобках что угодно, чему мы не даём имя, потом знак равенства, а потом что угодно, что мы назовём “r”».

Впрочем, имя «r» тут вписано лишь для удобочитаемости, поскольку нас интересует исключительно «l», который как раз и будет извлекаться при проверке выражения на соответствие шаблону.

Это как раз и используется в следующем фраменте.

Переданное в качестве аргумента функции выражение exp — это то, что было вписано в квадратные скобки SmartModule: разделённые точкой с запятой выражения.

В коде программы они визуально выглядят как строки (или как одна строка с точками с запятой внутри), однако на самом деле это — «псевдофункция» CompoundExpression. А «строки» — это её «аргументы».

Например, если написать

то это на самом деле написано

Просто для удобочитаемости оно отображается иначе.

CompoundExpression мы можем трактовать как обычный список — ведь одно отличается от другого только тем, что в начале написано CompoundExpression вместо List, а потому можем делать с этой конструкцией всё то, что могли бы делать со списком. Например, искать «элементы», соответствующие шаблону.

Сейчас из этого «списка» нам надо вычленить все выражения, в которых есть присваивание, и одновременно с тем извлечь имена переменных, которым что-то присваивается.

Напомню, «переменные» на самом деле не переменные, а заданные правила автоматической замены. По сути, присваивание к чему-то как раз и определяет новое правило.

Мы делаем это при помощи Cases — поиска по шаблону.

Что такое «{2}» внутри Cases?

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

Тут надо отметить, что не только выражения, разделённые точкой с запятой, но вообще абсолютно любой код на Wolfram — это набор вложенных друг в друга «псевдофункций». Однако, если бы код писался прямо в таком виде, то понять, что именно написано, было бы очень тяжело, а потому по умолчанию определены не только правила каких-то вычислений, но и правила превращения результатов в форматированный и удобочитаемый вид, и, наоборот, из форматированного вида — в набор вложенных друг в друга «псевдофункций». При этом туда—сюда автоматически преобразуются и некоторые «внутристрочные» конструкции — например, выражения, разделённые знаками «+» и «—», верхние и нижние индексы и т.п.

То есть «в глубине» разбитый на строки текст со всеми специальными символами — это всегда «дерево» из вложенных друг в друга «псевдофункций». Так, например, конструкция

это на самом деле

«Глубина вложенности» в этом смысле означает, сколько раз мы зашли внутрь квадратных скобок. Например, здесь «res» находится на первом уровне вложенности, «x» — на втором, а «y» — на третьем.

Те выражения, из которых мы будем извлекать список переменных для объявления их локальными, будут иметь вид

То есть поиск нам надо вести строго на втором уровне вложенности — именно там находятся все те выражения, в которых могут слева от «=» могут встретиться символы, которые мы хотели бы автоматически превратить в локальные.

Именно это и означает «{2}»: ровно на втором уровне вложенности («2» без фигурных скобок означало бы «от первого до второго»).

Тут ещё следует пояснить, что такое «Hold» — а заодно и «Inactive», который фигурировал после стрелочки в определении pattern.

Суть такова. Если написать что-то вроде

то во второй строке сначала «x» будет заменён на «1» (поскольку в первой как раз и было определено такое правило), потом «1 + 1» будет заменено на «2», и только после этого будут искаться правило замены для «f[2]».

Однако если внутри f мы собираемся производить какие-то манипуляции с самим выражением «x + 1», то надо каким-то образом заблокировать автоматические замены для него.

Именно это и делается при помощи Hold или Inactive: если вписать выражение внутрь квадратных скобок какого-то из них, то оно не будет автоматически вычисляться, пока не снимется эта «блокировка вычислений» — при помощи ReleaseHold или Activate, соответственно.

Ключевая разница между этими конструкциями в том, что ReleaseHold отменяет только наименее вложенный Hold, а более глубоко вложенные сохраняет. Тогда как Activate отменяет Inactive на всех уровнях вложенности.

Итак, в extractVars передаётся защищённое от автоматического вычисления составное выражение, мы превращаем оное в список «строк», ищем в них по шаблону те строки, в которых есть присваивание, и извлекаем из каждой такой строки имена переменных, которым что-то присваивается, защищая их от автоматических вычислений при помощи Inactive — на тот случай, если где-то такая переменная уже определена.

И вот этот список возвращаем в качестве результата, предварительно удалив дублирующиеся имена при помощи DeleteDuplicates.

Вызов DeleteDuplicates можно было бы написать «традиционным способом»:

Однако глубоко вложенные выражения довольно тяжело читать. Причём тем тяжелее, чем больше у них уровень вложенности.

И это, кстати, большая проблема — зачастую код на Wolfram пишут так, что получается одно гигантское и совершенно нечитаемое выражение.

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

Тут можно было поступить аналогично: записать результат выполнения Cases в какую-то временную переменную, а потом в следующей строке вызывать для неё DeleteDuplicates, однако в Wolfram имеется удобное синтаксическое соглашение, которое позволяет сократить запись, сохранив при этом удобочитаемость.

Код вида

Можно записать как

Что можно прочитать буквально как «выполнить f[x], а потом к результату применить функцию g, а потом к результату её применения применить функцию h».

То есть это — способ разбить вложенное выражение на отдельные фрагменты, не заводя при этом временных переменных для промежуточных результатов.

Этому, кстати, помогает и то, что функцию тоже можно сделать безымянной.

Например, можно было бы завести функцию, которая в некотором списке, данном в качестве параметра, ищет заранее заданный шаблон.

Однако в точности то же самое можно записать без имени переменной и стрелки, как

Здесь решётка указывает на место для параметра, а амперсант в конце — на то, что тут определяется функция.

То есть вышеупомянутое выражение можно было бы записать как

но для данного случая это уже перебор, поэтому остановимся на варианте

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

С этого места начинается ещё более сложная часть, но, благо, практически все используемые конструкции за время разбора предыдущей уже были объяснены — и «ручная» замена фрагментов выражения, и «удержание» выражения от автоматических замен, и даже создание безымянных функций на ходу, которое, впрочем, в этот раз не понадобится.

Единственное, что пока не сказано, это как задать свою собственную «нотацию», «внешнюю» по отношению к «умному блоку».

Казалось бы, а зачем «внешнюю»? Чем плохо сделать «умный блок» просто функцией?

Дело в том, что мы действительно можем определить какую-то свою функцию, которая будет извлекать переменные из составного выражения, которое передано ей в качестве аргумента и подставлять их в Module — вместе с самим выражением. Однако проблема в том, что конструкции с многострочными вычислениями обычно используются для определения функций, куда-то подставляются или к чему-то присваиваются.

Положим, у нас есть функция SmartModule, которая делает вот это самое: извлекает переменные и т.д.

Теперь при помощи SmartModule мы решили определить ещё какую-то свою функцию f.

Вроде бы всё в порядке, всё работает. Однако, поскольку SmartModule написан справа от стрелки, при каждом вызове функции f он будет делать всё заново: извлекать все присваивания из составного выражения, написанного в квадратных скобках, формировать Module, и только после этого оно уже будет превращаться в результат вычисления функции. С точки зрения быстродействия это, мягко говоря, не очень хорошо: ведь если, например, вызывать f миллион раз, то миллион же раз проделается весь этот относительно медленный синтаксический разбор, вместо того, чтобы сделать его единожды, а миллион раз вызывать уже получившуюся в его результате быструю функцию.

Одновременно с тем это не очень хорошо с точки зрения удобочитаемости — в Wolfram ведь можно запросить определение функции, и нам даже для самых простейших из них будет выдаваться вся эта кухня с вычленением переменных.

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

В общем, целая масса минусов, которых хорошо было бы избежать, определив какой-то такой синтаксис, который сразу будет возвращать результат всех этих манипуляций и именно его уже запоминать в качестве автоматических правил преобразования или, если угодно, в «переменных».

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

Например, если у нас где-то написана операция отложенного присваивания (то есть такая, что присвоенное значение вычисляется каждый раз, когда где-то запрашивается «значение переменной»)

которая без «синтаксического сахара» записывается как

то вместо неё должна вызываться наша версия SetDelayed, которая сначала запустит преобразование SmartModule в обычный Module, и только потом вызовет встроенную SetDelayed с результатом преобразования, который уже и будет связан с x.

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

То, что здесь определяется именно шаблон, как и прежде, можно определить по знакам подчёркивания после имён «переменных». Тут буквально написано «если мы встретили “SetDelayed”, где в скобках стоит что угодно, что мы дальше будем называть “x”, а дальше после запятой написано “SmartModule”, у которого в скобках написано что угодно, что мы дальше будем называть “body”, то это следует заменить на то, что стоит после “≔”».

Но что же там должно стоять вместо многоточия? Очевидно, тоже SetDelayed, но уже с Module вместо SmartModule. А внутрь этого Module уже должны быть вписаны переменные, которые мы вычленили из body, и само body.

Заметьте, в extractVars, как уже говорилось раньше, передаётся тело, «защищённое» от автоматического вычисления при помощи Hold — ведь нам надо туда передать не результат вычисления, а сам его текст: составное выражение, подобное «y = 1; y + 1», а не результат его вычисления — «2».

Hold@body — чуть более краткая запись для Hold [body].

Однако написанное тут не сработает: сам по себе Module тоже определён так, что принимает в себя параметры в защищённом от вычислений виде, а потому extractVars[Hold@body] не будет вычислен, и прямо в виде текста «extractVars[Hold@body]» попадёт в Module.

Таким образом, нам надо подставить на нужное место извлечённые при помощи extractVars переменные при помощи «ручной замены», то есть операции «/.», где через стрелку сообщается что на что следует заменить.

В знаках доллара нет никакого особого смысла: vars$$ — это обычное имя «переменной». Она так названа просто для того, чтобы её было проще увидеть в данном выражении.

Это, в свою очередь, тоже ещё не финальный вариант, поскольку в данной версии сначала вычислится SetDelayed, и только потом в полученный результат будет что-то подставляться «вручную».

А нам надо, наоборот, сначала подставить, а потом уже вычислить. Поэтому SetDelayed надо защитить от вычисления при помощи Hold, потом сделать ручную подстановку vars$$, а потом снять блокировку при помощи ReleaseHold.

Последний оставшийся тут нюанс: SetDelayed, как и многие другие встроенные функции, сделана «защищённой от изменений» — чтобы случайно не сломать крайне важную практически для всей системы функциональность. Эту защиту можно программно снять, а потом вернуть обратно, однако более удачный вариант — проассоциировать данную замену не с самой SetDelayed, а со SmartModule.

Wolfram, встретив конструкцию вида «f[g]», для того чтобы сделать автоматические преобразования, проверяет, нет ли среди определённых для f преобразований такого, что внутри квадратных скобок стоит символ g. Однако, кроме этого, он ещё проверяет, нет ли для g таких преобразований, что сам этот символ стоит в скобках у f.

Нам это оказывается полезным в том смысле, что SetDelayed защищена, а вот введённый нами SmartModule — нет. Поэтому мы можем связать это преобразование с ним, вместо SetDelayed.

В данном случае «SmartModule /:» как раз это и означает: связать написанное дальше со SmartModule, а не с тем, с чем его надо связывать по умолчанию — с SetDelayed.

Собственно, вот эта конструкция, в совокупности с ранее определённым extractVars — это всё, что надо написать, чтобы SmartModule заработал как надо.

Правда, на данный момент оно заработает как надо только в конструкциях с отложенным присваиванием (SetDelayed), а нам требуется ещё ряд подобных конструкций.

Вдобавок, кроме Module, есть ещё аналогичные конструкции Block и DynamicModule. Block отличается тем, что «не помнит» свои локальные переменные после своего выполнения, в результате чего работает быстрее и тратит меньше памяти, но не позволяет реализовать функции, запоминающие какие-то результаты своих предыдущих запусков. DynamicModule же используется для создания интерактивных интерфейсов внутри блоков.

Разумеется, мы могли бы повторить вышенаписанную эту конструкцию для Block и DynamicModule при помощи копирования через буфер обмена и изменения одних слов на другие, а потом проделать всё то же самое для всех остальных интересующих нас конструкций: с Block, Module и DynamicModule для каждой, — однако такой подход чреват ошибками, затрудняет чтение кода и в целом является примером плохого стиля.

Вместо этого лучше реализовать функции, которые генерируют подобные вышеприведённому выражения по заданному нами шаблону операции, и сами «размножают» их для всех трёх вариантов — Block, Module и DynamicModule.

Заметьте, кстати, если сделать так, то случись обнаружиться непредусмотренному нами сейчас четвёртому виду блока, нам будет достаточно добавить его в единственное место — туда, где делается «размножение», а не копипастить с однотипными модификациями вручную всё то, что мы уже написали для каждой из операций.

Шаблоны операций мы будем задавать в максимально простом виде. Например, для определения «псевдофункции» шаблон бы выглядел как

где под именем «smart» фигурирует то место, на котором должен стоять один из «умных блоков».

«Размножать» генерацию определений операций с «умными блоками» на три случая — Block, Module и DynamicModule — мы будем при помощи вот такого метода:

в котором на место form подставляется шаблон операции, подобный вышеприведённому примеру.

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

Да-да, оно выглядит как C++ или Java, однако это — «синтаксический сахар», при помощи которого достигается лишь внешнее сходство. На самом же деле всё это — одно выражение, пусть и визуально разделённое на три строки.

Ещё ненаписанная функция smartNotation, которая здесь вызывается, имеет три аргумента. Второй и третий — это имя «умного блока» и соответствующее ему имя встроенного блока.

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

Сама же функция smartNotation выглядит следующим образом.

Ранее функции определялись при помощи стрелочки с перпендикулярной чертой, однако здесь мы для тех же целей используем более полную форму, поскольку нам надо определить функцию, у которой все аргументы защищены от автоматических вычислений. Это реализуется при помощи подстановки на место третьего аргумента Function константы HoldAll («не вычислять аргументы»). На месте же второго аргумента Function стоит тело этой функции, завёрнутое в Block.

В первом аргументе Block перечисляются все используемые далее символы с целью сделать их локальными — на тот случай, если в момент вызова этой функции в глобальном контексте уже определены одноимённые символы.

Дальше мы из переданного в качестве аргумента «шаблона» form конструируем левую и правую часть создаваемого нами правила автоматической замены вида

Как уже говорилось, в качестве form будет передаваться что-то типа такого

Поэтому для конструирования source из form нам нужно заменить в нём smart на «SmartModule[body_]» — с поправкой на то, что вместо SmartModule тут должно использоваться имя «умного блока», переданное в качестве аргумента. И, естественно, эта конструкция должна быть защищена от автоматических вычислений.

Эта часть позволит нам из form вида

получить

Для target надо сделать аналогичное, только вместо «умного блока» использовать соответствующий ему встроенный, имя которого тоже передано в качестве аргумента, а вместо «body_» («что угодно, что дальше будет называться “body”») надо использовать «body» (без подчёркивания — то есть «то самое, что мы назвали “body”»).

Правда, не только «шаблон body» надо заменить на «то самое body», а вообще все шаблоны, которые могут встретиться в form. Поэтому после вышенаписанной операции для target ещё делается такая замена.

Это означает «заменить любой шаблон на его первый аргумент — в котором как раз и находится конкретное имя “переменной”». То есть, например, «y_» такой заменой будет превращено в «y».

Таким образом, из, например,

для target будет конструироваться

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

Inactivate заменяет в данном выражении все подвыражения вида f[x] на Inactive[f][x].

Внутри Inactivate написана обобщённая версия того, что раньше делалось для частного случая — SetDelayed

…с поправкой на то, что сюда ещё добавлен Activate — для того, чтобы снять блокировку Inactive с имён переменных, извлечённых extractVars.

Выражение, ранее сохранённое нами в target, должно быть защищёно от автоматических вычислений на время подстановки vars$$. Однако если просто написать Hold[target], то оно в таком виде и останется — на место «target» уже не будет автоматически подставлено его текущее значение. Поэтому вместо этого мы пишем Hold[target$$], а потом на место target$$ «вручную» подставляем ранее вычисленный вариант при помощи «/.».

Именно это делается здесь последней строкой.

Таким образом, для вышеупомянутого примера

получится выражение exp, равное

Это выражение на время конструирования было завёрнуто в Inactivate, поэтому после завершения конструирования мы при помощи Activate снимаем с него блокировку

и в этот момент оно автоматически вычисляется, создавая новое правило замены.

Смысл этого правила

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

При этом после каждой автоматической замены на написанное во вторых круглых скобках оно сразу же начнёт вычисляться.

Cначала оно извлечёт переменные из body — того, что было написано в квадратных скобках SmartModule.

Потом оно подставит этот список на место vars$$ в Module.

Потом оно уберёт отовсюду Inactive при помощи Activate.

Наконец, оно снимет блокировку Hold при помощи ReleaseHold.

Причём, для всего этого будут использоваться не обязательно SmartModule и Module, а любая заданная нами пара — например, SmartBlock и Block.

В результате всего этого, если мы, например, напишем выражение

то оно автоматически превратится в

Нам этого не покажут в явном виде — в самой программе так и останется текст со SmartBlock, однако если мы после выполнения этого текста спросим определение f, то в нём уже будет выражение c Block.

Единственный упущенный пока что момент: выражения внутри умных блоков не должны автоматически вычисляться.

Однако это решается одной строкой:

В ней говорится, что, независимо от того, определены ли уже такие «псевдофункции» или ещё нет (а реально-то мы и не собираемся их определять — вместо этого мы определили преобразования выражений, где упоминаются их имена), их следуется считать такими, для аргументов которых заблокированы автоматические вычисления.

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

Интересно, сколько таких операций? Ранее для примера мы рассматривали операцию «≔». Однако кроме неё есть и обычное «=». Ещё есть определения «настоящих» функций (те, которые со стрелкой) — там тоже может быть «умный блок». Ещё он может быть при определении «псевдофункций» — быстром и отложенном. Ещё может быть определение функции на месте аргумента при вызове какой-то другой функции. Ещё может быть определение на месте аргумента с мгновенным вычислением результата там же. Ещё бывают определения безымянных функций (с решёткой и амперсантом) — там тоже может использоваться «умный блок».

В общем, глаза разбегаются — как бы что-то не упустить.

Тем не менее, несмотря на всё обилие вариантов и несмотря на то, что smartNotations мы определили так, что туда можно подставить любое выражение, для всех случаев жизни нам понадобится лишь единственный «шаблон».

Как уже говорилось выше, любой код на Wolfram представляет собой вложенные «псевдофункции»: даже присваивание к переменной, коим создаётся новое правило, — это псевдофункция «Set» или «SetDelayed». Таким образом, достаточно задать шаблон, в котором «умный блок» стоит на произвольном месте псевдофункции, и тем самым будут охвачены все вышеперечисленные случаи. И неперечисленные тоже.

Правда, для надёжности это единственное правило мы завернём в Block: на тот случай, если используемые в «шаблоне» имена уже где-то использованы, а потому одноимённые «переменные» уже содержат значения.

Написанный тут шаблон

означает: «псевдофункция с каким угодно именем, которое мы дальше будем называть “f”, где сначала идёт сколько угодно аргументов (в том числе, ноль), потом на месте какого-то аргумента стоит „умный блок“, а потом снова сколько угодно аргументов».

То есть, внутри smartNotation это превратится в следующее правило замены.

Поскольку правило всего одно, мы бы могли сократить smartNotation до вот этого текста — с поправкой на то, что вместо SmartModule и Module нам надо подставить переданные в аргументах названия блоков.

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

То есть вместо

писать

Вроде бы пустячок, однако обратите внимание, сколько переменных мы были вынуждены перечислить при реализации smartNotation. А ведь это была, мягко говоря, не самая длинная функция из возможных. Но уже там не только ухудшалась читаемость и вызывалось раздражение необходимостью постоянно возвращаться к началу блока и вписывать туда переменные, но и появлялась почва для ошибок.

Я, конечно, сожалею, что подобные конструкции до сих пор не включены в стандартную поставку и одновременно с тем сам язык Wolfram не решает подобные проблемы простым введением ключевого слова для введения локальных переменных в любом месте блока. Однако, как легко видеть, если понимать концепцию языка (для разъяснения практически всех основ которого этот пример отлично подходит), эту функциональность можно довольно несложно реализовать самостоятельно, написав очень небольшое количество кода. И в этом большое преимущество «метаязыков» программирования — то есть тех языков, в которых программа может генерировать программу же.

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

Поскольку любое выражение языка трактуется не как результат его вычисления, а как, собственно, сам его текст — конструкция из вложенных друг друга «псевдофункций» — в этом тексте при помощи определённых преобразований отыскиваются заданные в них шаблоны, которые заменяются на то, что поставлено им в соответствие, и так до тех пор, пока очередной «проход с заменой» по выражению не оставит выражение нетронутым. На этом этапе мы будем либо иметь финальный результат в виде решения задачи — например, упрощённого выражения или решения уравнения — либо констатацию факта: «задачу теми преобразованиями, которые мы определили, решить невозможно».

Для интересующихся далее приведена вся программа, определяющая «умные блоки», целиком.

Лекс Кравецкий :