Разные мысли о метавычислениях, суперкомпиляции, специализации программ. И об их применениях. Желающие приглашаются в соавторы блога.

понедельник, 29 июня 2009 г.

Проблемно-ориентированные языки и суперкомпиляция

Наконец, наступил подходящий момент, чтобы обсудить связь между языком Рефал, суперкомпиляцией и "проблемно-ориентированными языками" (которые на великом и могучем англосаксонском наречии часто именуют как "domain-specific languages").

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

Чисто условно, первый из подходов я буду называть подходом "первого порядка", поскольку он предполагает, что есть некий язык первого порядка, на котором можно писать интерпретаторы. При этом, программы, подлежащие интерпретации, тоже являются сущностями первого порядка. А как же иначе? Раз интерпретатор написан на языке первого порядка, он, по-определению, может обрабатывать только данные первого порядка (числа, строки, списки, деревья и тому подобное), а функции ему на вход подавать нельзя, ибо они не могут быть значениями.

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

Итак, начнём с языка Рефал. Было время, когда он весьма продуктивно использовался в СССР для "решения задач, имеющих важное народнохозяйственное значение". Прагматично настроенные люди (среди которых был и я) рассматривали Рефал примерно как ишака, на котором можно было ездить и возить воду. Т.е., попросту говоря, использовали Рефал в качестве "обычного" языка программирования.

Однако, в момент своего возникновения, Рефал задумывался не в качестве ишака, а в качестве то ли лебедя, то ли орла, предназначенного для воспарения в высшие сферы. И назывался он более возвышенно и поэтично: "метаалгоритмический язык" (в пику всяким жалким фортранам и алголам, претендовавшим в том время всего-навсего на звание "алгоритмических языков")! Если кто мне не верит, может раскрыть статью 1968 года и посмотреть, что там написано:

  • Турчин В.Ф. Метаалгоритмический язык. — Кибернетика № 4, 1968. С. 116−124. DJVU PDF

Рефал был предназначен не для того, чтобы писать на нём программы напрямую, а для того, чтобы с его помощью реализовывать языки, ориентированные на конкретные классы задач (проблемно-ориентированные языки, 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).

Итак, суперкомпиляция позволяет избавиться от накладных расходов, связанных с интерпретацией, при реализации проблемно-ориентированных языков.

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

  • Подход "первого порядка" заключается в том, что программы на проблемно-ориентированном языке дожны представляться в виде констант первого порядка, а язык - реализовываться с помощью интерпретатора. Накладные расходы на интерпретацию можно устранить, проспециализировав интерпретатор по отношению к проблемно-ориентированной программе. (Эта идея известна под именем "первая проекция Футамуры".)
  • "Комбинаторый" подход заключается в том, что программы на проблемно-ориентированном языке дожны представляться в виде некоторой суперпозиции функций, а язык - реализовываться с помощью набора "комбинаторов", преобразующих одни функции в другие. Накладные расходы, связанные с комбинаторами, можно устранить, просуперкомпилировав программу, в результате чего вызовы комбинаторов исчезают. (Для этой идеи, кажется, ещё не установилось общепринятое название.)

Какой же из двух подходов "лучше"? А кто-ж его знает... Как говорится, "на вкус и цвет - товарищей нет".

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

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

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

  • Хождение по синтаксическому дереву и анализ этого дерева.

  • Работа со средой (поддержание списков переменных и их значений).

  • Деятельность, связанная с организацией управления в интерпретируемой программе (циклы, рекурсия, вызов функций и передача параметров).

  • И, наконец, операции над теми данными (проверки и вычисления), которые должна обрабатывать ПО-программа. Строго говоря, только эти операции и являются "практически полезными". А всё остальное - это "административные накладные расходы".

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

