AWL (Another Web Language) — новый экспериментальный язык прикладного программирования, разрабатываемый автором статьи при участии еще нескольких специалистов. Язык ориентирован на широкий круг задач — от математических вычислений и обработки текстовой информации до графических и веб-приложений, что и определило его название. В плане идей AWL, заимствовавший многое из таких популярных языков, как LISP, Perl, Ruby, Python, Tcl/Tk, находится на стыке трех парадигм: процедурного, функционального и объектно-ориентированного программирования. Язык разрабатывается как аппаратно- и системно-независимый: хотя в настоящее время графический интерфейс реализован лишь для ОС Windows, его перенос в другие оконные среды (например, в Unix/X11 или Mac OS) не потребует больших усилий. Переносимость языка проявляется и в том, что интерактивные интерфейсные компоненты (например, Java-аплеты) могут выполняться и как независимые (stand-alone) приложения, и как элементы более сложных структур, таких как веб-страницы. При этом для программ на AWL (в отличие от программ на C++, Java или C#) не требуется явной компиляции: язык, работая как интерпретатор (фактически как псевдокомпилятор), максимально упрощает технологию разработки и отладки программ (и даже может использоваться, например, в качестве мощного интерактивного калькулятора).
На официальном сайте проекта (http://awl-project.narod.ru) доступна последняя версия интерпретатора AWL (на момент написания статьи — 0.5.4, версия 0.5.5 готовится к выходу); там же можно найти документацию и большое количество демонстрационных AWL-программ. Данная статья поможет читателю получить представление об основных идеях и возможностях языка, достаточных для создания простых консольных приложений (отложим рассмотрение средств оконного и графического программирования до следующих публикаций). Более детальное описание можно найти в руководстве по языку (http://awl-project.narod.ru/AWL-doc.html или, в формате PDF, http://awl-project.narod.ru/AWL-doc.pdf ).
Типы данных и операции
Если в большинстве языков в качестве элементов структуры программы различаются операции, функции и операторы (инструкции), то в AWL все перечисленное заменено одним базовым понятием: функциональный оператор (или функтор). Выполняя вычисление над своими операндами и возвращая его результат (как операция или функция), функтор способен иметь и побочный эффект выполнения (как инструкция). С технической точки зрения реализация языка практически полностью состоит из функторов, напоминая этим такие языки, как LISP или Forth.
Один из фундаментальных типов данных языка — числовой. Фактически есть целый и вещественный подтипы, и обычно они неявно приводимы друг к другу. Все операции над числами реализуют встроенные в язык функторы (примитивы). Например, результатом add (x, y) является сумма результатов x и y; mul (x, y) — произведение x и y; min (x, y), max (x, y) — арифметические минимум и максимум от x и y; sin (x) и cos (x) — значения синуса и косинуса от x радиан и т. д. Конечно, записывать сложные выражения в чисто функциональном стиле (как принято в LISP) неудобно, поэтому для многих примитивных функторов допустима более традиционная запись (операторная), где используются привычные унарные и бинарные операции и система приоритетов. Например, add (x, y) может быть записано как x + y, sub (x, y) — как x – y, mul (x, y) — как x * y, и т. п. Таким образом, выражение mul (add (a, 2), sub (b, 3) равносильно (a + 2) * (b – 3). Последняя нотация, конечно, удобнее, но при компиляции она все равно автоматически преобразуется во внутреннюю (функциональную) форму. Какая именно форма использовалась при вводе выражений, при выполнении несущественно. Помимо простейших арифметических, к числовым операциям относятся операции сравнения (возвращающие 0 или 1 в зависимости от условия), битовые операции и сдвиги, основные математические функции. Для многих из них есть равносильная операторная форма.
Не менее важен строковый тип. Для символьных строк в языке предусмотрен большой набор функторов-примитивов, многие из которых имеют и операторную форму записи. Так, s_len (s) (или #$ s) возвращает длину строки s; s_cat (s, t) (или s +$ t) — конкатенацию строк s и t; s_rep (s, N) (или s *$ N) — строку s, сцепленную с собой N раз, и т. д. Строки можно сравнивать: например s_lt (s, t) (или s <$ t) возвращает 1, если s алфавитно до t, и 0 в противном случае. (Заметим, что все строковые операции имеют суффикс $, однозначно отличающий их от числовых). Есть примитивы, позволяющие получить отрезок строки (s_slice), выполнять в строке поиск (s_findfirst/s_findlast) и пр. В последних версиях языка предусмотрена экспериментальная поддержка Unicode-строк.
Важное свойство строк — их неизменяемость (иммутабельность): нельзя изменить содержимое существующей строки, но можно создать новую, в том числе и из фрагментов существующих. Между строковыми и числовыми операндами возможны неявные преобразования (приведения): когда функтору требуется строковый операнд, число неявно преобразуется в свое строковое представление, и наоборот — строка может неявно распознаваться как число.
Числа и строки относятся к простейшим (бесструктурным) данным — скалярам. Но AWL работает и со сложными типами. Важнейшим структурным типом данных является список: упорядоченный набор элементов, каждый из которых, в свою очередь, может иметь произвольный тип. Например, список (10, 20.78, «aab», «cdd») содержит четыре элемента: два числа и две строки. Элементом списка также бывает и другой список: списки могут быть вложены на неограниченную глубину. Пустой список () представляет отсутствие значения в AWL (как nil в LISP; иногда называется undef). Все функторы, требующие более одного аргумента, фактически имеют аргумент-список. Заметим, что если в обычных (так называемых замкнутых) списках последний элемент также список, то он неявно становится продолжением начального списка, т. е. списки (10, (20, 30)) и (10, 20, 30) эквивалентны. Для случаев, когда важно, чтобы последний элемент списка сохранил структуру, применяется специальная (открытая) форма списков, например (10, (20, 30),) содержит два элемента: 10 и (20, 30) (фактически последним элементом является undef, но большинство списковых операций его игнорируют). В тех случаях, когда элементы списка — синтаксически примитивные выражения (например, числовые и строковые литералы или другие списки), допускается более компактная запись, с квадратными скобками вместо круглых и без явных разделителей (список, приведенный выше, мог быть записан в виде [10 20.78 «aab» «cdd»].)
Переменные
Переменные в языке не требуют явного объявления (если в каком-либо выражении вы используете идентификатор, автоматически создается глобальная переменная с таким именем) и могут содержать значения любого типа, так как в языке нет жесткой типизации. Простейший способ изменить значение переменной — присваивание: при выполнении выражения var = expr (синоним для set (var, expr)) выражение expr вычисляется и результат присваивается переменной. Так же как в C++ и Java, для числовых значений определены операции инкремента (++var, var++) и декремента (– –var, var– –) — их функциональные синонимы inc (var), inc_p (var), dec (var), dec_p (var). Операция обмена var_a:=: var_b (синоним swap (var_a, var_b)) меняет местами значения var_x и var_y.
Все эти операции объединяет то, что они изменяют значения своих операндов (такие операции называются мутаторами) и требуют, чтобы соответствующий операнд был мутабелен, т. е. доступен изменению. Например, все переменные мутабельны — но это далеко не единственный вид мутабельных выражений. Списки (в отличие от строк!) также мутабельны: их содержимое можно изменять. Например, операция L [N] (синоним l_item (N, L)) дает доступ к N-му (считая от 0) элементу списка L. Результат этой операции мутабелен, т. е. ее можно использовать в операциях-мутаторах, например, ++ L [I] увеличивает значение элемента I списка L. Обычно списки рассматриваются как линейные последовательности, но возможен и альтернативный взгляд на них как на бинарные деревья. Любой список состоит из «головы» (первого элемента) и «хвоста» (всех следующих элементов). Функторы l_head (L) и l_tail (L) возвращают ссылки на «голову» и «хвост» списка L (аналогично car и cdr в LISP), их результаты также мутабельны, поэтому список может быть изменен не только поэлементно, но и путем отсечения или добавления целых «ветвей» дерева. Для списков определено множество других операций, в частности длина (l_len), конкатенация (l_cat), репликация (l_rep) и реверсия (l_rev). Также есть операции преобразования списков, поиска в них по заданному критерию, сортировки и т. п. Причем все эти операции рассматривают скаляры как списки длины 1, а undef — как список длины 0.
Наконец заметим, что присваивание применимо не только к скалярам. Если операндами присваивания являются списки, то их присваивание происходит поэлементно, например, [a b c] = (‘x’, ‘y’, ‘z’) равносильно выполнению a=’x’, b=’y’, c=’z’. При списковом присваивании копирование значений выполняется синхронно. Так, выражение [a b c] = [b c a] «циклически вращает» значения переменных a b и c, и при этом ни одно из значений не теряется.
Конечно, в языке есть операции ввода-вывода. Они используют данные еще одного типа — потоки, позволяющие осуществлять обмен с файлами и внешними устройствами. Вот две из самых употребительных операций: Out <: (expr1, expr2,…) выводит значения выражений expr1, expr2… в выходной поток Out; In:> (var1, var2,…) вводит (построчно) значения переменных var1, var2… из входного потока In. (Если Out и In опущены, подразумеваются стандартные вывод и ввод.)
Условия и циклы
У всех рассмотренных выше операций-функторов есть одна особенность: они вычисляют свои операнды до выполнения, работая с их результатами. Однако в общем случае передача аргумента функтору не означает его автоматического вычисления/выполнения. Важную роль в языке играют следующие функторы: вычисляющие операнд только в отдельных случаях (условные операции); вычисляющие его многократно (циклические операции, или итераторы); вычисляющие операнд в сопровождении каких-либо дополнительных действий (оболочки).
Так, примитив if (condition, do_then, do_else) реализует основную форму условного выполнения: если условие condition истинно, вычисляется операнд do_then, иначе вычисляется do_else (и возвращается значение вычисленного выражения). Имеется более компактная запись, как в Cи и Java: condition? do_then: do_else. В AWL нет отдельного логического типа: undef, 0 или «» считаются ложью; остальные значения — истиной. Примитив while (condition, do_loop) (для него также есть компактная запись: condition?? all do_loop) реализует цикл с предусловием: вычисляет условие condition многократно и, пока оно истинно, вычисляет тело цикла do_loop, а его последнее вычисленное значение будет результатом.
Примитивы unless и until аналогичны if и while, но они проверяют условие на ложность вместо истинности, имеются также циклы с постусловием: do_while и do_until. Вот еще несколько примитивных итераторов: for_inc/for_dec реализуют перебор последовательности целых чисел по возрастанию/убыванию (например, for_inc (I, 10.. 20, loop) выполняет loop для всех значений I от 10 до 19), а примитив l_loop очень удобен для прохода по списку — l_loop (v, List, loop) выполняет loop для каждого элемента списка List, присваивая при этом v значения элементов.
Для группировки последовательных вычислений удобно использовать блоки. Блок {expr1; expr2;… exprN} последовательно вычисляет или выполняет операции expr1… exprN, возвращая значение последнего выражения как результат блока. Например, если надо ввести значения переменных a и b и присвоить их произведение c, это можно сделать одним выражением: с = {:> a; a} * {:> b; b}. Заметим, что ‘;’ является разделителем элементов блока — если точка с запятой следует за последним элементом блока, возвращаемым значением будет undef.
Условные и циклические функторы неявно используют механику так называемых «ленивых», или отложенных, вычислений. В языке есть средства, позволяющие прибегать к ним явным образом. Например, унарная операция ‘@’ подавляет вычисление своего операнда (аналогично LISP-функции quote): выполнение xx = @ (2*a + 3*b) присваивает переменной xx само выражение add (mul (2, a), mul (3, b)) вместо результата его вычисления. Обратная операция ‘^’ обеспечивает вычисление «замороженных» выражений, т. е. если a==30 и b==50, то результатом ^xx станет 210. Отметим, что «ленивость» вычислений в AWL сочетается с их явным характером (в отличие от функциональных языков типа Haskell, где принципиальная недетерминированность порядка вычислений приводит ко многим проблемам). В AWL даже есть механизмы, позволяющие динамически конструировать выражения (их не рассматриваем здесь из-за недостатка места).
Подробнее о функторах
Язык вряд ли был бы полноценным, если бы пользователь не мог создавать собственные функторы. Общий синтаксис определения пользовательского функтора таков:
! myfunc (par1 par2… parN): [loc1 loc2… locM] = body;
Новый функтор myfunc имеет N параметров (par1…parN) и M локальных переменных (loc1…locM). Каждый из списков необязателен и может быть полностью опущен. При вызове функтора — myfunc (arg1,…, argN) — значения параметров par1…parN инициализируются аргументами arg1…argN, после чего вычисляется и возвращается значение тела функтора body (им может быть произвольное выражение, обычно блок). Имена параметров и переменных локальны для функтора, т. е., если они совпадают с именами внешних переменных, на последних это никак не отразится. Допустимы и локальные функторы, т. е. объявление одних функторов внутри других.
Например, функтор hypot возвращает гипотенузу треугольника с катетами x и y:
! hypot (x y) = sqr (x*x + y*y);
В AWL есть встроенный функтор rad (x, y), выполняющий аналогичную операцию. Более сложный пример, когда сумма всех элементов списка List использует итератор l_loop:
! list_sum (List): [S elem] = {
S = 0;
l_loop (elem, List, S = S + elem);
S};
Результатом вычисления функтора может быть не только скаляр, но и любая структура данных. Например, transform (x, y) возвращает результат линейного преобразования вектора (X, Y) в виде списка (коэффициенты преобразования xX, xY, x0, yX, yY, y0 определены глобально):
! transform (X Y) = (X*xX + Y*xY + x0, X*yX + Y*yY + y0);
Конечно, функторы могут быть рекурсивными. Вот пара очевидных примеров, где fact (n) вычисляет n!, comb (n, m) — биномиальный коэффициент С (n, m).
! fact (n) = n? n*fact (n–1): 1;
! comb (n m) = 0 < m && m < n? comb (n–1, m) + comb (n–1, m–1): 1;
Наконец, очень важно то, что, хотя по умолчанию параметры функторов неявно вычисляются при вызове, можно отключить их вычисление, поставив в списке перед параметром ‘@’. В этом случае происходит «ленивая» передача параметра (как и во встроенных условных и итеративных функторах), а ответственность за вычисление значения перекладывается на сам функтор. Этот механизм является мощным средством, позволяющим программисту легко расширить набор управляющих функторов языка. Например, в AWL отсутствует итератор, похожий на for в C++ или Java, но его легко определить, допустим, так:
! c_for (@init @cond @iter @body) = {^init; while (^cond, {^body; ^iter});};
Теперь, например, c_for (I = 1, I < 10, ++ I, <: I) выведет все цифры от 1 до 9 (хотя в этом случае проще использовать for_inc (I, 1.. 10, <: I)).
Кроме того, ссылки на функторы — еще один полноправный тип данных. Их можно присваивать переменным, передавать другим функторам как параметры или возвращать из них как значения. Выражение fref =!myfunc присваивет переменной fref ссылку на myfunc, а apply (fref, args), что обычно сокращается до fref! args, вызывает функтор, на который ссылается fref, c аргументами args. (Например, выражение (x < PI?!sin:!cos)! (x) возвращает sin (x) или cos (x) в зависимости от результата сравнения x с PI.) Ряд встроенных функторов имеют функторы-аргументы: l_map (funcref, list) применяет функтор funcref к каждому элементу списка list, возвращая список результатов. Так, l_map (!exp, list) возвращает список значений экспонент от элементов list, l_map (! (x) = (2*x – 1), list) — список значений 2*x – 1 (используя анонимный функтор, аналогичный lambda-определениям в LISP или Python).
Кратко перечислим другие типы данных, доступные в языке. Массивы в AWL в общем случае являются многомерными и могут содержать элементы произвольного типа. Массив обычно создается обращением к примитиву array — array (Dim1,… DimN) возвращает новый N-мерный массив с размерами Dim1…DimN. Доступ к элементу массива array c индексами Index1…IndexN можно получить с помощью arr {Index1,… IndexN}. Словари (или хеши) — это наборы пар «ключ—значение», доступных по ключу, которым может быть выражение произвольного типа. Примитив hash () создает пустой хеш; h_elem (Hash, key) обеспечивает доступ к элементу с ключом key из hash (отсутствующие ключи создаются автоматически). Наконец, в языке есть и образцы — расширенный аналог регулярных выражений Perl или Ruby с добавлением некоторых идей из SNOBOL. Образцы создаются из строк и операций-конструкторов. Например, образец rx_cat (rx_alt («a», «b»), rx_alt («c», «d»)) успешно сопоставляется со строками символов «ac», «ad», «bc» и «bd».
Объекты и классы
Важнейшим механизмом пользовательских типов данных являются объекты. Описание класса должно предшествовать созданию его экземпляров и в простейшем случае выглядит так:
! all myClass (par1… parM): [mem1… memN] {decl1,…, declP};
Оно очень похоже на описание функтора (что неслучайно). Новый класс myClass имеет локальные компоненты — параметрические par1…parN и неинициализируемые mem1…memN, а также набор внутренних деклараций (decl1…declP), обычно описывающих локальные для класса функторы (методы), но допускающих и определение вложенных классов. Каждый класс также есть разновидность функтора: обращение к нему создает новый объект (экземпляр) класса со значениями параметров, заданными аргументами. Вот пример простого класса, реализующего комплексные числа (упрощенная версия библиотечного класса complex):
! all complex (Re Im) {
! abs = sqr (Re*Re + Im*Im),
! add (a b) = complex (a. Re + b. Re, a. Im + b. Im),
! sub (a b) = complex (a. Re – b. Re, a. Im – b. Im),
! mul (a b) = complex (a. Re*b. Re – a. Im*b. Im, a. Im*b. Re + a. Re*b. Im),
! div (a b): [D] = {
D = b. Re*b. Re + b. Im*b. Im;
complex (
(a. Re*b. Re + a. Im*b. Im) / D,
(a. Im*b. Re – a. Re*b. Im) / D
)
}
};
У классов и функторов много общего: параметры классов также могут иметь инициализаторы по умолчанию и допускать «ленивую» семантику передачи, а вызов класса, как и функтора, может быть опосредованным, по ссылке. Классы могут иметь конструкторы и деструкторы, обеспечивающие корректную инициализацию и уничтожение каждого экземпляра данного класса. Конечно, важнейшую роль играет наследование — класс может иметь прародителей. Например, запись вида
! all [SuperClass] MyClass (…) {…}
означает, что класс MyClass — прямой потомок SuperClass, наследующий все его компоненты и методы. По умолчанию вызов методов класса не является полиморфным, но если специально объявить метод суперкласса как виртуальный, он сможет иметь собственные версии для каждого из классов-потомков, и механизм виртуализации обеспечивает правильный вызов этих методов для объектов.
В завершение отметим, что, в отличие от многих объектно-ориентированных языков, в AWL отсутствует жесткая связь между классами и методами. Например, в языке есть операция доступа Class! allExpr, интерпретирующая выражение Expr в контексте класса Class (что позволяет иметь в Expr полный доступ к компонтентам и методам Class). Таким образом, если дано описание
! mymethod (…) = MyClass! allBody;
функтор mymethod (не являясь формально методом MyClass) сможет применяться к любому его экземпляру так же, как и методы. Более того, легко реализуется создание так называемых мультиметодов, работающих с объектами разных классов. Например, выражение
! mutual_method_AB (…) = ClassA! allClassB! allBody;
описывает функтор mutual_method_AB, служащий «методом» как для ClassA, так и для ClassB.
В следующей статье будет подробно рассказано о прикладных библиотеках AWL, реализующих инструментарии графического программирования и создания электронных документов. Заметим, что в их реализации такие языковые средства, как функторы, иерархии классов и «ленивые» вычисления, играют принципиальную роль.