Наконец, наступил подходящий момент, чтобы обсудить связь между языком Рефал, суперкомпиляцией и "проблемно-ориентированными языками" (которые на великом и могучем англосаксонском наречии часто именуют как "domain-specific languages").
Тема, конечно, необъятная, поэтому я ограничусь рассмотрением только двух подходов к использованию суперкомпиляции для разработки, реализации и использования проблемно-ориентированных языков.
Чисто условно, первый из подходов я буду называть подходом "первого порядка", поскольку он предполагает, что есть некий язык первого порядка, на котором можно писать интерпретаторы. При этом, программы, подлежащие интерпретации, тоже являются сущностями первого порядка. А как же иначе? Раз интерпретатор написан на языке первого порядка, он, по-определению, может обрабатывать только данные первого порядка (числа, строки, списки, деревья и тому подобное), а функции ему на вход подавать нельзя, ибо они не могут быть значениями.
А второй подход я, также чисто условно, буду называть подходом "высшего порядка". А в чём он состоит - увидим по ходу дела.
Итак, начнём с языка Рефал. Было время, когда он весьма продуктивно использовался в СССР для "решения задач, имеющих важное народнохозяйственное значение". Прагматично настроенные люди (среди которых был и я) рассматривали Рефал примерно как ишака, на котором можно было ездить и возить воду. Т.е., попросту говоря, использовали Рефал в качестве "обычного" языка программирования.
Однако, в момент своего возникновения, Рефал задумывался не в качестве ишака, а в качестве то ли лебедя, то ли орла, предназначенного для воспарения в высшие сферы. И назывался он более возвышенно и поэтично: "метаалгоритмический язык" (в пику всяким жалким фортранам и алголам, претендовавшим в том время всего-навсего на звание "алгоритмических языков")! Если кто мне не верит, может раскрыть статью 1968 года и посмотреть, что там написано:
Рефал был предназначен не для того, чтобы писать на нём программы напрямую, а для того, чтобы с его помощью реализовывать языки, ориентированные на конкретные классы задач (проблемно-ориентированные языки, domain-specific languages).
Задумано было хорошо и правильно! Но, увы, в должной мере "процесс" тогда "не пошёл"...
Скорее всего из-за того, что замысел требовал слишком много от тогдашнего "железа". На дворе был 1968 год. В Институт Прикладной Математики привезли новый могучий компьютер БЭСМ-6, который выполнял аж 1 миллион операций в секунду. Памяти у БЭСМ-6 было аж 192 килобайта! И даже кеш памяти был размером в 16 48-битных слов: 4 чтения данных, 4 чтения команд, 8 — буфер записи.
(В скобках замечу, что в моём настольном компьютере, на котором я и сочиняю это послание, в данный момент имеется 4 гигабайта памяти. 4 000 000 / 192 = 20833.)
Вот, именно поэтому, в момент рождения Рефала воспарять было труднее, чем сейчас. Произошло примерно то же, что с самолётом Можайского: концептуально у Можайского всё было правильно. Но пропеллер крутила паровая машина...
Но с тех пор много воды утекло. Когда Рефал только появился, он вызвал у многих откровенный скепсис, переходящий в отвращение и возмущение. Главным образом из-за того, что был слишком не похож на "нормальный" язык. Люди пробовали на нём писать и начинали спрашивать: "а где в Рефале оператор цикла?", "а где в Рефале оператор присваивания?"... А в Рефале ничего этого не было: были только данные в виде деревьев, сопоставление с образцом и рекурсивные функции. Это было чудовищно даже по сравнению с Лиспом. В Лиспе всё же были и предикаты, и условные выражения, и присваивания, и даже goto! Словом, "настоящему" программисту было на что опереться, было где "голову преклонить". А в Рефале у человека последнее отнимали... И приговор у многих был однозначный: "Рефал - плохой язык. Потому что программировать на нём - неудобно!"
А самый убийственный аргумент состоял в том, что "Рефал невозможно эффективно реализовать"... Действительно, если нет "эффективной" реализации ("эффективной" по отношению к "железу", которое было тогда, а не к тому, которое есть "сейчас"), то какое уж тут "воспарение"? О какой реализации проблемно-ориентированных языков с помощью Рефала можно говорить?
И действительно, на изготовления реализации, пригодной для использования "в народном хозяйстве", ушло несколько лет: История Pефал-компилятора.
Впрочем, первоначальная цель, ради которой создавался Рефал, хотя и отодвинулась в тень, никогда полностью не забывалась: Ранняя история суперкомпиляции. Хотя и страшно медленно, некоторое продвижение вперёд в области суперкомпиляции всё же происходило.
Впрочем, на первый взгляд, связь между Рефалом, проблемно-ориентированными языками и суперкомпиляцией не очевидна. Но она существует и заключается в следующем.
Рефал - язык первого порядка. И "классический" подход Турчина состоял в том, что на "метаалгоритмическом языке" нужно писать интерпретаторы, реализующие проблемно-ориентированные языки.
Допустим, есть язык L, который мы хотим реализовать. Пишем на Рефале интерпретатор intL, который берёт на входе программу prog и исходные данные этой программы input и выдаёт результат работы prog для входных данных input. Или, выражаясь более кратко, вычисляем intL(prog,input) и получаем результат применения prog к input.
При этом, теоретически, intL можно написать на любом "алгоритмическом языке": можно на языке ассемблера, можно и на языке машины Тьюринга, а можно и на Рефале. В этом смысле, конечно, Рефал - это просто (ещё один) "алгоритмический язык". Однако, был всё же некоторый резон, что при рождении Рефала он был объявлен "метаалгоритмическим языком"! Когда Рефал появился на свет, приятных и удобных языков для быстрого написания интерпретаторов просто не существовало! Ну, во всяком случае, на Рефале интерпретатор писался и отлаживался в 10 (а то и в 100) раз быстрее, чем на чём-либо другом.
Но сейчас, конечно, ситуация уже не та. Сегодня Рефал уже не воспринимается как что-то диковинное. Все новаторские идеи, которые когда-то были в него заложены, сегодня растащены по нескольким другим хорошо известным языкам. Например, рефальские структуры данных (не бинарные, как в Лиспе, а произвольные деревья) - это xml (который можно обрабатывать с помощью xslt). Сопоставление с образцом и рекурсивные функции - это Standard ML (SML) и Haskell. Так что, можно с полным основанием утверждать, что идеи, на которых был основан Рефал были правильными и здравыми, поскольку они выжили, победили, и превратились нечто "очевидное" и "тривиальное". :-)
Вот и HLL, входной язык суперкомпилятора HOSC, по сути, включает Рефал в качестве подмножества: есть в нём и деревья, в качестве данных, и сопоставление с образцом, и рекурсивные функции. Правда, как объясняется в послании Суперкомпиляция функций высших порядков, есть и кое-что ещё, поэтому этот язык можно считать (если нравится) и подмножеством Хаскеля.
Попробуем-ка реализовать на HLL интерпретатор какого-нибудь проблемно-ориентированного языка!
В этом месте я призадумался... Извечная проблема всех программистов состоит в том, что трудно излагать какие-то абстрактные идеи, не приводя конкретных примеров их применения. А когда программист пытается придумать пример, он "попадает в клещи": если придумать "реалистичный" пример, то он получается таких размеров, что не влезает ни в статью, ни в слайды. А если пример маленький, дегенеративный, то в нём и смысла никакого нет: он получается слишком "тривиальным" и "нереалистичным".
В конце-концов, остановился на следующем примере, который постарался минимизировать, но не настолько, чтобы он обессмыслился.
Допустим, нам хочется реализовать проблемно-ориентированный язык, предназначенный для распознавания регулярных выражений. Будем, для краткости, называть его языком R. И реализуем в виде интерпретатора intR, который будет принимать на входе программу на языке R и входную цепочку, которую нужно проанализировать. Другими словами, вызов интерпретатора будет выглядеть так:
intR r i
где r - программа на языке R, а i - входная цепочка. Задача интерпретатора - "откусить" от i начало, удовлетворяющее программе r. В случае успеха, intR будет выдавать Some j, где j - остаток входной цепочки, а в случае неуспеха intR будет выдавать None.
Теперь нам нужно конкретизировать, что должны из себя представлять r и i? Будем считать, что на вход подаётся цепочка из букв A, B и С, завершающаяся "признаком конца файла" Eof. На языке HLL совокупность таких цепочек описывается с помощью объявления типа данных Input:
data Input = Eof | A Input | B Input | C Input;
А множество допустимых программ на языке R опишем с помощью такого типа данных:
data R = Seq R R | Alt R R | Opt R | Rep R | IsA | IsB | IsC;
Смысл конструкций языка R следующий:
- Seq r1 r2. Попытаться отщепить от цепочки начальный кусок с помощью r1. Если получится - попытаться отщепить от остатка начальный кусок с помощью r2.
- Alt r1 r2. Попытаться отщепить от цепочки начальный кусок с помощью r1. Если не получится - попытаться отщепить от той же цепочки начальный кусок с помощью r2.
- Opt r1. Попытаться отщепить от цепочки начальный кусок с помощью r1. Если не получится, отщепить пустую цепочку.
- Rep r1. Попытаться отщепить от цепочки начальный кусок с помощью r1 максимально возможное число раз.
- IsA, IsB, IsC. Попытаться отщепить от цепочки A, B или C соответственно.
Для выдачи результатов из интерпретатора нам также понадобится тип данных Option:
data Option a = None | Some a;
Теперь мы можем записать на языке R, например, такую программу:
Seq IsA (Rep (Alt IsA IsB))
соответствующую, в более привычных обозначениях, регулярному выражению A (A | B)*. Т.е. входная цепочка должна начинаться с A, а потом должна следовать цепочка, состоящая только из A и В.
Теперь можно написать функцию, реализующую интерпретатор intR. Вызов этой функции, как уже говорилось, должен иметь вид intR r i. Понятно, что intR должен анализировать r, и для каждой разновидности r должна быть предусмотрена соответствующая обработка. Поэтому, intR имеет следующую структуру:
intR = \r i -> case r of { Seq r1 r2 -> ...; Alt r1 r2 -> ...; Opt r1 -> ...; Rep r1 -> ...; IsA -> ...; IsB -> ...; IsC -> ...; };
где внутри многоточий интерпретатор что-то делает и, если надо, рекурсивно вызывает сам себя.
Дописав недостающие части интерпретатора и собрав все куски вместе, получаем такую программу на языке HLL:
data Input = Eof | A Input | B Input | C Input; data R = Seq R R | Alt R R | Opt R | Rep R | IsA | IsB | IsC; data Option a = None | Some a; intR (Seq IsA (Rep (Alt IsA IsB))) i where intR = \r i -> case r of { Seq r1 r2 -> case intR r1 i of { None -> None; Some j -> intR r2 j; }; Alt r1 r2 -> case intR r1 i of { None -> intR r2 i; Some j -> Some j; }; Opt r1 -> case intR r1 i of { None -> Some i; Some j -> Some j; }; Rep r1 -> case intR r1 i of { None -> Some i; Some j -> intR (Rep r1) j; }; IsA -> case i of { Eof -> None; A j -> Some j; B j -> None; C j -> None; }; IsB -> case i of { Eof -> None; A j -> None; B j -> Some j; C j -> None; }; IsC -> case i of { Eof -> None; A j -> None; B j -> None; C j -> Some j; }; };
В этой программе перед ключевым словом where находится "целевое" выражение
intR (Seq IsA (Rep (Alt IsA IsB))) i
содержащее одну свободную переменную i, через которую и передаются исходные данные в HLL-программу. А исполнение программы сводится к вычислению "целевого" выражения.
Теперь засовываем всю программу в суперкомпилятор HOSC и получаем такую остаточную программу:
data Input = Eof | A Input | B Input | C Input; data R = Seq R R | Alt R R | Opt R | Rep R | IsA | IsB | IsC ; data Option a = None | Some a; case i of { Eof -> None; A y2 -> (letrec f=(\s16-> case s16 of { Eof -> (Some Eof); A u12 -> (f u12); B x16 -> (f x16); C x15 -> (Some (C x15)); }) in (f y2)); B x2 -> None; C v1 -> None; }
Всё без обмана! Кто не верит, может посмотреть этот пример "вживую": Parsing (first order).
Хорошо видно, что, в результате суперкомпиляции, и исходная программа на языке R, и интерпретатор intR как бы "растворились" в остаточной программе. Из программы на языке R получилась программа на языке HLL, которая напрямую делает "то, что надо".
"Научно" это можно сформулировать так: "остаточная программа, которая получается в результате специализации интерпретатора по отношению к конкретной программе по существу является результатом компиляции этой программы на язык, на котором написан интерпретатор". Этот приём был открыт Футамурой (и, независимо от него, Турчиным) и в настоящее время его чаще всего называют "первой проекцией Футамуры". Подробнее я об этом писал в послании Ранняя история суперкомпиляции.
А применительно к проблемно-ориентированным языкам вывод такой: суперкомпиляция даёт возможность реализовывать объектно-ориентированные языки с помощью интерпретаторов, а потом удалять "слой интерпретации" с помощью суперкомпиляции.
Зафиксировав это приятный факт, переходим к "следующему пункту цирковой программы": использованию функций высших порядков для реализации объектно-ориентированных языков.
Этот подход основан на использовании "комбинаторов": функций, которые берут одну или несколько функций в качестве аргументов и вырабатывают новую функцию в качестве результата.
Попробуем применить комбинаторы для анализа регулярных выражений. Для этого введём понятие "парсера". А именно, будем называть "парсером" любую функцию p, берущую на входе цепочку i типа Input
data Input = Eof | A Input | B Input | C Input;
и возвращающую либо None (что означает, что парсер "отвергает" цепочку), либо Some j, где j - "хвост" цепочки i (что означает, что парсер "откусил" от i начальный участок).
Кстати, интерпретатор IntR, с формальной точки зрения, можно было бы рассматривать как "преобразователь программ на языке R в парсеры", считая, что intR принимает два аргумента r и i не сразу, а по-очереди. Т.е., сначала вычисляем intR r и получается парсер, который потом и применяем к i. Формально-то это, конечно, так, на на самом-то деле intR устроен так, что он ничего даже и не начинает вычислять до тех пор, пока не получит оба аргумента!
А в случае "комбинаторного" подхода идея заключается в том, что можно работать с парсерами напрямую: реализовать несколько простейших парсеров, а потом собирать более сложные парсеры из более простых с помощью "комбинаторов".
Другими словами, в случае подхода "первого порядка" у нас был язык, в котором было несколько синтаксических конструкций, и эти конструкции позволяли собирать новые программы, исходя из более простых. Например, в языке R была конструкция Alt, которая из двух программ r1 и r2 делала новую программу Alt r1 r2. Т.е. одни сущности первого порядка преобразовывались в другие сущности первого порядка.
А в случае "комбинаторного" подхода идея заключается в том, что синтаксические конструкции можно заменить на комбинаторы, преобразующие одни функции в другие. В частности, языку R соответствует такой набор комбинаторов:
- seq r1 r2. Пытается отщепить от цепочки начальный кусок с помощью r1. Если получилось - пытается отщепить от остатка начальный кусок с помощью r2.
- alt r1 r2. Пытается отщепить от цепочки начальный кусок с помощью r1. Если не получилось - пытается отщепить от той же цепочки начальный кусок с помощью r2.
- opt r1. Пытается отщепить от цепочки начальный кусок с помощью r1. Если не получилось, отщепляет пустую цепочку.
- rep r1. Пытается отщепить от цепочки начальный кусок с помощью r1 максимально возможное число раз.
- isA, isB, isC. Пытается отщепить от цепочки A, B или C соответственно.
Например, seq реализуется следующим образом:
seq = \p1 p2 i -> case p1 i of { None -> None; Some j -> p2 j; };
При этом, вызов интерпретатора intR
intR (Seq IsA (Rep (Alt IsA IsB))) i
превращается в
seq isA (rep (alt isA isB)) i
Теперь мы можем переписать всю программу в духе "высшего порядка", рассыпав интерпретатор intR на отдельные комбинаторы. Получается
data Input = Eof | A Input | B Input | C Input; data Option a = None | Some a; seq isA (rep (alt isA isB)) i where seq = \p1 p2 i -> case p1 i of { None -> None; Some j -> p2 j; }; alt = \p1 p2 i -> case p1 i of { None -> p2 i; Some j -> Some j; }; opt = \p i -> case p i of { None -> Some i; Some j -> Some j; }; rep = \p i -> case p i of { None -> Some i; Some j -> rep p j; }; isA = \i -> case i of { Eof -> None; A j -> Some j; B j -> None; C j -> None; }; isB = \i -> case i of { Eof -> None; A j -> None; B j -> Some j; C j -> None; };
Засовываем эту программу в суперкомпилятор HOSC и получаем остаточную программу
data Input = Eof | A Input | B Input | C Input; data Option a = None | Some a;
case i of { Eof -> None; A v1 -> (letrec f=(\y3-> case y3 of { Eof -> (Some Eof); A s2 -> (f s2); B u -> (f u); C t -> (Some (C t)); }) in (f v1)); B r -> None; C x1 -> None; }
И эта программа (с точностью до имён переменных) совпадает с остаточной программой, которая получилась в результате специализации интерпретатора intR по отношению к R-программе! "Вживую" этот пример можно посмотреть здесь: Parsing (higher order = combinators).
Итак, суперкомпиляция позволяет избавиться от накладных расходов, связанных с интерпретацией, при реализации проблемно-ориентированных языков.
Причём, при наличии суперкомпилятора, способного работать с функциями высших порядков, можно использовать два подхода:
- Подход "первого порядка" заключается в том, что программы на проблемно-ориентированном языке дожны представляться в виде констант первого порядка, а язык - реализовываться с помощью интерпретатора. Накладные расходы на интерпретацию можно устранить, проспециализировав интерпретатор по отношению к проблемно-ориентированной программе. (Эта идея известна под именем "первая проекция Футамуры".)
- "Комбинаторый" подход заключается в том, что программы на проблемно-ориентированном языке дожны представляться в виде некоторой суперпозиции функций, а язык - реализовываться с помощью набора "комбинаторов", преобразующих одни функции в другие. Накладные расходы, связанные с комбинаторами, можно устранить, просуперкомпилировав программу, в результате чего вызовы комбинаторов исчезают. (Для этой идеи, кажется, ещё не установилось общепринятое название.)
Какой же из двух подходов "лучше"? А кто-ж его знает... Как говорится, "на вкус и цвет - товарищей нет".
Но мне лично больше нравится комбинаторный подход, и аргументы в его пользу я сейчас попытаюсь изложить.
В случае интерпретатора первого порядка, приходится иметь дело как с синтаксисом, так и с семантикой интерпретируемого языка. Во-первых, только в случае совсем простых проблемно-ориентированных языков (ПО-языков) можно работать с исходной программой напрямую. Если ПО-язык не совсем тривиален, программу надо сначала подвергнуть лексическому и синтаксическому анализу и преобразовать её в дерево абстрактного синтаксиса, с которым уже и имеет дело интерпретатор.
Поэтому, в нетривиальных случаях, на вход интерпретатора первого порядка подаётся ПО-программа в виде синтаксического дерева и исходные данные для этой программы. Если изучить такой интерпретатор, то обнаруживается, что он выполняет следующие действия:
Хождение по синтаксическому дереву и анализ этого дерева.
Работа со средой (поддержание списков переменных и их значений).
Деятельность, связанная с организацией управления в интерпретируемой программе (циклы, рекурсия, вызов функций и передача параметров).
И, наконец, операции над теми данными (проверки и вычисления), которые должна обрабатывать ПО-программа. Строго говоря, только эти операции и являются "практически полезными". А всё остальное - это "административные накладные расходы".
С помощью проекций Футамуры можно убрать слой интерпретации, так, чтобы остались только "полезные" операции над данными.
В случае ПО-программы, реализованной через комбинаторы, получается другая картина.
Нет надобности работать с абстрактным синтаксисом в явном виде. ПО-программа и так получается читабельной, если язык, на котором пишутся комбинаторы, содержит некоторые "продвинутые" средства (функции высших порядков, инфиксные операторы).
Определения комбинаторов содержат очень мало "административных" действий: почти полностью они состоят из операций (проверок и вычислений) над данными, которые обрабатывает ПО-программа.
Отсюда и преимущества "комбинаторного" подхода:
Легче реализовать проблемно-ориентированный язык. Если писать интерпретатор первого порядка, то бОльшую часть интерпретатора занимают "административные" действия, а определения комбинаторов - почти полностью состоят из "полезных" действий. Поэтому, текст реализации ПО-языка через комбинаторы получается короче, чем текст соответствующего интерпретатора "первого порядка".
Реализация ПО-языка через комбинаторы более проста для понимания. При чтении интерпретатора происходит "раздвоение сознания" из-за того, что "содержательные" действия перемешаны с "административными", и приходится думать одновременно и о тех, и о других. А в случае комбинаторов, приходится разбираться только с "полезными" действиями.
Основная трудность при чтении интерпретатора состоит в том, что все его части завязаны в один "тугой узел". Т.е., невозможно понять ни одну из его частей, не разобравшись с остальными. А в случае набора комбинаторов, каждый комбинатор можно изучать отдельно от других, поскольку они связаны между собой, как правило, только через входные/выходные данные (про которые достаточно знать только их типы).
Интерпретатор первого порядка трудно расширять из-за сильной взаимосвязи всех его частей. Сделав локальное изменение, легко обрушить всю конструкцию. Следовательно, и ПО-язык, реализуемый этим интерпретатором, трудно расширять. А в случае набора комбинаторов, добавление новых комбинаторов обычно никак не затрагивает старые. Поэтому, и ПО-язык легче поддаётся расширению.
При реализации через интерпретатор, ПО-язык предстаёт не как "расширение" языка, на котором написан интерпретатор, а как какое-то "инородное тело". Иногда хочется всего лишь "чуть-чуть расширить" имеющийся язык, дополнительными ПО-средствами, но подход к реализации ПО-расширений через явный интерпретатор требует, чтобы был сразу реализован новый, полномасштабный ПО-язык! А комбинаторный подход позволяет разрабатывать ПО-язык "инкрементно": добавлять новые возможности к имеющемуся языку постепенно и по мере надобности. (И, в этом отношении, комбинаторный подход похож на расширение языка ассемблера с помощью макрокоманд.)
А с точки зрения суперкомпиляции, комбинаторный подход выглядит более привлекательным просто потому, что он "облегчает жизнь" суперкомпилятору. Да просто потому, что интерпретатор первого порядка, помимо "полезных" действий, содержит и массу "административных", которые суперкомпилятору приходится удалять. Получается абсурдная картина: человек старался-старался, писал интерпретатор, реализовывал анализ синтаксиса, работу с переменными, циклами и вызовами функций. И для чего? Только для того, чтобы частичный вычислитель или суперкомпилятор всё это удалил!
Получается, что сначала трудности героически создаются (человеком), а потом - героически преодолеваются (суперкомпилятором). А зачем? Вот основная идея комбинаторного подхода и состоит в том, что "умный в гору не пойдёт, умный гору обойдёт". И от этого становится лучше и программисту, и суперкомпилятору. Но при одном условии: если у суперкомпилятора "хватает мозгов" для работы с функциями высших порядков.
Спасибо!
ОтветитьУдалитьОтличная статья!
ОтветитьУдалитьИнтересно мнение автора по поводу Multi-stage programming, в том виде как это сделано в MetaOCaml или Template Haskell. Есть ли связь с суперкомпиляцией и как она выражается?
Начал писать ответ по поводу multi-stage programming, но этот ответ раздулся, и я выставил его в виде отдельного послания в блоге:
ОтветитьУдалитьhttp://metacomputation-ru.blogspot.com/2009/07/multi-stage-programming.html