В случае ПО-программы, реализованной через комбинаторы, получается другая картина.

  • Нет надобности работать с абстрактным синтаксисом в явном виде. ПО-программа и так получается читабельной, если язык, на котором пишутся комбинаторы, содержит некоторые "продвинутые" средства (функции высших порядков, инфиксные операторы).

  • Определения комбинаторов содержат очень мало "административных" действий: почти полностью они состоят из операций (проверок и вычислений) над данными, которые обрабатывает ПО-программа.

Отсюда и преимущества "комбинаторного" подхода:

  • Легче реализовать проблемно-ориентированный язык. Если писать интерпретатор первого порядка, то бОльшую часть интерпретатора занимают "административные" действия, а определения комбинаторов - почти полностью состоят из "полезных" действий. Поэтому, текст реализации ПО-языка через комбинаторы получается короче, чем текст соответствующего интерпретатора "первого порядка".

  • Реализация ПО-языка через комбинаторы более проста для понимания. При чтении интерпретатора происходит "раздвоение сознания" из-за того, что "содержательные" действия перемешаны с "административными", и приходится думать одновременно и о тех, и о других. А в случае комбинаторов, приходится разбираться только с "полезными" действиями.

  • Основная трудность при чтении интерпретатора состоит в том, что все его части завязаны в один "тугой узел". Т.е., невозможно понять ни одну из его частей, не разобравшись с остальными. А в случае набора комбинаторов, каждый комбинатор можно изучать отдельно от других, поскольку они связаны между собой, как правило, только через входные/выходные данные (про которые достаточно знать только их типы).

  • Интерпретатор первого порядка трудно расширять из-за сильной взаимосвязи всех его частей. Сделав локальное изменение, легко обрушить всю конструкцию. Следовательно, и ПО-язык, реализуемый этим интерпретатором, трудно расширять. А в случае набора комбинаторов, добавление новых комбинаторов обычно никак не затрагивает старые. Поэтому, и ПО-язык легче поддаётся расширению.

  • При реализации через интерпретатор, ПО-язык предстаёт не как "расширение" языка, на котором написан интерпретатор, а как какое-то "инородное тело". Иногда хочется всего лишь "чуть-чуть расширить" имеющийся язык, дополнительными ПО-средствами, но подход к реализации ПО-расширений через явный интерпретатор требует, чтобы был сразу реализован новый, полномасштабный ПО-язык! А комбинаторный подход позволяет разрабатывать ПО-язык "инкрементно": добавлять новые возможности к имеющемуся языку постепенно и по мере надобности. (И, в этом отношении, комбинаторный подход похож на расширение языка ассемблера с помощью макрокоманд.)

А с точки зрения суперкомпиляции, комбинаторный подход выглядит более привлекательным просто потому, что он "облегчает жизнь" суперкомпилятору. Да просто потому, что интерпретатор первого порядка, помимо "полезных" действий, содержит и массу "административных", которые суперкомпилятору приходится удалять. Получается абсурдная картина: человек старался-старался, писал интерпретатор, реализовывал анализ синтаксиса, работу с переменными, циклами и вызовами функций. И для чего? Только для того, чтобы частичный вычислитель или суперкомпилятор всё это удалил!

Получается, что сначала трудности героически создаются (человеком), а потом - героически преодолеваются (суперкомпилятором). А зачем? Вот основная идея комбинаторного подхода и состоит в том, что "умный в гору не пойдёт, умный гору обойдёт". И от этого становится лучше и программисту, и суперкомпилятору. Но при одном условии: если у суперкомпилятора "хватает мозгов" для работы с функциями высших порядков.

3 комментария:

  1. Отличная статья!
    Интересно мнение автора по поводу Multi-stage programming, в том виде как это сделано в MetaOCaml или Template Haskell. Есть ли связь с суперкомпиляцией и как она выражается?

    ОтветитьУдалить
  2. Начал писать ответ по поводу multi-stage programming, но этот ответ раздулся, и я выставил его в виде отдельного послания в блоге:

    http://metacomputation-ru.blogspot.com/2009/07/multi-stage-programming.html

    ОтветитьУдалить

Постоянные читатели