Стили и методы программирования

Модификации традиционной архитектуры


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

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

  • Появляются команды, кодирующие довольно сложные действия над операндами.

  • Процессор снабжается памятью для команд (кэш команд) и для данных (кэш данных). Кэш команд заполняется одновременно с выполнением текущей команды теми командами, которые, как кажется процессору, будут выполняться после текущей. В кэш данных дублируются значения, которые, как кажется процессору, будут операндами следующих команд.

  • Память разбивается по уровням доступа: регистры процессора; область быстрого доступа с непосредственной адресацией; область, адресация которой требует предварительного указания некоторого сегмента, ячейки которого адресуются непосредственно, и т. д.

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

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

    Модификации традиционной архитектуры
    Рис. 2.2.  Структура ячейки при тегировании

    Тегирование, реализованное аппаратно, дает следующие преимущества.

  • На аппаратном уровне происходит дополнительный контроль корректности вычислений и улучшается диагностика ошибок.
  • Элементарные операции становятся более адекватными данным (например, сложение модифицируется в соответствии с типами операндов: целые, действительные, ссылки). Время выполнения программ сокращается по сравнению с традиционными машинами соответствующей мощности.


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

    В языке C тегированное данное соответствует описанию структуры, подобному этому:

    struct tagged { int type_tag; union { int x; float y; } }

    В языке Pascal тегированная ячейка может быть представлена следующей вариантной структурой:

    record case tag : Boolean of true: (i :integer); false: (r :real) end;

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

    В стековой архитектуре машины уже физическая память организована как структурированный стек контекстов (см.


    рис. 2.3).

    Модификации традиционной архитектуры
    Рис. 2.3.  Стек и контексты

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

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

    Стековая архитектура была воплощена в системах команд машин серий Barroughs и Эльбрус. Они прекрасно зарекомендовали себя как машины для сложных ответственных вычислений, но не выдержали конкуренции с армадой PC, задавивших их числом и дешевизной.

    Есть и противоположное направление развития аппаратуры. В машинах с так называемой RISC-архитектурой машинные команды исключительно просты, но в полупостоянной части оперативной памяти заложены подпрограммы для команд `нормального' или даже `высокого' уровня, так что в принципе RISC-процессор может в зависимости от режима эмулировать5) машины разной архитектуры. Например, такая система была использована в машинах знаменитой серии Power PC - Power Mac, которые могли выступать для пользователя и программиста и как PC, и как Macintosh. Эта серия потерпела коммерческую неудачу скорее из-за ошибок в продвижении на рынок, чем из-за отсутствия реальных достоинств.


    Нетрадиционные архитектуры


    Крутой подумал. Ему понравилось. Он решил когда-нибудь на досуге подумать еще раз. Русский анекдот
    Исторический экскурс
    Во время Великой французской революции в связи с введением метрической системы мер, да и для улучшения качества артиллерии и флота, возникла необходимость быстро и качественно пересчитать множество таблиц: артиллерийские, навигационные, астрономические, геодезические и т. п.
    Решение этой задачи (вдохновителем и организатором работ был выдающийся математик и администратор Л. Карно) было гениально с точки зрения концептуальной и организационной проработки. Вначале с помощью методов интерполяции все функции были заменены их кусочно-полиномиальными приближениями, не имеющими разрывов (современный термин - сплайн). Полиномы могут вычисляться методом конечных разностей, поэтому алгоритм вычисления полинома был распределен на сложения, вычитания и небольшое число умножений на константы. Для организации таких вычислений было использовано две параллельно работающие роты грамотных солдат под руководством математиков. Одни и те же расчеты проводились ротами независимо. Каждый солдат получал аргументы от двух указанных ему товарищей, складывал либо вычитал их (действие было предопределено заранее и не менялось). Самые грамотные солдаты получали аргумент от одного товарища и умножали его на заданную константу. По команде результаты действий передавались далее. Математики-надзиратели проверяли полученные результаты на правдоподобие и пересчитывали их выборочно. В случаях расхождения результатов рот счет производился заново. Таким образом таблицы были пересчитаны практически без ошибок, с минимальными затратами, высокой точностью и в кратчайшее время. В результате артиллерийские таблицы французской армии оставались лучшими более пяти лет, до тех пор, пока русские артиллеристы под руководством гр. Аракчеева не посчитали их еще лучше6).
    Так что в том первом историческом случае, когда значительный объем вычислений был индустриализирован, была весьма квалифицированно выбрана модель вычислений.

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

    Прежде всего, можно просто иметь несколько процессоров. Многопроцессорность может быть использована несколькими способами.



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


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


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




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

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



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

    Здесь стоит обратить внимание на два взаимосвязанных момента. Во-первых, часто нет нужды в хранении промежуточных результатов счета и, как следствие, потребность в пассивной памяти существенно снижается. Во-вторых, ликвидируется примитивное устройство управления, а его роль принимают на себя элементы оборудования, отвечающие за выяснение готовности команд к выполнению. Это - одна из схем, которая подчиняет управление потокам данных (data flow). Такие схемы противопоставляются управляющим потокам (control flow) традиционных вычислений.

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

    Нетрадиционные архитектуры
    Рис. 2.4.  Поток данных

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

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


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

    Конечно же, при реализации идеи возникают сложности. Пару из них демонстрирует следующий поток.

    Нетрадиционные архитектуры
    Рис. 2.5.  Поток данных с взаимодействующими циклами

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

    Машины потоков данных уже несколько десятилетий используются на практике, так называемые суперЭВМ считают вычислительные задачи колоссальной трудоемкости (например, метеорологические). В подобных задачах, как правило, вычисление может быть записано в матричной алгебре, и новые матрицы строка за строкой вычисляются по старым. Таким образом, можно организовать конвейер, когда элементы целого вектора данных параллельно считаются по элементам предшествующего вектора, а переход к следующему происходит, когда все новые элементы посчитаны (так что вопрос о старении данных решается радикально: все, что использовалось для предыдущего вектора, по умолчанию считается устаревшим для следующего). Особенно хорошо конвейер работает, когда подпрограммы вычисления для каждого из элементов массива практически не изменяются (конечно же, для разных элементов они могут быть разные), поскольку инициализация процесса занимает много времени и сил. Конвейер с 1024 процессорами увеличивает производительность вычислений для некоторых реальных задач примерно в 300 раз, и для многих приблизительно в 100 раз.


    Первой серийной суперЭВМ, успешно применившей конвейерную организацию, стала система машин Cray.

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

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

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

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


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

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

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


    Алгоритм действия может быть задан в любом стиле (например, в рамках традиционного программирования), его результатом для системы является набор пар, порождаемых в ходе локального действия, а исходные аргументы при этом уничтожаются. Легко заметить, что ассоциативная система может рассматриваться как иная форма коммутационной системы, и с точки зрения возможностей, предоставляемых для программирования, они теоретически эквивалентны. Однако эта форма соответствует иному взгляду на описываемые вычисления, который лучше подходит, в частности, для работы с базами знаний7).
  • Аксиоматические системы. Если система отождествлений и замен фиксирована для целых классов задач и предметных областей, то мы работаем в фиксированном классе исчислений, и на первый план выходит задача описания знаний и предпочтений на фиксированном языке. Знания и предпочтения записываются в виде аксиом. Таким образом, формально аксиоматические системы являются частным случаем сентенциальных, но фиксированные правила замен позволяют перейти от общего пошагового моделирования символьных преобразований к неизмеримо более эффективному выводу, когда планируется сразу целая система преобразований8).

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



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


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

    Нетрадиционные архитектуры
    Нетрадиционные архитектуры
    Нетрадиционные архитектуры
      1)

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

      2)

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

      3)

     

    Схема выполнения команды на рис. 2.1 иллюстрирует интенсивность работы канала машины фон Цузе. Для выполнения двухадресной команды КО1О2 требуется шесть обращений к каналу. Интересно, что первоклассный программист, посмотрев на рисунок, увидел лишь четыре обращения. Дело в том, что на физическом уровне при чтении ячейки происходит ее стирание, и поэтому требуется восстановление ее содержимого. Для прочтения трех ячеек нужно шесть операций, а факт перезаписи некоторых из них не увеличивает числа обращений к памяти в том случае, если сама команда запрограммирована корректно на аппаратном уровне.

      4)

      Частичным аналогом этого может служить BIOS в общераспространенных персональных компьютерах.

      5)

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

      6)

      Никто не знает, каким способом!

      7)

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

      8)

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

    Нетрадиционные архитектуры

    Традиционная модель


    Из материалов предыдущего раздела видно, что подходы к решению программистских задач при использовании различных языков отличаются друг от друга. Иногда эти различия непринципиальны и сводятся лишь к текстовому представлению программы, а иногда они довольно существенны. Если различия непринципиальны, то мы говорим, что языки имеют сходную модель вычислений.
    Модель вычислений языка не обязательно совпадает с моделью вычислений, заложенной в оборудование. Эти модели расходятся, если сама машина имеет традиционную архитектуру. Более того, даже машины другой архитектуры программно моделируются на машинах традиционной архитектуры. В дальнейшем мы будем пользоваться термином традиционные языки, понимая под этим языки, модель вычислений которых унаследована от традиционной архитектуры машин. Архитектура, впервые использованная Конрадом фон Цузе1) еще на рубеже 30-40-х гг. XX в., в несколько модифицированной форме до сих пор принята почти для всех вычислительных машин.
    В этой архитектуре вычислительной системы имеются следующие три элемента:
  • Память,предназначенная для хранения произвольных значений. Значения на аппаратном уровне представляются как последовательности битов.
  • Процессор, способный выполнять команды, т. е. интерпретировать последовательности битов как инструкции для активизации предписываемых этими инструкциями действий.
  • Управляющее устройство, способное указывать команды, которые должен выполнять процессор (иногда управляющее устройство рассматривается как составная часть процессора).

  • Эти элементы обладают следующими особенностями.
  • Однородность памяти. Память машины рассматривается как вектор, состоящий из одинаковых ячеек, способных принимать (от процессора) любые значения.
    Значение в ячейке, с точки зрения процессора, является последовательностью битов фиксированной длины без каких бы то ни было ограничений.
  • Линейная адресация. Ячейки памяти идентифицируются адресами: числами от нуля до максимально возможной для данной машины величины (обозначающей последнюю ячейку).
    Адреса служат указателями для процессора, откуда следует извлекать или куда помещать значение.

    Из однородности памяти следует, что команды и данные (перерабатываемые значения) располагаются в единой общей памяти и одинаково адресуются.

  • Пассивность памяти. Ячейка памяти всегда содержит какое-то значение. Полученное ячейкой значение не может быть изменено иначе как при выполнении специальной команды процессора, предназначенной для этого действия, - команды засылки, или присваивания, значения. Изменяемая ячейка указывается своим адресом.
  • Активность процессора. Процессор всегда выполняет некоторую команду, закодированную последовательностью битов в ячейке и извлеченную из памяти. Команды могут иметь операнды, т. е. в них, как правило, указываются адреса ячеек, над которыми выполняются предписываемые действия. Именно процессор, в соответствии с тем, какую команду он должен выполнить, интерпретирует значение ячейки-операнда как число, символ, адрес в памяти и др. Число операндов команды называется ее адресностью, а адресность большинства команд - адресностью машины. Различаются одно-, двух- и (в настоящее время реже) трехадресные машины, а также машины с нефиксированной адресностью.
  • Централизация управления. Управляющее устройство содержит адрес команды, назначаемой для выполнения процессором. Если эта команда является командой передачи управления, при ее выполнении определяется адрес ячейки, содержащей команду, которая должна выполняться после текущей, и этот адрес становится новым содержимым устройства управления. В противном случае адрес команды, назначаемой процессором для выполнения следующей, есть текущее содержимое устройства управления, увеличиваемое на единицу (очередная выполняемая команда содержится в ячейке памяти, следующей за текущей). Таким образом, управляющее устройство можно моделировать как регистр, называемый счетчиком команд. Этот регистр модифицируется автоматически либо командами передачи управления2).
  • Наличие канала связи между памятью и процессором.


    Для передачи данных или команд между памятью и процессором используется специальное оборудование, называемое каналом связи. Работа канала осуществляется в случаях, когда требуется:
  • подать очередную команду процессору для выполнения (активизируется управляющим устройством);
  • получить операнды для выполнения команды (активизируется процессором);
  • изменить значение ячейки при выполнении команды (активизируется процессором).


  • Такую архитектуру будем называть традиционной.

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

    На рис. 2.1 показано взаимодействие устройств традиционной машины.

    Традиционная модель
    Рис. 2.1.  Схема выполнения двухадресной команды на машине традиционной архитектуры

    В команде КО1О2 К — код операции, O1 и O2 — адреса операндов. Команда размещена по адресу 3. Сплошными стрелками отмечена передача информации по каналу. Пунктирные стрелки обозначают действия, которые осуществляются непосредственно до исполнения команды (запрос кода команды по адресу 3) и после нее (указание на необходимость запроса команды, следующей в памяти за исполняемой).

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

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


    Темпы роста скорости процессора выше, чем у канала связи.

    На эти принципиально непреодолимые ограничения классической модели вычислений указал Бэкус еще в середине семидесятых годов (Тьюринговская лекция Бэкуса [35]), назвав канал связи памяти с процессором узким местом (буквально bottleneck) традиционной модели.

    Традиционная архитектура машин менее всего связана с конкретным классом решаемых задач. Она скорее связана с двумя наиболее распространенными и наиболее низкоуровневыми стилями программирования: структурным и автоматным программированием.

    Под стилем программирования мы понимаем внутренне концептуально согласованную совокупность средств, базирующуюся на некоторой логике построения программ. Теоретические исследования последних десятилетий показали, что различным классам задач и методов соответствуют различные логики построения программ, и эти логики несовместимы друг с другом. Логики (и, соответственно, стили программирования) дают самую общую оболочку, не зависящую от конкретных предметных областей и даже от конкретных методов. Как только мы уясняем себе особенности методов, стили начинают конкретизироваться далее. Так, известный Вам (поскольку именно он преподается в качестве единственно существующего во всех современных учебных пособиях по началам программирования) структурный стиль конкретизируется в циклический либо рекурсивный варианты (ипостаси).

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


    Конструкции традиционных языков


    Рассмотрим традиционные языки, ключевые средства которых "выросли" из традиционной архитектуры машин. Ниже перечислены те средства языков, которые унаследованы от этой архитектуры.
    Оператор. Основной конструкцией, выражающей действия, является оператор. Выполнение одного оператора зависит от выполнения другого только в том смысле, что более ранние вычисления могут менять память.
    Присваивание значений — наиболее часто встречающийся вид операторов. В большинстве языков программирования понятие переменной рассматривается как аналог ячейки (или группы ячеек) памяти. Присваивание предназначено для локального запоминания результата вычислений. Оно прямой потомок передачи значения по каналу связи от процессора к памяти.
    Структура управления. Если явно не предписано иначе, то операторы выполняются текстуально друг за другом. Это соответствует последовательному выполнению команд процессором. Оператор перехода трактуется как явное указание того оператора, который должен выполняться следующим. Он соответствует машинной команде безусловной передачи управления по адресу. То же можно сказать и о ситуациях, когда вычисления определяют, какой оператор будет выполняться следующим (условные операторы, циклы и т. п.). Прообразом этих операторов явились часто используемые приемы программирования на языке команд.
    Приведение. В традиционных языках обычным является употребление значения одного типа, которое присваивается переменной другого типа. Автоматические приведения вырабатываемых значений к типам, определяемым контекстом использования, — прямое следствие соглашения об однородности памяти. В развитых языках программирования (кроме семейства C) стремятся избегать неконтролируемых приведений1).
    Подпрограмма — группа операторов, имеющая собственное имя. К такой последовательности можно обращаться неоднократно. Подпрограммы появились еще на машинных языках, поскольку они необходимы для накопления программистских знаний и облегчения работы со сложными программами. Особенность подпрограмм в том, что их описание отделено от оператора вызова подпрограммы, а после исполнения оператора вызова управление возвращается в тот же контекст, который был до вызова, а управление передается оператору, непосредственно следующему за вызовом.
    Заметим, что в достаточно развитых языках программист строит тексты программ в соответствии с особенностями решаемой задачи, отходя от тех правил, которые явно поддерживаются языком (писать в другом стиле). Но если в языке не хватает возможностей, адекватных для такого построения (а это, вообще говоря, обычная ситуация), то приходится прибегать к хакерскому2) моделированию нужных возможностей. Когда при этом требуется высокая эффективность (а это опять-таки обычно) необходимо учитывать реалии конкретного компьютера. Как следствие, весьма вероятны нарушение адекватности моделирования и потеря наглядности решения.


    Примеры традиционных языков


    Приведем с небольшими комментариями далеко не полный перечень традиционных языков.
    1. FORTRAN. Патриарх среди языков программирования, постепенно вбирающий в себя новые веяния, но полностью сохраняющий преемственность в ее лучшем и худшем смысле. Сегодня FORTRAN — это эклектическое собрание как полезных, так и архаичных средств. Упоминая FORTRAN, мы имеем в виду язык, сформировавшийся в середине семидесятых, так называемый FORTRAN-77 (дальнейшие модификации уже не столь принципиальны). Модель вычислений языка FORTRAN в точности соответствует представлению о том, что нужно для реализации вычислительных алгоритмов и что можно реализовать на компьютерах традиционного типа, сложившемуся в 50-х гг. XX века. Таким образом, это наиболее последовательный пример традиционного языка.
    Разработчики ранних версий языка FORTRAN не принимали в расчет полезности определения языка, независимого от системы программирования. И формальное определение языка, по существу, задавалось транслятором. В результате случайные решения, принятые в ранних трансляторах, последующие разработчики вынуждены сохранять на многие годы. Для этого языка такое положение дел объяснимо и простительно3).
    FORTRAN служит классическим примером языка со статическим определением памяти: еще на стадии трансляции точно устанавливается, где что лежит.
    2. Algol 60. Язык, сформировавшийся как реализация идеи представления алгоритмов, одновременно понятного и для компьютеров, и для человека. Очень скоро утопичность идеи выяснилась, и Algol 604) стал в глазах программистов-практиков5) лишь более строгим, но зато более многословным, менее привычным и эффективным аналогом языка FORTRAN. На самом деле в Алголе появились принципиальные новшества, выгодно отличающие его от предшественника. Это, прежде всего, определение языка, не зависящее от транслятора, далее, структурность описания языка и определение действий абстрактного вычислителя на базе понятий из строгого описания языка.
    В Алголе впервые предложена структурная организация контекстов выполнения конструкций, которая выводит вычислитель языка за рамки канона однородности и прямой произвольной индексной адресации памяти, — так называемая блочная структура программы.
    Тем самым была изобретена стековая дисциплина распределения памяти в рабочих программах. При таком распределении памяти место для понятий, описанных в некотором блоке, выделяется при входе в данный блок и освобождается после его завершения.
    Стековая дисциплина с тех пор повсеместно используется при реализации языков программирования.
    Хотя понятие структурного программирования еще не было осознано, в Algol 60 появилась вся необходимая база для него.
    Не случайно именно Алгол стал отправной точкой развития большинства языков программирования, а также основой многих теоретических разработок, прояснивших языковые и программистские понятия. Появились и до эры персональных компьютеров были популярны вычислительные архитектуры, явно поддерживающие алголовскую организацию памяти. Наиболее известными из них являются машины фирмы Burroughs (США) и линия многопроцессорных вычислительных комплексов Эльбрус (СССР).
    Таким образом, Algol 60 стал существенным шагом вперед, а в отношении концептуального единства он до сих пор остается непревзойденным среди традиционных языков.
    3. Симула 67 характеризуется разработчиками как универсальный язык моделирования. Это, как и его предшественник Симула 1, — правильное расширение Algol 60, а потому сохраняет его достоинства и недостатки. Впрочем, именно недостатки этой базы стали главной причиной того, что данные языки оказались недооценены практиками. Разработчики Симулы 67 показали, как нужно выражать программистский опыт в языковых формах: воплощать не конкретные решения, а содержательные сущности, их обобщающие. В результате новые для того времени конструкции, отражающие объектный взгляд на обрабатываемые данные, стали основой для перспективных методологий программирования, в частности для методологии объектно-ориентированного программирования.
    4. PL/1. Этот язык разрабатывался как попытка объединения всего, что в принципе может потребоваться программисту. Неизвестно, так это или нет, но вполне правдоподобно, что число "1" в названии языка есть амбициозное "единственный", т.


    е. способный сделать бессмысленными любые другие языки программирования. Совсем не заботясь о чистоте объединения всех известных программистских средств, разработчики языка предложили изначально эклектичную коллекцию, которая лишь с натяжкой может быть названа системой. Непознаваемость языка отмечается многими критиками. По выражению Э. Дейкстры, PL/1 — это "рождественская елка", а не инструмент, который можно эффективно использовать. Разрозненность и несводимость к единым концепциям создает большие трудности и для реализации: системы программирования для PL/1 всегда выделяли некоторый диалект, по существу, определяя соответствующее подмножество средств, зависящее от транслятора.
    5. Алгол 68 — язык программирования, который, как и PL/1, претендовал на всеобщность, но уже на базе математического обобщения разрозненных средств программирования в традиционной модели вычислений. Можно сказать, что это PL/1 с элементами научности (С. Костер). Попытка распространения средств, хорошо себя зарекомендовавших в одной сфере, на уровень максимального обобщения в целом удалась, но разработчики зафиксировали в языковых формах лишь то, что уже было известно на момент появления проекта языка. По этой причине в Алголе 68 нет хороших средств поддержки модульного построения программ, в точном соответствии с традиционной архитектурой перерабатываемые данные не являются активными. К недостаткам языка следует отнести тяжеловесное формальное описание, совершенно недоступное для практического применения в качестве руководства для программиста6). Основная заслуга разработчиков Алгола 68 в том, что они сумели реализовать на практике принцип обобщения без потерь, продемонстрировали продуктивность этого принципа. Наличие в данном курсе ссылок на фактически не используемый сейчас язык Алгол 68 объясняется тем, что в этом языке, несмотря на явные недостатки в форме описания языка, одновременно имелся ряд блестящих концептуально важных находок, таких, в частности, как система понятий, остающаяся до сего дня наиболее последовательной и строгой.


    Некоторые из концепций Алгола 68 были восприняты в языках C и Ada, но сама система была при этом утеряна. До сих пор в журнале "Communications of the ACM" периодически появляются комментарии к опубликованным статьям, озаглавленные приблизительно в следующем стиле: "Algol 68 ignorance considered harmful", сводящиеся к тому, что очередные "новые" предложения являются лишь ухудшенной версией того, что уже давно было реализовано в Алголе 68.
    Одной из побудительных причин создания языка стало осознание стройной концепции системы типов, вычисляемых статически, т. е. во время трансляции программы.
    Базовая машина допускает произвольную трактовку смысла хранимых значений (принцип однородности памяти), но для человека смысл значений первичен, поэтому в программе нужно фиксировать не только вычисления, но и типы значений. Появляется возможность смыслового контроля программы, отделенного от обработки данных. Для этого нужно строго определить типы и дать точные механизмы построения одних типов через другие. Такое определение позволяет, не зная конкретных значений переменных, входящих в выражение, определять тип выражения. В результате целый ряд содержательных потенциальных ошибок в программе может быть выявлен уже при трансляции. Но в Алголе 68 эта концепция была недостаточно хорошо реализована.
    6. Pascal — один из самых распространенных языков программирования. По этой причине он представлен множеством диалектов и версий. Первый Pascal был предложен Н. Виртом (N.Wirth) в ответ на принципиальное несогласие с позицией руководства рабочей группы по созданию Алгола 68, в частности с ван Вейнгаарденом. Главное в критике Вирта — чрезмерная сложность языка и особенно метода его формального описания.
    В частности, Pascal дал более изящную и приемлемую для программистов реализацию идеи статической системы типов, возникшей еще в то время, когда первоначальная рабочая группа по Алголу 68 не распалась7).
    Есть и другие особенности языка Pascal, которые делают этот язык строгим, есть и естественные ограничения возможностей, которые упрощают понимание языковых средств программистом.


    Язык создан так, чтобы поддержать написание хороших программ (в понимании, которое сложилось к концу шестидесятых годов). Все это позволило утверждать Вирту, что он разработал язык для обучения студентов программированию. Это утверждение не лишено основания, особенно если иметь в виду, что существует методика обучения с его помощью, которую разработал сам Вирт. В учебных заведениях, где имеются серьезные курсы информатики, Pascal остается самым распространенным языком обучения.
    Вскоре после своего появления Pascal становится популярным языком программирования. Но программирование продолжало развиваться, появлялись новые концепции. Сам Вирт разрабатывает на базе языка Pascal языки-наследники, которые в большей степени поддерживают составление программ с независимыми модулями: Modula и Modula-2.
    Заслуживают внимания и более близкие родственники стандартного языка Pascal, последовательно версией за версией предлагавшиеся коллективом фирмы Borland: языки и системы программирования линии Turbo Pascal. В них новые, весьма развитые возможности органично укладываются в строй родительского языка, не нарушая концепций. К сожалению, в разработанной этой же фирмой системе Delphi язык Object Pascal выпадает из этого ряда: концептуальной целостности сохранить не удалось.
    7. Язык С и другие машинно-ориентированные языки. Осознание того, что программирование с использованием универсального языка, не отражающего особенности конкретного вычислительного оборудования, не дает возможности достичь предельной эффективности программ на этом оборудовании, привело к появлению так называемых машинно-ориентированных языков. Популярность таких языков зависит не только от распространенности оборудования, для которого они рассчитаны, но и от успешности выполненных с их помощью проектов.
    В этом отношении показателен язык С, успех которого был во многом обеспечен удачным решением операционной системы Unix. Для разработки этой системы на машины серии PDP и был построен язык C, с помощью которого, в частности, предложен технологичный метод переноса программного обеспечения.


    Справедливости ради следует сказать, что хотя вклад системы Unix в успех С очень велик, но не менее важно и то, что этот язык предоставляет достаточно удобную для составления эффективных программ оболочку, закрывающую невыразительные средства машинного уровня. Растущая популярность этого языка повлияла на то, что компьютеры стали конструировать так, чтобы их архитектура соответствовала модели вычислений языка C. В результате популярность таких архитектур выросла. В этом процессе наиболее преуспела фирма Intel, процессоры которой становятся стандартом де-факто в сфере разработки персональных компьютеров. Сегодня уже нельзя представить массовый компьютер, который не поддерживал бы программное обеспечение, разработанное под эти процессоры8).
    Язык С если не первый, то один из первых языков программирования, в которых с самого начала существования провозглашалась идея рассматривать в качестве вычислителя не уровень команд конкретного компьютера, а уровень операционной системы. Иными словами, в данном языке присутствуют конструкции, выполнение которых не может осуществляться без соответствующего обращения к средствам операционной системы. Конечно же, это не новшество. Во всех практически используемых языках программирования такие средства есть, и всегда они расширяют программистский взгляд на среду функционирования программы. Но чаще всего они представлены в языке как подчиненные средства, вынесенные на уровень библиотек, и далеко не всегда точно специфицированы. В С ситуация иная. В частности, наряду с автоматическим распределением памяти, в языке определены механизмы предоставления участков памяти по запросам и возврата их, когда они перестают быть нужными.
    На фоне заслуженной популярности С уместно упомянуть менее распространенный язык Bliss. Этот машинно-ориентированный язык программирования мог бы быть концептуально более выверенной альтернативой С, но отсутствие разработанного с его помощью проекта, сравнимого по значимости с Unix, не позволило ему выделиться. И хотя в идейном плане Bliss повлиял на языкотворчество, интерес к нему не вышел за рамки академических исследований.


    Отечественный опыт разработки машинно- ориентированных языков демонстрирует поддержку архитектуры, отличную от Intel-подобной. Укажем на два проекта этого рода. Первый — язык ЯРМО (аббревиатура: язык реализации машинно-ориентированный), построенный для ЭВМ БЭСМ-6 и отражающий все тогдашние веяния в информатике. О качестве и востребованности этого языка можно судить хотя бы по тому, что было реализовано несколько его версий. Второй пример — Эль-76, разработанный в качестве аналога ассемблерного языка для многопроцессорного вычислительного комплекса Эльбрус. Оставаясь в целом традиционной машиной, архитектура этого комплекса достаточно далеко отходит от канонических принципов. В частности, в ней предусмотрена аппаратная поддержка вызова процедур, стековая организация памяти, тегирование и другие высокоуровневые средства программирования.
    Все архитектурные особенности Эльбруса отражены в Эль-76, что позволило рассматривать данный язык в качестве единственного инструмента программирования системных программ. Конечно, нельзя говорить о механическом переносе этого языка в архитектурную среду другого типа, а потому время использования его, как и любого машинно-ориентированного языка, ограничено временем жизни данной архитектуры9).
    8. Язык Ada. Он разрабатывался по заказу Министерства обороны США на конкурсной основе с предварительным сбором и анализом требований, с обширной международной экспертизой. По существу, в нем воплощена попытка определить язык программирования как экспертно обоснованный комплекс средств программирования. На завершающем этапе конкурса приняли участие около ста коллективов. В результате проверки на соответствие представленных разработок сформулированным требованиям для обсуждения общественности было отобрано четыре языка, зашифрованных как Red, Green, Blue и Yellow. В разной степени критике подверглись все четыре кандидата. Особенно острым критиком оказался Э. Дейкстра, который камня на камне не оставил от Red, Blue и Yellow, но чуть-чуть "пожалел" Green, доказывая, что все недостатки языка связаны с несвободой в выборе решений, обусловленных жестко фиксированными требованиями: там, где авторы могли бы решать какие-либо проблемы по-своему, они были вынуждены идти на поводу у априорных предписаний.


    Тем не менее Green стал приемлемым вариантом и получил одобрение. Как оказалось, это был единственный из финалистов язык, предложенный не американцами. Конкурсная комиссия утвердила его в качестве единого официального языка Министерства обороны США для разработки программ для встроенных систем и дала ему имя Ada — в честь Ады Августы Лавлейс, дочери Байрона и ученицы Бэббиджа — первой в истории программистки.
    Отметим, что успеха разработчики языка добились благодаря акценту на концептуальную целостность — именно это выделяло Green среди конкурентов. Заметим, что в ходе последующего развития в язык Ada стали включать новые средства в угоду появившимся пользователям. В результате к концу девяностых годов Ada по стилю построения стала подобна PL/1: в ней есть средства поддержки всего, что можно найти в работе программистов в конце XX века. Как отмечал известный советский программист В. Ш. Кауфман, язык Ada 9х можно рассматривать как добротную энциклопедию программистского знания и опыта, но никак не в качестве инструмента, ориентированного на пользователя.
    9. Объектно-ориентированные языки. Последним достижением в области программистского языкотворчества считается поддержка объектно-ориентированной методологии. Эта сфера интересует многих разработчиков языков начиная с восьмидесятых годов. Первым проектом, провозгласившим принцип перехода от пассивных данных к активным объектам, стал Smalltalk. В этом языке объектам предоставляется возможность действовать в ответ на получаемые ими сообщения без каких бы то ни было иных способов активизации действий. Эта возможность реализована в рамках идеи динамической типизации (в отличие от статической типизации, тип выражения может в определенных пределах вычисляться наряду с его значением). В качестве наглядной демонстрации мощи идеи была предложена система программирования Smalltalk-80 с очень богатой библиотечной поддержкой конструирования графических интерфейсов.
    Smalltalk — последний крупный проект, который был представлен на всестороннее обсуждение программистской общественностью.


    В результате таких обсуждений выяснилось, что нужно для поддержки объектной ориентированности в языках промышленного производства программ. Такие языки появились достаточно скоро.
    Заметными объектными языками стали Turbo Pascal версий с 5.5 до 7.0 и Object Pascal системы Delphi.
    Общим для промышленного развития линии языка Smalltalk является возврат к статическим типам данных, повышенное внимание к вопросам защиты. Появились системы программирования, приемлемые по эффективности объектного кода и удовлетворяющие требованиям технологичного программирования. Однако, как это обычно бывает с производственными системами, на смену аналитическим исследованиям границ применимости и роли языка пришли реклама достоинств и замалчивание недостатков.
    10. Язык C++. Наибольшее распространение из объектно-ориентированных языков получил С++, по-видимому, из-за огромной популярности С.
    C++ был создан в конце 80-х гг., он практически являлся расширением C. В отличие от языков семейства Simula, в С++ воплощались не столько концепции, сколько конкретные, полезные для его создателей, приемы. Язык С++ по конструкции намного сложнее С, а определение его производит впечатление еще большей эклектичности. Но С++, усугубив недостатки С с точки зрения человека, сохранил при колоссальном расширении возможностей языка все достоинства С, касающиеся машинной ориентированности и эффективности.
    С++ отличается прежде всего значительным усилением системы описаний (объектно-ориентированные возможности являются одним из наиболее применяемых расширений)10).
    Еще более укрепляют позиции языка С++ многие современные инструментальные системы, создававшиеся на нем без учета потребностей других языковых средств. В частности, системы работы с динамически подключаемыми программами (middleware) CORBA и COM практически требуют, чтобы программа, к ним обращающаяся, была написана на С++, поскольку вся система интерфейсов ориентирована на типы данных этого языка и порою даже на конкретные их представления.
    11. Язык Java.


    Заметным этапом в развитии объектно- ориентированного подхода стало появление языка Java, который был предложен как средство программирования не для отдельного компьютера, а сразу для всех машин, имеющих реализацию так называемой Java-машины — исполнителя программ на промежуточном языке более высокого уровня, нежели командный язык обычных машин. Иными словами, реализуется система программирования с явно определенным промежуточным языком. Другая особенность Java-проекта — его ориентация на Интернет-программирование: поддерживается возможность построения приложений, работающих сразу на нескольких машинах.
    Схема трансляции с выделенным промежуточным языком, не зависящим от исходного языка, не нова. В шестидесятые годы ее пытались применять для сокращения расходов на разработку трансляторов (например, в США в качестве промежуточного языка был разработан язык Uncol, в Советском Союзе для тех же целей предлагался язык АЛМО).
    Пусть требуется реализация m языков на n машинах. В схеме без промежуточного языка в этом случае нужно запрограммировать m ? n трансляторов, тогда как использование такого языка позволяет сократить это число до m + n: для каждого языка пишется транслятор в промежуточный язык (m) и для каждой машины создается транслятор или интерпретатор промежуточного языка (n). Можно предположить, что затраты на 'половинки' трансляторов сократятся. Схема может быть реализована, если удается построить промежуточный язык, удовлетворяющий следующим условиям:
  • все реализуемые языки можно вложить в промежуточный язык, т. е. их модели вычислений совместимы;
  • все целевые машины можно непротиворечиво представить в одной модели вычислений промежуточного языка так, чтобы трансляция программ для этой общей модели давала бы эффективный код для конкретных вычислителей.

  • Выполнить эти условия весьма сложно даже для близких языков и машин, близких по архитектуре. Затраты на решение этих задач часто неизмеримо и неоправданно превышают стоимость пресловутых m ? n трансляторов, поэтому после серии экспериментальных проектов идея промежуточного языка была предана забвению.


    В проекте Java она возродилась (правда, в урезанном до одного языка варианте) благодаря почти унифицированной архитектуре массовых компьютеров и значительному росту технических возможностей машин.
    Именно эти дополнительные условия, а также квалифицированное сужение исходного языка С++, позволили воплотить старую идею в промышленной разработке11).
    В контексте обсуждения традиционности языков необходимо рассмотреть вопрос о том, насколько далеко язык Java и Java-машина отходят от традиционной модели вычислений. Совместная разработка этих двух компонентов системы программирования для нового языка позволила прийти к достаточно практичным компромиссам, удовлетворить условиям выбранной схемы реализации, о которых шла речь выше.
    Условие (1) выполняется почти автоматически, и можно сосредоточить внимание на том, чтобы обеспечить наиболее рациональное вложение модели вычислений языка в модель машины. Что касается условия (2), то здесь ставка делалась на фактическое сходство архитектуры конкретных вычислителей, для которой уже накоплен опыт программистских решений во многих типовых ситуациях. В результате отход от традиционной модели вычислений в Java-системе хотя и заметен, но не столь значителен. Достаточно сказать, что Java-машина построена на принципах, предложенных еще в 1963 году для организации вычислений в рабочей программе Ветстоунского компилятора для Algol 6012) [24].
    Важным новым качеством Java-машины является поддержка работы программиста с потенциально неограниченной памятью. При выполнении конкретной программы на языке Java можно не заботиться о том, что какая-то часть памяти перестает быть нужной. Система программирования сама сделает так, что та память, которая оказалась недоступной, а значит, ненужной, возвращается для использования в новых запросах. Такие ситуации выявляются в процессе вычислений, когда фактические ресурсы, предоставляемые для размещения данных, требуется пополнить для переиспользования. В угоду этому соглашению отказались от ряда приемов организации ручного переиспользования памяти, необходимых, например, при программировании на С.


    Модель вычислений Java в точности соответствует тому, что требуется от объектно-ориентированного программирования: активность памяти на уровне методов объектов, совместное описание данных и программ методов, отделение предоставляемых средств от их реализации. Все это сочетается с традиционной схемой управления вычислениями при описании алгоритмов обработки.
    Следует отметить, что разработчики языка не стали включать в него средства, с трудом укладывающиеся в концептуальную схему Java-машины и обычно предоставляемые через довольно произвольные реализационные соглашения (как, например, в С++). Ориентация Java-машины на классическую со времен Algol 60 схему организации вычислений, повлияла на язык в том отношении, что все, выходящее за рамки принятой модели, представлено таким образом, чтобы это можно было вычислить в период трансляции. К примеру, проблемы статической типизации в данном языке решены радикально: в нем просто нет средств конструирования структурных типов, отличных от классов объектов. В результате язык стал лаконичнее, например, по сравнению с С++.
    Для первого опыта Java достаточно гармонично соединила ряд нововведений, которые в разрозненном виде появлялись в различных языках, и которые в совокупности повышают уровень исходных понятий примерно до второго типа в иерархии типов. Программирование на Java принципиально отходит от ориентации на особенности конкретных вычислителей. Остается лишь предположить (далеко не всегда оправданно!), что используемые машины выполняют требуемые действия с приемлемым уровнем эффективности.
    В значительной степени для того, чтобы перехватить инициативу у языка Java, и был создан C#, эффективно работающий прежде всего с системами middleware, и стремящийся сохранить в новой области эффективность C/C++. Для наших целей в большинстве случаев различия между С++ и С# несущественны.
    Как видим, традиционные языки прошли путь серьезной эволюции. Посмотрим, как трансформируются традиционные принципы вычислений при использовании объектной модели.


    Очевидно, что отход от однородности памяти для этой модели более радикален, нежели, к примеру, в языке Pascal. Если рассматривать объекты как хранимые в памяти данные, то за счет связанности этих данных с программами (методами объектов) память в объектной модели приобретает активность. На концептуальном уровне рассмотрения можно увидеть, что модернизируется управление: объект сам знает, какую программу-метод нужно активизировать, чтобы выполнить то или иное действие. Несомненно, все это повышает гибкость программирования, способствует расширению возможности отхода от традиционного взгляда на программу как на автомат, выполняющий предписания-команды. Однако на уровне реализации программ-методов все остается по-старому: то же последовательное выполнение операторов, те же подходы к разветвлениям вычислений и к организации циклической обработки.
    Более того, последовательный характер вычислений остается и при задании взаимодействия объектов. Следовательно, объектный подход, хотя и способствует взгляду на вычислительные процессы, отличающемуся от традиционного стиля, сам по себе не приводит к смене базовой модели вычислений.
    Объектно-ориентированный подход применяется в настоящее время для организации вычислений на основе моделей, отличных от традиционных. При этом совершенно не принимается во внимание концептуальная совместимость объектно-ориентированного подхода с новым базисом и относительный уровень базиса и надстройки. Пример рассматривается в главе, посвященной функциональному программированию.
    Примеры традиционных языков
    Примеры традиционных языков
    Примеры традиционных языков

      1)
      Даже в семействе С слишком необычные приведения рассматриваются всеми нынешними трансляторами как события, требующие выдачи предупреждений программисту.

      2)
      Хакерство — неестественное применение конструкций, а также использование недокументированных возможностей либо явных недоработок языка.

      3)
      Удивительно, что и сегодня, в XXI веке, появляются языки, зависимые от реализации, которые претендуют на универсальное применение (например, Delphi Object Pascal, C#, Java).

      4)
      До него был еще Algol 58, но лишь Algol 60 был утвержден в качестве стандарта и получил широкое распространение. Его часто называют в литературе просто Алгол.

      5)
      Прежде всего, физиков и американцев; европейцы, не являвшиеся физиками, приняли его хорошо.

      6)
      Такова цена, которую пришлось заплатить за полную формальную точность описания.

      7)
      Одно из центральных понятий программирования — это призрак: то, что нужно для понимания программы, но в программу не входит. Pascal и Алгол 68 позволили многим призракам стать реальными программными сущностями.

      8)
      Безусловно, это тормозит развитие машинных архитектур.

      9)
      Это утверждение не противоречит долголетию С. Как уже отмечалось, данный язык сегодня фактически навязывает архитектуру машин, которая не противоречит его эффективной реализации. Например, использование С в качестве базового языка для Эльбруса явно ограничивало бы доступ ко многим развитым машинным возможностям.

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

      11)
      Про новые условия данного проекта говорят обычно с неохотой или же вовсе замалчивают их. К примеру, в книге "Java-технология" успех старой идеи в новом проекте объясняется тем, что в прежние времена эту идею изучали в университетах, тогда как сегодня за нее взялись промышленники.

      12)
      В этой связи уместно следующее замечание. Если бы книга [24] не была бы сегодня библиографической редкостью, то ее главу 2 "Рабочая программа" можно было бы рекомендовать в качестве пособия для тех, кто желает изучить устройство Java-машины. После прочтения понимание Java-машины окажется более глубоким.
    Примеры традиционных языков

    Абстрактный и конкретный синтаксис


    При рассмотрении приемов программирования и примеров на разных языках необходимо как можно больше отвлекаться от частных особенностей и учиться видеть за ними общее с тем, чтобы оставшиеся различия были бы уже принципиальными. Отделить существенное от несущественного помогает, в частности, соотношение между синтаксисом и семантикой. Например, оказывается, что некоторые синтаксические особенности КС-грамматики языка не нужны для описания его семантики.
    Обычное синтаксическое определение языка задает конкретные синтаксические правила построения программы как строки символов. При этом определяется, какие структурные элементы могут быть выделены в тексте программы (конкретное представление программы).
    Действия абстрактного вычислителя определяются на структурном представлении программы и не зависят от многих особенностей конкретного синтаксиса. Например, для присваивания важно лишь то, что в нем есть получатель и источник, сам по себе знак присваивания (=, := или, скажем, LET) совершенно не важен. Более того, неважно, в каком порядке расположены составные части присваивания в тексте программы. Например, конструкция языка COBOL
    MOVE X+Y TO Z
    выражающая присваивание получателю Z значения выражения X+Y, совершенно аналогична обычному оператору присваивания.
    Для того, чтобы четко отличать конкретное представление от существенной структуры, стоит рассматривать конкретный и абстрактный синтаксис. Скажем, абстрактный синтаксис всех перечисленных форм операции присваивания один и тот же.
    Аналогично, три оператора
    X = a * b + c * d; X = (a * b) + (c * d); (4.1) X = ((a * b) + (c * d));
    и подобные им полностью эквивалентны с точки зрения абстрактного синтаксиса, тогда как с точки зрения текстового представления — различны.
    Таким образом, нужна структура синтаксических понятий, которая соответствует некоторому алгоритмически разрешимому8) (см., напр. [20]) понятию эквивалентности программ. Но это понятие эквивалентности должно быть исключительно простым, поскольку теоретические результаты показывают, что нетривиальные понятия эквивалентности программ неразрешимы.
    Выбранное понятие эквивалентности определяет структурное представление синтаксиса, используемого для задания абстрактного вычислителя (абстрактно-синтаксическое представление).

    Фрагментом абстрактно-синтаксического представления является чаще всего применяемый на практике ход. Задают понятие синтаксической эквивалентности, которое очевидным образом согласуется с функциональной эквивалентностью. Так, например, предложения, перечисленные в примере 4.1, могут описываться следующим понятием синтаксической эквивалентности: скобки вокруг подвыражений, связанных операцией более высокого приоритета, чем операция, примененная к их результату, могут опускаться. В данном смысле присваивание рассматривается как операция, имеющая более низкий приоритет, чем любая из арифметических операций. Таким образом, например, определяется эквивалентность выражений в языке Prolog. Подвыражение X + 3, скажем, является в нем всего лишь другим вариантом записи для +(X,3), и при вычислении характеристик выражения оно прежде всего преобразуется в форму без знаков операций.

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

    A + B - B + A.

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

    Абстрактный и конкретный синтаксис
    Рис. 4.1.  Оператор печати

    Пример на рис. 4.1 иллюстрирует вызов функции:

    printf ("\nX1 = %f, X2 = %f\n", X1, X2);

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

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

    . . . if ( D > 0 ) { D = sqrt ( D );

    Пример 4.5.1.


    Текст некоторой программы

    Абстрактный и конкретный синтаксис
    Рис. 4.2.  Представление фрагмента текста программы

    Абстрактный и конкретный синтаксис
    Абстрактный и конкретный синтаксис
    Абстрактный и конкретный синтаксис
      1)

      Язык программирования и алгоритмический язык почти всегда являются синонимами. В данном пособии они синонимичны.

      2)

      Нельзя абсолютизировать это требование. Совместные вычисления могут производиться в неизвестном программисту порядке. Более того, автору известен случай, когда прагматика (системы Common Lisp) четко отмечает, что в некотором случае нельзя пользоваться конкретным линейным порядком, который система порождает, пополняя заданный пользователем частичный порядок зависимостей, поскольку алгоритм упорядочивания может быть в любой момент и без предупреждения заменен. На самом деле такая прагматика часто полностью соответствует содержательному смыслу создаваемых программ: если два действия независимы, нельзя предполагать, что одно из них происходит раньше другого (хотя обычно программист вынужден это предполагать ввиду идиотизма "современных" систем). Если бы такие прагматические замечания встречались почаще!

      3)

     

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

      4)

      Даже возможная противоречивость библиотек друг другу соответствует стилю содержательного мышления (И.Н. Скопин).

      5)

      Вообще говоря, данная подсказка избыточна для современных трансляторов, поскольку распознать шаблон вида <переменная> ":=" <переменная /* та же самая */> "+" 1 и им подобные не представляет труда не только в тех случаях, когда <переменная> указана явно, но и тогда, когда она вычисляется (индексирование, косвенная адресация и т. д.).

      6)

      Сегодня с помощью семантической прагматики выражают также расширения модели вычислений, выводящие за рамки исходного абстрактного исполнителя. К примеру, — системы параллельного программирования над C++ и FORTRAN MPI и OPEN MP (И. Н. Скопин).

      7)

      Модель вычислений препроцессора можно охарактеризовать следующими свойствами. Исходные перерабатываемые данные — это текст (любой структуры, не обязательно на языке С), в котором имеются строки, начинающиеся с символа '#'. Такие строки представляют команды, управляющие работой препроцессора. Например, #include . . . заставляет препроцессор вставить некоторый файл в перерабатываемый текст (не следует понимать это буквально — нужно просто обеспечить соответствующий эффект). Другой пример: #define two 2 дает указание препроцессору на то, что в оставшейся части текста идентификатор two должен заменяться числом 2. Результат вычислений препроцессора — текст, который не содержит его команд.

      8)

      Как принято в современной теории, "алгоритмически разрешимое" далее называется просто "разрешимое".

    Абстрактный и конкретный синтаксис

    Прагматика


    До сих пор речь шла об определении языка его абстрактным вычислителем. Прагматика задает конкретизацию абстрактного вычислителя для данной вычислительной системы. Большая часть прагматики размазана по тексту документации о реализации языка (эту часть прагматики программист варьировать не может). Например, прагматическим является замечание, что тип Longint в системе Visual C++ определяется как 32-разрядное двоичное число с фиксированной точкой, занимающее слово памяти.
    Та часть прагматики, которую может варьировать программист, требует отдельного синтаксического оформления. В языке Pascal есть так называемые прагматические комментарии, например, {$I+}, {$I-} (включение/выключение контроля ввода-вывода). Многие из таких комментариев практически во всех версиях одни и те же. В самом стандарте языка явно предписана лишь их внешняя форма: {$...}.
    Даже если язык ориентирован на реализацию в единственной операционной обстановке (например, это какой-нибудь язык скриптов для микропроцессора, встроенного в собачий ошейник), для понимания сути того, что относится к действиям, а что — к их оптимизации, прагматику нужно выделить хотя бы в документации для программистов.
    Принципиально различаются два вида прагматики языка программирования: синтаксическая и семантическая.
    Синтаксическая прагматика — это правила сокращения записи, можно сказать, скоропись для данного языка. Пример, который можно рассматривать как синтаксическую прагматику — команды увеличения и уменьшения на единицу. В С/С++ они представлены операторами
    <переменная>++; или ++<переменная>;
    и
    <переменная>--; или --<переменная>;
    В С/С++ команды такого рода следует относить к модели вычислений языка, так как для нее постулируется, что язык является машинно-ориентированным и отражает особенности архитектуры вычислительного оборудования, а команды увеличения и уменьшения на единицу предоставляются программисту на уровне оборудования достаточно часто.
    В Turbo Pascal и Object Pascal эти команды выражены следующим образом:

    Inc (<переменная>)

    и

    Dec (<переменная>)

    соответственно. Если рассматривать Turbo Pascal как правильное расширение стандартного языка Pascal, не содержащего обсуждаемые команды, то эти команды — просто подсказка транслятору, как надо программировать данное вычисление. Следовательно, указанные операторы для данного языка можно относить к прагматике 5).

    Другой пример — возможность записи в языке Prolog вместо вызова +(X,Y) выражения X+Y.

    Семантическая прагматика — это определение того, что в описании языка оставлено на усмотрение реализации или предписывается в качестве вариантов вычислений.

    Например, стандарт языка Pascal утверждает, что при использовании переменной с индексом на уровне вычислений контролируется выход индекса за диапазон допустимых значений. Однако в объектном коде постоянные проверки этого свойства могут показаться накладными и избыточными (например, когда программа написана настолько хорошо, что можно гарантировать соответствующие значения индексов). Стандарт языка для таких случаев предусматривает сокращенный, т. е. без проверок, режим вычислений. Выбор режимов управляется пользователем с помощью прагматических указаний для транслятора, выражаемых в конкретном синтаксисе как прагматические комментарии {$R+} и {$R-}6).

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

    Пример 4.4.1. Стандарт языка С предписывает, что системы программирования на нем должны предусматривать специальный инструмент для обработки программных текстов, который называется препроцессором. Препроцессор делает массу полезных преобразований. Как уже упоминалось, он берет на себя решение задачи подключения к программе внешних (библиотечных) файлов, с его помощью можно скрывать утомительные детали программирования, достигать ряда нужных эффектов, не предусмотренных в основных средствах языка (например, именованные константы).


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

    Пусть написано

    if (x > 0) Firstmacro else PerformAction;

    Кажется, что действие выполняется, если x Прагматика 0, но первый макрос раскрывается как

    PrepareAction; if (x <= 0) CancelAction

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

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

    Наложение команд препроцессора на текст программы — это смешение двух моделей вычислений: одна из них — модель базового языка С, другая — модель препроцессора7). В результате программист при составлении и изучении программ вынужден думать на двух уровнях сразу, а это трудно и провоцирует ошибки.

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

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


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

    Даже концептуально целостные системы в результате развития часто сползают к эклектичности. В этой связи поучительно обсудить развитие языка Pascal линии Turbo.

    Модель вычислений стандартного языка Pascal изначально была довольно целостна, поскольку в ней четко проводились несколько хорошо согласованных базовых идей и не было ничего лишнего. Но она не во всем удовлетворяла практических программистов. В языке Pascal, в частности, не было модульности, и требовалась значительно более глубокая проработка прагматики, что стало стимулом для развития языка, на которое повлияла конкретная реализация: последовательность версий Turbo Pascal. Разработчики данной линии смогли сохранить стиль исходного языка вплоть до версии 7, несмотря на значительные расширения. В этом им помогло появление нового языка Modula, построенного как развитие языка Pascal в направлении модульности. Идея модульности и многие конкретные черты ее реализации, созданные в Modula, были добавлены к языку Pascal.

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

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

    Однако со столь трудной проблемой не удалось справиться тем же разработчикам, когда они взялись за конструирование принципиально новой системы программирования Delphi и ее языка Object Pascal. Одним из многих отрицательных следствий явилась принципиальная неотделимость языка от системы программирования.А далее история Delphi с точностью до деталей повторяет то, что было с языком С/С++. В последовавших версиях системы, вынужденных поддерживать преемственность, все более переплетаются модель вычислений и прагматика. Заметим, что "прагматизм" не принес никакого прагматического выигрыша: все равно Delphi плохо поддерживает современные системы middleware, ориентированные на C++ и Java.

    Внимание!

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


    Текст некоторой


    . . . if ( D > 0 ) { D = sqrt ( D );
    Пример 4.5.1. Текст некоторой программы
    Закрыть окно



    Различные стороны определения языка


    Для создания, проверки и преобразования программ, построения систем программирования, а также для многих других нужд нам необходимо если не определение, то хотя бы описание алгоритмического языка. При этом требуются точные описания как текстов, так и их интерпретации. Рассмотрим существующие варианты.
  • Сама программа-транслятор считается описанием языка. Тут сразу точно описаны и тексты (правильная программа — та, на которой транслятор не выдает ошибки), и их интерпретация (интерпретация программы — то, как исполняется ее текст после перевода транслятором).
    Именно так пытались поступать на заре программирования, когда, скажем, легендарный язык FORTRAN создавался одновременно с первым транслятором с данного языка.
  • Определением языка считается формальная лингвистическая система (грамматика). Впервые этот подход был последовательно применен в Алголе. Встречавшиеся вам при изучении языков синтаксические диаграммы являются непосредственными потомками того, что было сделано в Алголе.
  • Определением языка считается соответствие между структурными единицами текста и правилами интерпретации. Этот вариант был полностью реализован при определении языка Алгол 68.

  • Первый вариант — совершенно неудовлетворительный путь, поскольку всякое изменение в программе-трансляторе может полностью изменить смысл некоторых конструкций языка со всеми вытекающими отсюда последствиями.
    Второй вариант соответствует взгляду на язык как на множество правильно построенных последовательностей символов. Если последовательность символов принадлежит языку, то она считается синтаксически правильной. Для программы это означает, что транслятор на ней не выдает ошибки. Но синтаксическая правильность не гарантирует даже осмысленности программы. Таким образом, здесь определяется лишь одна сторона языка.
    Третий вариант работает только вместе со вторым, поскольку структурные единицы должны соединиться в синтаксически правильную систему. Он раскрывает еще одну сторону языка.
    Таким образом, мы видим, что каждый язык имеет три стороны: синтаксис (второй вариант), семантика (третий вариант), прагматика (первый вариант).

    Синтаксис алгоритмического языка — совокупность правил, позволяющая:

  • формально проверить текст программы (выделив множество синтаксически правильных программ);
  • разбить эти программы на составляющие конструкции и в конце концов на лексемы.


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

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

    Прагматика алгоритмического языка — то, что связывает программу с ее конкретной реализацией. При этом, в частности, происходит следующее.

  • Все определения становятся явными (изгоняются такие понятия, как "не определено", "определяется реализацией" и т. п.)2).
  • Появляются дополнительные конструкции, описатели и др., обусловленные реализацией. Они обязательно учитывают:
  • особенности вычислительной машины и среды вычислений;
  • особенности принятой схемы реализации языка;
  • обеспечение эффективности вычислений;
  • ориентацию на специфику пользователей.




  • Прагматика иногда предписывается стандартом языка, иногда нет. Это зависит от того, для каких целей предназначены язык и его реализация.

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


    Семантика


    Мы определили семантику как соответствие между синтаксически правильными программами и действиями абстрактного исполнителя. Но остается вопрос, как задавать это соответствие. Семантика чаще всего определяется индукцией (рекурсией) по синтаксическим определениям.
    Цель программиста - получить нужный ему эффект в результате исполнения программы на конкретном оборудовании. Но, составляя программу, он думает о программе как об абстрактной сущности и чаще всего совсем не хочет знать о регистрах, процессоре и других объектах конкретного оборудования. В соответствии с позицией программиста моделью вычислений языка программирования естественно считать то, какой абстрактный вычислитель задается описанием языка. Эта позиция подкрепляется также тем, что трансляция и исполнение может осуществляться на разных конкретных вычислителях. Следуя этой точке зрения, мы, говоря о модели программы, всегда имеем в виду ее образ в виде команд абстрактного, а не конкретного вычислителя.
    Понятие модели вычислений языка естественно распространяется на случаи, когда используются библиотеки программ. Библиотеки, стандартизованные описанием языка, можно считать частью реализации языка независимо от того, как реализуются библиотечные средства: на самом языке или нет. Иными словами, библиотечные средства - дополнительные команды абстрактного вычислителя языка. Не зависящие от определения языка библиотеки можно рассматривать как расширения языка, т. е. как появление новых языков, включающих в себя исходный язык. И хотя таких расширений может быть много, рассмотрение модели вычислений для языка вместе с его библиотеками хорошо соответствует стилю мышления человека, конструирующего программу4).
    Задаваемое семантикой соответствие между входными данными, программой и действиями, вообще говоря, определяется лишь полным текстом программы, включающим, в частности, все тексты используемых библиотечных модулей, но для понимания программы и работы над ней необходимо, чтобы синтаксически законченные фрагменты программы могли интерпретироваться автономно от окружающего их текста. Надо заметить, что современные системы практически никогда этому требованию не удовлетворяют. Слишком часто для понимания ошибки в программе нужно анализировать необъятные тексты библиотек.
    Реализованный язык всегда является прагматическим компромиссом между абстрактной моделью вычислений и возможностями ее воплощения.


    и самая разработанная часть описания


    Синтаксис — самая простая и самая разработанная часть описания алгоритмического языка.
    Грамматика определяется системой синтаксических правил (чаще всего в описаниях языков называемых просто правилами). На уровне грамматики определяются понятия, последовательное раскрытие которых, называемое выводом, в конце концов дает их представление в виде последовательностей символов. Символы называются также терминальными понятиями, а все остальные понятия нетерминальными. Понятия бывают смысловые, т. е. языковые конструкции, для которых определено то или иное действие абстрактного вычислителя, и вспомогательные, нужные лишь для построения текста, но самостоятельного смысла не имеющие. Минимальные смысловые понятия соответствуют лексемам. Некоторые понятия вводятся лишь для того, чтобы сделать текст читаемым для человека. Минимальные из них (они подобны знакам пунктуации) естественно считать вспомогательными лексемами.
    Но даже задача полного описания синтаксиса достаточно сложна и требует выделения подзадач.
    Синтаксис принято разделять на две части:
  • контекстно-свободную грамматику (КС-грамматику) языка;
  • правила задания контекстных зависимостей (например, соответствия между описаниями и именами).

  • Понятие контекстно-свободной грамматики стало первым строгим понятием в описаниях практических алгоритмических языков. За понятием КС-грамматики при внешней его простоте стоит достаточно серьезная теория. Эта грамматика представляется во многих формах (синтаксические диаграммы, металингвистические формулы Бэкуса-Наура либо расширенные металингвистические формулы) и, как правило, сопровождает систематизированное изложение конкретного языка. Каждое такое конкретное представление КС-грамматики достаточно просто, и может быть изучено по любому учебнику программирования.
    Содержательно можно охарактеризовать КС-грамматику языка как ту часть его синтаксиса, которая игнорирует вопросы, связанные с зависимостью интерпретации лексем от описаний имен в программе.
    Контекстные зависимости сужают множество правильных программ.
    Например, правило " все идентификаторы должны иметь описания в программе" указывает на то, что программа с неописанными именами не принадлежит данному языку (хотя она и допустима с точки зрения контекстно-свободного синтаксиса).
    Неоднократные попытки формально описывать контекстные зависимости при определении языков показали, что эта задача гораздо более сложная, чем задание контекстно-свободного синтаксиса. Вдобавок ко всему, даже такие естественные правила, как только что представленное, при формальном описании становятся громоздкими и весьма трудными для понимания человека. По этой причине в руководствах редко прибегают к формализации описаний контекстных зависимостей (одним из немногих исключений является Алгол 68).
    Пример 4.2.1. Требование о том, что каждое имя должно быть описано (в частности, в языках Pascal и C), конкретизируется в следующей форме.
  • Для каждого имени должно быть описание, в котором оно встречается.
  • Это описание должно стоять либо в данном, либо в охватывающем его блоке и предшествовать в тексте программы использованию данного имени.
  • Два описания одного и того же имени в одном и том же блоке не считаются ошибкой лишь в том случае, если первое из них является предварительным упоминанием, а второе — полноценным описанием.
  • Если есть несколько описаний одного и того же имени в разных блоках, действующим считается то из них, которое стоит в самом внутреннем блоке.
  • Если действующее описание определяет имя как глобальное, то оно не должно противоречить никакому другому глобальному описанию того же имени, встречающемуся в других блоках программы.

  • Такая совокупность требований достаточна для того, чтобы человек мог проверить по тексту программы, как в данном месте понимается данное имя3).
    В практических описаниях языков и в курсах программирования обычно довольствуются неформальным, но достаточно точным описанием контекстных зависимостей. Приведем пример такого описания.

    Алгоритм для сопоставления объектного выражения E с образцом P в Рефал-5.


    Вхождения атомов, скобок и переменных будут называться элементами выражений. Пропуски между элементами будут называться узлами. Сопоставление E : P определяется как процесс отображения, или проектирования, элементов и узлов образца P на элементы и узлы объектного выражения E. Графическое представление успешного сопоставления приведено на рис. 5.2. Здесь узлы представлены знаками o.
    Следующие требования являются инвариантом алгоритма сопоставления и их выполнение обеспечивается на каждой его стадии.


    Дополнительные возможности


    В нашей программе 5.3.1 алфавит определен статически. Было бы хорошо иметь возможность заменять эту глобальную информацию. Для хранения динамической глобальной информации (чаще всего числовых характеристик либо словарей) в языке Рефал имеются стандартные функции работы со стеками закопанных данных. Функция


    выкапывает верхушку стека. Если стек пуст, то ошибки нет, просто выдается пустое выражение. Несколько других функций дополняют возможности работы с глобальной информацией. Cp копирует верхушку стека без ее удаления, Rp замещает верхушку стека на свой аргумент, DgAll выкапывает сразу весь стек.
    Ввод-вывод организован в Рефале довольно лаконично, без излишеств. Имеется функция открытия канала ввода, которая открывает файл либо для ввода, либо для вывода (в этом случае первым аргументом служит 'r' и присваивает ему номер. Одна строка символов из файла читается с помощью функции Get, заменяющей свой вызов на прочитанную строку, одна строка пишется в файл путем функций

    либо

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

    или или или

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

    Только что перечисленные функции вместе с функцией Go требуют объяснения инструментов модульности в Рефале. Рефал-модуль — просто Рефал-программа, не обязательно включающая Go. Функции, предоставляемые в пользование другим модулям, описываются спецификатором $ENTRY как входы. В свою очередь, использующий модуль должен описать внешние функции:

    $EXTRN F1,F2,F3;

    Вызов программы, состоящей из нескольких модулей, производится оператором примерно следующего вида:

    refgo prog1+functions+reflib

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

    В частности, только что описанные расширенные функции ввода-вывода определяются в стандартном модуле reflib.

    Важнейшими средствами современного Рефала является работа с метавыражениями. Базовое ее средство — встроенная функция Mu, которая заключает свой аргумент в функциональные скобки и тем самым дает возможность вычислить динамически построенное выражение. По словам Турчина, Mu работает так, как работало бы определение

    Mu { s.F e.X = },

    если бы оно было синтаксически допустимо.

    В частности, через Mu работает стандартный модуль Рефала e (Evaluation), дающий возможность вычислить динамически введенное выражение. Он обрабатывает это выражение через функцию Upd, которая должна быть добавлена к модулю, где осуществляется динамическое вычисление выражений. Например, если добавить описание

    $ENTRY Upd {e.X = ;}

    то командная строка refgo e+prog1 приведет к требованию написать выражение. Это выражение будет сделано полем памяти программы prog1 и вычислено, а результат выведен.


    Например, написав для программы 5.3.1



    мы получим в качестве результата

    'abcdefghijklmnopqrstuvwxyz'

    Естественно возникает вопрос об обработке внутри языка не только объектных, но и произвольных выражений. Для этого имеются стандартные функции Up и Dn. Первая из них превращает объектное выражение в выражение произвольного вида, вторая кодирует свою область зрения (ею, по семантике языка, может быть лишь объектное выражение) в форме, годящейся для общих выражений и даже метавыражений. В стандартном комплекте Рефала есть даже модуль прогонки, который позволяет подать на вход программы метавыражение и вычислить его настолько, насколько это возможно без знания значений переменных.

    При решении сложных задач на Рефале естественно возникла задача представления мультииерархической структуры. Для частного случая двух независимых иерархий, одна из которых считается главной, а вторая начнет работать после исчерпания главной, в Рефале разработано мультискобочное представление выражений.Оно поддерживается библиотечными функциями кодирования и декодирования выражений и программ, переводящих мультискобочное выражение в его стандартный код и наоборот. Для частного случая, когда вторичная иерархия — наши обычные скобки, а первичная иерархия — ссылка глубоко внутрь скобочного выражения, неявно задающая одноуровневую скобочную структуру, независимую от стандартной, алгоритм кодирования исключительно прост. Выражение разбито на две несбалансированные по скобкам части: левую и правую. В обеих частях непарные скобки заменяем на пары )(. Открывающую скобку высшего уровня представляем как ((, место, куда ведет ссылка — как ))(, закрывающую скобку высшего уровня как )).

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

    Пусть у нас дано выражение с различными парными скобками (в конкретном случае мы используем пары '()[]{}<>', но программа составлена так, чтобы эти пары можно было заменить в любой момент).


    Для эффективной работы на Рефале это выражение нужно закодировать, используя структурные скобки. Кодом пары скобок Левая Правая будут скобки (Левая и Правая). Ниже дан алгоритм кодировки.

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

    Иерархически вложенные функции и условия в принципе не нужны для Рефала, но их использование позволяет сократить и, главное, лучше структурировать текст программы.

    $ENTRY Go{=;} $EXTRN Xxout; * Инициализация поля зрения и констант Init{=)";} Acquire { e.Got ()= e.Got; * Конец ввода - пустая строка e.Got (e.New)=)>; } Brackets {=('()')('[]')('')('<>');} Trans { e.A = >; } Pairing { * В первой скобке содержится последовательность всех незакрытых * скобок вместе с сегментами данных, подлежащими помещению * в даннуюпару скобок; * каждый сегмент данных также заключен в скобки (e.Unclosed (e.LastUn)(s.Lbrack e.Middle)) s.Rbrack e.Last, : e.A(s.Lbrack s.Rbrack ) e.B = * Встретилась правая скобка, парная последней незакрытой; * выбрасываем отработанный сегмент из поля зрения ; ((s.Lbrack e.Middle)) s.Rbrack e.Last, : e.A(s.Lbrack s.Rbrack ) e.B = * Парная незакрытой, находящейся внутри другой незакрытой (s.Lbrack e.Middle s.Rbrack) ; (e.Unclosed (e.LastUn)(s.Lbrack e.Middle)) s.Rbrack e.Last, : e.A(s.Lbrack1 s.Rbrack ) e.B = * Непарные скобки Error; (e.Unclosed ) s.Lbrack1 e.Last, : e.A(s.Lbrack1 s.Rbrack ) e.B = * Еще одна открывающая скобка; создаем новую группу данных ; () s.Lbrack e.Last, : e.A(s.Lbrack s.Rbrack ) e.B = * Первая открывающая скобка ; () s.Rbrack e.Last, : e.A(s.Lbrack s.Rbrack ) e.B = * Первая скобка - закрывающая Error; * Нейтральный символ вне скобок () s.Other e.Last = s.Other ; * Выражение и скобки исчерпаны - успех () =; * Выражение исчерпано, а скобки - нет (e.Unclosed (e.Lastun))=; } Result { * Если была ошибка, выйти e.A Error=; * Иначе вывести результат для дальнейшего использования e.A =; }



    Листинг 5.4.1. Мультискобочное выражение: Рефал

    Для того, чтобы нагляднее увидеть влияние стиля на программные решения, сравните эту программу с развитием программы в традиционном стиле, приведенным в § 11.5 книги [22]. Данная программа намного выразительнее, короче, легче модифицируема и не менее эффективна, чем программа 11.5.3 из указанного параграфа.

    В практике работы со студентами автору приходилось сталкиваться с ситуацией, когда программа для одной и той же задачи, написанная на традиционном языке, была более чем в пять раз длиннее программы на Рефале. Так что использование специализированных инструментов окупается (естественно, когда они применены в подходящих случаях).

    Из литературы по языку Рефал можно рекомендовать учебные пособия [38], первое из которых имеется в общедоступном русском переводе, в частности на сайте http://www.refal.ru/diaspora.html.

    Дополнительные возможности
    Дополнительные возможности
    Дополнительные возможности
      1)

      Здесь слово 'структура' понимается не в узко программистском, а в научном и математическом смысле.

      2)

      Здесь у языка Рефал общая особенность с языками Prolog и LISP!

      3)

      Заметьте тонкую разницу между словами 'формироваться' и 'задаваться'! Имена функций заданы статически, но конкретное имя из заданного конечного множества функций может быть сформировано динамически.

      4)

     

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

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

      5)

      Изменение, введенное в ныне работающую реализацию Refal-PZ. В учебнике Турчина двойные кавычки являются альтернативным вариантом ограничителя строк.

      6)

      К этой концептуальной конфетке еще бы красивую обертку!

    Дополнительные возможности

    Конкретизация


    Язык Рефал был создан В. Ф. Турчиным для программирования символьных преобразований. Исходный толчок он получил от идеи алгорифмов Маркова (см., например, теоретическое приложение к книге [21]), но эта идея была полностью пересмотрена в ходе работы по созданию языка. Идейный и математический уровень проработки языка исключительно высокий, но вопросы дизайна почти проигнорированы.
    В основе языка лежит (другой по сравнению с языком PROLOG) частный случай операции отождествления: конкретизация метавыражения.
    Под конкретизацией понимается такой частный случай отождествления, при котором переменные встречаются лишь в метавыражении и поиск их значений происходит путем подбора без рекурсии.
    Язык Рефал определен через три компонента: структуру данных, Рефал-машину, обрабатывающую эти данные, и собственно конкретный синтаксис языка.


    Модель вычислений и Рефал-программа


    Основные два шага в Рефал-вычислениях — конкретизация переменных в образце в соответствии с областью зрения и подстановка полученных значений в другое метавыражение. В языке рассматривается лишь частный случай конкретизации.
    Конкретизация образца Me в объектное выражение E называется такая подстановка значений вместо переменных Me, что после применения данной подстановки Me совпадет с E.
    Заметим, что одно и то же метавыражение может иметь много конкретизаций в одно и то же объектное выражение. Например, рассмотрим метавыражение
    e.Begin s.Middle e.End (5.3)
    и объектное выражение
    AhAhAh 'OhOhOh' (Ugu','Udgu) '(((' Basta'!' (5.4)
    Имеется 11 вариантов конкретизации (5.3) в (5.4) (проверьте!).
    Если у метавыражения Me есть много вариантов конкретизации в E, то они упорядочиваются по предпочтительности в следующем порядке.
    Пусть Env1 и Env2 — два варианта конкретизации Me в P. Рассмотрим все вхождения переменных в Me. Если Env1 и Env2 не совпадают, они приписывают некоторым переменным различные значения. Найдем в P самое первое слева вхождение переменной, которому Env1 и Env2 приписывают разные значения и сравним длину этих значений. Та из конкретизаций, в которой значение, приписываемое данному вхождению переменной, короче, предшествует другой и имеет приоритет перед ней.
    Например, сопоставим объектное выражение (A1 A2 A3)(B1 B2) с образцом e.1 (e.X s.A e.Y) e.2. В результате получится следующее множество вариантов сопоставления:
    {e.1 = , eX = , sA = A1, eY = A2 A3, e.2 = (B1 B2) } {e.1 = , eX = A1, sA = A2, eY = A3, e.2 = (B1 B2) } {e.1 = , eX = A1 A2, sA = A3, eY = , e.2 = (B1 B2) } {e.1 = (A1 A2 A3), eX = , sA = B1, eY = B2, e.2 = } {e.1 = (A1 A2 A3), eX = B1, sA = B2, eY = , e.2 = }
    Пример 5.1. Множество вариантов сопоставления
    Варианты сопоставления перечислены в соответствии с их приоритетами, т. е. самый первый вариант находится на первом месте и т. д. Описанный способ упорядочения вариантов сопоставления называется сопоставлением слева направо.
    Тот алгоритм конкретизации, который используется в Рефале, называется проецированием и согласован с введенным нами отношением порядка. Опишем его (описание взято из учебника Турчина [38]). Обратите внимание, как в данном случае общая и не всегда эффективно реализуемая операция 'проецируется' на свою частную реализацию, одновременно повышающую эффективность, сохраняющую общность и предписывающую методику программирования.


    Общие требования к отображению P на E (сопоставлению E : P)


  • Если узел N2 расположен в P правее узла N1, то проекция N2 в E может либо совпадать с проекцией N1, либо располагаться справа от нее (линии проектирования не могут пересекаться).
  • Скобки и атомы должны совпадать со своими проекциями.
    Общие требования к отображению P на E (сопоставлению E : P)
    Рис. 5.2.  Сопоставление E : P является отображением P на E. Здесь объектным выражением E является 'A'((2'B'))'B', а образцом P является 'A'(e.1 t.2)s.3
  • Проекции переменных должны удовлетворять синтаксическим требованиям их значений; т. е., быть в соответствии с типом переменной атомами, термами или произвольными выражениями. Различные вхождения одной переменной должны иметь одинаковые проекции.

  • Предполагается, что в начале сопоставления граничные узлы P отображаются в граничные узлы E. Процесс отображения описывается при помощи следующих шести правил. На каждом шаге отображения правила 1–4 определяют следующий элемент, подлежащий отображению; таким образом, каждый элемент из P получает при отображении уникальный номер.


    Правила отображения


  • После того как отображена скобка, следующей подлежит отображению парная ей скобка.
  • Если в результате предыдущих шагов оба конца вхождения некоторой переменной для выражений уже отображены, но эта переменная еще не имеет значения (ни одно другое ее вхождение не было отображено), то эта переменная отображается следующей. Такие вхождения называются закрытыми e-переменными. Две закрытые e-переменные могут появиться одновременно; в этом случае та, что слева, отображается первой.
  • Вхождение переменной, которая уже получила значение, является повторным. Скобки, атомы, символьные и термовые переменные и повторные вхождения переменных для выражений в P являются жесткими элементами. Если один из концов жесткого элемента отображен, проекция второго конца определена однозначно. Если Правила 1 и 2 неприменимы, и имеется несколько жестких элементов с одним спроектированным концом, то из них выбирается самый левый. Если возможно отобразить этот элемент, не вступая в противоречие с общими требованиями 1–3, приведенными выше, тогда он отображается, и процесс продолжается дальше. В противном случае объявляется тупиковая ситуация.
  • Если Правила 1–3 неприменимы и имеются несколько переменных для выражений с отображенным левым концом, то выбирается самая левая из них.Она называется открытой e-переменной. Первоначально она получает пустое значение, т. е. ее правый конец проектируется на тот же узел, что и левый. Другие значения могут присваиваться открытым переменным через удлинение (см. Правило 6).
  • Если все элементы P отображены, это значит, что процесс сопоставления успешно завершен.
  • В тупиковой ситуации процесс возвращается назад к последней открытой e-переменной (т. е. к той, что имеет максимальный номер проекции), и ее значение удлиняется; т. е. проекция ее правого конца в E продвигается на один терм вправо. После этого процесс возобновляется. Если переменную нельзя удлинить (из-за Общих требований 1–3), удлиняется предшествующая открытая переменная, и т. д. Если не имеется подлежащих удлинению открытых переменных, процесс сопоставления не удался.


  • На рис. 5. 2 сопоставление производится следующим образом. Вначале имеется два жестких элемента с одним отображенным концом: 'A' и s.3. В соответствии с Правилом 3 отображается 'A', и этот элемент получает при отображении номер 1.Номера 2 и 3 будут назначены левой и правой скобкам согласно Правилам 3 и 1. Внутри скобок начинается перемещение справа налево, так как t.2 является жестким элементом, который может быть отображен, в то время как значение e.1 еще не может быть определено. На следующем шаге обнаруживается, что e.1 является закрытой переменной, чью проекцию не требуется обозревать для того, чтобы присвоить ей значение; что бы ни было между двумя узлами, это годится для присвоения (на самом деле, значение e.1 оказывается пустым). Отображение s.3 завершает сопоставление. Расположение отображающих номеров над элементами образца дает наглядное представление описанного алгоритма:

    1 2 5 4 3 6 'A' ( e.1 t.2 ) s.3

    Этот сложный алгоритм упрятан в простые программные конструкции.

    Программа на Рефале является последовательностью определений функций. Каждое описание функции в Рефале имеет вид

    Имя функции {Последовательность сопоставлений}

    Каждое сопоставление имеет вид

    Образец = Метавыражение;

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

    Пример 5.3.2. Рассмотрим пример Рефал-программы.

    Pre_alph { *1. Отношение рефлексивно s.1 s.1 = T; *2. Если буквы различны, проверить, входит ли * первая из них в алфавит до второй s.1 s.2 = >; } Before { s.1 s.2 In e.A s.1 e.B s.2 e.C = T; e.Z = F; } Alphabet { = 'abcdefghijklmnopqrstuvwxyz'; }

    Листинг 5.3.1. Программа вычисления предиката предшествования одного символа другому в заданном алфавите

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

    A1 A2, sA


    {e.1 = , eX = , sA = A1, eY = A2 A3, e.2 = (B1 B2) } {e.1 = , eX = A1, sA = A2, eY = A3, e.2 = (B1 B2) } {e.1 = , eX = A1 A2, sA = A3, eY = , e.2 = (B1 B2) } {e.1 = (A1 A2 A3), eX = , sA = B1, eY = B2, e.2 = } {e.1 = (A1 A2 A3), eX = B1, sA = B2, eY = , e.2 = }
    Пример 5.1. Множество вариантов сопоставления
    Закрыть окно



    Структура данных


    Данные, обрабатываемые языком Рефал, представляют собой последовательности атомов, структурированные несколькими согласованными между собою видами скобок. Например, в некотором конкретном синтаксисе такое выражение могло бы иметь вид {a+[b-(c+d)**2]/3}*(a+d). В поле памяти Рефал-машины задействованы два вида скобок: структурные и функциональные.
    Выражения языка Рефал.
  • Атомы являются выражениями.
  • Любая последовательность выражений является выражением.
  • Выражение E, заключенное в структурные скобки, является выражением. Атомы и выражения в структурных скобках носят общее имя термов.
  • Если E — выражение, A — описанный в программе атом, AE, заключенное в функциональные скобки, является выражением (называемым функциональным выражением). A называется детерминативом этого выражения.

  • В конкретном синтаксисе Рефала функциональные скобки обозначаются < >, структурные -- ( ). Атомы делятся на:
  • Символы (байты, простые символы).
  • Составные символы (имена, определенные в программе) представлены идентификаторами, начинающимися с большой буквы.
  • Целые числа без знака, меньшие 232, например, 123456789.
  • Действительные числа, например, 123456789.E-5.

  • В поле памяти выделяется основная часть, а также стеки глобальных данных - закопанные данные. Перед каждым шагом исполнения в основной части поля памяти выделяется активная часть - поле зрения. Данные в поле зрения и закопанные данные имеют общую структуру1), которая является подструктурой структуры поля памяти. Поскольку и программа имеет практически ту же самую структуру2), в ходе развития языка появилась третья структура данных (метаданные), расширяющая поле памяти.
    Дадим точные определения.
    Объектное выражение — выражение, не содержащее функциональных скобок.
    Минимальное функциональное выражение — выражение, имеющее вид , где E — объектное выражение.
    поле памяти Рефал-машины представляет собой набор выражений, одно из которых называется основной частью. В основной части в любой момент, за исключением момента выдачи окончательного результата и остановки программы, есть функциональные скобки.

    Поле зрения (активная часть) поле памяти — первое из встречающихся в основной части поля памяти минимальных функциональных выражений.

    Детерминатив — первый символ в функциональной скобке.

    Детерминатив интерпретируется как имя функции, обрабатывающей содержимое функциональных скобок. Эта функция должна определяться статически, поэтому в ходе вычислений не могут образовываться выражения вида < e.2>. В подавляющем большинстве случаев детерминатив должен быть именем, но некоторые обычные символы, например +, также могут использоваться в качестве детерминативов.

    Пример 5.2.3. Рассмотрим пример памяти Рефал-машины в ходе вычислений.

    Поле памяти:

    'aaxzACDE' Perm 'G'1.5E5> >'XZ<(')

    Поле зрения выделено в поле памяти жирным шрифтом. Заметим, что в поле памяти можно выделить данные, обработка которых уже завершена и которые не изменятся до конца исполнения программы (те, которые находятся вне программных скобок; в нашем случае 'aaxzACDE' и 'XZ<('). У символов, представляющих скобки, есть 'обычные' двойники, не обязательно имеющие парные и не влияющие на структурирование выражения. Первый атом Perm стоит в позиции функции, а последний из атомов Perm стоит в позиции данных, так что имена функций могут формироваться3) динамически. При записи программы пробелы, если они не находятся внутри символьных констант, игнорируются, за исключением тех, которые отделяют один символ от другого (эти пробелы просто опускаются после того, как на этапе лексического анализа сыграли роль разделителей). И, наконец, еще одна тонкость. 123 — это один атом, '123' — три атома, -123.0 — опять один атом, -123 — два атома: символ '-', после которого стоит число.

    Кроме основной части поля памяти, в ходе исполнения может появиться несколько стеков закопанных выражений, например:

    Stack1 '=' 15 Stack2 '=' Perm B A 21 Perm C2 C1 -45 Perm 'X' 20 60

    В принципе, несколько стеков — избыточная конструкция.


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

    Допустим, есть строка < AB(CD)(E)F>. Она во всех известных нам Рефал-системах представляется двунаправленным списком, изображенным на рис. 5.1. Этот формат представления стал общераспространенным для Рефал-систем, начиная с реализации, описанной в [25]. Однако

    Структура данных
    Рис. 5.1.  Реализация структуры данных Рефала

    он не является обязательным4). Преобразование выражений в формат для обработки делается один раз, при вводе информации либо при трансляции программы.

    Рассмотрим конкретный синтаксис выражений.

    Идентификатор — любая последовательность цифр и букв, начинающаяся с большой буквы. Идентификатор является символьным литералом и представляет составной символ. Символы, заключенные в одинарные ' ' кавычки, являются символьными литералами, представляют сами себя. Дважды повторенная кавычка представляет кавычку. Двойные кавычки " " используются для ограничения имени составного символа, не обязательно являющегося идентификатором5). Если вне кавычек встречается символ, не являющийся скобкой, частью идентификатора или числа, это трактуется как ошибка.

    Определение метавыражения Рефала получается из определения выражения добавлением еще одного базисного случая: переменная является метавыражением.Переменные не могут быть детерминативами.

    Метавыражение называется образцом, если оно не содержит функциональных скобок.

    В конкретном синтаксисе обозначение переменной включает тип и символ переменной, записываемые как тип.атом. В стандартном Рефале имеются переменные трех типов: символьные (s.First), термовые (t.Inner) и общие (e.Last).

    Значением символьной переменной служит атом, термовой — терм (символ или выражение в скобках), общей — произвольное (может быть, пустое) объектное выражение.

    Рефал развивался как язык символьных преобразований в самом широком смысле этого слова, и поэтому в него была заложена возможность обрабатывать собственные программы.Он предоставляет стандартные функции метакодирования, взаимно-однозначно и взаимнообратно преобразующие произвольное метавыражение в объектное и наоборот. Таким образом, появляется возможность создавать программы в ходе выполнения других программ и затем выполнять их "на лету". Такая возможность, просто гибельная для программ в большинстве систем и стилей программирования, в данном случае не приводит ни к каким отрицательным последствиям из-за уникального концептуального единства и продуманности языка6).


    Динамическое пополнение и порождение программы


    Поскольку структура программы и структура Поля зрения практически изоморфны, естественно ставить вопрос о динамическом порождении PROLOG-программ. Кроме этого высокоуровневого соображения, есть и прагматическое, которое можно извлечь из нашей программы 6.3.2. В нашу программу мы были вынуждены записать и определение лабиринта.Конечно же, можно было бы прочитать с помощью встроенной функции read определение лабиринта и записать его в список, но тогда мы почти утратили бы все преимущества языка PROLOG, не избавившись при этом ни от одного его недостатка. Гораздо естественнее иметь возможность прочитать базу данных из файла.
    Для этой цели в PROLOG был введен встроенный предикат consult(file[.pl]). Он читает предложения и факты из файла и помещает их в конец программы, тем самым оставляя в неприкосновенности ранее данные определения предикатов. С его использованием наша программа может быть переписана в следующем виде.
    way0(X,Y,Z):-consult(labyr),way(X,Y,Z). way(X,X,[X]). way(X,Y,[Y|Z]):-connect(U,Y), not member(Y,Z),way(X,U,Z). way(X,Y,[Y|Z]):-connect(U,Y), way(X,U,Z).
    Листинг 6.4.1. Вводимый лабиринт
    Пример файла labyr.pl:
    connect(begin,1). connect(1,begin). connect(1,2). connect(2,3). connect(3,1). connect(3,4). connect(4,end).
    Программа 6.4.1 представляет лишь идею решения, но эту идею она представляет исключительно выразительно. PROLOG приспособлен для нахождения решения, но не для его оптимизации. Например, если стремиться найти в некотором отношении оптимальный путь, то предыдущая программа окажется затемнена частностями языка PROLOG, которые испортят выразительность, и тем не менее не дадут возможность решить задачу столь же эффективно, как это делается на традиционном языке.
    Есть еще один класс встроенных отношений, которые действуют как встроенные функции, но не требуют явной активизации операцией is. К ним, в частности, относятся многие действия над списками. Рассмотрим, например, предикат append(E1,E2,E3). Он корректно унифицируется, когда объединение первых двух списков является третьим.
    Соответственно, он может использоваться для вычисления любого из своих трех аргументов, если два других заданы. Например,

    append(X,Y,Z)

    при Z=[a,b,c,d], Y=[c,d] унифицируется как X=[a,b].

    Очень жаль, что в PROLOG таким же образом не реализованы арифметические функции!

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

    Метапредикат assert(P:-P1,. . . ,Pn) помещает свой аргумент в PROLOG- программу. Имеются несколько его вариантов, располагающие новое предложение или факт в начало или в конец программы. Метапредикат retract(P:-P1,. . . ,Pn), наоборот, удаляет из программы предложение или факт, унифицируемый с его аргументом.

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

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

    Далее, после того, как использованы динамически порожденные факты или предложения, их можно удалить из программы при помощи предиката

    retractall(Name / Arity)

    Этот предикат удаляет все предложения и факты, говорящие о предикате Name арности Arity. Естественно, при этом удаляется лишь определение. Если Вы его использовали, то нужно потрудиться удалить также использующие предложения.


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

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

    Внимание!

    В новых версиях языка PROLOG предикаты, которые Вы намерены динамически видоизменять, нужно объявить. Например, dynamic(connect).

    Для проверки типов термов имеются, в частности, следующие встроенные предикаты.

  • var(Term). Унифицируется, если Term свободная переменная.
  • nonvar(Term). Унифицируется, если textsfTerm не свободная переменная.
  • integer(Term) Успешен, если Term является целым числом (именно числом, а не выражением).
  • float(Term) Успешен, если Term является действительным числом.
  • number(Term) Успешен, если Term является числом.
  • atom(Term) Успешен, если Term является атомом.
  • string(Term). Успешен, если Term является строкой.
  • atomic(Term). Успешен, если Term является неделимым значением (число, строка или атом).
  • compound(Term). Успешен, если Term является сложным выражением.
  • ground(Term). Успешен, если Term не содержит свободных переменных.


  • Для анализа и построения термов имеются, в частности, следующие предикаты.

  • functor(Term, Functor, Arity) Унифицируется, если Term является термом с главным функтором Functor арности Arity. Term, являющийся переменной, унифицируется с новой переменной. Если Term является атомом либо числом, то его арностью считается 0, а функтором он сам.
  • arg(Arg, Term, Value) Выделение аргумента терма Term по его номеру Arg.Номера начинаются с 1.Естественно, что данный предикат может быть использован и для определения номера аргумента в терме.
  • Term =.. List Унифицируется, если List является списком, головой которого является функтор терма Term, а оставшиеся члены задают аргументы (сравните с тем, что ниже рассматривается в языке LISP!) Если не использовать его как операцию, то имя этого предиката Univ. Естественно, он может работать в обе стороны, разбирая либо собирая терм.


  • Примеры.

    ?- send(hello, X) =.. List. List = [send, hello, X] ?- Term=.. [send, hello, X] Term = send(hello,X)

    free_variables(Term, List) List унифицируется как список новых переменных, каждая из которых равна свободной терма Term.

    atom_codes(Atom, String) Преобразование атома в строку и наоборот.

    Многие из реализаций языка PROLOG включают пакет прогонки, позволяющий осуществлять частичное вычисление PROLOG-программы.


    Общие концепции


    Язык логического программирования PROLOG представляет собой одну из моделей сентенциального программирования. Мы используем лишь те возможности языка PROLOG, которые согласуются с принятым в 1996 г. стандартом [36]. Как руководство по программированиюна языке PROLOG можно использовать, скажем, книгу [6]. В ней содержится наименьшее число недочетов и откровенных ошибок, а также наибольшее число практических советов по сравнениюс другими известными автору пособиями.
    Первой находкой создателей языка PROLOG явилось понятие унификации, изобретенное в методе резолюций для доказательства формул классической логики предикатов.
    Два выражения называются унифицируемыми, если они могут быть приведены к одному и тому же виду подстановкой значений вместо свободных переменных. Унификация—вид конкретизации, при котором границы всех синтаксических единиц фиксированы, структура выражения однозначно определена и подстановка, приводящая два выражения к одному и тому же виду, вычисляется рекурсивно.
    Второй находкой, перенесенной авторами языка PROLOG из специализированных программ (для логики и искусственного интеллекта) в языки программирования, стала система обработки неудач. Успешно произведенная унификация является лишь разрешением выполнить некоторое действие. После проверки других условий, возможно, мы будем вынуждены вернуться и выбрать другой вариант.
    Третья находка языка PROLOG, перенесенная в программирование из метода резолюций, — это стандартизация цели. Целью доказательства в методе резолюций всегда является получение пустого дизъюнкта, то есть стирание доказываемого выражения (с логической точки зрения, приведение его к абсурду). Точно так же и в языке PROLOG: успешное исполнение программы означает стирание поля зрения.
    Четвертая находка создателей языка PROLOG взята из ограничения классической логики. Хорновские формулы
    Общие концепции

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


    Организация вычислений и ввода-вывода


    Во многих случаях даже в поисковой программе необходимо производить вычисления. В языке PROLOG имеется способ вычисления значения по аргументам. Это так называемые встроенные функции, которые могут быть заменены на свое значение, если их аргумент известен. Такими функциями служат, в частности, числовые арифметические операции. Заметим, что даже встроенная функция не вычисляется, пока не будет дан явный сигнал. У Вас в программе может, скажем, накопиться в качестве значения переменной выражение 1 + 1 + 1, но оно не будет равно 3.
    Для организации вычисления имеется специальное отношение X is E. В этом отношении Е является таким выражением, которое после подстановки текущих значений переменных конкретизируется7) в композицию встроенных функций от константных аргументов. Эта композиция вычисляется, и переменная Х унифицируется как ее значение.
    Таким образом, можно постепенно накапливать вычисления, а затем в подходящий момент их произвести. Смотрите пример.
    ?- assert(a(1+1)). Yes ?- assert(b(2 * 2)). Yes ?- a(X), b(Y), Z is X + Y. X = 1+1 Y = 2*2 Z = 6
    Внимание!
    is не является присваиванием! Для того, чтобы убедиться в этом, исполните простейшее предложение языка PROLOG:
    ?- X is 1, X is X + 1.
    Лучше всего и естественней всего вводятся в PROLOG-программу данные, полностью соответствующие синтаксису предложений и фактов языка. Если файл данных не очень велик, то для ввода достаточно воспользоваться уже описанным нами предикатом consult.
    Конечно же, имеется и более традиционная система ввода-вывода. Опишем ее базовые возможности.
    open(SrcDest, Mode, Stream, Options)
    Открытие файла. SrcDes является атомом, содержащим имя файла в обозначениях системы Unix. Mode может быть read, write, append или update. Два последних способа открытия используются, соответственно, для дописывания в существующий файл и для частичного переписывания его. Stream либо переменная, и тогда ей присваивается целое число, которое служит для идентификации файла, либо атом, и тогда он служит внутри программы именем файла.
    Options могут быть опущены, среди них нам важна одна опция: type(binary), которая позволяет записать коды в двоичный файл. Опции образуют список.

    Конечно же, имеется возможность вручную установить текущую позицию внутри файла:

    seek(Stream, Offset, Method, NewLocation)

    Method — это метод отсчета относительной позиции. bof отсчитывает ее с начала файла, current от нынешней точки, eof от конца. Переменная NewLocation унифицируется с новой позицией, отсчитываемой обязательно с начала.

    Предикат close(Stream) комментариев не требует.

    read(Stream, Term)

    Переменная Term унифицируется с термом, прочитанным из потока Stream.

    read_clause(Stream, Term)

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

    read_term(Stream, Term, Options)

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

    writeq(Stream, Term)

    Term пишется в Stream, вставляются кавычки и скобки, где нужно.

    write_canonical(Stream, Term)

    Term пишется в Stream таким способом, чтобы его однозначно прочитала любая PROLOG-программа, а не только Вы и Ваша программа на Вашей системе.

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

    Внимание!

    Если Вам нужно ввести в PROLOG файл иноязычного или просто обычного формата либо вывести из него в предписанном Вам не укладывающемся в систему термов формате, напишите переходник на Рефале или на Perl.

    Тем не менее вот минимальный (и практически полный) список предикатов символьного и двоичного ввода и вывода.

    get_byte(Stream, Byte)

    Byte рассматривается как целое число и унифицируется со следующим байтом входного потока. Конец файла читается как -1.

    get_char(Stream, Char)

    Аналогично, но следующий байт рассматривается как имя атома, состоящее из одного символа. Конец файла унифицируется с атомом end_of_file.


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

    get(Stream, Char)

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

    skip(Stream, Char)

    Пропускает все, пока не встретится символ Char либо конец файла. Само первое вхождение Char также будет пропущено.

    put(Stream, Char)

    Вывод одного символа либо байта. Char унифицируется либо как целое число из диапазона [0, 255], либо как атом с именем из одного символа.

    nl(+Stream)

    Вывести перевод строки.

    Организация вычислений и ввода-вывода
    Организация вычислений и ввода-вывода
    Организация вычислений и ввода-вывода
      1)

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

      2)

      Внутри языка они называются атомами.

      3)

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

      4)

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

      5)

     

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

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

    Желающие в качестве упражнения выловить ошибку самостоятельно, сравните алгоритмы унификации, описанные в книге [30] и [12].

      6)

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

      7)

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

    Организация вычислений и ввода-вывода

    В любой момент исполнения программы


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

    Теперь перейдем к конкретному представлению данных.

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

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

    'C:\"SICS Prolog"\program.pl'.

    Любое константное имя может служить функтором. Функторы различаются арностью (т. е. количеством аргументов), таким образом, может быть сразу несколько функторов с одним и тем же именем. Например, write(a(1)) и write(a(1),file1) используют различные функторы. Некоторые функции могут быть описаны как операции (инфиксные, префиксные либо постфиксные). В отличие от почти всех остальных языков, операции рассматриваются лишь как сокращение для выразительности. Например, x+y означает в точности то же, что и +(x,y).

    Для некоторых из предопределенных в системе функций и предикатов имеются дополнительные ограничения на аргументы3). Например, в функции load(f) f должно быть именем файла.

    Поле зрения (цель), содержащее непосредственно обрабатываемые программой данные, состоит из последовательности термов, разделенных либо запятыми (в этом случае они понимаются как "последовательно достигаемые подцели"), либо символами |, в этом случае подцели "альтернативны".Наиболее важным и классическим является случай последовательно достигаемых подцелей, через который определяется и семантика альтернативных подцелей4).

    В конкретном представлении предложение, например,

    grandfather(X,Z) :- parent(X,Y), father(Y,Z).



    также рассматривается как терм, поскольку имена , (здесь символ запятой — имя операции) и :- рассматриваются как инфиксные операции, причем запятая связывает сильнее.

    Как правило, предложения, относящиеся к одному и тому же предикату, группируются вместе, например:

    parent(X,Y) :- mother(X,Y). parent(X,Y) :- father(X,Y).

    Порядок предложений существенен.

    База данных состоит из фактов. Факты могут выражаться в одном из двух видов. Во-первых, факт может рассматриваться как предложение, немедленно приводящее к успеху (успех— это стирание целей). Поэтому он может быть записан:

    father(ivan,vasilij):-true.

    Здесь мы встретились с одной из двух стандартных целей: true обозначает очевидную удачу, а fail — очевидную неудачу.

    Во-вторых, специально для фактов имеется скоропись, означающая то же самое:

    father(ivan,vasilij).

    В принципе, все остальные структуры языка PROLOG выражаются через элементарные, определенные выше. Но некоторые из структур прагматически настолько важны, что получили отдельное оформление и более эффективную реализацию. Это, прежде всего, списки и строки. Список, в принципе, определяется как терм, построенный из других термов и пустого списка [] применением двухместного функтора .(head,tail). Выстроенная в стандартном порядке композиция

    .(a,.(b,. . . , .(z,[]). . . ))

    понимается как линейный список и обозначается [a,b,. . . ,z].

    Для обозначения присоединения нескольких данных термов к началу списка имеется стандартная операция

    [t,u|L].

    Строки рассматриваются как линейные списки кодов символов и обозначаются последовательностью символов, взятой в двойные кавычки:

    "Ну, получили то, что искали? Ответьте y или n."

    Заслуживает упоминания механизм введения новых операций в язык PROLOG. Каждый пользователь может определить свои собственные унарные или бинарные операции или переопределить стандартные. Приведем в качестве примера описания некоторых стандартных операторов языка PROLOG:

    :- op(1200,xfx, ':-'). :- op(1200,fx, [':-','?-']). :- op(1000,xfy, ','). :- op(700, xfx, [=,is,<,=<,==]). :- op(500, yfx, [+,-]). :- op(500, fx, [+,-,not]). :- op(400, yfx,[*,/,div]).



    Первый аргумент в этих описаниях — приоритет операции. Он может быть от 1 до 1500. Второй аргумент — шаблон операции; x обозначает выражение с приоритетом, строго меньшим приоритета операции; y — выражение с приоритетом, который меньше или равен приоритету операции, f — положение самого символа операции относительно аргументов. Таким образом, шаблон yfx для операции - означает, что выражение X-Y-Z понимается как (X-Y)-Z, шаблон xfy для запятой означает, что t,u,r понимается как t,(u,r),шаблон xfx для :- означает невозможность использования нескольких таких операций подряд без дополнительных скобок. Операции с меньшими приоритетами связывают свои аргументы сильнее. Один и тот же атом может быть определен и как унарная, и как бинарная операция.

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


    Поле зрения, поле памяти и PROLOG-программа


    Когда рассматривается исполнение программы в нетрадиционном языке (например, PROLOG-программы), то естественно воспринимать конкретную реализацию языка как новую машину нетрадиционной архитектуры с высокоуровневыми командами (в данном случае как PROLOG-машину).
    Данные, используемые PROLOG-машиной, размещаются во всех частях поля памяти и имеют общую структуру.
    Рассмотрим на уровне абстрактного синтаксиса структуру данных, обрабатываемых языком PROLOG. Все данные языка PROLOG являются термами. Термы построены из атомов при помощи функциональных символов. Атомами могут быть переменные и константы, в свою очередь, делящиеся на имена и числа. Функциональные символы являются именами и называются функторами. Среди функторов выделяются детерминативы, которые в реализации делятся на предикаты и встроенные функции (функции обычно используются внутри выражений, а предикаты являются основными единицами управления и обычно используются вне скобок как основной функциональный символ выражения). Детерминативы должны быть описаны в программе, а остальные функторы рассматриваются просто как структурные единицы и могут оставаться неописанными.
    В поле памяти выделяется поле зрения, содержащее непосредственно обрабатываемые программой данные. Оно называется также целью и состоит из последовательности термов.
    Поле памяти имеет скрытую (при использовании стандартных возможностей языка) часть, в которой прослеживается история выполнения программы с тем, чтобы в случае необходимости произвести обработку неудачи.
    И, наконец, в поле памяти помещается сама PROLOG-программа, которая естественно структурируется на две части, нередко перемешанные в тексте самой программы, но обычно разделяемые при использовании внешней памяти: база данных и база знаний.
    База данных состоит из фактов, представляющих собой предикат, примененный к термам.
    База знаний состоит из предложений (клауз). Каждое предложение имеет вид, подобный хорновской формуле
    grandfather(X,Z) :- parent(X,Y), father(Y,Z).
    Предложение состоит из головного выражения (соответствующего заключению хорновской формулы) и его раскрытия: нескольких выражений, соединенных как последовательно достигаемые подцели (они соответствуют посылкам хорновской формулы)1).


    Пример greater(X,Y):-greater1(X,Y).


    greater(X,Y):-greater1(X,Y). greater(X,Y):-greater1(Z,Y),greater(X,Z). greater1(X,f(X)). estimation(X,Y):-greater(X,Y),known(Y). known(f(f(f(f(a))))). unknown(a). unknown(b).
    Пример 6.3.1.
    Закрыть окно



    Управление исполнением программы


    Джулией Робинсон доказано (см., напр. [30]), что для выражений первого порядка имеется эффективный алгоритм унификации, находящий для двух выражений унифицирующую подстановку либо обосновывающий, что такой подстановки нет.
    Пример 6.3.1. Две последовательности выражений
    Управление исполнением программы

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

    подстановкой
    Управление исполнением программы

    А в двух последовательностях
    Управление исполнением программы

    никакие два соответственных выражения унифицированы быть не могут.
    Уже в приведенном примере видно, что унификация — глобальная операция.
    Заметим, что логический алгоритм унификации обладает свойством частичного исполнения: если унифицировать две подструктуры, то после исполнения унифицирующей подстановки можно продолжить унификацию остальных подструктур, и результат унификации не изменится. Так что выражения могут унифицироваться одно за другим5).
    Рассмотрим, как исполняется программа на языке PROLOG. В программе может быть одно целевое предложение, не имеющее головной части. Оно начинается с функтора :- или ?-. В программе, транслируемой и исполняемой в пакетном режиме, обычно используется первый функтор, а при задании цели с терминала в режиме диалога—второй. Разница между ними проявляется лишь в режиме диалога. Второй вариант цели позволяет пользователю после нахождения одного из решений продолжить выполнение программы для поиска следующего решения. Первый такой возможности ему не представляет, программа находит какое-нибудь решение и останавливается.
    Исходная цель называется запросом. Переменные, входящие в запрос, носят особый статус. Их значения в ходе последовательных унификаций накапливаются в скрытой части поля памяти программы и при успешном исполнении выдаются в качестве ответа на запрос.
    В каждый момент рассматривается первый из термов цели. Если его детерминатив не является встроенной функцией или встроенным оператором с особым определением, то ищется предложение, голова которого унифицируется с этим термом. При этом прежде всего проверяется наличие предложений, детерминатив которых совпадает с детерминативом первого терма.
    Если таких предложений несколько, то создается точка возврата, в которой запоминается состояние программы для отработки возможных неудач.

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

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

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

    Стандартным ответом программы на запрос служит Yes, если программа закончилась удачно, и No, если она закончилась неудачно. При удаче выводятся значения всех переменных исходного запроса.

    Так, например, если программа и ее база данных имеют вид

    greater(X,Y):-greater1(X,Y). greater(X,Y):-greater1(Z,Y),greater(X,Z). greater1(X,f(X)). estimation(X,Y):-greater(X,Y),known(Y). known(f(f(f(f(a))))). unknown(a). unknown(b).

    Пример 6.3.1.

    то ответом на запрос

    ?-unknown(Y),estimation(Y,X).

    будет

    Y=a X=(f(f(f(f(a)))) Yes

    а при попытке ответа на запрос

    ?-estimation(b,X).



    программа зациклится.

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

    estimation(X,Y):-known(Y),greater(X,Y).

    программа успешно ответит на второй запрос

    No

    Еще более впечатляющий пример рассмотрен в упражнении 5.

    Есть еще одна особенность языка PROLOG, которая кажется явным ляпсусом, но на самом деле является отражением результата Косовского и др. (неизвестного реализаторам и пользователям языка PROLOG, но лучшими из них ощущаемого интуитивно) о несовместимости моделей отождествления PROLOG и Рефала. Вся работа системы PROLOG основана на предположении, что значения унифицируемых переменных набираются однозначно, и следующий вариант может получиться лишь в результате унификации с другим фактом либо предложением. Поэтому когда (как в случае со списками или строками) PROLOG встречается с неоднозначным отождествлением, он никогда не будет перебирать разные его варианты. Он либо выберет первый из них (например, при унификации неизвестных [X|Y] с уже известным списком Z X будет пустым списком), либо зациклится на бесконечном повторении одного и того же варианта (смотри предыдущую скобку, которая иллюстрирует сразу две неприятности: если к такой унификации вернутся, будет выбран 'новый' пустой список в качестве X).

    Для того, чтобы вычислить выражение, имеется предопределенная бинарная операция is. Она должна иметь вторым аргументом выражение, составленное из атомов при помощи функций. После применения X is 1+2 вместо X подставится 3. Даже выражение 1+2 остается в таком же виде, пока оно не попадет во второй аргумент is.

    Внимание!

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


    При этом необязательно делать его операцией.


    Рассмотренные до сих пор средства языка PROLOG не дают возможности сформулировать отрицание. Впрочем, отрицание и не может присутствовать в хорновых формулах, его наличие разрушает свойства, которые послужили основой для модели вычислений языка PROLOG. Но на практике оно нужно, и поэтому в языке PROLOG введен его суррогат. Этот суррогат дает возможность программисту минимально управлять точками возврата. Если в цели встал на первое место атом ! (называемый предикатом отсечения), то он успешно унифицируется и уничтожает последнюю точку возврата. Предикат ! используется прежде всего для определения отрицания как явного неуспеха подцели.

    Пример 6.3.2. Рассмотрим, как с помощью ! и списков программируется поиск пути в лабиринте (и даже в произвольном ориентированном графе).

    way(X,X,[X]). way(X,Y,[Y|Z]):-connect(U,Y), nomember(Y,Z),way(X,U,Z). way(X,Y,[Y|Z]):-connect(U,Y), way(X,U,Z). nomember(Y,Z):-member(Y,Z),!,fail. nomember(Y,Z). connect(begin,1). connect(1,begin). connect(1,2). connect(2,3). connect(3,1). connect(3,4). connect(4,end).

    Листинг 6.3.2. Статический лабиринт

    В ответ на запрос

    ?-way(begin,end,X).

    программа выдаст

    X = [end, 4, 3, 2, 1, begin] Yes

    Вместо определения nomember можно написать предложение

    way(X,Y,[Y|Z]):-connect(U,Y),not (member(Y,Z)),way(X,U,Z).

    Предикат отсечения можно использовать для того, чтобы превратить PROLOG-программу в программу с традиционным управлением, оставив из специфики языка лишь операцию унификации. На практике и при обучении часто бывают случаи, когда кому-нибудь никак не удается совладать с PROLOG и отладить свою программу, поскольку он зациклен на императивном стиле программирования. Узнав о !, он облегченно вздыхает и переписывает свою программу таким образом, что она становится изоморфной программе в обычном языке программирования. Как правило, при такой трансформации теряются главные преимущества языка PROLOG, но зато сохраняются все его недостатки.

    Внимание!



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

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

    A:-A.

    и этот оператор6) выполнится, то программа зациклится. И по этой причине, и по причине ошибки в унификации предложения языка PROLOG, сохранив внешнюю форму логических, по существу отношения к логике уже не имеют.

    Конечно же, несообразности были использованы и для получения новых эффектов. Рассмотрим следующее определение.

    repeat. repeat:-repeat.

    Если вставить теперь цель repeat в раскрытие другой цели и позаботиться о том, чтобы последующие подцели в большинстве случаев заканчивались неудачей, а после удачи поставить !, то эти подцели будут повторяться вплоть до удачи и их побочные эффекты будут исполняться в цикле.

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

    Недетерминированную модель вычислений, соответствующую PROLOG, можно определить в общем виде следующим образом:

  • Имеются точки разветвления, допускающие переходы без проверки условий выбора вариантов — точки недетерминированного разветвления.
  • Вычислительный процесс выбирает в такой точке любое из возможных продолжений.
  • Некоторые из возможных продолжений объявляются тупиковыми, или тупиками, т. е. такими, которые не приводят вычислительный процесс к заранее определенной цели. Принципиально, что тупик продолжения не может определиться в точке разветвления.
  • Успешным вычислением называется такая последовательность выбираемых продолжений в каждой точке недетерминированного разветвления, которая приводит процесс к цели (т.


    е. ни одно из выбранных продолжений не является тупиком).


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

    Есть теорема, доказывающая, что в принципе недетерминированный конечный автомат всегда можно преобразовать в детерминированный. Идея преобразования — склейка состояний, как показано на рис. 6.1. При этом, содержательно говоря, мы создаем линейный порядок на множестве альтернатив и выбираем альтернативы в строгом соответствии с этим порядком. Именно так с самого начала поступили в языке PROLOG. Беды в этом нет. В то время, когда создавался язык PROLOG, идея совместности (т. е. безразличия некоторых последовательностей предложений к порядку их исполнения) и недетерминированности (т. е. ситуации, когда один и тот же оператор в одном и том же контексте может давать разные результаты) как положительного фактора была только что осознана. А те, кто делают что-либо принципиально новое, почти всегда забывают согласовать свою находку с другими принципиальными достижениями того же времени, предпочитая локализовать новизну и в остальных пунктах работать как можно более традиционно. Беды начались, когда особенности конкретного упорядочивания стали беспощадно использоваться в хакерском духе, да еще и выставляться как принципиальные новации.

    Управление исполнением программы
    Рис. 6.1.  Преобразование недетерминированного поиска в детерминированный

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


    Развитие языка Prolog


    Британско-канадская группа, создававшая Prolog, первоначально ориентировалась на задачи математической лингвистики, где сложные преобразования данных сопряжены с проверкой гипотез.
    Это естественно навело их на ортогональный Рефалу подход1), формально мотивированный математической логикой. Наличие именно формальной мотивировки оказало медвежью услугу языку Prolog и всему направлению. Его сущность оказалась замаскирована примитивным методологически-теоретическим анализом и неадекватным названием: логическое программирование.
    Prolog вдохновлялся ограничением классического исчисления предикатов, предложенным Хорном. Как известно (см., например, книгу [20]), в классической логике в любой достаточно богатой теории появляются чистые теоремы существования, когда из доказательства невозможно выделить построение требуемого объекта. Если ограничиться конъюнкциями импликаций вида
    Развитие языка Prologx1, . . . , xnРазвитие языка Prology1, . . . , yk . . . (A1&· · ·&An Развитие языка Prolog B),
    где каждая составляющая является элементарной формулой и само доказанное утверждение имеет такой же вид, то получить построение из доказательства возможно, поскольку у нас не остается даже косвенных способов сформулировать и нетривиально использовать что-либо похожее на A Развитие языка Prolog ¬A. Такие формулы называются хорновыми.
    От лишних кванторов можно избавиться при помощи сколемизации, когда кванторы существования заменяются на функцию, аргументами которой являются все переменные, связанные ранее стоящими кванторами всеобщности2). Таким образом, мы приходим к простейшему виду хорновых формул (хорновы импликации):
    Развитие языка Prologx1, . . . , xn (A1&· · ·&An Развитие языка Prolog B).
    Применив к последней формуле преобразование, выполненное лишь в классической логике, мы приходим к хорновым дизъюнктам:
    Развитие языка Prologx1, . . . , xn (¬A1 Развитие языка Prolog · · · Развитие языка Prolog ¬An Развитие языка Prolog B).
    Именно от последней формы представления хорновых утверждений получила имя основная структура данных языка Prolog. Но тот факт, что импликация преобразована в дизъюнкцию, на самом деле нигде в этом языке не используется, он послужил лишь для установления взаимосвязей с алгоритмом метода резолюций [30], который в тот момент был последним криком моды в автоматическом доказательстве теорем (и действительно громадным концептуальным продвижением).
    Метод резолюций до сих пор остается одним из нескольких наиболее часто применяемых методов автоматического доказательства, и единственным, известным широкой публике. Этот метод поставил три идеи (унификация, стандартизация цели, стандартизация порядка вывода), красивой программистской реализацией которых явился Prolog. Но находки языка Prolog не исчерпываются реализацией идей, взятых из теории. Они внесли существенный вклад в теорию и методологию программирования3).

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

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

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


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

    Одним из классических примеров применения первоуровневых моделей и плоских, неадекватных практике, но привлекательно звучащих и просто объясняемых, выводов, которые делаются на их основе, является утверждение: "Логика Хорна достаточна для спецификации вычислений, поскольку, как было доказано, она эквивалентна машине Тьюринга4)". С этим утверждением можно было бы согласиться, если бы решения реальных задач, которые апеллируют к базам данных-фактов и должны решаться с использованием баз знаний-утверждений, являющихся следствиями из имеющихся фактов, всегда можно было бы формулировать в такой разделенной манере: сначала задаются факты, затем предложения-соотношения и далее идет манипулирование информацией. На самом деле таким образом формулируемые решения пригодны только для узкого класса задач, которые характеризуются стабильностью фактов, и только в том случае, когда не принимается в расчет эффективность.


    Развитие языка Рефал и его диалекты


    Язык Рефал был создан В. Ф. Турчиным для аналитических вычислений в физике. Первоначально Турчин продумал саму идею конкретизации и представил ее в виде языка, демонстративно записанного в не слишком прямо представимой форме. Например, не было понятия детерминатива, предложения имели вид, подобный
    §1.1.2 E1 + (E2 * E3)= (K E1 + E2 .)* (K E1 + E3 .)
    Конкретно-синтаксическая форма языка, данная в теоретической работе Турчина (см., напр. [27]) сразу же была изменена для удобства представления и работы5). Уже при первой реализации был продуман и проверен приведенный выше алгоритм отождествления, были выброшены иерархические комментарии в начале предложений, а вместо них появились понятия детерминатива и функции.
    Дальнейшая доработка потребовала процедур ввода-вывода и механизма хранения глобальных данных, что было реализовано через стеки закопанных данных. Получившийся язык Рефал-2 длительное время был практическим стандартом Рефал-систем.
    В языке Рефал-4 были сделаны две попытки расширения языка. Во-первых, как и во множестве других систем, к Рефалу были достаточно механически добавлены нарождавшиеся модные объектно-ориентированные средства. Эта попытка быстро зашла в тупик и была оставлена. Во-вторых, были определены метаоперации. Это нововведение доказало свою жизнеспособность и выжило. В языке Рефал-5 [38], который сейчас является фактическим стандартом6), объекты были отброшены, зато последовательно была проведена как стандартная надстройка над языком идея метакодирования. В нем получили свое окончательное оформление вложенные процедуры и дополнительные условия.
    Из других существующих версий языка стоит отметить Рефал-6 и Рефал+ [10], которые развивают одну и ту же линию. В реализации Рефал+ отошли от представления, принятого в [25], с тем чтобы воспользоваться современными алгоритмами сборки мусора. Вместо стеков закопанных значений в этих языках предлагаются объекты, которые имеют лишь одно значение. В частности, такие объекты используются для описания графического ввода и вывода, что полностью игнорируется в стандартном Рефале.
    В этих версиях позволяется объявить функцию откатной и пытаться при невозможности отождествлений обработать неудачу. Но автор этих версий проигнорировал концептуальную несовместимость неудач с общей структурой управления в языке Рефал. Из находок Рефал+, помимо новой структуры данных, стоит отметить концепцию упорядочения возможных отождествлений и возможность до некоторой степени управлять этим упорядочением (правда, в языке предусмотрен лишь переход от прямого порядка к его обращению, но уже это дает в некоторых случаях большой выигрыш в выразительности).

    Наиболее заметным недостатком новых версий языка Рефал7) явилось отсутствие различения абстрактного и конкретного синтаксиса. Можно было бы просто отказаться от фиксации оформления в определении языка и дать возможность определять синтаксические расширения и представления самим разработчикам.

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

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


    Сравнение версий сентенциального программирования


    Прежде всего, методы управления языка Prolog и языка Рефал принципиально отличаются. В Prolog'е неудача глобальна, но исправима, а в Рефале — локальна, но фатальна.
    Унификация в Prolog и конкретизация в Рефале являются операциями примерно одного и того же уровня общности. Но направления унификации и конкретизации ортогональны.
  • Конкретизация имеет дело с выражениями, подвыражения в которых могут выделяться различными способами, и древовидная иерархическая структура сочетается с линейной структурой внутри одного уровня иерархии, а при унификации в Prolog и в логике все выражения жестко ограничены скобками либо запятыми, то есть присутствует лишь иерархическая структура.
  • Конкретизация не заглядывает внутрь иерархии глубже, чем это явно указано в соответствующем правиле, а унификация может двигаться по уровням иерархии вглубь настолько, насколько это необходимо.
  • При конкретизации переменные локальны, и их значения не влияют на интерпретацию остальной области памяти, а при унификации переменные глобальны, полученная в результате унификации подстановка производится сразу во всем поле зрения, а не только в его активной части.
  • При конкретизации в ходе установления значений переменных возможны неудачи и возвраты, а при унификации значения переменных рекурсивно набираются в соответствии с алгоритмом нахождения унифицирующей подстановки, и неудача этого единственного варианта означает неудачу всей попытки унификации.

  • Внимание!
    Модели унификации и конкретизации формально несовместимы, поскольку, соединив их, мы получаем алгоритмически неразрешимое понятие отождествления (Н. К. Косовский и др., 1990).
    Это — глубочайший теоретический результат и одно из редких прямых предупреждений, сделанных теорией практике8). Поэтому при сходстве многих идей, положенных в их основу, эти модели развиваются и должны дальше развиваться независимо (что не исключает творческого заимствования идей из одной ипостаси подхода в другую). Обратите внимание, что мы столкнулись с той же самой ситуацией, которая имеет место для циклической и рекурсивной ипостасей структурного программирования.

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

    Даже машинная реализация этих языков сродни двум ипостасям структурного программирования. В Рефале вызовы, которые выглядят как рекурсивные, на самом деле рекурсией не являются9), поскольку мы заканчиваем породивший вызов, и лишь после этого переходим к отработке порожденных. Prolog больше похож на рекурсию, поскольку после вызова внутреннего предиката мы можем возвратиться к внешнему и продолжить попытки его унификации. В Рефале достаточно общего поля памяти, а в Prolog вынуждены запоминать также управляющие данные ( точки возврата10)).

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

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

    Концепция унификации и возвратов после неудач исключительно хорошо подходит для выражения Сравнение версий сентенциального программирования-параллелизма (см. § 15.2). Более того, уже имеются системы (в частности, Muse), реализующие этот вариант параллелизма в языке Prolog.

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


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

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

    При создании и развитии языков PROLOG и Рефал ярко проявились сильные и слабые стороны русской и англо-американской школ науки.

    В. Ф. Турчин проделал адекватный анализ, не устаревший за 40 лет, но он не стремился к общепонятности и слишком большой популяризации (может быть, потому, что ему не нужно было выбивать гранты). Его последователи четко восприняли идею и развивали ее, практически полностью избегая концептуально противоречащих ей возможностей. Но популяризация языка и внешняя сторона работы с ним (удобные системы, интерфейсы и т. п.) оказались практически полностью проигнорированными, и поэтому язык остается достоянием узкой группы приверженцев11).

    Создатели Prolog с самого начала в значительной мере использовали теорию и методологию как заклинания либо молитвы, не имеющие отношения к сути дела и произносимые для его освящения. Тем самым теоретическая база сразу же оказалась неадекватной, что и привело к быстрому расползанию системы и потере концептуального единства. Язык в значительно большей мере, чем Рефал, оказался загрязнен чужеродными элементами12).

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


    Зато развитие внешнего оформления и удобных ( с точки зрения дизайна) средств работы с языком шло адекватными темпами, а реклама его действительных и мнимых достижений далеко опережающими. Язык вошел в практику обучения многих университетов мира и попал даже в стандарты программ обучения специалистов IFAC.

    Если говорить о практических задачах, то Prolog значительно лучше подходит для поиска, а Рефал — для синтаксических преобразований. Стоит также помнить, что в нынешнем состоянии Prolog не может служить даже для прототипирования, это язык лишь для моделирования решений и логики поведения программы.Но это — особенность нынешних конкретных реализаций идеи унификации и возвратов, она связана с тем, что примитивный концептуальный механизм пришел в противоречие с выявившимися богатейшими потенциальными возможностями подхода.

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

    Таким образом, Рефал по эффективности и ясности представления уже сейчас может служить языком прототипирования, и, если бы его снабдить удовлетворительными интерфейсами, вполне мог бы служить и для окончательных решений, работающих в многоязыковой среде. В момент написания книги существует единственный имеющийся надежно работающий интерфейс Рефала: с языком PHP преобразования HTML-текстов.

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

    У Prolog ныне интерфейсы имеются, но они, как правило, ориентированы лишь на C++ и LISP13) и совершенно не стандартизованы. Каждая реализация имеет свой интерфейс.

    Мы затронули общий недостаток нынешних систем сентенциального программирования.


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

    Сравнение версий сентенциального программирования
    Сравнение версий сентенциального программирования
    Сравнение версий сентенциального программирования
      1)

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

      2)

      Как именно это делается, сейчас неважно.

      3)

      Одновременно данное направление нанесло и существенный вред этой теории и методологии, который был бы еще глубже, если бы у Prolog'а с самого начала не было бы друга-соперника Рефала.

      4)

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

      5)

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

      6)

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

      7)

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

      8)

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

      9)

      Примером того, насколько многие привыкли судить о явлениях по внешнему виду, а не по сущности, является кочующее из книги в книгу утверждение о том, что Рефал — язык функционального рекурсивного программирования.

      10)

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

      11)

     

    Сами представители Рефал-сообщества объясняли свою позицию относительно интерфейсов примерно следующим образом.

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

    Автор в свое время вынужден был писать переходник между Рефалом-2 и Алголом 68, но, конечно же, такое решение и приведенная выше аргументация неудовлетворительны.

      12)

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

      13)

      Некоторые коммерческие системы Prolog имеют интерфейсы еще и с Java.

    Сравнение версий сентенциального программирования

    Модель вычислений LISP


    Для LISP (как и для любого другого функционального языка) не обязательно2) говорить, где и как размещаются структуры данных (списки).
    Модель вычислений LISP
    Рис. 8.1.  Структура информации, сопоставленной атому языка LISP
    Их стоит рассматривать как сугубо математические объекты со сложной структурой, которая всегда точно указывает на текущие вычислительные элементы:
  • До выполнения шага вычисления — это список, включающий имя функции и ее аргументы.
  • Во время выполнения шага вычисления — это те фрагменты списочной структуры поля зрения, которые доступны для использования вычисляемой функцией (в частности, среди них есть список, связанный с именем функции, который определяет ее вычислительный процесс).
  • После выполнения шага вычислений — это результаты вычислений. Результаты можно разделить на три группы:
  • значение, выдаваемое вызовом функции: оно замещает в поле зрения отработанный вызов функции;
  • побочные эффекты, разбросанные по структуре поля данных;
  • очередная функция, которая будет вычисляться далее. В традиционном программировании обычно возвращаются к вычислениям той функции, которая активизировала завершаемую. В функциональном программировании может быть и по-другому. Результат может оказаться функцией, либо описанной в статическом тексте программы, либо скомпонованной в ходе вычислений.


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

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

    В практике реализации функциональных систем программирования имеется три варианта конкретизации представления графа:

  • Списки LISP, которые связаны с последовательными вычислениями. Структура графа задается как совокупность линейных списков, объединяющих имена функций и указатели аргументов. Голова списка трактуется как указание функции, а хвост — как аргументы.
  • Коммутационные схемы, которые строятся на основе разделения функций и данных: функции представляются вершинами графа, а их аргументы-данные передаются по дугам, соединяющим вершины. Дуги рассматриваются в качестве каналов связи. Функция активизируется, когда ее аргументы появляются в каналах.
  • Ассоциативные схемы, в которых вершины-функции остаются виртуальными. Они образуются в результате связывания данных, имеющих одинаковый ключ. Ситуация, когда такие данные появляются в ассоциативной памяти, трактуется как готовность аргументов для вычисления функции, идентифицируемой этим ключом.


  • Коммутационные и ассоциативные схемы рассмотрены при обсуждении неимперативных моделей вычислений (см. § 1.2 и 3.1). Выбор последовательно просматриваемой структуры для первого функционального языка обусловлен единственной для того времени возможностью реализации функциональности путем моделирования ее операционными средствами традиционной модели вычислений. Списочная структура также строится посредством более или менее стандартного для традиционной модели адресного представления. С языковой точки зрения именно этот выбор обеспечивает однородность структуры программы и данных, на базе которой Дж. Маккарти удалось построить систему средств, достаточную для практического функционального программирования.

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


    Как правило, заданные в программе списки интерпретируются как применения функций и вычисляются, если другое не определяют ранее активированные функции (вы видели, что функция quote запрещает вычисление своего аргумента, функция setq запрещает вычисление лишь первого из двух аргументов, а функция setf заставляет вычислить первый аргумент лишь до стадии, когда получена ссылка на его значение). Любое выражение выдает значение, что используется, в частности, при диалоговой работе с LISP (на примере которой иллюстрируются понятия).

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

    (block name e1 . . . en) (8.1)

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

    (return-from name value) (8.2)

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

    Далее, блоком считается любое описание функции. Описание функции производится при помощи функции defun, которая, в свою очередь, определяется через примитивы function и lambda. Первый из них задает, что имя, являющееся его аргументом, рассматривается как функция (он часто сокращается в конкретном синтаксисе до #’), второй образует значение функционального типа. Имя функции является и именем функционального блока.

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

    [1]> (defun fact (n) (if (= n 0) 1 (* (fact (- n 1)) n))) FACT [2]> (fact 40) 815915283247897734345611269596115894272000000000 [3]> ((lambda (x) (fact (* x x))) 5) 15511210043330985984000000 [4]> (setq g ’(lambda (x) (fact (* x x)))) (LAMBDA (X) (FACT (* X X))) [5]> (eval (list g 3)) 362880



    Нужно заметить, что определение функции с данным именем и значение имени могут задаваться независимо. Например, мы можем в этом же контексте задать (setq fact 7), хотя, конечно же, это отвратительный способ программирования.

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

    [23]> (defun f (x) (progn (setf (get ’x ’weight) ’(25 kg)) (+ x 3))) F [24]> (setf (get ’x ’weight) ’(30 kg)) (30 KG) [25]> (get ’x ’weight) (30 KG) [26]> (setq x 5) 5 [27]> (f 3) 6 [28]> x 5 [29]> (get ’x ’weight) (25 KG)

    В LISP имеется возможность создать анонимный блок со своими локальными переменными, не объявляя его функцией. Создание такого блока называется связыванием переменных и производится функцией let.

    Значение имени, унаследованного извне, все равно будет внешним! Смотрите пример ниже.

    [32]>(setq a ’(b c d)) (B C D) [33]>(setq b 5) 5 [34]> (list (let ((b 6)) (eval (car a))) (eval (car a))) (5 5) [35]> (list (let ((b 6)) b) (eval (car a))) (6 5) [36]> (list (let ((b 6)) (list b a)) (eval (car a))) ((6 (B C D)) 5) [37]> (list (let ((b 6)) (eval (car (list ’b a)))) (eval (car a))) (5 5)

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

    [57]> (setq a (list 1 5 7 9 11 13 15 19 22 28)) (1 5 7 9 11 13 15 19 22 28) [58]> (mapcar (function (lambda (x) (* x x))) a) (1 25 49 81 121 169 225 361 484 784)

    Функционал mapcar применяет свой первый аргумент ко всем членам второго.

    Такие функционалы, в частности, делают циклы практически ненужными. Тем не менее в языке LISP есть конструкции циклов как дань программистской традиции. Чужеродность циклов подчеркивается тем, что они всегда выдают значение NIL.

    И, наконец, приведем пример3).

    Пример 8.4.2. Данная программа строит автомат для нахождения всех вхождений некоторой системы слов во входной поток.


    Позже она анализируется с точки зрения автоматного программирования.

    ;================================================== ; ; свертка/развертка системы текстов ; текст представлен списком ;((Имя Вариант ...)...) ; первое имя в свертке - обозначение системы текстов ; (Элемент ...) ; (Имя Лексема (Варианты)) ; ((пример (ма (ш н) ; (ш а) ) ; ( ш н ) ) ; ((н ина)) ) ;================================================== ; реализация свертки: unic, ass-all, swin, gram, bnf

    (defun unic (vac) (remove-duplicates (mapcar ’car vac) )) ;; список уникальных начал

    (defun ass-all (Key Vac) ;; список всех вариантов продолжения ( что может идти за ключом) (cond ((Null Vac) Nil) ((eq (caar Vac) Key) (cons (cdar Vac) (ass-all Key (cdr Vac)) )) (T (ass-all Key (cdr Vac)) ) ) )

    (defun swin (key varl) (cond ;; очередной шаг свертки или снять скобки при отсутствии вариантов ((null (cdr varl))(cons key (car varl))) (T (list key (gram varl)) ) ))

    (defun gram (ltext) ;; левая свертка, если нашлись общие начала ( (lambda (lt) (cond ((eq (length lt)(length ltext)) ltext) (T (mapcar #’(lambda (k) (swin k (ass-all k ltext ) )) lt ) ) ) ) (unic ltext) ) )

    (defun bnf (main ltext binds) (cons (cons main (gram ltext)) binds)) ;; приведение к виду БНФ

    ;=================================================== ; реализация развертки: names, words, lexs, d-lex, d-names, ; h-all, all-t, pred, sb-nm, chain, level1, lang

    (defun names (vac) (mapcar ’car vac)) ;; определяемые символы

    (defun words (vac) (cond ;; используемые символы ((null vac) NIL) ((atom vac) (cons vac NIL )) (T (union (words(car vac)) (words (cdr vac)))) ))

    (defun lexs (vac) (set-difference (words vac) (names vac))) ;; неопределяемые лексемы

    (defun d-lex ( llex) ;; самоопределение терминалов (mapcar #’(lambda (x) (set x x) ) llex) ) (defun ( llex)

    ;; определение нетерминалов (mapcar #’(lambda (x) (set (car x )(cdr x )) ) llex) )

    (defun h-all (h lt) ;; подстановка голов (mapcar #’(lambda (a) (cond ((atom h) (cons h a)) (T (append h a)) ) ) lt) )



    (defun all-t (lt tl) ;; подстановка хвостов (mapcar #’(lambda (d) (cond ((atom d) (cons d tl)) (T(append d tl)) ) ) lt) )

    (defun pred (bnf tl) ;; присоединение предшественников (level1 (mapcar #’(lambda (z) (chain z tl )) bnf) ))

    (defun sb-nm (elm tl) ;; постановка определений имен (cond ((atom (eval elm)) (h-all (eval elm) tl)) (T (chain (eval elm) tl)) ) )

    (defun chain (chl tl) ;; сборка цепочек (cond ((null chl) tl) ((atom chl) (sb-nm chl tl))

    ((atom (car chl)) (sb-nm (car chl) (chain (cdr chl) tl) ))

    (T (pred (all-t (car chl) (cdr chl)) tl)) ))

    (defun level1 (ll) ;; выравнивание (cond ((null ll)NIL) (T (append (car ll) (level1 (cdr ll)) )) ))

    (defun lang ( frm ) ;; вывод заданной системы текстов (d-lex (lexs frm)) (d-names frm) (pred (eval (caar frm)) ’(()) ) )

    Листинг 8.4.1. Автомат для нахождения всех вхождений некоторой системы слов во входной поток


    Объекты и LISP


    Стандартная надстройка над Common Lisp, имитирующая объектно-ориентированный стиль, это модуль CLOS — Common Lisp Object System. Сама по себе объектность не дает никакого выигрыша по сравнению с языком LISP, поскольку возможности динамического вычисления функций в LISP даже шире. Видимо, именно поэтому в CLOS имеются две интересных модификации, делающие его не совсем похожим на стандартное ООП.
    Начнем с понятия структуры данных в языке Common Lisp. Структура определяется функцией defstruct вида
    (defstruct pet name (species ’cat) age weight sex)
    Задание структуры автоматически задает функцию-конструктор структуры make-pet, которая может принимать ключевые аргументы для каждого из полей:
    (make-pet :nick ’Viola :age ’(3 years) :sex ’femina))
    и функцию доступа для каждого из полей, например pet-nick, использующуюся для получения значения поля или ссылки на него. Если поле не инициализировано (ни по умолчанию, ни конструктором), оно получает начальное значение NIL. Никакой дальнейшей спецификации полей структур нет6).
    В объекте для каждого поля могут явно указываться функции доступа и имена ключевых параметров для инициализации аргументов. Приведем пример класса, определенного на базе другого класса.
    (defclass pet (animal possession) ( (species :initform ’cat) (nick :accessor nickof :inintform ’Pussy :initarg namepet) )
    Этот класс наследует поля, функции доступа и прочее от классов animal и possession. Например, поле cost имеется в значении класса, если оно имеется в одном из этих классов. Поскольку статических типов у полей нет, нет и конфликтов.
    Основная функция наследования в CLOS — определение упорядочения на классах. С каждым классом связано свое упорядочение. Наследник меньше своих предков, из предков меньшим считается тот, который раньше перечислен в списке наследования при определении класса. CLOS достраивает этот частичный порядок до линейного. Способ пополнения порядка может быть в любой момент и без оповещения изменен, и хакерское использование особенностей конкретного пополнения считается грубой стилистической ошибкой.
    Если система находит несовместимость в определении порядка, то она выдает ошибку, как в следующем примере:

    [6]> (defclass init () ()) # [7]> (defclass a (init) ()) # [8]> (defclass b (init) ()) # [9]> (defclass c1 (a b) ()) # [10]> (defclass c2 (b a) ()) # [11]> (defclass contr (c1 c2) ()) *** - DEFCLASS CONTR: inconsistent precedence graph, cycle (# #)

    В CLOS могут задаваться методы, отличающиеся от функций тем, что их аргументы специфицированы, например

    (defmethod inspectpet ((x pet) (y float)) (setf weightofanimal 3.5))

    Как видно из этого примера, методы не обязательно связаны с классами. Они могут быть связаны с любыми типами. Методы в CLOS могут иметь дополнительные спецификации. Для того, чтобы разобраться, как эти спецификации взаимодействуют с упорядочением типов классов, рассмотрим следующую программу и генерируемый при ее исполнении результат.

    (defclass thing () ((weight :initform ’(0 kg) :accessor weightof :initarg :weight))) (defclass animal (thing) ((specie :accessor specieof :initarg :spec) (sex :accessor sexof :initform ’m :initarg :sex))) (defclass possession (thing) ((owner :accessor ownerof :initform ’nnn) (cost :accessor costof :initform ’(0 bucks) :initarg :cost)) ) (defclass person (animal) ((specie :initform ’human) (name :initarg :thename :accessor nameof))) (defclass pet (animal possession) ((nick :initarg :thenick :accessor nickof) (specie :initform ’cat)))

    (defmethod act :before ((p pet)) (print "Cat mews")) (defmethod act :after ((p pet)) (print "Cat turns")) (defmethod act :around ((p pet)) (progn (print "You have a cat") (call-next-method)))

    (defmethod act ((p animal)) (progn (print "Animal is close to you") (call-next-method))) (defmethod act :before ((p animal)) (print "You see an animal")) (defmethod act :after ((p animal)) (print "You send the animal off")) (defmethod act :around ((p animal)) (progn (print "You don’t like wild animals") (call-next-method)))



    (defmethod act ((p possession)) (progn (print "You test your property") (call-next-method))) (defmethod act :before ((p possession)) (print "You see your property")) (defmethod act :after ((p possession)) (print " You are pleased by your property")) (defmethod act :around ((p possession)) (progn (print "You admire your property if it is in good state") (call-next-method)))

    (defmethod act ((p thing)) (print "You take the thing")) (defmethod act :before ((p thing)) (print "You see something")) (defmethod act :after ((p thing)) (print "You identified this thing")) (defmethod act :around ((p thing)) (progn (print "You are not interested in strange things") (call-next-method)))

    (act (make-instance ’pet :thenick "Viola" :cost ’(25 kop)))

    Листинг 8.6.1. Взаимодействие дополнительных спецификаций методов в CLOS с упорядочением типов классов

    При загрузке этого файла происходит следующее:

    [1]> (load ’myclasses) ;; Loading file E:\clisp-2000-03-06\myclasses.lsp ... "You have a cat" "You don’t like wild animals" "You admire your property if it is in good state" "You are not interested in strange things" "Cat mews" "You see an animal" "You see your property" "You see something" "Cat purrs" "Animal is close to you" "You test your property" "You take the thing" "You identified this thing" "You are pleased by your property" "You send the animal off" "Cat turns" ;; Loading of file E:\clisp-2000-03-06\myclasses.lsp is finished. T

    Пример 8.6.2. Результат загрузки программы 8.6.1

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

    Поскольку в CLOS нет ни механизмов скрытия конкретных представлений, ни механизмов замены прямого доступа к данным на функции, ни других характерных особенностей ООП, мы видим еще один пример того, как модным словом (в данном случае ООП) прикрывается другая, не менее интересная сущность: начатки планирования действий по структуре типов данных.


    В связи с этим стоит напомнить блестящий эксперимент планирования вычислений по структуре данных, в настоящий момент (судя по всему, временно) забытый: эстонскую систему PRIZ [28].

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

    Объекты и LISP
    Объекты и LISP
    Объекты и LISP
      1)

      И уж точно первый из выживших, поскольку Plankalkul Цузе умер вместе с его машинами.

      2)

      А для предотвращения хакерских трюков и нежелательно!

      3)

      Любезно предоставленный Л. Городней (ИСИСО РАН и НГУ)

      4)

      Как сделано в системе TEX-LATEX.

      5)

      Примером, иллюстрирующим ситуацию, может служить XML, линейность конкретного синтаксиса которого является лишь следствием стихийных стандартов.

      6)

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

    Объекты и LISP

    Поле зрения и поле памяти


    Если не применены специальные операции блокирования вычислений, первый аргумент списка интерпретируется как функция, аргументами которой являются оставшиеся элементы списка. Это позволяет программу также задавать списком.
    Таким образом, в LISP, так же, как в сентенциальных языках, структура данных программы и поля памяти, обрабатываемого программой, совпадают. Видимо, это одна из удачнейших форм поддержания концептуального единства для высокоуровневых систем.
    В поле памяти с каждым атомом-именем могут быть связаны атрибуты. Стандартный атрибут — значение атома. Для установки этого атрибута есть функция (setq atom value), аналогичная присваиванию. Эта функция не вычисляет свой первый аргумент, она рассматривает его как имя, которому нужно приписать значение.
    Значение в языке LISP может быть локальным. Если мы изменили значение атома внутри некоторого блока, то такое ‘присваивание’ действует лишь внутри минимальных объемлющих его скобок и исчезает снаружи блока. Кроме значения, имена могут иметь сколько угодно других атрибутов, которые обязательно глобальны. Они принадлежат самому имени, а не блоку. Способ установки значений этих атрибутов несколько искусственный. Имеется еще одна функция setf, вычисляющая свой первый аргумент, дающий ссылку на место, которому можно приписать значение (например, на атрибут). Функция получения значения атрибута get, даже если атрибута еще нет, указывает на его место. Следующий пример демонстрирует, как это работает.
    [38]> (setf (get ’b ’weight) ’(125 kg)) (125 KG) [39]> (get ’b ’weight) (125 KG)
    Рассмотрим подробнее структуру данных языка LISP. Она является двухуровневой. На верхнем уровне имеется структура списков. На нижнем находится структура информации, сопоставленной атому. Она изображена на рис. 8.1. Оба этих уровня рекурсивно ссылаются друг на друга, например, атрибуты атома являются списками.
    Типы данных (в смысле программирования) в LISP есть, но они определяются динамически. В частности, если действительное число придано атому как значение, то тип атома становится float.


    Прагматические добавления и динамическое порождение программ


    Разберем возможности языка LISP в комплексе.
    Выразительные средства конкретно-синтаксического представления общей структуры данных и программ языка LISP крайне скупы. Но это представление позволяет практически однозначно связать синтаксис и реализационную структуру.
    Реализационное представление как нельзя лучше соответствует соглашению об общности функциональной структуры и структуры данных: в каждом списке голова рассматривается как указание (имя, ссылка или что-то подобное) на функцию, а хвост — как последовательность указаний на аргументы. Задание свойства списка не быть функцией, т. е. отмена выделенного статуса головы, обозначающей функцию, достигается с помощью блокировок. Это удачное решение в условиях принятого соглашения, позволяющее трактовать нефункциональный список как константную функцию, "вычисляющую" свое изображение (представление). Еще более важно то, что оно обеспечивает гибкость представления: функцию eval, заставляющую список принудительно вычисляться, естественно трактовать просто как снятие блокировок. Заметим, что на уровне абстрактного синтаксиса функция eval обязана быть универсально применимой к любому списку.
    К сожалению, такой универсализм провоцирует крайне ненадежное и неэффективное программирование, поэтому это решение нельзя считать удачным. Справедливости ради заметим, что в те времена, когда разрабатывался язык, задача надежности еще не была поставлена, но плохо то, что сформировался стихийный стандарт, не способствующий качеству программирования.
    Для обеспечения практической пользы функции eval следовало бы предусмотреть компенсирующие регламенты ее корректного применения на уровне конкретного синтаксиса, режимов вычислений и системных механизмов.
    Внимание!
    На уровне абстрактного и конкретного синтаксиса разные семантические возможности имеют разный статус, поэтому в конкретном представлении необходимо предусматривать механизм скрытия и даже полного запрета тех возможностей, которые концептуально разумны лишь на уровне абстрактного синтаксиса4).

    Структура списков LISP идеальна для представления абстрактного синтаксиса языка. И хотя злые языки называют этот синтаксис "утомительным нагромождением скобок", он в точности соответствует абстрактному синтаксису. Если даже не учитывать преимущества указанного соответствия, то остается простота представления программ и данных в виде линейной текстовой последовательности символов.

    Другие гипотетические кандидаты на роль конкретного синтаксиса по этому критерию явно проигрывают. Традиционные математические формы задания функций и их применений являются текстуально избыточными (как префиксная, так и постфиксная записи требуют обязательного обрамления параметров скобками), а бесскобочная нотация Лукасевича (и прямая, и обратная) еще более запутывали бы тексты по сравнению с "утомительным нагромождением скобок". Но за счет внеязыковых прагматических соглашений о том, как располагать на двумерном носителе (на бумаге или на экране) скобочную структуру, можно существенно облегчить. Если же система программирования будет поддерживать (и проверять!) прагматические соглашения (что характерно для развитых систем), то вид программ станет вполне читаемым. Таким образом преодолеваются неудобства линейного представления.

    Сегодня можно было бы говорить о других форматах конкретного синтаксиса LISP, в том числе и не связанных с линейным представлением. Использование развитых средств графики при экранном наборе программ позволяет на полную мощь подключить двумерную работу. Однако традиции и стихийно сформировавшиеся стандарты не оставляют возможности для внедрения таких форматов. Здесь в очередной раз и в полной мере проявились вредные эффекты ранней стандартизации, мешающие развитию направления, остающегося одним из самых концептуально богатых и перспективных.

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


    что может идти за ключом)


    ;================================================== ; ; свертка/развертка системы текстов ; текст представлен списком ;((Имя Вариант ...)...) ; первое имя в свертке - обозначение системы текстов ; (Элемент ...) ; (Имя Лексема (Варианты)) ; ((пример (ма (ш н) ; (ш а) ) ; ( ш н ) ) ; ((н ина)) ) ;================================================== ; реализация свертки: unic, ass-all, swin, gram, bnf
    (defun unic (vac) (remove-duplicates (mapcar ’car vac) )) ;; список уникальных начал
    (defun ass-all (Key Vac) ;; список всех вариантов продолжения ( что может идти за ключом) (cond ((Null Vac) Nil) ((eq (caar Vac) Key) (cons (cdar Vac) (ass-all Key (cdr Vac)) )) (T (ass-all Key (cdr Vac)) ) ) )
    (defun swin (key varl) (cond ;; очередной шаг свертки или снять скобки при отсутствии вариантов ((null (cdr varl))(cons key (car varl))) (T (list key (gram varl)) ) ))
    (defun gram (ltext) ;; левая свертка, если нашлись общие начала ( (lambda (lt) (cond ((eq (length lt)(length ltext)) ltext) (T (mapcar #’(lambda (k) (swin k (ass-all k ltext ) )) lt ) ) ) ) (unic ltext) ) )
    (defun bnf (main ltext binds) (cons (cons main (gram ltext)) binds)) ;; приведение к виду БНФ
    ;=================================================== ; реализация развертки: names, words, lexs, d-lex, d-names, ; h-all, all-t, pred, sb-nm, chain, level1, lang
    (defun names (vac) (mapcar ’car vac)) ;; определяемые символы
    (defun words (vac) (cond ;; используемые символы ((null vac) NIL) ((atom vac) (cons vac NIL )) (T (union (words(car vac)) (words (cdr vac)))) ))
    (defun lexs (vac) (set-difference (words vac) (names vac))) ;; неопределяемые лексемы
    (defun d-lex ( llex) ;; самоопределение терминалов (mapcar #’(lambda (x) (set x x) ) llex) ) (defun ( llex)
    ;; определение нетерминалов (mapcar #’(lambda (x) (set (car x )(cdr x )) ) llex) )
    (defun h-all (h lt) ;; подстановка голов (mapcar #’(lambda (a) (cond ((atom h) (cons h a)) (T (append h a)) ) ) lt) )
    (defun all-t (lt tl) ;; подстановка хвостов (mapcar #’(lambda (d) (cond ((atom d) (cons d tl)) (T(append d tl)) ) ) lt) )
    (defun pred (bnf tl) ;; присоединение предшественников (level1 (mapcar #’(lambda (z) (chain z tl )) bnf) ))
    (defun sb-nm (elm tl) ;; постановка определений имен (cond ((atom (eval elm)) (h-all (eval elm) tl)) (T (chain (eval elm) tl)) ) )
    (defun chain (chl tl) ;; сборка цепочек (cond ((null chl) tl) ((atom chl) (sb-nm chl tl))
    ((atom (car chl)) (sb-nm (car chl) (chain (cdr chl) tl) ))
    (T (pred (all-t (car chl) (cdr chl)) tl)) ))
    (defun level1 (ll) ;; выравнивание (cond ((null ll)NIL) (T (append (car ll) (level1 (cdr ll)) )) ))
    (defun lang ( frm ) ;; вывод заданной системы текстов (d-lex (lexs frm)) (d-names frm) (pred (eval (caar frm)) ’(()) ) )
    Листинг 8.4.1. Автомат для нахождения всех вхождений некоторой системы слов во входной поток
    Закрыть окно



    Списки и функциональные выражения


    Основной единицей данных для LISP-системы является список.
    Списки задаются следующим индуктивным определением.
  • Пустой список () (обозначаемый также nil) является списком.
  • Если l1,. . . , ln, n Списки и функциональные выражения 1 — атомы либо списки, то (l1, . . . , ln) — также список.

  • Элементами списка (l1, . . . , ln) называются l1, . . . , ln. Равенство списков задается следующим индуктивным определением.
  • l = nil тогда и только тогда, когда l также есть nil.
  • (l1, . . . , ln) = (k1, . . . , km) тогда и только тогда, когда n = m и соответствующие li = ki.

  • Пример 8.2.2. Все списки (), (()), ((())) и т. д. различны. Различны также и списки nil, (nil, nil), (nil, nil, nil) и так далее. Попарно различны и списки ((A,B), C), (A, (B,C)), (A,B,C), где A, B, C — различные атомы.
    Поскольку понятие, задаваемое индуктивным определением, должно строиться в результате конечного числа шагов применения определения, мы исключаем списки, ссылающиеся сами на себя. Списки в нашем рассмотрении изоморфны упорядоченным конечным деревьям, листьями которых являются nil либо атомы.
    Вершины списка L задаются следующим индуктивным определением.
  • Элементы списка являются его вершинами.
  • Вершины элементов списка являются его вершинами.

  • Длиной списка называется количество элементов в нем. Глубиной списка называется максимальное количество вложенных пар скобок в нем. Соединением списков (l1, . . . , ln) и (k1, . . . , km) называется список
    (l1, . . . , ln, k1, . . . , km).
    Замена вершины a списка L на атом либо список M получается заменой поддерева L, соответствующего a, на дерево для M. Замена обозначается L[a | M]. Через L[a || M] будем обозначать результат замены нескольких вхождений вершины a на M.
    Атомами в языке LISP являются числа, имена, истина T. Ложью служит пустой список NIL, который в принципе атомом не является, но в языке LISP при проверке на то, является ли он атомом, выдается истина. Точно так же выдается истина и при проверке, является ли он списком. Однако все списковые операции применимы к NIL, а те, которые работают с атомами, часто к нему неприменимы.
    Например, попытка присваивания значения выдает ошибку.

    Основная операция для задания списков (list a b . . . z). Она вычисляет свои аргументы и собирает их в список. Для этой операции без вычисления аргументов есть скоропись ’(a b . . . z). Она является частным случаем функции quote (сокращенно обозначаемой ’), которая запрещает всякие вычисления в своем аргументе и копирует его в результат так, как он есть.

    По традиции, элементарные операции разбора списков обозначаются именами, которые начинаются с c и кончаются на r, а в середине идет некоторая последовательность букв a и d; (car s) выделяет голову (первый член списка), (cdr s) — хвост (подсписок всех членов, начиная со второго). Буквы a и d применяются, начиная с конца. Общее число символов в получающемся атоме должно быть не больше шести. Рассмотрим фрагмент диалога, иллюстрирующий эти операции. Как только в диалоге вводится законченное выражение, оно вычисляется либо выдается ошибка.

    [13]>(setq a ’(b c (d e) f g)) (B C (D E) F G) [14]> (cddr a) ((D E) F G) [15]> (cddar a) *** - CDDAR: B is not a list 1. Break [16]> ^Z [17]> (caaddr a) D [18]> (cdaddr a) (E)


    ?-абстракции


    В некоторых случаях осознанное усвоение концепций даже на самом низком уровне нереально без базовых теоретических сведений. А знакомство с таким базисом, в свою очередь, стимулирует значительно более глубокий интерес к теории и способствует пониманию того, что на высшие уровни знаний и умений не подняться без овладения теорией.
    Теоретической основой языка LISP является логика функциональности: комбинаторная логика или (по наименованию одного из основных понятий в наиболее популярной из нынешних ее формализаций) ?-исчисление.
    В ?-исчислении выразительные средства, на первый взгляд, крайне скупы. Имеются две базисные операции: применение функции к аргументу (?x) и квантор образования функции по выражению ?x t[x]. В терминах ?-исчисления функция возведения числа в квадрат записывается как ?x (sqrx) или, если быть ближе к обычным математическим обозначениям, ?x x2.
    Основная операция — символьное вычисление применения функции к аргументу: (?x t[x] u) преобразуется в t[u]. Но эта операция может применяться в любом месте выражения, так что никакая конкретная дисциплина вычислений не фиксируется. Более того, функции могут вычисляться точно так же, как аргументы. Уже эта маленькая тонкость приводит к принципиальному расширению возможностей ?-исчисления по сравнению с обычными вызовами процедур. Если мы желаем ограничиться лишь ею, рассматривается типизированное ?-исчисление, в котором, как принято в большинстве современных систем программирования, значения строго разделены по типам. В типизированном ?-исчислении есть только типы функций, но этого хватает, поскольку функции могут принимать в качестве параметров и выдавать функции.
    Но в исходной своей форме ?-исчисление является нетипизированным, любой объект может быть и функцией, и аргументом, и, более того, функция может применяться к самой себе. Конечно же, при этом появляется возможность зацикливания, но без нее не обойдется ни одна "универсальная" алгоритмическая система. Например, выражение
    (?x (xx) ?x (xx))
    вычисляется бесконечно, а чуть более сложное выражение
    ((?x ?y x a) (?x (xx) ?x (xx)))
    может либо дать a, либо зациклиться, в зависимости от выбора порядка его вычисления. Но все равно, если мы приходим к результату, то он определяется однозначно. Так что совместность вычислений не портит однозначности, если язык хорошо сконструирован.
    Дж. Маккарти перенес идеи ?-исчисления в программирование, не потеряв практически ничего из исходных концепций. Далее, он заметил, что в рудиментарном виде в ?-исчислении появилось понятие списка, и перенес списки в качестве основных структур данных в свой язык. ?-исчислением было навеяно и соглашение языка LISP о том, что первый член списка трактуется как функция, применяемая к остальным.


    Автоматные задачи


    Многие программистские задачи удобно решать с помощью методов, формализацией которых могут служить таблицы состояний и переходов (напр., их собрание см. в [32] и на сайте http://is.ifmo.ru).
    Пример 9.1.1. Модель изменяющейся системы.
    Пусть мы моделируем динамическую либо экологическую систему, у которой в различных областях принципиально разное поведение. Вместо того, чтобы совмещать внутри одного и того же программного модуля анализ, какой области принадлежит точка, и вычисление следующего состояния системы, мы можем написать несколько простых модулей моделирования поведения системы в каждой из областей (единственная проверка, которая при этом может понадобиться, вышли мы при очередном шаге моделирования за границы области или нет). Как отдельный модуль строится глобальная управляющая программа, которая проверяет, в каком состоянии находится система, и вызывает соответствующий вычислительный модуль.
    В данном случае выигрыш не столько в длине программы, сколько в ее обозримости и модифицируемости (хотя именно эти важнейшие качества программы начинающие программисты чаще всего недооценивают). Но если при входе в новую область нужно проделать ряд организационных действий (в каждой области различных), а уже в зависимости от их результата выбрать дальнейшую траекторию системы, то описание в виде автомата становится все более выигрышным.
    Если при анализе задачи удается выявить набор состояний описываемого процесса, условия перехода между состояниями и действия, ассоциированные с состояниями, то задачу уместно решать методами таблиц состояний. При анализе таких методов можно применять конечные автоматы Мура.
    Теоретически автомат Мура представляется как матрица переходов, строками которой служат состояния автомата, а столбцами - входные символы1). В качестве входных символов на практике можно рассматривать результаты проверки некоторых условий. Неявное в теории, но важнейшее на практике содержимое каждого состояния автомата - процедура, приводящая к глобальному изменению состояния вычислительной системы.
    Такие процедуры назовем действиями.

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

    Автоматные задачи
    Рис. 9.1.  Таблица состояний. Граф переходов.

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

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

    Имеется вариация понятия автоматов, порождающая другой метод автоматного программирования. В теории автомат Мили отличается тем, что результат, записываемый на выходную ленту автомата, может зависеть от выбранного перехода. На практике действия в таблице состояний и переходов могут ассоциироваться либо с состояниями (с вершинами графа, автомат Мура), либо с переходами (с дугами графа, автомат Мили). Ниже Вы увидите примеры, когда при программировании естественно возникают оба этих варианта2). Модель вычислений автомата Мили лучше использовать, если проверки в каждом состоянии по существу различны, а модель автоматов Мура - если проверки по существу однородны, как в примере 9.1.1.Метод программирования, когда действия сопоставляются переходам, назовем преобразованиями на переходах (сокращенно просто "на переходах"), метод, когда действия производятся в состояниях, назовем преобразованиями в состояниях (сокращенно просто "в состояниях").

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

    Внимание!

    В данном случае мы видим один из неистощимых источников ошибок в программах, который впервые заметил Д.


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


    Как было показано в нашем примере, таблицы переходов и состояний являются естественным способом программирования для модуля, имеющего дело с глобальными операциями над некоторой средой (эти глобальные операции сами, как правило, программируются в другом стиле). Для автоматного программирования характерно go to, и здесь оно на месте.

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


    Методы действий в состояниях и на переходах: анализ состояний и построение таблицы


    В данном разделе начинается детальный показ двух методов автоматного программирования, основанных на модели Мура и Мили. Методика работы в них почти одна и та же, но, как чаще всего бывает, маленькое и вроде бы техническое различие (чему сопоставляются действия: состояниям или переходам?) порождает два несовместимых метода. Их несовместимость не столь грубая, как во многих других случаях (два таких случая - несовместимость между автоматами и присваиваниями и между циклами и рекурсиями - рассмотрены ниже). Если это и противоречие, то противоречие технологическое. Произвольно перемешивая эти два метода, мы в дальнейшем затрудняем модификацию программы, а в настоящем вынуждены множить число подпорок.
    Человек даже грубые противоречия игнорирует или обходит 4). Для применения правила важно знать не только его, но и когда можно его нарушать. Общеметодологический принцип здесь такой: если уж нарушать, то на полную катушку (проехав перекресток на красный свет, глупо останавливаться сразу за ним)! Второй принцип: если нельзя, но очень нужно, то надо! А. А. Шалыто указал, что в теории 5) есть и такое смешанное понятие, как автоматы Мили-Мура. Одной из моделей таких автоматов является автомат с двумя выходными лентами: на одну пишется результат в состояниях, на другую - на переходах. Программной интерпретацией такой теоретической модели является, например, модуль, который в состояниях ведет диалог с окружающей средой, в результате чего получает данные для определения очередного перехода, а на переходах производит внутренние вычисления.
    Для отработки методики используется простая задача, не затемняющая ее суть частными тонкостями и пригодная для реализации любым из двух методов. Полученная методика переносится на широкий класс задач и не отказывает вплоть до тысяч состояний.


    Основные структуры автоматного программирования


    Информационное пространство всех блоков и процедур при автоматном программировании в первом приближении одно и то же: состояния системы, моделируемой совокупностью программных действий. Но на самом деле многие блоки либо процедуры работают с подсистемами. Подсистемы, ввиду их автономности, могут иметь характеристики, прямо недоступные для общей системы, и ограниченный доступ к общему системному пространству данных. Более того, подсистемы могут общаться прямо, в обход иерархически вышестоящей системы (см. рис. 9.2). Таким образом, структура информационного пространства при автоматном программировании в общих чертах соответствует той, которая навязывается современными системами с развитой модульностью3). В системах модульности есть понятия, предоставляемые для пользования другими модулями, есть модули, которые автоматически получают доступ ко всем понятиям дружественного модуля, и есть интерфейсы между модулями.
    Основные структуры автоматного программирования
    Рис. 9.2.  Информационное пространство систем
    Светло-серые области - традиционный общий контекст системы и подсистемы. Темно-серые иллюстрируют, что доступность может быть односторонней, и не только по иерархии. Одна из систем может влиять на часть информационного пространства другой, а та может лишь пассивно следить, что натворил коллега. Области, связанные двусторонними стрелками, иллюстрируют прямое общение в обход иерархии.
    Исторически первой моделью автоматного программирования, использованной как на практике, так и для теоретических исследований, было представление программы в виде блок-схемы (см., напр., рис. 9.3), узлы которой являлись состояниями. Узлы блок-схемы делятся на пять типов:
  • начальная вершина, в которую нет входов и где производится инициализация переменных либо состояния вычислительной системы;
  • действия, при которых исполняется вызов процедуры либо оператор и после которых автомат однозначно переходит в следующее состояние;
  • распознаватели, проверяющие значение переменной либо предиката и затем передающие управление по разным адресам;
  • соединения, в которые имеется несколько входов и один выход;
  • выход, попав в который, программа заканчивает работу.


  • Основные структуры автоматного программирования
    Рис. 9.3.  Блок-схема

    Представление программ в виде блок- схем было целесообразно для многих классов программ, писавшихся в машинных кодах без средств автоматизации программирования. Блок-схемы тогда были основным средством планирования разработки программ и их документирования. Традиционные блок-схемы - предмет изучения, в частности, теоретического программирования (см. книги Котова [16], [17]).

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

    Действия в автоматном программировании глобальны, а условия локальны. Проверка условия не изменяет состояния всей системы (ни одного из ее параметров или характеристик), она лишь переводит саму программу в то или иное состояние.

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

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

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

    Внимание!

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

    Граф состояний и переходов, называемый также таблицей переходов — нагруженный ориентированный граф G. Каждой вершине графа G сопоставлено наименование состояния, а каждой дуге — условие.

    Условие AB, сопоставленное дуге, ведущей из a в b, содержательно интерпретируется следующим образом. При выполнении AB в состоянии a управление передается состоянию b (или же в другом смысле осуществляется переход по данной дуге).

    Когда граф состояний и переходов используется для документирования программы, наименования состояний, как правило, совпадают с именами процедур, выполняющихся в данном состоянии.


    Постановка задачи и первичный анализ


    Пусть требуется решить следующую задачу. Словом называется любая непустая последовательность букв латинского алфавита (для простоты - только строчных букв). Перепечатать из входной последовательности символов все максимальные входящие в нее слова в следующем виде:
    <слово> - <длина слова><конец строки печати>
    Ввод заканчивается пустой строкой.
    Например, по строкам
    попугай бегемот 1мот2крот1мот
    нужно выдать что-либо вроде
    попугай 7 бегемот 7 мот 3 крот 4 мот 3
    Для решения задачи входную последовательность символов естественно считать потоком, читаемым слева направо, пока не будет прочитана пустая строка. При чтении букв, других символов и конца строки действия, которые необходимо выполнять, различны. Кроме того, различаются действия для первой буквы и для последующих букв слова.


    Построение графа состояний


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

  • Есть еще одно действие, не отраженное в этом списке, но подразумеваемое: стандартное действие, происходящее перед началом отработки нового состояния. В данном случае это чтение очередного символа, которое должно выполняться, когда переход срабатывает. В других случаях автоматных моделей роль чтения очередного символа может играть, скажем, последующий шаг моделирования. Для данного и для большинства других автоматных примеров стандартное действие не нужно указывать явно, поскольку оно автоматически сопоставляется каждому переходу и полностью соответствует математической модели.
    Из перечня действий для рассматриваемой задачи следует, что должно быть, как минимум, два класса состояний, имеющие разные действия-реакции.
    Следующий методический прием - это определение начального состояния, т. е. того, в котором вычислительный процесс активизируется. Нужно указать, какие действия возможны в данном состоянии и какие условия перехода в нем должны быть.
    Для рассматриваемого случая в начальном состоянии (St1), возможны переходы:
  • с переходом в другое состояние - St2 (поскольку следующая буква требует другой реакции),
  • с сохранением состояния и
  • отработка первого перевода строки.

  • Построение графа состояний
    Рис. 9.4.  Начало построения графа состояний по Муру
    Результат только что проведенного анализа можно представить в виде графа, изображенного на рис. 9.4. С каждой дугой графа связано условие перехода. С каждым состоянием может быть связано действие.
    В состояниях St1 и St3 действием является пропуск очередного символа и чтение следующего, но соединить их сейчас нельзя, поскольку в первом из них переход на завершение работы невозможен, так что перевод строки анализируется по-разному. Это позволяет предположить, что в данной задаче действия стоит ассоциировать с переходами, а не с состояниями. Правильный выбор того, с чем ассоциировать действия, может сильно оптимизировать программу. Для показа двух возможных вариантов, которые в данном случае почти эквивалентны по сложности, мы будем действовать как на переходах, так и в состояниях. В частности, предварительный анализ состояний при методе преобразований на переходах дан на рис. 9.5.

    Построение графа состояний
    Рис. 9.5.  Начало построения графа состояний при использовании метода на переходах

    Внимание!

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

    На схемах вход и выход обозначаются черными прямоугольными блоками. При желании их можно считать специальными состояниями, с которыми ассоциированы действия инициализации и завершения процесса. Состояние St2 изображено штриховой линией, поскольку анализ его еще не проведен. Для каждого из еще не проанализированных состояний (в данном случае для St2) следует определить условия перехода (и соответствующие им действия, если выбрали работу на переходах).

    В рассматриваем случае в состоянии St2 возможны переходы:

  • с сохранением состояния;
  • окончание слова и отработка вывода слова, поскольку следующий символ не буква, с переходом в другое состояние, которому дается временное имя St4;
  • отработка окончания слова и перевода строки, ему можно дать временное имя St5.


  • После этого построения проверяется, не является ли новое состояние копией уже имеющихся как по действиям, так и по переходам. Если это так, то новое состояние можно отождествить (склеить) с одним из ранее построенных.


    Вновь появляющиеся состояния анализируются аналогично.

    Для решаемой задачи легко выяснить, что состояние St3 требует тех же действий и переходов, что и St5, а St4 изоморфно St1. Следовательно, возможна склейка этих двух состояний со старыми и построение графа завершается, так как новых состояний нет (см. рис. 9.6). С тем, чтобы не дублировать действия <Завершить процесс>, можно определить еще одно, заключительное состояние, с выходом из которого будет ассоциировано это действие (один раз!).

    Построение графа состояний
    Рис. 9.6.  Полученный граф состояний при действиях на переходах

    Аналогично можно поступить и когда мы работаем в состояниях. Здесь процесс удлиняется на один шаг, поскольку необходимо выделить завершение обработки слова в отдельное действие (причем в двух вариантах: после перевода строки и после других небуквенных символов). Полученный результат показан на рис. 9.7.

    Построение графа состояний
    Рис. 9.7.  Полученный граф состояний при действиях в состояниях


    Программные представления графа состояний


    Отметим, что программные представления графа состояний сильно зависят от динамики данного графа. Стоит выделить четыре подслучая.
  • Состояния и таблица переходов жестко заданы постановкой задачи (например, такова задача синтаксического анализа). В этом случае лучшее программное представление переходов между состояниями - go to, независимо от размера таблицы.
  • Состояния и таблица переходов пересматриваются, но фиксированы между двумя модификациями задачи. При небольших размерах таблицы по-прежнему предпочтительней всего реализация через переходы, а при достаточно больших - необходима ее декомпозиция, в связи с чем часто целесообразно представление состояний объектами.
  • Состояния и таблица переходов динамически порождаются перед выполнением данного модуля и фиксированы в момент его выполнения. Лучший способ реализации - задание таблицы переходов в виде структуры данных и написание интерпретирующей программы для таких таблиц.
  • "Живая таблица": модифицируется в ходе исполнения. Пока что дать методологические советы для таких таблиц мы не можем, хотя очевидно, что, несмотря на внешнюю рискованность, такой путь чрезвычайно выигрышен для многих систем адаптивного реагирования. Заранее нужно обговорить, что модули, перестраивающие таблицу, и модули, исполняющие ее, должны быть как можно более жестко разделены.



  • Табличное представление графа состояний


    Графическое или иное представление графа состояний конечного автомата, явно отражающее наименования состояний, условия переходов между состояниями и ассоциированные с ними действия, называют таблицей переходов. Такое представление является одним из центральных компонентов широко используемого в индустриальном программировании языка объектно-ориентированного дизайна UML (в частности, в форме, реализованной в системе Rational Rose) - state transition diagrams (диаграммы состояний и переходов).
    Различают визуальные представления таблиц переходов, которые предназначены для человека, и их программные представления. Требования к визуальным представлениям - понятность для человека, статическая проверяемость свойств; требования к программным представлениям - выполнимость при посредстве какой-либо системы программирования; общее требование к представлениям обоих видов - однозначное соответствие между ними, позволяющее обоснованно утверждать вычислительную эквивалентность.
    Ответ на вопрос, какое представление графа состояний лучше всего использовать, зависит от сложности графа, статичности либо динамичности его и того, для каких целей требуется спецификация в виде графа состояний. Понятно, что важнейшей для нас промежуточной целью является программа на алгоритмическом языке. Но подходы к построению такой программы могут быть различны. Существует две принципиально различные методики применения спецификаций:
  • трансляционная - генерируется программа, структура управления которой определяется графом состояний;
  • интерпретационная - строится специальная программа, которая воспринимает некое представление графа как задание для интерпретации.

  • Как при трансляционной, так и интерпретационной позиции возможен целый спектр реализаций. Ближайшая же цель в том, чтобы научиться удобно для человека представлять граф состояний и переходов. Наиболее естественно описывать его в виде таблицы. Для методов на переходах и в состояниях таблицы несколько различаются. На табл. 9.1 представлен случай Мили.
    Опишем значение колонок таблицы.

  • Наименование состояния - входы в таблицу.
  • Условие (срабатывания) перехода - логическое выражение или служебное слово failure, которое указывает на действие, выполняемое, если ни одно из условий не срабатывает.
  • Действие, ассоциированное с переходом, - последовательность операторов, выполняемая, когда условие перехода истинно.
  • Адрес перехода - наименование состояния-преемника.


  • Кроме того, определяется специальная (первая) строка, в которой помещаются операторы, инициирующие процесс, и адрес перехода начального состояния.

    В табличном представлении графа переходов в состояниях (таблица 9.2) действия помещаются во втором столбце, сразу после обозначения состояния, а уже затем идут переходы. Анализируя таблицу 9.2, можно заметить ряд повторяющихся действий. Конечно же, если бы эти действия были более сложными, их просто описали бы как процедуры. Но такие повторения заставляют задуматься, а нельзя ли сократить число состояний? Введя дополнительную булевскую переменную, отметившую, что последним символом был перевод строки, мы могли бы избавиться от пары состояний. Но такой переменной нужно присваивать новое значение в процессе проверки условий, и при этом в строго определенный момент: сначала проверить, не является ли символ концом строки, а уже затем переприсваивать данную переменную. Это решение сильно отдает хакерством, и, если Вы хотите писать качественные, легко перестраиваемые программы, то таких решений нужно избегать.

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



    Таблица 9.1. Табличное представление графа для действий на переходахSt1St1St2St1St3St2St2St1St3St3St2St1exitexit
    char symbol; // Чтение потока символов неявное

    // Чтение происходит перед выполнением проверок и действий

    int Cnt; . . . // Инициализация
    'a'<=symbol && symbol <= 'z'printf ("%c", symbol); Cnt = 1;
    /*(symbol<'a'|| 'z'/* Действий нет */
    symbol='\n'/* Действий нет */
    'a'<=symbol && symbol <= 'z'printf ("%c", symbol);cnt++;
    /*(symbol<'a'|| 'z'printf ("- %i\n", Cnt);
    symbol='\n'printf ("- %i\n", Cnt);
    'a'<=symbol && symbol <= 'z'printf ("%c", symbol); Cnt = 1;
    /*(symbol<'a'|| 'z'/* Действий нет */
    symbol='\n'Второй '\n' дальше не нужно читать
    /* Нет неявного чтения потока */return 0; // Считать данную секцию таблицы состоянием или нет - дело вкуса
    Таблица 9.2. Табличное представление графа для действий в состоянияхSt1St1St2St3St2St2St5St3St2exitSt4St2St3St5St2exitexit
    char symbol; // Чтение потока символов неявное

    // Чтение происходит после выполнения действия, перед проверками

    int Cnt; ...Cnt=0;

    // Инициализация
    /* Действий нет */'a'<=symbol && symbol <= 'z'
    /*(symbol<'a'|| 'z'St1
    symbol='\n'
    printf ("%c", symbol); Cnt++;'a'<=symbol && symbol <= 'z'
    /*(symbol<'a'|| 'z'St4
    symbol='\n'
    /* Действий нет */'a'<=symbol && symbol <= 'z'
    /*(symbol<'a'|| 'z'St1
    Второй '\n' дальше не нужно читать symbol='\n'
    printf ("- %i\n", Cnt); Cnt=0;'a'<=symbol && symbol <= 'z'
    /*(symbol<'a'|| 'z'St1
    symbol='\n'
    printf ("- %i\n", Cnt); Cnt=0;'a'<=symbol && symbol <= 'z'
    /*(symbol<'a'|| 'z'exit
    Второй '\n' дальше не нужно читать symbol='\n'
    /* Нет неявного чтения потока */return 0; // Считать данную секцию таблицы состоянием или нет - дело вкуса
    Табличное представление графа состояний
    Табличное представление графа состояний
    Табличное представление графа состояний
      1)

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

      2)

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

      3)

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

      4)

      Данный абзац добавлен после обсуждения с проф. А.А. Шалыто.

      5)

      Сделаем общее замечание насчет доказательной силы аргумента: "Это исследовано в теории". При применении теории необходимо всегда помнить, что вовсю исследуются не только разумные понятия, но и извращения.

    Табличное представление графа состояний

    Анализ состояния дел


    Построение таблиц заканчивает этап спецификации нашей программы. Таблицы 9.1 и 9.2-другое формализованное представление рисунков 9.6 и 9.7. Как всегда, разные формализмы отличаются по практической направленности. Граф в некоторых случаях может быть автоматизированно преобразован в прототип программы (попытайтесь сами проделать это со спецификацией на языке UML), но получающиеся программы всегда требуют ручной доработки. Табличное же представление допустимо рассматривать как мини-язык программирования. Для него возможно построение транслятора или интерпретатора, которые в частных типах таблиц давно уже используются (их широкому применению мешает привязка к конкретным предметным областям и приложениям, поскольку профессиональные системные программисты свысока смотрели на столь простые задачи).
    Обратимся к семантике вычислений на языке таблиц. Рассматривается контекст некоторой программы на языке C++ (или на другом языке традиционного типа). В этом контексте определяется значение переменной Entry (множество ее значений совпадает с множеством наименований состояний), далее выполняются следующие действия:
  • Выбрать вход в таблицу, соответствующий значению Entry (текущее состояние).
  • Если Условие отсутствует, то перейти к шагу 4.
  • Вычислить совместно все Условия срабатывания переходов (клетки второй колонки для данного входа):

  • Если среди результатов есть значения True, то выбрать одну из строк, Условие которой истинно, и перейти к шагу 4 для выбранной строки1);
  • Если все значения Условий ложны и есть строка failure, то выбрать эту строку и перейти к шагу 4 для нее;
  • Если все значения Условий ложны и нет строки failure, то завершить вычисления.

  • Выполнить Действие.
  • В качестве значения переменной Entry установить наименование состояния-преемника (из четвертой колонки таблицы). Перейти к шагу 1.

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

  • start - указание действий, которые выполняются до основных действий и проверок условий перехода (здесь таким действием является чтение очередного символа из файла);
  • finish-указание действий, которые выполняются после основных действий и проверок, но до перехода.


  • Можно также определять локальные данные для состояний (в примере такие данные определены только для начального состояния), но это должно быть согласовано с правилами локализации имен языка программирования и с общим стилем, в котором написана программа. Заметим, что локальные данные всех состояний конечного автомата должны быть помещены в общий контекст, а приписывание их к конкретным состояниям является ограничением видимости, подобным тому, которое используется в модульном программировании (если Вы уже программировали на Object Pascal Delphi, Java либо Oberon, то знакомы с модульностью). Это прагматически следует из того положения, что работа теоретического конечного автомата не требует привлечения памяти2).

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

  • Оттранслировать вручную.
  • Отдать препроцессору для превращения в нормальнуюпрограмму.
  • Использовать интерпретатор.


  • Рассмотрим последовательно эти три возможности.


    Автоматизированное преобразование таблиц переходов


    Если автоматизировать работу с табличным представлением, то прежде всего требуется строго определить структуру данных, представляющих таблицу переходов. Способ задания таблицы зависит от стратегии дальнейшей обработки. В частности, если вручную строится текстовое представление таблицы, которое в дальнейшем преобразуется к исполняемому виду, то приемлемо, например, такое представление.
    _1 char symbol; // Чтение потока символов неявное int cnt; . . . // Инициализация _4 St1 _5 _1 St1 _2 'a'<=symbol && symbol <= 'z' _3 printf ("%c", symbol); cnt = 1; _4 St2 _5 _1 _2 //symbol <'a' && 'z'< symbol && symbol != '\n' _3 //Так как нужно печатать только слова, //действий нет _4 St1 _5 _1 _2 failure _3 // symbol == '\n' не нужно читать _4 St3 _5 _1 St2 _2 'a'<=symbol && symbol <= 'z' _3 printf ("%c", symbol); cnt++; _4 St2 _5 _1 _2 //(symbol <'a'||'z'< symbol)&&*/ symbol!='\n' _3 printf (" - %i\n", Cnt); _4 St1 _5 _1 _2 failure _3 printf (" - %i\n", Cnt); _4 St3 _5 _1 St3 _2 'a'<=symbol && symbol <= 'z' _3 printf ("%c", symbol); cnt = 1; _4 St2 _5 _1 _2 //symbol <'a' && 'z'< symbol && symbol != '\n' _3 //Так как нужно печатать только слова, //действий нет _4 St1 _5 _1 _2 failure _3 // symbol == '\n' не нужно читать _4 exit_5 _1 exit_2 // условие истина, но без неявного чтения потока _3 return 0; _4 _5
    Листинг 10.3.1. Программа в виде размеченного текста.
    Здесь <_i>, i = 1, . . . , 5 обозначают позиции таблицы. Эти сочетания символов, нигде в обычной программе не встречающиеся, легко могут распознаваться препроцессором. Размещаемые между ними последовательности символов разносятся по соответствующим полям нужной структуры, которая, в зависимости от выбранной стратегии, интерпретируется либо транслируется. С помощью этих сочетаний осуществляется разметка текста, которая дает возможность распознавать и осмысливать его фрагменты.
    Стоит обратить внимание на то, что за счет специального взаимного расположения символов в данном тексте представляемая им таблица автомата вполне обозрима. Если нет более подходящего представления, то данную структуру можно рекомендовать для ввода.

    Однако непосредственная интерпретация универсального текстового размеченного представления довольно затруднительна. Предпочтительнее, чтобы единицами интерпретации были бы сами поля таблицы. Вообще, этому противоречит наличие в таблице полей, значения которых - фрагменты исполняемого кода на языке программирования (такая запись удобна для человека, так как для заполнения таблицы не приходится прибегать ни к каким дополнительным соглашениям). На самом деле противоречие возникает только для поля действий, поскольку последовательности символов между <_2> и <_3> имеют ясно выраженную семантику: это проверка условия. Если всегда рассматривать условия как проверку того, чему равен входной символ, то вполне понятны, легко задаются, распознаются и интерпретируются специальные обозначения: перечисления значений, их диапазоны и т.д. Трактовка этих обозначений не вызывает осложнений, а потому подобные приемы кодирования применяются довольно часто.

    Если говорить о полях действий, то их представление для большинства языков программирования плохо разработано. Данное обстоятельство препятствует использованию автоматного программирования: кодирование действий усложняет обработку. Но если в языке можно задавать подпрограммы как данные, то описание структуры таблицы, которое будет согласовано с дальнейшей трансляцией или интерпретацией, возможно, в частности, в языках функционального программирования (семейство LISP и ML) и в языке Prolog. Но функциональное и сентенциальное программирование хорошо интерпретируют лишь частные случаи таблиц.

    Пусть тип T_StructTableLine описывает структуру одной строки таблицы, а вся таблица представлена в виде массива Table таких структур. Пусть далее iSt - переменная, используемая для индексирования массива Table, некоторые из ее значений представляют состояния конечного автомата (но не все, а только те, к которым определен переход!).


    Наконец, пусть

    int handler ( int );

    -обработчик строки (ее интерпретатор или транслятор), <понимающий> структуру T_StructTableLine, специфицируемый как функция, при выполнении которой должно быть определено очередное значение iSt. Значение iSt, не указывающее ни на какую строку таблицы, служит сигналом для завершения обработки (при желании можно заняться кодированием того, как завершилась обработка с помощью задания различных таких значений).

    Тогда схема программы, которая оперирует с таблицей с помощью функции handler, может быть задана следующим циклом:

    iSt = 0; do { iSt = handler ( iSt ); } while ( OUTSIDE ( iSt ) );

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

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

  • Извлечь значение Table[iSt];
  • Активизировать вычисление условия обрабатываемой строки таблицы;
  • Если условие истинно, то

  • активизировать подпрограмму действий;
  • читать очередной символ;
  • завершить handler со значением, извлекаемым из поля следующего состояния;


  • Если условие ложно, то

  • iSt++;
  • Перейти к 1;




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

    Таким образом, нужно научиться описывать структуру строки таблицы. На языке C++/С# эта задача может решаться довольно просто:

    struct T_StructTableLine { // поле для имени состояния избыточно int (*condition)(); // поле условия void (*action)(); // поле действия int ref; // поле перехода: индекс строки таблицы, // которая будет обрабатываться следующей, // или признак завершения процесса }



    Сама таблица описывается следующим образом:

    T_StructTableLine Table[]

    или

    T_StructTableLine Table [Размер таблицы]

    если значения строк задаются вне этого описания.

    Однако сразу же видно ограничивающее соглашение, которое пришлось принять в связи с особенностями языка реализации: все условия и все действия оформлены как функции, тогда как задача, по своей сути, этого не требует. Более того, отделенное от таблицы описание функций, предписываемое языком С++/C#, резко снижает выразительность представления:

    int Cond_St1_1() - условие в Table[1]

    и

    void Act_St1_1() - действие в Table[1] (строка 1 состояния St1);

    int Cond_St1_2() - условие в Table[2]

    и

    void Act_St1_2() - действие в Table[2] (строка 2 состояния St1);

    и т. д.

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

    Последний недостаток неустраним ни в каком языке, если проверки и действия трактовать как процедуры, которые работают в общем контексте, задаваемом в заголовке таблицы. Так что лучше сразу отказаться от описания этого контекста в рамках таблицы (задание инициализации в ней возможно). Что касается первого недостатка, то он носит почти синтаксический характер, и если бы можно было задавать тела процедур как значения полей структур, то наглядность представления можно было бы сохранить. В языковом оформлении, похожем на С/С++/C#, это выглядело бы как следующее присваивание значения Table в качестве инициализации.

    Table[]= { {{return true;}, /* инициализация */, 1}, /*St1*/{{return 'a'<=symbol && symbol <= 'z';}, {printf ("%c", symbol); cnt = 1;}, 4}, {{return symbol != '\n';}, /* Так как нужно печатать только слова, действия не заполняются */, 1}, {{return true}, /* Переход не требует чтения, symbol == '\n' не нужно читать */, 0}, /*St2*/{{return 'a'<=symbol && symbol <= 'z';}, {printf ("%c", symbol); cnt++;}, 4}, {{return symbol!='\n';}, {printf ("-%i\n",cnt);}, 1}, {{return true }, {printf ("- %i\n",cnt);}, 0} };



    Листинг 10.3.2.

    Но для С/С++/C#, а также для других языков, не ориентированных на работу с таблицами, говорить о естественности представления не приходится. По-видимому, наиболее разумно строить автоматический преобразователь (типа препроцессора, но не путать этот термин с С!), который составляет текст программы по таблице. В этом случае снимается много проблем:

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


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

    Проанализировав исходную таблицу, легко заметить, что используется фактически два варианта функций-условий и три - функций-действий. Из-за нестандартности работы с инициализацией (нулевая строка таблицы) удобно пополнить этот набор еще двумя функциями: <условием> и <действием>, выполняемыми при переходе к этой строке, которая интерпретируется как завершение работы автомата (таким образом, предикат OUTSIDE (iSt) можно представить как равенство аргумента нулю).

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

  • Когда читать? Поскольку прочитанный символ используется после его распознавания, естественно "не терять" его, пока выполняется действие.


    Следовательно, читать очередной символ целесообразно после отработки действия.
  • Как поступать в начале процесса: должен ли быть прочитан символ перед первым вызовом функции handler? Понятно, что чтение символов является частью функциональности этой функции. Если в ней реализовать чтение до вызовов условия и действия, то это противоречит предыдущему соглашению, а противоположное решение делает нестандартным начало процесса. В предлагаемой программе принято решение об особой обработке нулевой строки. Поскольку, являясь инициализацией, она действительно особая, это соответствует сделанному соглашению.
  • Как поступать в конце процесса? Чтение заключительного символа ('\n') должно прекращать возможные попытки последующего обращения к чтению. Следовательно, необходимо позаботиться о завершающих переходах. В предлагаемой программе принято решение о завершающем переходе к нулевой строке, которая, согласно предыдущему, является нестандартной.


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

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

    #include char symbol; // переменная для чтения потока символов int cnt; // переменная для счета длин слов

    int c0(), c1(), c2(); // функции-условия void a0(), a1(), a2(), a3(); // функции-действия int handler ( int i ); // обработчик строк таблицы struct T_StructTableLine { // поле для имени состояния избыточно int (*condition)(); // поле условия void (*action)(); // поле действия int ref; // поле перехода: индекс строки таблицы, // которая будет обрабатываться следующей, // или признак завершения процесса } T_StructTableLine Table[]={ {c0,a0,1}, // таблица инициализируется статически, {c1,a1,2}, // альтернативное решение - специальная {c2,a0,1}, // функция задания начальных значений. {c0,a0,3}, // Оно более гибкое, но менее {c1,a2,2}, // эффективно. {c2,a3,1}, // О наглядности см.комментарий {c0,a3,3}; // в тексте. {c1,a2,2}, {c2,a3,1}, {c0,a3,0}}; void main() { int iSt=0; do { iSt = handler ( iSt ); } while ( iSt); }

    int handler ( int i ) { if (Table[i].condition()) { Table[i].action(); if (Table[i].ref) { symbol = getchar (); return Table[i].ref; } } else return ++i; }

    // Описания используемых функций: int c0(){return symbol == '\n';} int c1(){return 'a'<=symbol && symbol <= 'z';} int c2(){return 'a'>symbol ||symbol > 'z';}

    void a0(){} void a1(){printf ("%c", symbol);cnt = 1;} void a2(){printf ("%c", symbol);cnt++;} void a3(){printf ("- %i\n", cnt);}

    Листинг 10.3.3. Длины слов: интерпретатор конечного автомата.


    Обсуждение решения


    Как уже отмечалось, фрагменты, описывающие условия и действия в таблице переходов и реализованные как процедурные вставки, с точки зрения препроцессора (не препроцессора системы программирования, а специального преобразователя, генерирующего представление таблицы для интерпретации), являются нераспознаваемыми данными, которые он просто пропускает без изменений для обработки компилятором. Интерпретатор же, наоборот, не в силах сам исполнять процедуры, а потому трактует ссылки на них (в соответствующих полях) как данные. Таким образом, условия и действия в таблице двойственны: они являются одновременно и данными, и программными фрагментами. Автоматический преобразователь таблицы, не понимая языка таких двойственных данных, пытается, тем не менее, их объединить с обычными данными (в рассматриваемом случае это индексы строк переходов) в одной структуре.
    На первый взгляд кажется, что ситуация существенно упростилась бы в языке, в котором есть возможность воспринимать данные как выполнимые фрагменты. Пример такого рода - команда <Вычислить строку>. Эта команда давала бы возможность интерпретатору понимать программы-данные, не оставляя их нераспознанными. Подобная команда порою встречается в языках скриптов, ориентированных на интерпретацию. А в языке LISP, в котором структуры данных и программы просто совпадают, указанная двойственность не возникает. Но по опыту известно, что столь общее решение чревато столь же общими и вездесущими неприятностями, и нужно искать частный его вариант, адекватный данной задаче.
    Метод решения задач с помощью построения таблиц и графов состояний и переходов часто удобен для обработки потоков данных. В книгах [32], [33], а особенно в задачнике [2] и на сайте http://is.ifmo.ru, имеется ряд задач такого рода. Решения стоит оценить количественно и качественно: сколько требуется состояний и действий, как лучше получается в данном случае (в состояниях или на переходах). Может оказаться, что понадобятся средства, выходящие за рамки описанных методов.
    Это допустимо, следует только осознавать грань между использованием конечного автомата и применением другого подходящего метода, а также четко разделять внутри программы возможности, относящиеся к разным стилям программирования.

    Подведем итоги

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

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

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

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

    В связи с этим возникает вопрос об улучшении поддержки методов табличного и графового представления автоматных программ.

    Обсуждение решения
    Обсуждение решения
    Обсуждение решения
      1)

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

      2)

      Памятью можно считать номер текущего состояния (замечание проф. А. А.Шалыто).

      3)

      Это верно и для унификационного варианта.

      4)

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

    Обсуждение решения

    Вторая программа, чуть дальше от


    ENTRY Go{=>}; Letters {='abcdefghijklmnopqrstuvwxyz';}; Init{=; e.1=;}; St1 { s.1 e.3,: e.A s.1 e.B =; = >; s.1 e.3 =; }; St2 {(e.2)= >; s.1 e.3 (e.2),: e.A s.1 e.B =; s.1 e.3 (e.2)=; }; * St3 не нужно Outstr { e.2, : {s.1 e.2 = >;}; }; * * Вторая программа, чуть дальше от непосредственной автоматной модели * $ENTRY Go{=>}; Letters {='abcdefghijklmnopqrstuvwxyz';}; Init {=; e.1=;}; Parse { s.1 e.3 (e.2),: e.A s.1 e.B =; (e.2)= >; s.1 e.3 (e.2)=; }; Outstr { = ; e.2, : {s.1 e.2 = >;}; };
    Листинг 10.2.1. Длины слов: рефал
    Закрыть окно




    // Реализация автомата с табл. 9.1 #include #include #include
    char symbol; int cnt;
    enum States { St1, St2, St3 } State; void main( void ) {fstream a,b; a.open("input.txt",ios::in); b.open("output.txt",ios::out); State = St1; while (true ) {symbol=a.get(); switch ( State ) { case St1: if ('a'<=symbol && symbol <= 'z') { b<
    Листинг 10.2.2. Длины слов: явный цикл обработки потока
    Закрыть окно




    #include #include #include
    char symbol; int cnt; enum States { St1, St2, St3, Exit } State;
    inline States f_St1 () { if ('a'<=symbol && symbol <= 'z') printf ("%c", symbol); cnt = 1; symbol = getchar (); return St2; } else if (symbol != '\n') { symbol = getchar (); return } else {symbol = getchar (); return }
    inline States f_St2 () { if ('a'<=symbol && symbol <= 'z') printf ("%c", symbol); cnt++; symbol = getchar (); return St2; } else if (symbol != '\n') { printf (" -%i\n", cnt); symbol = getchar (); return St1; } else { printf (" - %i\n", cnt); symbol = getchar (); return St3; } }
    inline States f_St3 () { if ('a'<=symbol && symbol <= 'z') { printf ("%c", symbol); cnt = 1; symbol = getchar (); return St2; } else if (symbol != '\n') { symbol = getchar (); return St1; } else return Exit; }
    void main( void ) { symbol = getchar (); State = St1; for (;;) { switch ( State ) { case St1: State = f_St1 (); break; case St2: State = f_St2 (); break; case St3: State = f_St3 (); break; default: return; } } }
    Листинг 10.2.3. Длины слов: использование процедур для описания состояний.
    Закрыть окно




    program funcgoto; {$APPTYPE CONSOLE} {$T+} uses SysUtils;
    type P=procedure; type Pp= ^P; const maxstate = 7; const maxcond = 3; type states = 1.. maxstate; conds = 1.. maxcond; type table = array [states, conds] of states; type act = array [states] of P; const gotos: table = ((2,2,2),(3,2,4),(3,5,6),(3,2,7),(3,2,4),(3,2,7),(1,1,1)); var Symbol: char; var Cnt: integer; var Inf, Outf: text; var state: states;
    procedure Start; begin Cnt:=0; AssignFile(Inf, 'input.txt'); Reset(Inf); AssignFile(Outf, 'output.txt'); Rewrite(Outf); end;
    procedure Finish; begin Closefile(Inf); Closefile(Outf); Abort; end;
    procedure St1; begin {No actions} end;
    procedure St2; begin write(outf,Symbol); Inc(Cnt); end;
    procedure St4; begin writeln(outf,' - ',Cnt); Cnt:=0; end;
    const actions: act = (Start, St1,St2,St1,St4,St4,Finish);
    begin state:=1; while true do begin actions[state]; if (state <>1) and (state<>7) then begin read(inf,Symbol); if Ord(Symbol)=10 then read(inf,Symbol) end; if Symbol in ['a'..'z'] then state:= gotos[state,1]; if not(Symbol in ['a'..'z']) and (Ord(Symbol)<>13) then state:=gotos[state,2]; if Ord(Symbol)=13 then state:=gotos[state,3]; end; end.
    Листинг 10.2.4. Длины слов: массив функций и переходов
    Закрыть окно




    #include #include #include
    char symbol; int cnt; void main( void ) { symbol = getchar (); St1: if ('a'<=symbol && symbol <= 'z') { printf ("%c", symbol); cnt = 1; symbol = getchar (); goto St2; } else if (symbol != '\n') { symbol = getchar (); goto St1; } else /* (symbol == '\n') */ {symbol = getchar (); goto St3;}; St2: if ('a'<=symbol && symbol <= 'z') { printf ("%c", symbol); cnt++; symbol = getchar (); goto St2; } else if (symbol != '\n') { printf (" -%i\n", cnt); symbol = getchar (); goto St1; } else { printf (" -%i\n", cnt); symbol = getchar (); goto St3; }; St3: if ('a'<=symbol && symbol <= 'z') { printf ("%c", symbol); cnt = 1; symbol = getchar (); goto St2; } else if (symbol != '\n') { symbol = getchar (); goto St1; } else /* (symbol == '\n') */ return; }
    Листинг 10.2.5. Длины слов: состояния - метки в программе.
    Закрыть окно



    Ручная трансляция таблиц переходов


    В решениях 1 и 2 возникает следующий вопрос: во что транслировать таблицу переходов? Вариантов множество, ниже рассматриваются лишь некоторые из них.
    Вариант 1.Можно считать St1 и St2 функциями, реализующими все действия состояний.При использовании верно подобранных стиля и инструментальных средств этот подход дает отличный результат. Рассмотрим, в частности, как может быть реализована наша задача (еще точнее, автомат таблицы 9.1) на языке Рефал.
    ENTRY Go{=>}; Letters {='abcdefghijklmnopqrstuvwxyz';}; Init{=; e.1=;}; St1 { s.1 e.3,: e.A s.1 e.B =; = >; s.1 e.3 =; }; St2 {(e.2)= >; s.1 e.3 (e.2),: e.A s.1 e.B =; s.1 e.3 (e.2)=; }; * St3 не нужно Outstr { e.2, : {s.1 e.2 = >;}; }; * * Вторая программа, чуть дальше от непосредственной автоматной модели * $ENTRY Go{=>}; Letters {='abcdefghijklmnopqrstuvwxyz';}; Init {=; e.1=;}; Parse { s.1 e.3 (e.2),: e.A s.1 e.B =; (e.2)= >; s.1 e.3 (e.2)=; }; Outstr { = ; e.2, : {s.1 e.2 = >;}; };
    Листинг 10.2.1. Длины слов: рефал
    Эти программы настолько коротки и естественны, что практически не требуют комментариев. Единственная новая возможность, использованная здесь, в принципе излишняя, но делает программу красивее. Конструкции : e.A s.1 e.B и e.2, : являются, соответственно, вложенной проверкой условия на выражении, порождаемом вызовом , и вызовом определяемой дальше анонимной функции на выражении, получившемся после запятой.
    Стандартная функция добавляет первым символом к выражениюего длину (количество термов в нем). При проверке и при вычислении вспомогательной функции переменные, уже получившие значения, не изменяются.

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

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

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

    Вариант 2. Считать St1, St2, St3 значениями некоторого перечислимого типа State.

    // Реализация автомата с табл. 9.1 #include #include #include

    char symbol; int cnt;

    enum States { St1, St2, St3 } State; void main( void ) {fstream a,b; a.open("input.txt",ios::in); b.open("output.txt",ios::out); State = St1; while (true ) {symbol=a.get(); switch ( State ) { case St1: if ('a'<=symbol && symbol <= 'z') { b<


    Листинг 10.2.2. Длины слов: явный цикл обработки потока

    Программа 10.2.2 хороша только в том случае, если ограничиться автоматными моделями в достаточно узком смысле. Программа точно соответствует табличному представлению автомата, почти все дублирования действий, связанные с таким представлением, остались. Возникает некоторая неудовлетворенность тем, что появилось лишнее действие: выбор выполняемого фрагмента по значению переменной State. Если ограничиться управляющими средствами методологии структурного программирования, то это - наилучший выход. Попытки применить циклы внутри фрагментов, помеченных как case St1: и case St2:, приводят лишь к уменьшению наглядности по сравнению с табличным представлением.

    Есть еще один вопрос, который требуется решить при таком подходе, - выбор типа результата функций-состояний и способа возвращения результата. Естественный результат такой функции - это новое состояние, в которое попадает автомат при переходе. Он может быть представлен, например, глобальной переменной (такой как State в программе 10.2.2, которой присваивается новое значение при выполнении функции-состояния). Но предпочтительнее не трогать State в каждой из функций, а предоставить задачу фактического изменения состояния общему их контексту. Наиболее правильной реализацией состояний при таком подходе являются функции, которые вырабатывают в качестве результата значение типа, перечисляющего все состояния.

    Следующая программа 10.2.3 почти буквально реализует данную идею. В отличие от программы 10.2.2, тип States содержит четыре значения: St1, St2, St3 и Exit. Последнему из них не соответствует ни одна функция из-за тривиальности переходов и действий в данном состоянии.

    #include #include #include

    char symbol; int cnt; enum States { St1, St2, St3, Exit } State;

    inline States f_St1 () { if ('a'<=symbol && symbol <= 'z') printf ("%c", symbol); cnt = 1; symbol = getchar (); return St2; } else if (symbol != '\n') { symbol = getchar (); return } else {symbol = getchar (); return }



    inline States f_St2 () { if ('a'<=symbol && symbol <= 'z') printf ("%c", symbol); cnt++; symbol = getchar (); return St2; } else if (symbol != '\n') { printf (" -%i\n", cnt); symbol = getchar (); return St1; } else { printf (" - %i\n", cnt); symbol = getchar (); return St3; } }

    inline States f_St3 () { if ('a'<=symbol && symbol <= 'z') { printf ("%c", symbol); cnt = 1; symbol = getchar (); return St2; } else if (symbol != '\n') { symbol = getchar (); return St1; } else return Exit; }

    void main( void ) { symbol = getchar (); State = St1; for (;;) { switch ( State ) { case St1: State = f_St1 (); break; case St2: State = f_St2 (); break; case St3: State = f_St3 (); break; default: return; } } }

    Листинг 10.2.3. Длины слов: использование процедур для описания состояний.

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

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

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



    Особенность последовательностей действий

    State = <значение>;

    switch ( State )

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

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

    Вариант 4. Матрица переходов и вектор-функций, соответствующих состояниям4)

    program funcgoto; {$APPTYPE CONSOLE} {$T+} uses SysUtils;

    type P=procedure; type Pp= ^P; const maxstate = 7; const maxcond = 3; type states = 1.. maxstate; conds = 1.. maxcond; type table = array [states, conds] of states; type act = array [states] of P; const gotos: table = ((2,2,2),(3,2,4),(3,5,6),(3,2,7),(3,2,4),(3,2,7),(1,1,1)); var Symbol: char; var Cnt: integer; var Inf, Outf: text; var state: states;

    procedure Start; begin Cnt:=0; AssignFile(Inf, 'input.txt'); Reset(Inf); AssignFile(Outf, 'output.txt'); Rewrite(Outf); end;

    procedure Finish; begin Closefile(Inf); Closefile(Outf); Abort; end;

    procedure St1; begin {No actions} end;

    procedure St2; begin write(outf,Symbol); Inc(Cnt); end;

    procedure St4; begin writeln(outf,' - ',Cnt); Cnt:=0; end;

    const actions: act = (Start, St1,St2,St1,St4,St4,Finish);

    begin state:=1; while true do begin actions[state]; if (state <>1) and (state<>7) then begin read(inf,Symbol); if Ord(Symbol)=10 then read(inf,Symbol) end; if Symbol in ['a'..'z'] then state:= gotos[state,1]; if not(Symbol in ['a'..'z']) and (Ord(Symbol)<>13) then state:=gotos[state,2]; if Ord(Symbol)=13 then state:=gotos[state,3]; end; end.

    Листинг 10.2.4. Длины слов: массив функций и переходов

    Это решение неплохое, но оно годится лишь для вычислений в состояниях.


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

    Рассмотрим последний вариант.

    Вариант 5. Использование статической информации о разветвлениях вычислений.

    #include #include #include

    char symbol; int cnt; void main( void ) { symbol = getchar (); St1: if ('a'<=symbol && symbol <= 'z') { printf ("%c", symbol); cnt = 1; symbol = getchar (); goto St2; } else if (symbol != '\n') { symbol = getchar (); goto St1; } else /* (symbol == '\n') */ {symbol = getchar (); goto St3;}; St2: if ('a'<=symbol && symbol <= 'z') { printf ("%c", symbol); cnt++; symbol = getchar (); goto St2; } else if (symbol != '\n') { printf (" -%i\n", cnt); symbol = getchar (); goto St1; } else { printf (" -%i\n", cnt); symbol = getchar (); goto St3; }; St3: if ('a'<=symbol && symbol <= 'z') { printf ("%c", symbol); cnt = 1; symbol = getchar (); goto St2; } else if (symbol != '\n') { symbol = getchar (); goto St1; } else /* (symbol == '\n') */ return; }

    Листинг 10.2.5. Длины слов: состояния - метки в программе.

    В данном варианте исчезает необходимость вычисляемого перехода (результат внедрения статической информации в текст программы), и, как следствие, становятся избыточными описания типа States и переменной State этого типа. В программе появляются операторы безусловного перехода, из-за этого структура управления программы полностью расходится с канонами структурного программирования, что дает повод догматически мыслящим программистам и теоретикам подвергать критике такой вариант программы. Но в данном случае отступление от канонов структурного программирования полностью оправдано, поскольку за счет специального расположения фрагментов текста вся программа оказалась похожа на таблицу конечного автомата, а структура передач управления копирует граф конечного автомата.Более того, такое представление нейтрально по отношению к моделям Мура и Мили. Таким образом, лишь после полного отхода от канонов структурности программа стала адекватна своей спецификации.


    Стили и методы программирования


    Пример 10.2.1
    Анализ состояния дел
    Ручная трансляция таблиц переходов
    Автоматизированное преобразование таблиц переходов
    Обсуждение решения



    Опционы

    История опционов начинается в XVIII веке в Голландии, где впервые их стали использовать на рынке живых цветов. C тех пор опционы обращаются на многих мировых рынках: продуктов питания, ценных бумаг и, конечно, валютном рынке. Бум опционов, равно как и всех остальных производных инструментов, пришелся на 80-90-е годы.

    Анализ опционов
    Бинарные опционы
    Виды опционов
    Закон об опционах
    Учебник по опционам

    Опционы на Форекс
    Контракт на опционы
    Математика опционов
    Оценка опционов
    Реальные опционы

    Классический подход к опционам
    А-опционы
    Опционный риск
    Опционы в России
    Спрэд на опционах

    Опционные стратегии
    Торговля опционами
    Цена опциона

        Программирование: Языки - Технологии - Разработка




    Постановка задачи


    При обсуждении программы 10.3.1 было показано, что предложенный текст размечен символами _i, i=1,...,5 а также то, как от размеченного текста переходить к программе на языке С++ или к интерпретации таблицы. У такой разметки только один недостаток: она не стандартизована, а потому о распространении за пределы среды разработчиков системы, предназначенной для поддержки применения таблиц, говорить не приходится.
    В настоящее время используются следующие языки разметки, которые можно считать стандартными:
  • TEX и, в первую очередь, надстройка над ним LATEX — для типографской подготовки текстов.
  • HTML — стандартный язык разметки текстов в Интернет.
  • XML — язык разметки текстов в Интернет, ориентированный на активную работу с ними.

  • Их основное назначение — структурированное представление визуализации информации. С точки зрения автоматической трансляции таблиц целесообразно обсудить алгоритмический аспект этих языков, т. е. ответить, в какой мере разметка текста позволяет описывать требуемые преобразования. Но предварительно зададимся вопросами о том, правомерно ли языки разметки считать языками программирования, и если да, то какие стили программирования они в принципе могут поддерживать.
    Построение разного рода визуализаций текстов и сопутствующей им информации — определяющая функция языков разметки, которая реализуется путем размещения разметочных команд вместе с предъявляемой информацией. Эти команды являются управляющими сигналами для вычислителя, который отвечает за визуализацию. Внутреннее совместное представление команд и данных для их выполнения естественно рассматривать в качестве операционной и одновременно информационной среды вычислителя–визуализатора. Язык разметки от языка программирования отличает в первую очередь совмещение данных и задания на их обработку.
    Пример 11.2.1. Текст данного примера в языке разметки LATEX (точнее, в том пакете определений над ним, который разработан для верстки данного текста и других работ автора) для автоматического размещения заголовка, создания идентификатора, по которому в дальнейшем можно ссылаться на Пример 11.2.1, форматирования тела примера и отработки его конца, окружен командами

    \begin{example}\label{markupeample0} и \end{example}.

    Для того, чтобы напечатать первуюи з этих команд, пришлось применить дополнительнуюразметку (чтобы команда была проинтерпретирована как часть текста)

    \verb|\begin{example}\label{markupeample0}|\\.

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

    Для употребляющихся в Интернет языков разметки визуализатором служит браузер. Но визуализация — далеко не единственное вычисление, которое можно связывать с браузером или другой программой, обрабатывающей размеченный текст. Над информацией можно задавать разные обобщенные вычисления. Эти вычисления могут быть либо совмещены в одном процессе, либо разнесены во времени, что непринципиально. Применительно к числовой разметке, использованной в §10.3 для задания таблицы конечного автомата, правомерно трактовать разметку как команды трех видов вычислений:

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


  • Подобные вычисления можно определять для любого языка разметки, рассматривая подходящие абстрактные вычислители. Иными словами, существует программирование на языке разметки. Наиболее полно эта концепция прослеживается в технологии XML/XSL.

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


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

    Ниже описывается решение задачи автоматической трансформации таблиц конечного автомата с использованием наиболее подходящего для этих целей языка XML (см. книгу [19]).

    Если описывать тексты в современных языках разметки, таких, как LATEX [15] или XML, то возникает задача описывать и программировать преобразования подобных текстов. Решением этой задачи могут быть специализированные языки преобразований текстов либо соответствующие методики программирования, поддержанные автоматизированным преобразованием спецификаций в программу. Здесь мы рассмотрим методику, базирующуюся на автоматах.

    Применим возможности системы XML/XSL к нашей задаче: описание конечного автомата (за основу взята таблица переходов 9.1).

    St1 St2 St1 St3 St2 St1 St3 St2 St1 Exit



    Листинг 11.2.1. Решение задачи автоматической трансформации таблиц конечного автомата

    В этом описании нашли отражение следующие свойства нашего конечного автомата (за основу взята таблица переходов 9.1):

  • Конечный автомат (тег ) включает в себя теги — перечень всех состояний в любом порядке, а также теги — действие и — ссылка на исходное состояние3).
  • Каждый содержит атрибут name, чтобы на него можно было ссылаться, и набор условий: один тег и любое количество тегов (означающих "else if"). Эти два тега также можно было бы заменить одним универсальным, тем более что структура их потомков не различается, но опять же этого не сделано по соображениям облегчения форматирования.
  • Каждый или включает в себя три тега: — собственно условие и уже описанные теги и — действие при выполненном условии и ссылка на следующее состояние.
  • Теги и содержат специальный тег для включения строк на языке C++.


  • В описание не включено состояние Exit. Эта часть одинакова у различных автоматов, а потому нецелесообразно ее описывать явно.

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





    Листинг 11.2.2. Решение задачи автоматической трансформации таблиц конечного автомата

    Следующая визуализация — это автоматическое преобразование XML–основы в программу на языке С/С++. Стоит обратить внимание на то, что результирующий текст оказывается практически тем же, что и в программе 10.2.5. Причины тому глубже, чем простота преобразования. Существование локального (без использования контекстно"=зависимой информации) описания следует из структуры конечного автомата, не требующей для определения перехода ничего вне состояния.

     //C code for automat ""  #define failure true void main( void ) {]]>  goto ;
    : symbol = getchar ();
    if ( )
    { goto ; }
    else if ( )
    { goto ; }
    Exit: return;
    }


    Листинг 11.2.3. Решение задачи автоматической трансформации таблиц конечного автомата

    Как легко видеть, разница этого и предыдущего стилевых файлов только в том, что там, где для визуализации в таблицу вставляются графические элементы, изображающие рамки, для трансляции помещаются части синтаксиса языка С++/C# и отступы в целях лучшей читаемости получаемой программы.



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

    Отметим теперь еще несколько интересных возможностей, которые мы бы могли использовать.

    Хотя данная технология позволяет легко создавать С++/С# программы, основой для них остается язык XML. Чтобы составить новый автомат, программист должен как минимум знать синтаксис этого языка. Следующим естественным шагом будет исключение этого звена: требуется скрыть внутреннее представление данных от конечного пользователя, оставив только интуитивно понятное представление в виде таблицы. Но для этого в первую очередь необходимо выбрать:

  • преобразование таблица => XML представление и
  • средство для удобного редактирования таблиц.


  • Естественно редактировать таблицы там же, где мы их уже научились генерировать — в окне браузера. Доступ к редактированиюэтих таблиц может предоставить DOM (стандарт, реализованный в браузерах Internet Explorer 5.0 и Netscape Navigator 6.0). Изменения, добавления и другие редактирующие действия определяются довольно просто. Например, на языке Java script добавление новой ячейки в таблицу можно описать следующим образом:

    var oRow; var oCell;

    oRow = oTable.insertRow(); oCell = oRow.insertCell(); oCell.innerHTML = "This cell is new."

    Точно так же можно создавать таблицы, строки и ячейки в них. Реализация же самого интерфейса (кнопочек, средств выделения, полей ввода) зависит только от вашей фантазии.

    Тот же DOM, точно таким же образом, может работать с XML, реплицируя все действия конечного пользователя с таблицей в XML представление для записи последнего в виде готового файла со структурой нового конечного автомата.

    Еще одним шагом в развитии проекта, использующим язык XML, может быть формализация представления: ведь как только определены все теги представления, правила их вложения и способы задания, получается новый язык (по аналогии с современными языками, построенными таким же образом, мы можем назвать его automatML).


    Пока теги и элементы XML используются исключительно ради удобства для вашего собственного проекта (как если бы вы применяли CSS на своей домашней страничке), то не имеет никакого значения, что вы даете этим элементам и тегам имена, смысл которых отличается от стандартного и известен только вам. Если же Вы хотите предоставлять данные внешнему миру и получать информацию от других людей, то это обстоятельство приобретает огромное значение. Элементы и атрибуты должны употребляться вами точно так же, как и всеми остальными людьми, или, по крайней мере, вы должны документировать то, что делаете.

    Для этого придется использовать определения типов документов (Document Type Definition, DTD). Хранимые в начале файла XML или внешним образом в виде файла *.DTD, эти определения описывают информационную структуру документа. DTD перечисляют возможные имена элементов, определяют имеющиеся атрибуты для каждого типа элементов и сочетаемость одних элементов с другими.

    Каждая строка в определении типа документа может содержать декларацию типа элемента, именовать элемент и определять тип данных, которые элемент может содержать. Она имеет следующий вид:



    Например, декларация



    определяет элемент с именем action, содержащий символьные данные (т. е. текст). Декларация



    определяет элемент с именем special_report, содержащий подэлементы state_1, state _2 и state_3 в указанном порядке, например:

    XML : время пришло XML превосходит самое себя Управление сетями и системами с помощью XML

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


    д. DTD могут содержать декларации !ENTITY, где приводятся ссылки на объекты, и декларации !NOTATION, указывающие, что делать с двоичными файлами, представленными не в формате XML.

    Серьезное и несколько удивительное ограничение DTD состоит в том, что они не допускают типизации данных, т. е. ограничивают данные конкретным форматом (таким, как дата, целое число или число с плавающей точкой). Как вы, вероятно, уже заметили, DTD используют иной синтаксис, нежели XML, и не очень-то интуитивно понятный. По названным причинам DTD будут, видимо, заменены более мощными и простыми в использовании схемами, работа над которыми ведется в настоящее время.

    Постановка задачи
    Постановка задачи
    Постановка задачи
      1)

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

      2)

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

      3)

      Можно было бы оформить эти теги с помощьюо особого состояния Init, но при этом мы бы потеряли универсальность тега , т. к. состояние Init, например, не нуждается в чтении символа, не содержит условий и должно быть всегда первым. Как будет понятно впоследствии, универсальность составных частей модели упрощает работу с ней.

    Постановка задачи

    Так как нужно печатать только


    St1 St2 St1 St3 St2 St1 St3 St2 St1 Exit
    Листинг 11.2.1. Решение задачи автоматической трансформации таблиц конечного автомата
    Закрыть окно




    Листинг 11.2.2. Решение задачи автоматической трансформации таблиц конечного автомата
    Закрыть окно




     //C code for automat ""  # define failure true void main( void ) {]]>  goto ;
    : symbol = getchar ();
    if ( )
    { goto ; }
    else if ( )
    { goto ; }
    Exit: return;
    }
    Листинг 11.2.3. Решение задачи автоматической трансформации таблиц конечного автомата
    Закрыть окно



    Требования к автоматической трансляции таблиц


    Серьезный недостаток предложенного в §10.3 решения задачи автоматического преобразования таблиц переходов в программы связан с идеей препроцессорного построения, удобного для обработки представления таблиц переходов. Игнорирование обратной связи между исходным представлением автомата и его интерпретируемым представлением порождает проблемы. Если не рассматривать развитие программы, то отслеживать эту связь не нужно. Но как только встает вопрос хотя бы о минимальных переделках, возникают проблемы.
  • Внесение изменений в таблицу не влечет за собой автоматического изменения внутреннего представления, а возможные нарушения соглашений, связанных с исходным решением, могут сделать бессмысленным переиспользование. Это особенно заметно, если обратить внимание на то, что в результирующей таблице отсутствуют имена состояний.
  • Поиск и диагностика ошибок (речь идет не только о синтаксисе) возможны лишь на уровне интерпретируемого представления, что противоречит осмыслению программы в прежних терминах.
  • Наличие программы, не зависящей от исходного представления, провоцирует на ее модификации, которые не будут перенесены в исходное представление. В результате концептуальная и реализационная модели расходятся между собой.
  • Таким образом, предложенное решение не является технологичным. Оно может быть распространено на другие ситуации лишь для ограниченного класса задач.

  • Разработка технологии включает в себя создание и использование инструментов поддержки всех этапов, из которых состоит технологическая цепочка оперирования с таблицами. Ключевым моментом является выбор формата представления таблиц, приспособленный как к ручной, так и к автоматической обработке. Есть смысл попытаться отыскать универсальное решение. Если, вдобавок, оно будет стандартизовано, то появится возможность создать технологию. Сегодня предпосылки для такого решения существуют, в частности, использование так называемых языков разметки текстов и, в первую очередь, XML.


    Данная лекция предназначена для тех,


    Данная лекция предназначена для тех, кто уже имеет начальные навыки работы на языке разметки XML1).

    следующая строка if


    s = getchar (); i = 1; for (;;) { if (Table[i].Tag) { if ( Table[i].Symb == s ) { i = Table[i].yes; // следующая строка if ( s != ’\n’) s = getchar (); else return; } else i = Table[i].no; // следующая строка } else { Current_Reaction(); // M[Table[i].Num]++ i = Table[i].yes; // следующая строка } } Final_Reaction(); // Распечатка M
    Листинг 12.1.4.
    Закрыть окно




    s = getchar ();
    i = 1;
    for (;;) {
    if (Table[i].Tag) {
    if ( Table[i].Symb == s ) {
    i = Table[i].yes; // следующая строка
    if ( s != ’\n’)
    s = getchar ();
    else return;
    }
    else
    i = Table[i].no; // следующая строка
    }
    else {
    Current_Reaction(); // M[Table[i].Num]++
    i = Table[i].yes; // следующая строка
    }
    }
    Final_Reaction(); // Распечатка M

    Цикл пока не будет все


    3: Цикл пока не будет все сделано { 5: Подготовка данных; 10: Обработка текущих изменений; } 4: Демон {Если Поступили данные То Прием рутинных данных}; 8: Демон {Если Поступили экстренные данные То Запоминание экстренных изменений}; 12: Демон {Если Авария То Аварийные действия};
    Пример 13.2.1.
    Закрыть окно




    a: PRIO 3 {Prepare Data; SLEEP}; b: PRIO 10 {Process Changes; SLEEP}; c: PRIO 8 DAEMON IF Extra Data THEN Store Extra Data; AWAKE b FI; d: PRIO 12 DAEMON IF Alert THEN Emergency; FI; e: PRIO 2 DAEMON IF Idle THEN AWAKE a; FI;
    Пример 13.2.2.
    Закрыть окно



    Программирование от приоритетов


    Обсуждая событийное программирование, мы упомянули о том, что при программировании в этом стиле может возникнуть потребность в упорядочивании выполнения нескольких конкурирующих между собой реакций на события. Достаточно универсальным средством удовлетворения этой потребности является приписывание реакциям приоритетов: сначала выполняются те реакции, которые имеют больший приоритет. Этот прием, как выявилось на практике, пригоден для решения достаточно широкого круга задач. Он стал основой для формирования самостоятельного варианта событийного стиля: программирования от приоритетов.
    Данная ипостась стиля порождена практическими потребностями. Она предназначена для организации работы многих взаимодействующих процессов, динамически порождаемых и исчезающих. Фундаментальных теоретических исследований в области программирования от приоритетов почти нет. В частности, в классической работе Хоара [29], в которой рассматривается управление взаимодействующими процессами, предполагается, что программа написана в традиционном структурном стиле и никаких попыток приспособить ее к обстановке, где имеется много процессов, нет2).
    Стиль программирования от приоритетов не реализован систематически в виде законченного языка (в качестве некоторой попытки можно привести проект Joule, см. http://www.agorics.com/Library/joule.html), но часто используется в прикладных языках скриптов для управления процессами или событиями либо в распределенных системах, либо на полуаппаратном уровне (так называемые встроенные программы, являющиеся частью специализированного прибора или устройства).
    В программировании от приоритетов, как и в сентенциальном программировании, порядок расположения операторов в программе не играет принципиальной роли, зато важен приоритет оператора, то есть некоторое значение, принадлежащее в самом общем случае частично-упорядоченному множеству и почти всегда рассматриваемое как элементарное. После завершения очередного оператора среди оставшихся выбирается оператор с максимальным приоритетом.
    Если таких операторов с равными или несравнимыми приоритетами несколько, то, вообще говоря, в идеале надо было бы выбирать один из них недетерминированно.

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

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

    На практике обычно используют жесткую дисциплину, базирующуюся на порядке операторов в программе. Часто управление по приоритетам соединяется с так называемой системой демонов, то есть процедур, вызываемых при выполнении некоторого условия, а не в тот момент, когда при движении по тексту программы мы подошли к их явному вызову (сидит такой демон в засаде и выжидает момент, когда можно будет начать исполняться). И текущий "нормальный" процесс, и проснувшиеся демоны имеют свои приоритеты, и демон начнет исполняться, даже если он активизирован, лишь в том случае, если среди активных не осталось процесса с большим, чем у демона, приоритетом. Эта схема реализована, в частности, в системе UNIX.

    Уместно отметить, что событийно-ориентированное программирование порою может рассматриваться как вырожденный случай стиля программирования от приоритетов: для каждого экземпляра структурной единицы, способной реагировать на события, выставляется (бесконечно большой) приоритет, если этот экземпляр фактически должен активизировать реакцию, и не выставляется приоритет (выставляется бесконечно малый приоритет), если экземпляр не должен реагировать на событие.


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

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

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

    Рассмотрим написанный в двух вариантах на двух условных языках пример программы с приоритетами.

    3: Цикл пока не будет все сделано { 5: Подготовка данных; 10: Обработка текущих изменений; } 4: Демон {Если Поступили данные То Прием рутинных данных}; 8: Демон {Если Поступили экстренные данные То Запоминание экстренных изменений}; 12: Демон {Если Авария То Аварийные действия};

    Пример 13.2.1.

    a: PRIO 3 {Prepare Data; SLEEP}; b: PRIO 10 {Process Changes; SLEEP}; c: PRIO 8 DAEMON IF Extra Data THEN Store Extra Data; AWAKE b FI; d: PRIO 12 DAEMON IF Alert THEN Emergency; FI; e: PRIO 2 DAEMON IF Idle THEN AWAKE a; FI;



    Пример 13.2.2.

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

    Заметим, что назначение приоритетов операционной системой, "сверху", принципиально ничего в данной картине не меняет. С точки зрения пользовательских программ эти приоритеты все равно статические.

    Рассмотрим теоретически, какие классы программ можно описать при помощи статических и динамических приоритетов. Оказывается, при помощи статических приоритетов, являющихся натуральными числами, невозможно описать даже примитивно-екурсивные композиции исходных действий3).

    Таким образом, концептуально натуральных чисел недостаточно для описания приоритетов, но оказывается, что для них хватает ординалов (см. курс математической логики). Поскольку ординалы до ?0 имеют хорошую вычислительную модель (см., напр., [20]), можно рассматривать целочисленные приоритеты как конкретную реализацию абстрактного класса таких ординалов.

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

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


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

    Программирование от приоритетов
    Программирование от приоритетов
    Программирование от приоритетов
      1)

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

      2)

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

      3)

      Правда, здесь мы отвлекаемся от реальных ограничений и считаем 1010000000000 столь же легко достижимым числом, как и 10.

    Программирование от приоритетов

    Событие, сообщение, демон


    Упомянутое ранее (утверждение (5.2)) качество
    Подготовка информации для действий в ходе распознавания условий применимости действий, (13.1)
    пожалуй, является критическим моментом для разделения случаев использования сентенциального и событийного программирования. Проверка приоритетов и выбор действия с наивысшим приоритетом в качестве активного точно так же, как отождествление метавыражения в сентенциальном случае, запрятана в атомарные действия программной системы, но при проверке приоритетов программист не получает никакой полезной информации для проведения выбранного действия. Если какая-то полезная информация и получается будущим действием, то она задается отдельно и императивно, в параметрах сообщения, вызывающего событие. В свою очередь, наличие либо практическое отсутствие такой информации определяет разницу двух ипостасей событийного программирования: программирования от событий и от приоритетов.
    Следует заметить, что есть еще одна особенность, общая для двух стилей: сентенциального и событийного.
    Отделение проверки условий от выполнения действий (13.2)
    Это представляется общей характеристикой технологических решений для стилей, где условия глобальны.
    До сих пор мы ничего не говорили о том, какие события возможны при программировании в событийно-ориентированном стиле. Исторически этот стиль как определенный художественный прием сформировался в области разработки операционных систем, где естественно связывать понятие события с прерываниями. Прерывание - это сигнал от одного из устройств (может быть, и от самого процессора), который говорит о том, что произошло нечто, на что следует обратить внимание. Когда происходит прерывание, операционная система распознает, какая причина его вызвала, и далее формирует событие как информационный объект, вызывающий реакцию программной системы. Возможны разные способы реагирования на события, в том числе и передача его для обработки той программе, при выполнении которой возникло прерывание, породившее это событие.
    Из потребности такой обработки, собственно говоря, и сформировался событийно-ориентированный стиль программирования.
    Наиболее очевидная область его адекватного применения - реализация интерактивных взаимодействий программы с пользователем и решение других подобных задач (например, тех, которые требуют опроса датчиков состояния каких-либо технологических процессов).
    В частности, при взаимодействии с пользователем чаще всего достаточно таких событий, как нажатие на клавишу, перемещение курсора мыши, указание световой кнопки и т. п. Не случайно именно те прерывания, которые способна перенаправить операционная система для обработки на уровень пользовательской программы, стали основой для выработки систем событий, реакция на которые задается в событийно-ориентированном стиле. События этого рода можно передать от одного обработчика к другому, не нарушая условий адекватного применения данного стиля: для этого достаточно объявить такое перенаправление события порождением нового события.
    Но требуется также генерация событий, не связанных с прерываниями. К примеру, программируя в событийно-ориентированном стиле, естественно объявить событием ситуацию, когда значение одной переменной становится больше другой. Однако такого рода свойства вычислительного процесса никак не отражаются в системе прерываний, а потому обычные языковые средства событийно-ориентированного программирования (например, в Delphi), часто провозглашаемые как универсальные, здесь не помогут. Необходимы иные механизмы, которые уводят программиста из области шаблонов проектов, стандартизующих обработку событий.
    Об этом обстоятельстве, ограничивающем применимость подхода, предпочитают умалчивать, и, как обычно бывает в подобных ситуациях, при беглом знакомстве с соответствующими средствами могут возникнуть ни на чем не основанные ожидания с последующими разочарованиями и даже отторжением стиля, хорошо работающего на своем месте.
    Примером языка, до некоторой степени ориентированного на событийно-ориентированное программирование, может служить Perl.
    Событийный стиль удобен не только на уровне конкретного программирования. Часто концептуальное осмысление задачи полезно проводить в терминах системы событий, а уж затем решать, как реализовывать систему: непосредственно использовать события как средство управления, моделировать событийное взаимодействие имеющимися средствами или вообще отказаться от этого механизма в данной разработке.


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

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


    Нет никакого противоречия между двумя схемами, и единственное различие между ними в том, что при синтаксическом управлении ожидание события оказалось возможным подменить прямым вызовом подпрограммы. Иными словами, в схеме трансляции удается статически, до выполнения программы вычислить события, наступление которых требует вызова их обработчиков (семантических подпрограмм). Вообще, сопрограммное взаимодействие можно трактовать как статически вычисленную событийность1).
    Разумеется, рамки событийного программирования много шире тех случаев, когда можно статически определять необходимость активизации обработчика. Полезно объединение генерации событий с их обработкой, и за счет него можно растворять события в программе. В частности, и по этой причине следует рассматривать событийное программирование как самостоятельный стиль. Но интересны и такие случаи, когда объединение, хотя и возможно, но не делается. Конкретный пример - XML/XSL технология, для которой разделение структуры и интерпретации текста считается принципиальным: это позволяет строить съемные системы обработки, иметь несколько независимых таких систем для разного назначения. В своей сфере применения многовариантность имеет большие преимущества, но, как всегда, перенос принципов данной технологии куда угодно чреват уже не раз отмеченной неадекватностью.

    О дисциплине циклического структурного программирования


    Сейчас сосредоточимся на том варианте структурного программирования, который ориентируется на циклы и массивы.
    Прежде всего, нужно остановиться на совместимости структурного циклического программирования с рекурсиями. Опыт показывает, что процедура, в которой есть ее рекурсивный вызов внутри цикла, практически почти всегда ошибочна, теоретически же она выходит за рамки примитивной рекурсии (см. курс логики и теории алгоритмов) и, как следствие, становится практически невычислимой. С тем, чтобы здесь не попасть впросак, соблюдайте простое правило.
    Внимание!
    Не используйте рекурсивный вызов процедуры внутри цикла! Рекурсия и циклы должны быть "территориально разделены"!
    Данное правило пригодно в подавляющем большинстве случаев. Оно является конкретизацией для структурного программирования известного политико-социологического наблюдения:
    Самые яростные противоречия возникают либо между двумя близкими сектами, либо при борьбе двух фракций одной и той же секты.
    Люди старшего поколения еще помнят, как во время ожесточенной вражды между китайскими и советскими коммунистами китайцы не переставая повторяли, что у них "из десяти пальцев девять - общие".
    Тем не менее иногда бывают исключения. Рассмотрим, например, схему поиска вглубь на дереве.
    int search (ELEMENT x) ELEMENT y; int result; if (good(x)){ return id(x)} else for(int i=0; i<100; i++) {y=get_successor(x,i); result=search(y); if (result>0) return result; } return 0; }
    Пример 14.4.1.
    Здесь рекурсии вместе с циклом задают обход дерева возможностей, и гибельного размножения рекурсивных вызовов не происходит. Причина этого исключения в том, что цикл в данной программе - всего лишь подпорка для рекурсии. В обычном программировании нет функционалов типа mapcar языка LISP, применяющих свой первый аргумент ко всем членам второго11).
    Структурное программирование основано на предположении о локальности действий и условий, поэтому для него в особенности органично подходит иерархическое разбиение задачи на подзадачи - так называемое нисходящее планирование.

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

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

    Нисходящее программирование - конкретизация нисходящего проектирования для нужд программирования. Оно обладает следующими преимуществами.

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


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

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




  • Это указывает на необходимость средств, позволяющих поднимать уровень понятий. Поэтому в разработке структурных программ применяется также подход, получивший название восходящего программирования. К примеру, когда строят библиотеку, занимаются обобщением задачи. Части, выделяемые в виде библиотечных средств, выбираются таким образом, чтобы они были применимы в различных контекстах.

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

    Реальный проект13) - это смесь нисходящего и восходящего подходов:

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


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

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


    Например, в цикле сортировки - это соотношение между отсортированными и неотсортированными фрагментами массива.

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

    Методика циклического и рекурсивного структурного программирования с использованием инвариантов и недетерминированности (но без явного упоминания сетей данных) прекрасно изложена в учебных пособиях Алагича, Арбиба и Гриса [1], [9]. Настольной книгой программиста должна служить также книга Кормена и др. (учебник MIT) [13], в которой можно найти множество хороших примеров того, как находить правильные программные решения и со вкусом использовать подпорки.

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

    И, наконец, необходимо заметить следующее.

    Внимание!

    Призраки и инварианты необходимо осознавать до начала написания текста работающей программы. Если Вы оставите это на потом, то наверняка спутаете подпорки, которые займут у Вас основную часть мысленных ресурсов на завершающем этапе реализации, с сущностями. Более того, восстановить обоснование по тексту уже готовой программы, как ни парадоксально, обычно труднее, чем построить его заранее (то, что восстановить его во всяком случае не легче, было обосновано даже теоретически; смотри последнюю главу книги [20] ).


    Общая характеристика структурного программирования


    На самом деле изложение структурного стиля не может уместиться в рамки одной лекции. Но данный стиль программирования (вернее, его вариант, основанный на циклах и массивах, слегка пополненный рекурсивными процедурами) описывается и навязывается как единственно возможный во всех ныне предлагаемых учебных пособиях по программированию на традиционных языках. В связи с этим мы имеем право предположить, что обучающийся знаком с ним (более того, знаком только с ним, и мы надеемся, что он еще не потерял способность воспринимать другие стили). И хотя Вы считаете, что с этим вариантом структурного стиля уже освоились, особенности, опускаемые в традиционных изложениях, могут полностью изменить Ваш взгляд на данный стиль.
    Мы рассматриваем структурное программирование как равноправный член сообщества альтернативных ему друзей-соперников1).
    * * *
    Начнем с того, что обратимся к истории.
    В теории схем программ было замечено, что некоторые случаи блок-схем легче поддаются анализу [16]. Поэтому естественно было выделить такой класс блок-схем, что и сделали итальянские ученые С. Бем и К. Джакопини в 1966 г. Они доказали, что любую блок-схему можно привести к структурированному виду, использовав несколько дополнительных булевых переменных. Э. Дейкстра подчеркнул, что программы в таком виде, как правило, являются легче понимаемыми и модифицируемыми, так как каждый блок имеет один вход и один выход.
    В качестве методики структурного программирования Э. Дейкстра предложил пользоваться лишь конструкциями цикла и условного оператора, изгоняя go to как концептуально противоречащее этому стилю2).
    Структурное программирование основано главным образом на теоретическом аппарате теории рекурсивных функций. Программа рассматривается как частично-рекурсивный оператор [21] над библиотечными подпрограммами и исходными операциями. Структурное программирование базируется также на теории доказательств, прежде всего на естественном выводе. Структура программы соответствует структуре простейшего математического рассуждения, не использующего сложных лемм и абстрактных понятий3).

    Средства структурного программирования в первую очередь включаются во все языки программирования традиционного типа и во многие нетрадиционные языки. Они занимают основное место в учебных курсах программирования и в теоретических работах (например, [1],[4],[9]).

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

    Структурное программирование естественно возникает во многих классах задач, прежде всего в таких, где задача естественно расщепляется на подзадачи, а информация - на достаточно независимые структуры данных. Основной его инвариант:

    действия и условия локальны.

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



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

    Для структурного стиля программирования требуется следующее. Задача разбивается на подзадачи, и таким образом выстраивается дерево вложенности подзадач. Информационное пространство структурируется в точном соответствии с деревом вложенности: для каждой подзадачи оно состоит из ее локальных объектов, определяемых вместе с подзадачей и для нее, и так называемых глобальных объектов, определяемых как информационное пространство непосредственно объемлющей подзадачи. Таким образом, информационное пространство всей задачи (подзадачи самого верхнего уровня) расширяется по мере перехода к подзадачам за счет их локальных объектов.


    Для различных дочерних подзадач одной подзадачи оно имеет общую часть - информационное пространство родительской подзадачи4).

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


  • К структурным операторам добавляются либо циклы, либо рекурсии.

    Внимание!

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

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


  • Призраки. Часто даже сама программа не может быть объяснена через понятия, которые используются внутри нее. Еще чаще это происходит для ее связей с внешним миром. Понимание программы возможно лишь после сопоставления реальных внутрипрограммных объектов с идеальными внепрограммными. Эти идеальные внепрограммные объекты (призраки) часто не просто не нужны, но даже вредны для исполнения программы5).

    Первым обратил внимание на необходимость введения призраков для логического и концептуального анализа программ Г. С. Цейтин в 1971 г. В Америке это "независимо" открыли заново в 1979 г., хотя упомянутая статья Цейтина была опубликована на английском языке в общедоступном издании. Даже название сущностям было дано то же самое...


    Этому важнейшему и традиционно игнорируемому понятию посвящена отдельная лекция в курсе "Основания программирования" [21].

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

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



  • Для структурного программирования весьма важно требование:

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

    Это общее требование конкретизируется в следующие.

  • Необходимо, чтобы структура управления программы была согласована со структурой ее информационного пространства. Каждой структуре управления соответствуют согласующиеся с ней структуры данных и часть информационного пространства. Это условие позволяет человеку легко отслеживать порядок выполнения конструкций в программе.
  • Подзадачи могут обмениваться данными только посредством обращения к объектам из общей части их информационных пространств (в современных языках чаще всего к глобальным).
  • Информационные потоки должны протекать согласно иерархии структур управления; мы должны четко видеть для каждого блока программы, что он имеет на входе и что дает на выходе. Таким образом, свойства каждого логически завершенного фрагмента программы должны ясно осознаваться и в идеале четко описываться в самом тексте программы и в сопровождающей ее документации6).
  • Описание переменных, представляющих перерабатываемые объекты, а также других, вспомогательных переменных при структурном программировании строго подчиняется разбиению задачи на подзадачи.
  • Все призраки действуют на своем структурном месте и соответствуют идеальным сущностям, которые, согласно парадоксу изобретателя, должны вводиться для эффективного решения задачи.
  • Все подпорки строго локализованы в том месте, где их вынуждены ввести.Желательно даже обозначать их по-другому, чем идеальные сущности, например, оставляя мнемонические имена лишь для идеальных сущностей, а подпорки именовать джокерами типа x или i. Необходимо строго следить за тем, чтобы подпорки не искажали идеальную структуру программы.


  • Структурное программирование лучше всего описано теоретически, но частные описания не сведены в единую систему. Одни книги трактуют его с точки зрения программиста, другие - с точки зрения теоретика. Так что даже здесь единой системы взглядов еще нет, хотя, видимо, все основания для ее формирования уже имеются.


    Переходы и выдаваемые значения


    В общее употребление структурное программирование вошло после популяризировавшей его работы Э. Дейкстры, в которой, к сожалению, не было даже намека на его ограничения. Ограничения структурного программирования вытекают как из самой его сути, так и из теоремы Бема-Джакопини. Применение структурных переходов, которые ввел в практику и теорию Д. Кнут (откопавший оригинальную работу Бема-Джакопини и четко выделивший ограничения дейкстровского структурного подхода14)), избавляет от многих недостатков, присущих методике Дейкстры. Структурные переходы - переходы лишь вперед и на более высокий уровень стр
    уктурной иерархии управления, ни в каком случае не выводящие нас за пределы данного модуля.
    /* Примеры структурных goto.*/ . . . do {y = f( x ,y)}; if (y>0) break; x=g(x,y); }while (x>0); . . . {. . . { if (All_Done)goto Success; } . . . Success: Hurra;}
    Пример 14.5.1.
    Структурные переходы в настоящее время также включаются в общераспространенные языки программирования. Их использование понемногу проникает и в учебные курсы.
    Структурные переходы являются паллиативом. Они возникли из-за необходимости выразить мысль о том, что успех либо неудача глобального процесса может выявиться внутри одной из решаемых подзадач, и дальнейшая работа и над текущей задачей, и над всей последовательностью вложенных подзадач становится просто бессмысленной. В этом случае нужны даже не переходы, а операторы завершения. Но они во многих распространенных языках действуют лишь на один уровень иерархии вверх, а даже теоретически этого недостаточно15). Стоит заметить, что между идеей и ее корректной реализацией часто проходят долгие годы. Ныне в общераспространенном языке Java завершители наконец-то более или менее корректно реализованы.
    Есть одно ограничение структурных переходов, известное с 80-х гг. ХХ века (cм. [20]), по крайней мере один раз достоверно повредившее создателям отечественной серии машин Эльбрус, в которых на аппаратном уровне поддерживалось структурное и функциональное программирование.
    Структурные переходы (в том числе и завершители) некорректны, когда они выводят нас из функции-параметра вызова другой функции. Ирония в том, что абсолютно четкая и полная реализация завершителей еще до осознания необходимости данного средства и тем более задолго до осознания пределов его применимости была проделана именно там, где они в общем случае некорректны (в языке LISP). В нем, как мы видели, процедуры являются полноправными значениями, могут быть параметрами и результатами других процедур. Самый очевидный случай некорректности (правда, вылавливаемый системой обнаружения динамических ошибок Common Lisp), когда мы внутри созданной процедуры завершаем блок, существовавший в момент создания процедуры, но переставший существовать в тот момент, когда ее вызвали. Наверняка многие наталкивались на непонятное поведение программ с завершителями, но в соответствии с общераспространенным "позитивным" мышлением не обращали внимания на данный феномен.

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

    if x=0 then sin(y) else tan(y)

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

    Эту концепцию попытались перенести на язык традиционного типа в языке Алгол 68. На первый взгляд получилось очень красиво. Например, оператор

    if x>0 then a1 else a2 fi [if y>0 then j else i fi]:= if z>0 then sin(u) else tan(u) fi



    намного компактней и красивей последовательности условных операторов, которую придется написать в Pascal или C++. Но концепция вырабатываемого значения оказалась в концептуальном противоречии и с оператором присваивания, и с совместными вычислениями. Например, согласно семантике Алгола 68, следующая запись

    (x:=3, у:=x+y, z:=x+y)

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

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

    Переходы и выдаваемые значения
    Переходы и выдаваемые значения
    Переходы и выдаваемые значения
      1)

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

      2)

     

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

    К несчастью, оператор go to формально совместим с другими конструкциями традиционных (тогда говорили - универсальных) алгоритмических языков. Но реально он плохо взаимодействует с ними. Значит, он плох сам по себе.

      3)

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

      4)

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

      5)

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

      6)

      Как видим, программа должна составляться после того, как программист хорошенько подумал, а не до того.

      7)

      Конечно, уже всем в зубах навязли алгоритмы Евклида и факториалы! Но в изложении основ структурного программирования они как раз на месте, поскольку этот стиль создавался для сравнительно небольших (по нынешним масштабам) вычислительных программ.

      8)

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

      9)

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

      10)

      Для удобства хакеров, что ли?

      11)

      Прямо переписать в языке Common LISP данную программку в рекурсивную с использованием mapcar не удастся, поскольку реализаторы превратили данный функционал в макрос, для того чтобы в стандартных случаях чуть-чуть выиграть в эффективности (многим так не хочется писать тривиальные вещи!). Переопределив mapcar как функцию, можно реализовать рекурсивное определение указанной выше функции search.

      12)

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

      13)

      За исключением мелких.

      14)

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

      15)

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

    Переходы и выдаваемые значения

    ELEMENT x) ELEMENT y; int


    int search ( ELEMENT x) ELEMENT y; int result; if (good(x)){ return id(x)} else for(int i=0; i<100; i++) {y=get_successor(x,i); result=search(y); if (result>0) return result; } return 0; }
    Пример 14.4.1.
    Закрыть окно




    /* Примеры структурных goto.*/ . . . do {y = f( x ,y)}; if (y>0) break; x=g(x,y); }while (x>0); . . . {. . . { if (All_Done)goto Success; } . . . Success: Hurra;}
    Пример 14.5.1.
    Закрыть окно



    Сети данных


    Рассмотрим основную структуру данных, которая появляется при структурном программировании. Учет этой структуры позволяет преобразовать благие пожелания о согласованности информационных потоков и хода передач управления в достаточно строгую методику.
    Сеть данных может быть формально описана как ациклический ориентированный граф, в котором все ко-пути (т. е. пути, взятые наоборот) конечны и вершинам которого сопоставлены значения.
    Рассмотрим пример. Известному стандартному приему программирования в языках без кратных присваиваний - обмену двух значений через промежуточное
    z := second; second := first; first := z;
    соответствует следующая сеть данных:
    Сети данных
    Рис. 14.1.1.  Обмен значений
    Здесь first, second, z можно считать комментариями, а сами данные опущены, поскольку их конкретные значения не важны.
    На этом примере видно, что порой для лучшего структурирования сети целесообразно вводить дополнительные вершины, соответствующие сохраняющимся значениям. Ребро, ведущее из одной такой вершины в другую, обозначается при помощи стрелочки, похожей на равенство. Видно так же, как материя воздействует на идею, заставляя вводить дополнительные операторы и дополнительные значения. В данном случае переменная z и включающие ее операторы являются подпорками, и, если их исключить, сеть данных становится проще. Но в общераспространенных языках программирования нет кратных присваиваний типа
    first,second:=second,first;
    Даже если бы они были, представьте себе, как неудобно станет читать длинное кратное присваивание и понимать, какое же выражение какой переменной присваивается!
    В случае программы вычисления факториала7) сеть потенциально бесконечна вниз, поскольку аргументом может быть любое число, но по структуре еще проще:
    Сети данных
    Рис. 14.1.2. 
    Перекрестных зависимостей между параметрами нет, следовательно, возможны две известные реализации факториала: циклическая и рекурсивная. Покажем их на разных языках, ибо все равно, на каком традиционном языке их писать.
    function fact(n: integer): integer; var j,res: integer; begin res:=1; for j:=1 to n do res:=res*j; result:=res; end;

    int fact(int n) {if (n==0) return(1); else return(n*fact(n-1));}

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

    Для чисел Фибоначчи (та же схема (14.1.2)) структура уже несколько сложнее предыдущих, поскольку каждое следующее число Фибоначчи зависит от двух предыдущих, но метод потоковой обработки применим и здесь.

    int fib(int n) {int fib1,fib2; fib1=1; fib2=1; if (n>2){ for (int i=2;i
    Итак, в потоке изменяется структура из двух элементов. Ее можно было бы прямо описать как структуру данных, и это следовало бы сделать, будь программа хоть чуть-чуть посложнее. Тогда вместо подпорки j пришлось бы ввести в качестве подпорки новое значение структуры.

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

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

    int fib(int n) { if (n<3) return(1); else return(fib(n-1)+fib(n-2)); }

    Если n достаточно велико, каждое из предыдущих значений функции Фибоначчи будет вычисляться много раз, причем без всякого толку: результат всегда будет один и тот же! Зато все подпорки убраны...

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

    Сети данных
    Рис. 14.1.  Золотая гора

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


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

    Теперь рассмотрим случай, когда рекурсивная реализация намного изящнее циклической, легче обобщается и не хуже по эффективности8)

    Сети данных
    Рис. 14.1.3.  Алгоритм Евклида

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

    function Euklides(n,m: integer) integer; { предполагаем m<=n} begin if n=m then resut:=n else result:=Euklides(n mod m, m); end;

    Если пытаться вычислить наибольший общий делитель методом движения от данных к цели, то нам придется построить громадный массив значений НОД, лишь ничтожная часть значений в котором будет нужна для построения результата. Затраты на вычисление каждого отдельного элемента в данном случае малы, а при обратном направлении движения повторный счет не возникает.

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

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


    Как видно, в частности на примере золотой горы, этот метод недетерминирован и в значительной степени может быть распараллелен. Но в конкретном алгоритме нам придется выбрать конкретный способ ленивого движения, и он может быть крайне неудачен: например, он будет провоцировать длительное движение в тупик, когда у нас есть короткий путь к цели. Даже в теоретических исследованиях приходится накладывать условия на метод ленивого движения, чтобы гарантировать достижение результата (см., например, теорему о полноте семантических таблиц в книге [20]).

    Другой крайний случай движения по сети, когда сеть делится на одинаковые слои. Например, в сети (14.1.4)

    Сети данных
    Рис. 14.1.4.  Полностью заменяемый массив

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

    Сети данных
    Рис. 14.1.5.  Постепенно заменяемый массив

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

    Еще Э. Дейкстра в книге [11] предложил в каждом блоке описывать импортированные и экспортируемые им глобальные значения. Но такая "писанина" раздражала хакеров и в итоге так и не вошла в общепризнанные системы программирования. Сейчас индустриальные технологии требуют таких описаний, но из-за отсутствия поддержки на уровне синтаксического анализа все это остается благими пожеланиями, так что, если хотите, чтобы Ваша программа была понятна хотя бы Вам, описывайте все перекрестные информационные связи!

    Резюмируя вышеизложенное, можно сделать следующие выводы.

  • Сеть данных сама по себе в программу не переходит, в программу переходят лишь некоторые свойства сети в качестве призраков и некоторые куски сети в качестве реальных значений.
  • Программа определяется не только сетью данных, но и конкретной дисциплиной движения по этой сети.
  • В случае, если очередные слои сети, появляющиеся при движении согласно заданной дисциплине, примерно одинаковы и состоят из многих взаимосвязанных значений, у нас возникает циклическая программа.
  • В случае, если вычисление можно свести к вычислениям для независимых элементов сети, чаще всего удобней рекурсия.
  • Самый общий способ движения по сети - ленивое движение, когда мы имеем право вычислить следующий объект сети, если вычислены все его предшественники.
  • Структурное программирование нейтрально по отношению к тому, каким именно способом будет исполняться полученная программа: последовательно, детерминированно, недетерминированно, совместно, параллельно либо даже на распределенной системе, - поскольку сеть лишь частично предписывает порядок действий.
  • Конкретный выбор порядка действий в последовательной детерминированной программе является подпоркой, о которой постоянно забывают.Поэтому он чаще всего вредит при перестройке программы.


  • Внимание!

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


    Выбор


    Рассмотренные до сих пор сети данных представляли в первую очередь тот случай, когда в программе нет значительных альтернативных блоков. Условие было лишь средством проверки перехода от одного этапа вычислений к другому. Однако на самом деле, как правило, программа содержит выбор. Для представления выбора в языках программирования имеются условные операторы и операторы выбора. Рассмотрим, что же стоит за выбором.
    Пример 14.3.1. Пусть в некоторый момент исполнения программы Вам необходимо временно выбросить больший из двух хранимых в основной памяти обрабатываемых блоков на диск. Поскольку разница в длине менее 216=65536) несущественна, мы можем записать выбор примерно в следующей форме.
    if length(A)-lengtn(B)>65536 Выбор { Save(A); Dispose(A); A_present:=false;}, length(A)-lengtn(B)<65536 Выбор { Save(B); Dispose(B); B_present:=false;} fi
    Мы воспользовались данной формой, чтобы ярче подчеркнуть условия, при которых производятся действия.
    Предложенная форма записи базируется на концепции охраняемых команд, предложенной Э. Дейкстрой. Охраняемая команда исполняется лишь при условии, когда выполнена охрана. Но если данный текст читает программист, он должен понимать, что 'лишь' не всегда означает, что при выполнении условия команда будет выполнена. Оператор выбора по Дейкстре состоит из множества охраняемых команд. В конкретном синтаксисе мы используем для них форму
    Guard Выбор Command
    Относительное расположение охраняемых команд в операторе выбора безразлично9). Выполняется одна из охраняемых команд, охрана которой истинна. Имеющиеся в языках конкретные формы условных предложений и предложений выбора являются подпорками для реализации охраняемых команд.
    Из изложенного следует, что по своей сути выбор так же недетерминирован, как и исполнение структурной программы. Если выполнено несколько охран, с точки зрения задачи абсолютно все равно, какое из действий выбирать. Однако имеющиеся средства программирования10) заставляют нас однозначно сделать выбор, и конечно же почти всегда мы забываем написать в комментариях, что на самом деле выбор безразличен, а затем при модификациях программы появляются заплатки на подпорках и т.
    п.

    Когда имеется выбор, мы вынуждены переходить от сети данных к более сложной структуре: &-Выбор-графам. Некоторые вершины могут быть помечены как Выбор-вершины, это означает, что достаточно получить один из результатов, соответствующий входящим дугам, и инициировать лишь одно из исполнений, соответствующее выходящей дуге. Для структурированности &-Выбор-графа необходимо, чтобы он был сетью, удовлетворяющей следующему условию: имеется инъекция Выбор, сопоставляющая каждой Выбор-вершине ?, из которой выходит несколько дуг, Выбор-вершину Выбор(?), из которой выходит лишь одна дуга, такую, что любой путь, проходящий через первую вершину, проходит и через вторую. Это неудобоваримое теоретическое условие всего лишь формулирует на точном языке, что Выбор-вершины должны группироваться в структуры следующего вида, показанного на рис. 14.2 (количество вариантов может быть любым).

    Выбор
    Рис. 14.2.  Сеть охраняемых команд


    Естественный параллелизм алгоритмов


    При рассмотрении стилей программирования выяснилось, что зачастую линейный порядок исполнения операторов программы навязывается ей извне и служит лишь подпоркой, необходимой для реализации в конкретной системе.
    Обратимся к классическим примерам, когда вычислительный алгоритм хорошо распараллеливается.
    Пример 15.1.1. Умножение матриц - это случай, на котором достаточно широкие массы программистов и заказчиков вычислительных программ осознали потенциальную выгодность распараллеливания. Если разбить матрицу произведения k*m x l*n на подматрицы размера m x n, то для вычисления каждой такой подматрицы достаточно иметь m строк первого сомножителя и n столбцов второго. Разделив вычисления по независимым процессорам, мы можем ценой некоторого дублирования исходных данных значительно быстрее получить результат.
    Аналогично распараллеливается задача решения системы линейных уравнений.
    Пример 15.1.2. Пусть задача представляется как совокупность нескольких взаимодействующих автоматов (таким образом, в принципе она укладывается в рамки автоматного программирования, но единая система структурируется на подсистемы, и большинство действий работают внутри подсистем). Тогда у нас возникает естественный параллелизм.
    Таковы многие игровые задачи, в которых задействовано несколько персонажей, таково же и большинство задач синтаксического разбора.
    Уже на этих двух примерах видно, что имеются принципиально различные случаи. В первом примере задачи индифферентны по отношению к тому, как будет вычисляться результат. В таком случае мы используем для распараллеливания свойства конкретного алгоритма вычислений, который имеет множество потенциально подходящих для реализации информационных взаимосвязей в соответствующей сети данных вычислительных структур (последовательная обязательно находится среди них).
    Во втором примере параллелизм естественно диктуется самой задачей, поскольку система распадается на подсистемы. Надеюсь, у Вас не вызывает удивления то, что в данном случае естественное с теоретической точки зрения решение оказывается практически почти никогда не реализуемым. Нынешние параллельные системы имеют фиксированное (как правило, небольшое) число процессоров, если же процессоров много, то они завязаны в жесткую структуру. Поэтому задачи с естественным параллелизмом очень редко помещаются в прокрустово ложе такой системы. Гораздо легче запихнуть туда то, что не является параллельным по природе, но может быть распараллелено, поскольку в этом случае структуру реализации алгоритма можно подогнать к требованиям конкретной системы.
    Таким образом, основная беда параллелизма состоит в том, что программирование и архитектура машин вступают в концептуальное противоречие.
    В жизни каждый из Вас встречался с простейшим видом параллелизма: длительный и относительно автономный процесс печати запускается, как правило, параллельно с выполнением других программ. Случай, когда Вы одновременно запустили несколько программ, как правило, настоящим параллелизмом не является (это рассматривается чуть ниже).


    Виды параллелизма


    Результатом команды считается результат участника, пришедшего последним.
    (из правил соревнований по военно-прикладным видам спорта)
    - Доктор, а чем я болен?
    - Не волнуйтесь, больной, вскрытие покажет.
    (русский анекдот)
    Тот из видов параллелизма, который возникает при перемножении матриц, естественен для структурного программирования, но на самом деле параллелизмом не является. В структурном программировании данные организованы в сеть, по которой движется программа. Эта сеть является частичным порядком, и если в сети можно выделить несколько подсетей, слабо связанных друг с другом и проходящих через некоторый сегмент исходной сети с начала до конца, то эти подсети можно исполнять на независимых вычислителях, которые в критические моменты времени взаимодействуют между собой. Обычно такое разбиение сети данных не единственно, и поэтому можно один и тотже алгоритм исполнять на параллельных системах разной архитектуры.
    Пример 15.2.1. Пусть сеть данных имеет следующий вид (рис. 15.1)
    Виды параллелизма
    Рис. 15.1.  Развилка
    Тогда ее естественно исполнять на одно-, двух- либо трехпроцессорной машине. В случае многопроцессорной машины в начале и на заключительном этапе вычисление идет последовательно, а три ветви распределяются между процессорами. В частности, если две боковые ветви занимают меньше времени счета, чем главная, можно на двухпроцессорной машине распределить их обе на один и тот же процессор.
    В распараллеленной программе заключительный этап исполнения начинается лишь после того, как завершатся все три ветви, дающие для него исходные данные. Такой параллелизм, когда для завершения блока параллельных процессов нужно успешно завершить все параллельные ветви, стандартно возникает в вычислительных задачах и носит название &-параллелизма. Насколько успешен будет результат распараллеливания, зависит от равномерности распределения вычислительной нагрузки между ветвями (см. первый эпиграф).
    Другой вид параллелизма был впервые осознан на задачах поиска. Например, если нам известно, что сбежавший из зоопарка лев находится в одном из четырех кварталов и никак не может перебраться из одного в другой, для ускорения поиска можно (если у нас есть достаточное количество поисковых команд) послать по команде в каждый из кварталов, и закончить поиск, как только одна из команд найдет беглеца.
    Таким образом, возникает Виды параллелизма-параллелизм, когда для успешного завершения параллельного блока достаточно, чтобы к успеху пришел один из запущенных процессов.

    Рассмотрим теперь, каковы же общие условия применимости Виды параллелизма-параллелизма. Частный пример про льва показывает, что в принципе можно было бы представить "процедуру" поиска беглеца в виде следующего условного предложения:

    if Лев в квартале1 Виды параллелизма Искать в квартале 1, Лев в квартале2 Виды параллелизма Искать в квартале 2, Лев в квартале3 Виды параллелизма Искать в квартале 3, Лев в квартале4 Виды параллелизма Искать в квартале 4 fi

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

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

    Таким образом, Виды параллелизма-параллелизм является подпоркой для эффективной реализации призрачного условного оператора, удовлетворяющего двум требованиям:

  • проверить охрану ничуть не проще, чем выполнить соответствующую охраняемую команду;
  • выполнение команды из неверной альтернативы ничем, кроме растраты ресурсов, повредить не может.


  • Тогда можно запустить различные варианты параллельно на разных процессорах и посмотреть, какой первый придет к удаче.

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



    Третий вид параллелизма на самом деле параллелизмом не является, и, пожалуй, шире всего распространен в практике программирования. Это квазипараллелизм. Вы встречаетесь с ним в программах, когда порождаете подпроцесс командой типа thread. В этом случае несколько процессов исполняются как один процесс, в котором перемешаны шаги подпроцессов, так что у программиста создается впечатление, что процессы исполняются параллельно. Квазипараллелизм может с внешней стороны выглядеть как любой из двух рассмотренных ранее видов параллелизма. В первом приближении квазипараллелизм дает лишь чистый проигрыш, но фактически он позволяет занять естественно образующиеся в одном из процессов промежутки ожидания (например, ввода или вывода данных) работой других процессов. Именно так работает Ваш плейер, пока Вы ломаете голову над ошибками в своей программе.

    Поскольку здесь все управление на самом деле сосредоточено в одном месте, методы работы с квазипараллельными процессами наиболее развиты.

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

    Пример 15.2.2. Классическим примером эффективного использования Виды параллелизма-параллелизма на плохо связанной между собой распределенной системе является массированная хакерская атака какого-либо пароля. Тысячи хакеров, разделив между собою поле поиска, запускают программы перебора на своих машинах. Кто-нибудь да найдет, а тот факт, что другие могут еще несколько дней (поскольку по ночам хакеры обычно "работают", а днем спят) гонять программу перебора, уже ни на что не влияет: все равно выигрыш получен колоссальный.


    Взаимодействие процессов и распараллеливание


    До сих пор мы рассматривали идеальный случай, когда процессы независимы между собой и вопрос об их взаимодействии возникает лишь в момент их завершения. Но на практике так конечно же бывает крайне редко. Процессы практически всегда взаимосвязаны, и поэтому нужно рассмотреть вопрос об организации связей между ними.
    Первая связь - общие данные. Рассмотрим самый простой случай, когда у процессов есть общее поле памяти, в которое они записывают обновленные данные и читают их в случае необходимости. Даже в этом случае возникают сложности. Если один из процессов начал обновлять данные в поле, а другой как раз в это время их читает, то может случиться так, что он получит мешанину из старых и обновленных данных (такой случай в распределенных системах и в базах данных рассматривается как нарушение целостности данных1)).
    Здесь следует ввести понятие критического интервала, когда программа, занимающая некоторый ресурс, должна быть уверена в том, что никто больше к этому ресурсу обратиться не может, что ее работающие в параллель "друзья" не могут навредить либо не будут дезориентированы.
    Конечно, проще всего установить монопольное использование общего ресурса: программа, обратившаяся к этому ресурсу, устанавливает флаг, и пока флаг поднят, никто другой обратиться к нему не может. Но представьте себе, например, коллективную работу над пухлым техническим заданием. Мне нужно, скажем, внести изменения в главу 3, а я не могу сделать этого, поскольку уже пару часов некто корпеет над главой 13, которая практически от главы 3 не зависит. В этом случае прибегают к корпоративным системам поддержки распределенной работы (в качестве положительного примера устойчиво работающей системы можно привести Lotus Works, правда, с 2002 г. автор с ней не работал, так что улучшения, сделанные с тех пор, могли повлечь потерю надежности). В них решаются тонкие задачи поддержания целостности данных при поступлении изменений из множества источников, при сбоях серверов, сети и т.п. Все это делается таким образом, чтобы работа одного из сотрудников практически не мешала работе других (а если уж мешает, значит, оба пытаются залезть в одно и то же место, но для этих случаев предусмотрена система развязки конфликтов).

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

    Есть один важный частный случай &-параллелизма, когда критические точки и критические интервалы не вызывают проблем. Это - конвейерный параллелизм. При конвейерном параллелизме основную часть времени процессы работают независимо. Затем работа всех частных процессов приостанавливается, происходит обмен данными и просмотр происшедших событий. Такая организация работы аналогична заводскому конвейеру: пока конвейер стоит, рабочие выполняют свои операции; затем конвейер движется, передавая результаты операций следующему исполнителю.

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

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


    Так что оптимизация не всегда монотонна по отношению к разбиению на подсистемы.

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

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

    При компьютерном моделировании поведения объектов целесообразно выделить два аспекта:

  • образы действующих объектов;
  • моделирование времени.


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

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

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

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


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

    Покажем, что решение задачи можно построить как систему взаимодействующих процессов с дискретным временем. Это - хороший метод структурирования задачи и отделения деталей от существенных черт: сначала мы строим абстрактное представление, а затем конкретизируем его, например укладывая, по сути своей, параллельный алгоритм в рамки последовательной программы3). Идея этого подхода восходит к У.-И. Далу и Ч. Хоору, которые использовали данную задачу для демонстрации возможностей системы с дискретными событиями, предоставляемой языками SIMULA 60 и SIMULA 67, совпадавшими по модели времени и структуре управления процессами.

    Базовой концепцией в данной задаче естественно является Взаимодействие процессов и распараллеливание-параллелизм. Определение кратчайшего расстояния можно представить как соревнование действующих агентов4) , "разбредающихся" по разным дорогам. Сразу же разграничиваются два класса алгоритмов: прямые, когда общий процесс начинается с A, и обратные, для которых стартовым городом назначается B. Для показа принципиальных моментов достаточно рассмотреть по одному прямому и обратному алгоритму.

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

  • Если агент стоит в городе, то

  • Если местонахождение агента есть B, то цель достигнута. В качестве результата выдается пройденный путь.
  • Агент проверяет, является ли город запретным. Если это так, агент ликвидируется (понятно, что при этом информация по системе в целом не теряется - другие агенты продолжают действовать).
  • Город, в котором стоит агент, объявляется запретным.
  • Порождается столько наследников агента, сколько дорог исходит из текущего местонахождения данного агента. При этом в качестве локальных данных новых агентов задается пройденный путь, запомненный родительским агентом от A до текущего местонахождения (не принципиально, уничтожается ли родительский агент или он становится одним из экземпляров наследников).


    Если нет дорог из текущего местонахождения, то агент ликвидируется - он зашел в тупик.


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


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

    Как и обычно, более жесткие условия на окончательную программу влекут за собой во многих отношениях более мягкие требования к прототипу (нужно расширить возможности его перестройки в разные варианты окончательной программы), но при этом желательно максимально возможное повышение уровня понятий прототипа. Если в конце концов программа будет реализована квазипараллельно, не нужно обращать внимание на то, сколько агентов могут действовать одновременно. В частности, в случае заведомого наличия пути из A в B можно не проверять запретность либо ослабить ее до случая, когда предок данного агента посещал данный город, и, следовательно, он оказался на запомненном пути. В этом случае не потребуется запоминание глобальной, а точнее, общей для всех агентов информации о графе дорог и городов.

    Приведенный алгоритм нуждается в запоминании сведений о пройденном пути в локальных данных агентов. Таким образом, локальные данные каждого из агентов, которые будут ликвидированы, хранятся напрасно. Если же их забывать, то искомый путь не может быть получен, получаются лишь некоторые его характеристики. Этого недостатка лишено решение с помощью обратного блуждания, т.е. от B к A. Для него все работает точно так же, за исключением следующего:

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


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



    Рис. 15. 2 иллюстрирует работу всех трех алгоритмов: прямого (a), прямого с пометками (b) и обратного с пометками-рекомендациями (c). Надписи на дугах-дорогах обозначают агентов, верхние индексы на них - порядок порождения агентов. В косых скобках записаны локально хранимые сведения о посещаемых городах. Зачеркнутые надписи указывают на уничтожение агента в связи с использованием сведений о посещениях городов.

    Взаимодействие процессов и распараллеливание
    Рис. 15.2.  Логика алгоритмов поиска пути

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

    Если параллелизм возможен, но процессов недостаточно (например, если Вы решите воспользоваться для реализации этих алгоритмов Prolog-системой Muse), то мы находимся в трудной ситуации, требующей творческих решений, комбинации научного и инженерного подхода6).

    Возникает вопрос о средствах эмуляции дискретных событий. Имеются пакеты работы с дискретными событиями, но еще в 60-е гг. было показано, что мышление в терминах времени на самом деле требует собственного языка. Классическим примером здесь до сих пор является язык SIMULA 67. Он в явном виде провозглашает имитацию параллелизма и, следовательно, дает человеку возможность мыслить в терминах действующих агентов, что представляется более гибким и естественным способом выражения многих алгоритмов. Систему с дискретными событиями можно рассматривать в качестве общего метода преодоления противоречия между принципиальным параллелизмом и реальной последовательностью вычислений.

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



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

    Состояние называется:

  • активным, когда (реально) выполняется программа процесса;
  • приостановленным, когда выполнение программы процесса прервано, но запомнена точка возобновления и процесс находится в управляющем списке;
  • пассивным, когда процесс не выполняется и не находится в управляющем списке, но точка возобновления активности запомнена;
  • завершенным, когда выполнение его программы прервано и точка возобновления активности не запомнена.


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

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

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

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

    Как следует из перечисленного выше, применительно к решаемой задаче копирование агентов означает:



  • создание локальных структур данных агентов-процессов;
  • размещение агентов-процессов в управляющем списке;
  • перемещение каждого активного агента- процесса по управляющему списку на величину времени его задержки;


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

    К примеру, для обратного алгоритма с пометками-рекомендациями схема программы агента, пришедшего в город, сводится к следующему:

  • Выждать время, необходимое для перемещения из местоположения в данный город (фактически, это означает лишь соответствующую перестановку себя в управляющем списке, сопровождающуюся приостановкой процесса);
  • Если данный город уже посещался (в городе имеется пометка-рекомендация), то ликвидировать себя, т.е. завершить процесс;
  • Сделать пометку-рекомендацию в данном городе, используя местоположение, из которого пришел агент;
  • Если данный город есть A, то построить путь, используя пометки рекомендации, и завершить процесс;
  • Для каждой дороги, ведущей в данный город, породить процесс нового агента, в качестве параметра которого задается данный город. Назначить активизацию нового процесса непосредственно после прекращения активности родительского процесса;
  • Завершить процесс.


  • Взаимодействие процессов и распараллеливание
    Рис. 15.3.  Управляющий список

    На рис. 15.3 изображено начало последовательности состояний управляющего списка, которая получается при выполнении алгоритма с данными, представленными на рис. 15.2с. В верхней части рисунка изображена модель времени: на горизонтальной оси отмечены моменты, когда состояние агентов меняется (? - функция времени перемещения между городами; a, b, c и d - моменты для дальнейшего пояснения). В нижней части рисунка показана последовательность изменений управляющего списка (его состояния выделены прямоугольными блоками, в которых для наглядности вертикальные стрелки обозначают связи процессов, назначенных на одно и то же время, а горизонтальные стрелки - упорядоченность процессов по модельному времени).



    Разбредание агентов начинается с порождения процессов A1 и A2, что соответствует двум дорогам, ведущим в город B. Это момент модельного времени, помеченный как a. Моменту b соответствуют четыре состояния, сменяющие друг друга, c соответствуют три, а d - одно состояние. Из сопоставления рисунков 15.2a и 15.2b видно, что никакого специального моделирования времени, а тем более задержек не требуется: время изменяется "мгновенно", когда все события, назначенные к более ранним срокам, отработали и в голове управляющего списка появляется событие, назначенное на более поздний срок. В принципе, здесь не требуется даже атрибут времени - достаточно отношения порядка между событиями.

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

    Необходимо подчеркнуть ряд важных моментов:

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


  • В качестве хорошего изложения современного состояния дел в технике практического параллельного программирования можно рекомендовать книгу [14]. В частности, и система MPI, описанная в указанной книге, и ныне весьма широко и необоснованно рекламируемая система Open MP включают в себя средства квазипараллельного исполнения параллельных программ, отличные от системы с дискретными событиями. Эти средства мы не описываем, поскольку для реальных программ они нужны лишь как метод отладки параллельных программ на машине с недостаточной конфигурацией (причем метод, не вызывающий особых восторгов).

    Взаимодействие процессов и распараллеливание
    Взаимодействие процессов и распараллеливание
    Взаимодействие процессов и распараллеливание
      1)

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

      2)

      Поведение русских абсолютно противоположное.

      3)

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

      4)

      В данном случае для подчеркивания специфики экземпляры объектов лучше называть так.

      5)

      По крайней мере, с заведомо большим, чем нужно для данной задачи.

      6)

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

      7)

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

    Взаимодействие процессов и распараллеливание

    Что нужно для переиспользования


    Стиль от переиспользования характеризуется тем, что при составлении программы стремятся максимально использовать то, что уже сделано - самим программистом, его коллегами или же вообще где-либо. Давно уже общепризнано, что в идеале на смену программированию как кодированию алгоритмов должно прийти программирование как сборка из заранее заготовленных блоков - сборочное программирование. Этот термин введен в 1978 г. Г. С. Цейтин позднее отделил это понятие от теоретических рассмотрений и представил его с программистской стороны. Заметим, что математики уже давно отказались от построения новых теорем (соответствующих в информатике программам) и новых понятий (соответствующих абстрактным типам данных) с пустого места. В математике весьма профессионально переиспользуются ранее полученные результаты. Здесь из-за концептуального единства системы понятий и строгих критериев обоснованности не возникает проблемы совместимости новых версий, которая зачастую губит попытки не только переиспользования, но и просто использования старых программ1).
    Понятно, что при использовании готовых строительных блоков возможны потери. Тем не менее потенциальная выгода за счет переиспользования просто колоссальна. В математике, где имеются точные оценки, показано, что длину доказательства (читай - программы) можно сократить в БАШНЮ ЭКСПОНЕНТ РАЗ без существенной потери эффективности лишь за счет введения лемм (читай - использования уже построенных программ).
    Рассмотрим две реальные ситуации, которые демонстрируют разработку, не ориентированную и ориентированную на переиспользование. В первой ситуации действует программист, работающий с очень развитой системой, в которой есть все. Тем не менее он пишет свою процедуру лексикографического упорядочивания строк, потому что "легче самому написать, чем найти, а потом ведь еще и подгонять придется". Вывод: во-первых, здесь начисто отсутствует стремление к переиспользованию, а во-вторых, переиспользованию могут препятствовать затруднения поиска того, что требуется включить в составляемую программу, а также проблемы совместимости версий.

    Вторая ситуация - другая крайность. Программисту на Java потребовалось построить синтаксический анализ. На вопрос о том, как он это делает, получен ответ: "Зачем это знать? У меня есть пакет JavaCC, который все делает, как надо!" Вместе с тем, дальнейшие расспросы показали, что этот программист не представляет себе даже того, какого типа метод анализа поддерживает JavaCC, и, следовательно, ничего не может сказать о том, как задание грамматики для данного пакета связано с эффективностью анализа. Узнав возможные варианты, программист призадумался, но ничего менять не стал. Почему? Ответ простой: "Так ведь все уже работает!" Короче говоря, качество использования готовых компонентов системы зависит от знания о них.

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

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

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


    Можно дать лишь общий совет для тех, кто еще занимается (само)образованием.

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

    Иногда переиспользованию способствует применение развитых языковых средств. В частности, C++ и Java довольно часто позволяют переносить программы из одной операционной обстановки в другую (перенос - одна из форм переиспользования). Но те, кто реально занимался переносом программ, наверняка вспомнят при чтении предыдущего предложения досадные несоответствия, выявлению и предупреждению которых C++ и Java никак не помогают.

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

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

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

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

    Применение переиспользуемых компонентов характеризуется следующими особенностями стиля:

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


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




  • При разработке переиспользуемых компонентов необходимо учитывать, что подобная разработка всегда требует дополнительных затрат, которые связаны со следующими требованиями:

  • необходимо уделять особое внимание документированию (лучше всего, самодокументированию) переиспользуемых компонентов, причем документация должна четко выделять существенные особенности алгоритма, показывать имеющиеся призраки и использованные подпорки, таким образом, она должна быть не комментарием к тексту программы, а высокоуровневым описанием идеи и того, что оказалось необходимым для ее конкретной реализации;
  • требуется серьезный анализ того, что кандидаты на переиспользование могут быть отнесены к типовым приемам программирования, т.е. имеют достаточно широкую для использования в дальнейшем область применимости2);
  • нужно прорабатывать не только варианты полного переиспользования компонентов as is, но и частичного их переиспользования в виде шаблонов, готовых фрагментов и т. п., когда требуется настройка компонента;
  • необходимы спецификации как области адекватного применения компонента, так и границ применимости (вторая часть почти всегда отсутствует в нынешних спецификациях);
  • в спецификациях нужно четко отличать принципиальные моменты от подпорок и выделять призраки;
  • необходима оценка эффективности применения компонента;
  • необходима специальная забота о публикации компонента для потенциальных пользователей: они должны иметь возможность услышать о нем.



  • Переиспользование и стили


    В характеристиках обоих аспектов программирования от переиспользования нет явного упоминания специфики традиционных моделей вычислений. Поэтому они не зависят от стиля, в котором написаны компоненты, и в значительной степени от особенностей вычислительных систем, на которых они реализуются3). Но компоненты и модель вычислений выполняют роль фундамента, на котором базируется надстройка переиспользования. А устойчивость здания и даже его архитектура существенно зависят от качества фундамента.
    Рассмотрим ранее представленные стили с точки зрения их приспособленности к сочетанию со стилем переиспользования.
    Автоматное программирование явно связано с глобальным для каждой программы понятием набора состояний, и использовать фрагмент программы в отрыве от этого набора, вообще говоря, лишено смысла. Это указывает на естественные рамки переиспользования для данного стиля.
    Во-первых, если фрагмент может быть выделен как черный ящик, т. е. нас интересует лишь соотношение между его входными и выходными данными, то он на уровне программы может рассматриваться как самостоятельный узел переработки данных и тем самым получает независимость от состояний программы. В свою очередь, именно это позволяет использовать такой фрагмент, как самостоятельный узел переработки другой программы - переиспользовать его. На этом принципе строятся все библиотечные математические функции, реализацию которых достаточно часто не требуется даже знать при использовании.
    Обычными единицами переиспользования при любом стиле программирования являются процедуры. Именно они автономно описываются, и если оказываются независимыми от общего с другими компонентами контекста, то, по сути дела, становятся теми самыми черными ящиками, о которых только что шла речь.
    Еще один возможный случай, когда часть, ясно осознанная программистом, состояний внешней программы может быть интерпретирована как гомоморфный образ части (опять же, явно выделенной) состояний компонента (серый ящик). Тогда компонент можно даже модифицировать, т.
    е. возможно его переиспользование как фрагмента либо шаблона. Однако приемы установления гомоморфизма между состояниями - та высокоуровневая надстройка над автоматным программированием, которая пока еще не создана, и поэтому здесь программист в высшей степени зависит от качества содержательного концептуального анализа.

    Как ни странно, немногим лучше приспособлено к сочетанию со стилем переиспользования структурное программирование. Требования к структуре информационного пространства задачи и к согласованию с ним других компонентов программы обеспечивают регламентированные связи между подзадачами, а значит, облегчается (но не исчезает) задача выделения самостоятельных компонентов. В дополнение к полностью самостоятельным переиспользуемым компонентам здесь можно указать на переиспользование, при котором в новом применении обеспечивается необходимая часть контекста (т. е. переиспользуются компонент и эта часть контекста; смотри модули языков Modula-2 и Object Pascal). Модули можно рассматривать как серые ящики для структурного программирования. Возможности модуляризации достаточно хорошо исследованы и корректно реализованы в упомянутых выше языках.

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



    Внимание!

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

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

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

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

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


    Общая причина тому - гибкие средства абстракции соответствующих языков и четкое отделение интерфейсов от реализаций. Это повышает потенциальные и реальные возможности переиспользования. Вместе с тем, скажем, объектно-ориентированный стиль эффективен лишь для достаточно больших систем и тем самым вдохновляет программистов на построение больших систем классов и объектов, которые сильно взаимосвязаны. А это, в свою очередь, заставляет технологизировать разработки. Технология в настоящее время связывается с наработкой типовых моделей фрагментов объектно-ориентированных систем. Создаются шаблоны проектирования (так называемые паттерны), которые предписано использовать, чтобы минимизировать связи в системе, обеспечивать ее развиваемость [8].

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

  • Уровень приложений. В ходе ведения проекта заботятся о том, чтобы при декомпозиции и разработке компонентов системы выявлялись компоненты-кандидаты на переиспользование. Эти компоненты выделяются в самостоятельные единицы и оформляются независимо от проекта.
  • Уровень спецификаций и документации. В спецификациях четко описываются стоящие за программой призраки, в документации отделяются подпорки от решений и не забывают о призраках.
  • Уровень инструментов. Разработка проекта практически всегда включает в себя создание инструментальных средств, поддерживающих унификацию: единые библиотеки общедоступных для проекта средств, общий контекст и единообразные средства доступа к нему, средства поддержки выполнения технологических соглашений и регламентов, шаблоны проектирования и др. Этот инструментарий (или часть его) во многих случаях может быть оформлен независимо от проекта для возможного переиспользования в виде библиотек.
  • Уровень решений. Ценность для переиспользования может представлять архитектурный уровень проекта. Хорошие архитектурные решения, как правило, допускают распространение за рамки конкретного проекта, в котором они появились.


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


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

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

    Предупреждение!

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


    Программирование от образцов


    Программирование от образцов - вариант стиля от переиспользования, часто игнорируемый специалистами, но тем не менее он является весьма распространенным (даже профессионалы им пользуются). Это - подход к разработке программ по заданным заранее шаблонам. Это - скорее, целый набор вырожденных стилей, каждый из которых выделяется в связи с тем, что в той или иной ситуации скрывается под понятием образец, фрейм, шаблон.
    Не всякое переиспользование уместно считать программированием от образцов. Так, когда повторно используется фрагмент, который можно рассматривать как черный ящик, то ничего, кроме результатов вычислений фрагмента, не внедряется в новую программу. Следовательно, фрагмент не предписывает метода построения программы. Противоположная ситуация с переиспользованием уровня проектных решений. Эти решения диктуют, как будет построена программа. Шаблоны и каркасы, появившиеся в какой-либо разработке или построенные специально, становятся образцами для нового программного проекта. При этом совсем не обязательно, чтобы образец и конструируемая программа были бы написаны на одном языке. Напротив, можно извлечь определенную выгоду из того, что образец не привязывается к модели вычислений разрабатываемой программы. Если образец специально создается для данной программы, например, чтобы лучше понять решаемую задачу, то для него целесообразно выбирать язык повышенного уровня или другой модели вычислений и за счет этого иметь возможность быстрее реализовать пробную версию, макет и т. д. Подобные соображения мотивируют разновидность стиля программирования от образцов, получившую название макетирования (см. ниже). В данном случае понятно, что имеет место не переиспользование, а собственно программирование от образца. Но такой идеальный случай является скорее исключением, чем правилом: чаще всего трудно провести границу между самостоятельным стилем и технологическими приемами переиспользования.
    Можно указать следующие характерные случаи применения программирования от образцов.

  • Дается программа, написанная в каком угодно стиле, в которой нужно кое-что изменить. Точно известно, в каких местах нужно это изменять. В результате получается новая программа. Этот случай часто на профессиональном языке называется патчем программы. Квалифицированные программисты пользуются набором таких образцов, их стали включать и в руководства по ООП.
  • Дан набор программных инструментов, разработанный специалистами, и методика их применения, которая включает в себя схему составления требуемой программы. В идеальном случае применяется содержательно описанный алгоритм, порождающий программу. Такой набор часто называется технологической или инструментальной системой для некоторого класса приложений.
  • Предоставляется среда разработки новой программы, как и в предыдущем случае, созданная заранее специалистами, включающая в себя описания, фиксирующие систему понятий новой программы. Сама программа пишется обычным образом. Это один из распространенных способов работы и профессионалов, и полупрофессионалов, и дилетантов.
  • Программирование от макета. Разработчики быстро готовят прототип, который рассматривается как макет. Макет затем доводится до реального программного изделия. От макета в программной системе часто остается лишь система понятий, сам метод разработки полностью меняется (например, макет был написан на языке Prolog, а окончательная программа - на Java). Макет (особенно в системах, поддерживающих его представление в графической форме, таких как UML [18]) нередко становится частью документации готовой программы.
  • Предоставляется технологический фрейм: нечто, для чего известны слоты, т. е. позиции (пункты, пустые значения того или иного типа, в том числе и процедурного), которые требуется заполнить. В результате должна получиться программа, архитектурная схема которой задана априори. Это, собственно говоря, и есть программирование от образцов в самой чистой форме, которое, в свою очередь, распадается на ряд направлений:
  • семантические сети искусственного интеллекта;
  • фирменная методика и технология, которая погружает один из предшествующих случаев в систему стандартизованных форм и документов.


    Пример Rational Unified Process (RUP) [37];
  • табличное программирование, примеры которого приведены в данном пособии;
  • компонентное программирование, например, с использованием XML или иного языка разметки (которая задает фрейм) и языка обработчиков разметки (разделение, как говорят на программистском жаргоне, на парсер и обработчик). Это как раз то, что дает объектная модель документа.


  • Предоставляется технический фрейм - то, что нужно заполнять. Он, в отличие от технологического фрейма, совершенно не требует знания логики будущей программы. Это - облегченный и упрощенный вариант предыдущего подхода. Вообще говоря, неясно, программирование ли это, но такой подход очень даже востребован (см., например, язык Forms из Oracle), а потому замалчивать его нельзя, тем более что результат - все равно программа.


  • Использование технологического и технического фреймов демонстрирует возможность и особенности сочетания программирования от образцов с другими стилями. Фрейм, как основа конструируемой программы, может быть разработан в каком угодно стиле (например, как событийная система), но он предписывает программисту правила, а иногда и стиль, в котором должны заполняться слоты. Когда сочетание таких разнородных стилей, которое без специальной методики и поддержки было бы неосуществимым, становится продуктивным, тогда можно с полным правом говорить о программировании от образцов как о самостоятельном стиле и о реализующей его методике либо методологии.

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



    Программирование от образцов
    Программирование от образцов
    Программирование от образцов
      1)

     

    Тем не менее интересно проделать следующий мысленный эксперимент: а что было бы, если математики работали бы примерно в таких же условиях, как программисты?

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

    Даже если бы он не брал деньги за теорему саму по себе, но засекретил бы ее доказательство и угрожал бы уголовным преследованием всякому, кто осмелится понять (`дизассемблировать') написанный им текст, ситуация пришла бы к той же мертвой точке (здесь эта калька английского слова `deadlock' лучше русского слова `тупик').

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

    Как видите, мы можем сделать важный социальный и практический вывод: единственным шансом на излечение нынешних подростковых болезней программирования является работа сообществ открытого программного обеспечения и открытой проектной документации [34].

      2)

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

      3)

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

    Программирование от образцов

    Когда нужно использовать различные стили и как они взаимодействуют?


    Опыт автора показал, что в очень многих случаях студенты, которые имеют все исходные данные для того, чтобы решить задачу подстановкой конкретных значений в общее утверждение, тем не менее теряются, не увидев в частном общее.
    Пример. После того как была решена задача, можно ли разложить на множители многочлен x2 + (p - 1) над полем вычетов по модулю p, студенты глубоко задумались над вопросом, можно ли разложить многочлен x2 + 2 в поле по модулю 3.
    В связи с этим дадим некоторые советы по практическому применению общих принципов.
    Внимание!
    Не воспринимайте то, что дано ниже, как шаблоны! За шаблонами идите по другим адресам.
  • Если у Вас появляется много структурных или (не дай Бог) неструктурных переходов либо все время приходится присваивать значения признакам, а затем их проверять, посмотрите, нельзя ли выделить модуль в автоматном стиле.
  • Если Вы начинаете думать о задаче как о вычислении (не обязательно над числами), пользуйтесь структурным программированием.
  • Если Вы намерены переложить понравившуюся Вам нечисленную (например, алгебраическую, топологическую или логическую) теорему в алгоритм для решения Вашей задачи, подумайте о функциональном стиле.
  • Если у Вас каждое следующее значение требует немногих данных, но различные значения обращаются к одним и тем же, даже не пытайтесь программировать рекурсивно, рекурсивное описание можете сохранить в качестве документации к программе, а саму программу пишите в терминах циклов и массивов.
  • Если данные строго подразделяются на ветви, используемые разными последующими значениями, часто лучше писать рекурсивно.
  • Если Вы начинаете смотреть на данные как на активные единицы, которые взаимодействуют между собой, воспользуйтесь объектно-ориентированным программированием.
  • Если в предыдущем случае объекты в том виде, как они предоставляются современными системами программирования, не подошли, воспользуйтесь каким-либо пакетом8) для организации квазипараллельной работы в условном времени и программируйте от событий.
  • Если Вам нужно преобразовывать сложные структурированные тексты в другие тексты, не поленитесь воспользоваться Рефалом (или новым языком сентенциального конкретизационного программирования, если он уже появится к тому времени, когда Вы будете это читать).
  • В частности, если исходные данные или конечный результат имеет формат символьного файла, совершенно неудобоваримого для стандартных процедур ввода/вывода используемой Вами основной системы программирования, пишите препроцессоры или постпроцессоры на Рефале (либо, в крайнем случае, на Perl).
  • Если Вы путаетесь в многочисленных условиях, когда удача либо неудача порою проявляется после нескольких попыток и неясно, куда нужно вернуться, посмотрите, нельзя ли проверку условий запрограммировать на языке Prolog9) (или на новом языке сентенциального унификационного программирования, если он уже появится к тому времени, когда Вы будете это читать).



  • О сочетании стилей


    Если программа хотя бы средней величины (перерастает 500 строк на стандартном языке), то наверняка в ней найдутся модули, требующие разных стилей.
    Дадим некоторые практические рекомендации по сочетанию стилей.
  • Структурное и автоматное программирование нужно как можно жестче разделять на уровне модулей. Вопрос о языковой совместимости и интерфейсах здесь не возникает, поскольку оба они реализуются стандартными средствами традиционных языков.
  • Две ипостаси структурного программирования также нужно как можно жестче разделять средствами модульности.
  • Параллельная ипостась автоматного программирования в настоящий момент может быть реализована практически только последовательными средствами, воспользуйтесь системой моделирования в условном времени.
  • Событийное программирование стоит выделять в самостоятельные модули либо целиком выносить в Perl-программы.
  • В настоящий момент имеется целый ряд языков, основанных на идеях функционального программирования и продолжающих тенденции языка LISP на более современном уровне. Они имеют интерфейсы с С++ и Java, так что для написания модуля в функциональном стиле внутри большой, в основном традиционной, программы лучше воспользоваться Ocaml или Haskell.
  • Common Lisp обладает достаточно удовлетворительно проработанными интерфейсами с C++. Эти интерфейсы могут эффективно использоваться профессионалами. В отношении же концептуальной целостности Lisp остается несравненным, и, если значительная часть Вашей программы функциональная, стоит пользоваться им.
  • Common Lisp обладает удовлетворительно проработанными интерфейсами с Prolog. Но автор не считает разумным писать программу, соединяя функциональный стиль и унификационную ипостась сентенциального, и будет благодарен за примеры противного, если такие найдутся.
  • Тем не менее указанный в предыдущем пункте интерфейс полезен, если Вы решили использовать задел, накопленный на одном из двух упомянутых языков, в новой программе, написанной на другом. Но будьте готовы к тому, что концептуальные несовместимости могут в некоторый момент оказаться хуже необходимости переписать используемые программы по-своему.
  • Хороших интерфейсов с Рефалом нигде нет.
    Но, несмотря на это, есть один практический совет. Системы ввода/вывода Common Lisp и Prolog настолько неудобны и морально устарели, что написание пре- и постпроцессоров обработки входных и выходных данных на Рефале (либо Perl) окупается, а в Lisp или Prolog есть смысл пользоваться лишь простейшими возможностями ввода и вывода, принимающими предварительно обработанные и полностью укладывающиеся в структуру языка данные и выдающими их также без учета пользовательского формата.
  • О сочетании стилей
    О сочетании стилей
    О сочетании стилей
      1)

      Мы не употребляем здесь глагол "позволила", поскольку логика не открывала блестящие перспективы, а безжалостно обнажала неустранимые недостатки "квазирелигии прогресса", которая до сих пор остается основой "научного" мировоззрения большинства рационалистически мыслящих людей. Это мировоззрение основывается на слепой вере в прогресс и его блага. Его элементом является убежденность во всесилии (хотя бы в принципе) человеческого разума и возможности в любой ситуации сделать рациональный выбор, основываясь лишь на знании.

      2)

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

      3)

     

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

    Когда рассматривался вопрос, стоит ли делать трансляторы, лучшие московские программисты аргументировали, что делать этого не стоит. Они разработали исключительно совершенную систему разделения труда при кодировании на уровне машинных команд, когда программист писал все в "содержательных обозначениях", а техники (чаще всего девушки) чисто механически составляли таблицы и переписывали содержательные обозначения в двоичные коды. Система изложена в книге [7]. Поэтому они заявили, что трансляторы не нужны, машинные программы девочки напишут.

    Но поднялся молодой и считавший себя весьма знающим функционер из научного отдела ЦК и заявил примерно следующее:

    — Я приоткрою секретную информацию. У нас сейчас создается машина (это была БЭСМ-6), которая будет выполнять миллион команд в секунду. Это что же, вам девочки миллион команд напишут?

      4)

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

      5)

      Здесь термин значительное меньшинство не является опиской: уже достаточно много, но еще не большинство.

      6)

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

      7)

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

      8)

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

      9)

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

    О сочетании стилей

    Почему нет универсальных методов?


    К сожалению, из курсов наших университетов (как это обычно бывало во времена кризиса России) практически исчезла исключительно важная для мировоззрения и просто общей культуры наука: логика. Этот факт особенно прискорбен потому, что за XX век логика заставила научное мировоззрение перейти на качественно новый уровень1). Она впервые заставила науку задуматься над границами собственных возможностей.
    Здесь стоит сослаться хотя бы на теорему Гёделя о неполноте (см., напр., [20]), которая утверждает, что нет ни одной полной теории, описывающей хотя бы натуральные числа. Далее, теорема о неполноте тесно взаимосвязана с теоремой об универсальном алгоритме, и обойти ее невозможно (любая попытка обойти ее сама себя опровергает). Теорема об универсальном алгоритме влечет как следствие принципиальную непополнимость универсального алгоритма (это чисто функциональный факт, он никак не зависит от конкретного построения универсального алгоритма и даже от конкретного понятия вычислимости, положенного в основу). Вся архитектура и идеология современных вычислительных систем построена на базе понятия универсального алгоритма (например, само существование транслятора с "универсального" языка типа C++ является ее практической реализацией). Таким образом, понятие ошибки в программе не устранимо даже в принципе.
    Значит, нет универсального метода, который позволяет с гарантией и в ограниченное время решить любую поддающуюся решению программистскую задачу.
    Вы говорите, что люди же решают задачи. Но посмотрите: каждый специалист имеет свои излюбленные задачи, которые он хорошо решает. А для Вашего профессионального роста и трезвой самооценки очень полезно побывать в такой ситуации, когда Ваши излюбленные методы отказывают. Если Вы сумели проанализировать свою неудачу, Вы достойны звания специалиста, если же нет, Вы находитесь на пути к догматизму либо шарлатанству2).
    Часто теоретические рассмотрения первого уровня приводят к наукообразным выводам, которые рассыпаются, при углубленном исследовании.
    Но в данном случае повышение глубины лишь усиливает эффекты неуниверсальности.

    Рассмотрим пример типичной ошибки, которую неоднократно делают при теоретическом обосновании с помощью простейших моделей вычислимости.

    Все, что может быть вычислено, может быть вычислено на машине Тьюринга. Машину Тьюринга можно промоделировать на языке С++. Поэтому ничего, кроме языка С++, знать не нужно.

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

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

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

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


    Стили, их ипостаси, методологии, методики, технологии


    Поскольку ничего универсального нет, можно удариться в другую крайность, скатившись на набор рецептов и потеряв саму суть метода: гибкость и широкую применимость в самых разных областях. Если мы ориентируемся на такой ползучий эмпиризм, то дальнейшее развитие быстро упирается в эффект мусорной кучи: несистематизированные рецепты знаниями так и не становятся. Для каждого класса задач нужно искать специализированные методы и подходы.
    Чем уже класс задач, тем больше общих особенностей у этих задач. Это наиболее четко прослеживается в том случае, если класс задач выделяется не по номенклатурному признаку (например, бухгалтерские задачи, задачи автоматизации документооборота), а по критериям, отражающим суть вовлеченных в задачи структур. До некоторой степени, чем абстрактнее описание задачи, тем лучше для выработки метода программирования.
    Рассмотрим пример. Пусть имеется предприятие с некоторым количеством различных станков, выпускающее детали. Оно получает разнообразные заказы, и в соответствии с приоритетами заказов и технологией обработки деталей нужно динамически строить расписание работы станков. Решив эту задачу и столкнувшись через некоторое время с задачей документооборота в фирме, производящей спецификации большого числа разнообразных лекарств для потребителя и для специалистов, можно усмотреть подобие двух задач. В хорошо организованной бюрократической машине каждый исполнитель работает подобно станку, выполняя свою операцию, точно так же имеется сеть зависимостей (некоторые исполнители могут работать лишь после того, как им подготовили материал другие), и те же методы (а часто даже и почти те же программы) переносятся на планирование бумажного потока.
    Таким образом, для систематизации сведений и превращения их в знания необходимо выделять общие характеристики найденных частных решений. Эти общие характеристики превращаются в приемы, а затем, при их осознании и обобщении, в методы.
    Метод — сильное оружие, и поэтому наряду с большими достоинствами он обязательно имеет большие недостатки.
    В частности, применение метода требует достаточно высокого интеллектуального уровня и владения рядом навыков, прививаемых точными науками, в частности, умением переходить к абстрактному от конкретного и обратно. Поскольку подавляющее большинство специалистов не владеют мышлением на таком уровне, чтобы они могли применять метод в чистом виде, метод конкретизируют в методику.

    Методика — это набор шаблонов, процедур и рецептов, конкретизирующих метод4). Например, метод многоаспектного моделирования, положенный в основу UML (Unified Modelling language), конкретизирован в методику.

    Примером методики служит общая часть RUP (Rational Unified Process). Сам UML лишь поставляет материал для конкретной методики: формы, в которых можно строить систему взаимосвязанных моделей, описывающих разные стороны создаваемой программы, и процедуры отладки таких описаний. Методикой может пользоваться уже значительное меньшинство5) специалистов, поскольку ее применение требует элементарных операций конкретизации и композиции и доступно людям, владеющим комбинационным мышлением. Но для успешного применения в коллективе методика должна конкретизироваться далее.

    Тот же самый RUP, помимо методики применения многоаспектных моделей, содержит часть, касающуюся регламента работы программистского коллектива согласно данной методике. Это — технология. В технологии расписываются действия, даются шаблоны документов, определяются роли и функции лиц, задействованных в проекте. Поскольку в программистских коллективах чернорабочий имеет квалификацию техника обычного "железного" производства, программистские технологии, в отличие от индустриальных, оставляют некоторый простор для конкретизации и модификации, давая возможность подставлять в шаблоны целые серии динамически определяемых конкретных действий. На "железном" производстве технология — строго определенная последовательность операций, примерно соответствующая отлаженной программе в программистском мире.

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


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

    Но, кроме методов, есть еще два этажа. Первый из высших этажей начали рассматривать в 70-е гг. XX века. Это методологии программирования6). Методология на самом деле ортогональна методам: это надстройка на другом крыле программистского здания. В этом случае достаточно сослаться на структурное программирование. Метод реализуется при помощи методологии и в ее рамках.

    Второй из высших этажей впервые был систематически исследован в книге [22]. Он связан с тем, что в программировании мы имеем дело с намного более абстрактными и идеальными понятиями, чем в материальном производстве. Поэтому длина логических построений возрастает, сложность формализации используемых методов убывает, и мы в гораздо большей степени зависим от логики наших рассуждений, чем от "материи".

    Логика построения отличается от логики оформления готовых рассуждений, которую преподают на стандартных курсах и на которой основана классическая версия математики. Уже с начала ХХ века в математике появилось конструктивное направление (вариантами которого являются, в частности, интуиционизм и советский конструктивизм), для которого главное не доказательство, а содержащееся в нем идеальное построение. Основатель конструктивного направления Л. Э. Я. Брауэр (Голландия) установил, что причины, по которым математическое доказательство не всегда дает построение, лежат в логике. Так появилось понятие конструктивной логики, в которой мы интересуемся не истинностью формул, а их реализуемостью (возможностью построения решения задачи заданными средствами).

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


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

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

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

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

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

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

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

    Функциональное программирование в его нынешней форме соответствует взгляду математики конца ХХ века на мир как на совокупность структур высших порядков и функционалов, производящих на базе одних структур другие.


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

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

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

    ДействияУсловияСтиль
    ЛокальныеЛокальныеСтруктурный
    ГлобальныеЛокальныеАвтоматный
    ЛокальныеГлобальныеСобытийный
    ГлобальныеГлобальныеСентенциальный
    В каждом стиле имеются ипостаси. Ипостаси — формы, в которых высокоуровневое и абстрактное понятие стиля проявляется в наших конкретных построениях. Ипостаси логически друг другу не противоречат7), но фактически непримиримо враждуют (порою даже больше, чем разные стили), если пытаться использовать их вперемежку. Это связано с тем, что ипостаси настроены на разные дисциплины расходования ресурсов.

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

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

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

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

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


    

        Программирование: Языки - Технологии - Разработка