Язык KRL
В языке KRL впервые была сделана попытка собрать воедино результаты выполненных ранее исследований о структурировании элементов знаний и реализовать их в виде единой системы [Bobrow and Winograd, 1977]. Создание системы преследовало не только теоретические цели, но и имело достаточно четкую практическую направленность. В качестве "строительных блоков" системы использованы так называемые "концептуальные объекты", которые были сходны с фреймами, предложенными Минским, в том, что представляют прототипы и связанные с ними свойства. Основную идею авторы так изложили в опубликованной в 1977 году статье:
"...анализ последствий объектно-центрической факторизации знаний в противовес более общей факторизации, предполагающей структурирование знаний в виде набора фактов, каждый из которых ссылается на один или несколько объектов".
Такая ориентация повлекла за собой создание декларативного языка, основанного на описаниях, в которых концептуальные объекты рассматриваются не изолированно, а в совокупности с другими объектами-прототипами. Фундаментальное предположение состояло в том, что то, какие свойства некоторого объекта знаний оказываются существенными, представляющими интерес и т.д., зависит от точки зрения на объект и цели решаемой задачи. Например, если вам нужно сыграть музыкальную пьесу на пианино, то вас интересуют такие свойства этого объекта, как качество звучания, настройки и т.п. А вот грузчику более интересны такие свойства этого музыкального инструмента, как вес и габариты.
В этом свете описание новой сущности можно рассматривать как процесс сравнения ее с ранее описанными: нужно указать, на какие из известных объектов похож новый и чем именно, а в чем от них отличается. Так, мини-фургон очень похож на легковой автомобиль, но отличается от последнего отсутствием сидений для пассажиров и окон в задней части. Другими словами, полный набор понятий можно определить в терминах друг друга, а не в терминах более компактного множества примитивных идей. Сложность с использованием примитивов в представлении семантики состоит в том, что вряд ли когда-нибудь удастся прийти к единому мнению о том, что же представляют собой такие примитивные понятия и как их следует комбинировать при формировании более сложной идеи (с некоторыми соображениями на сей счет читатель может ознакомиться в работах [Schank, 1975] и [Schank andAbelson, 1977]).
В основе процедурных свойств языка KRL также лежат наиболее распространенные программные методы, предполагающие подключение процедур общего вида к классам объектов данных. Бобров и Виноград объединили этот вид подключения процедур со структурой фрейма и позволили подклассам наследовать как процедуры, так и данные своего суперкласса.
Разработчики языка предположили, что наследование процедур позволит программировать в терминах родовых операций, детали реализации которых конкретизируются по-разному для объектов разных классов. Так же, как абстрактные типы данных позволяют программисту забыть о деталях хранения конкретных данных в машине, так и родовые операции позволяют на определенной стадии не принимать во внимание детали реализации однотипных операций в каждом конкретном случае. Пример поможет вам четче представить смысл этой идеи.
Предположим, вас назначили Верховным главнокомандующим при проведении военной операции, в которой принимают участие различные рода войск. Отданные в ваше распоряжение танки, корабли и самолеты ждут приказа атаковать. Получив такой приказ, каждый из родов войск будет действовать по-своему: самолеты начнут бомбить, корабли — выпускать ракеты и т.д. Но поведение каждого из родов войск — это частная реализация общей концепции наступательных военных действий. Как главнокомандующего, вас мало интересуют детали выполнения приказа: на какие рычаги нажимать, какие переключатели включать, — все это должно быть определено на более низких уровнях.
Возвращаясь вновь к KRL, отметим, что идея, которая стоит за объектно-центрической организацией процедур, состоит в том, чтобы попытаться программно воспроизвести тот естественный стиль выработки суждений, который реализуется в мозге человека. В частности, предполагается, что управление логическим выводом реализуется на локальном уровне, в отличие от глобальных задач, ассоциирующихся, например, с автоматическим доказательством теорем. Другими словами, зная, как реализовать родовые операции, классы объектов будут обладать знаниями и о том, когда активизировать те многочисленные процедуры, к которым они имеют доступ.
Мы не затрагивали многих других аспектов языка KRL, например средств управления процессом или составления расписаний работ. Читателям, интересующимся этим языком, рекомендуем познакомиться с критическим анализом этого языка, который выполнили Ленерт и Уилкс [Lehnert and Wilks, 1979], и ответом разработчиков на эти критические замечания [Bobrow and Winograd, 1979]. Нельзя не отметить, что язык KRL явился тем локомотивом, который существенно подтолкнул исследования в области теории представления знаний и, в частности, способствовал появлению практических систем, о которых речь пойдет в следующем разделе.
7.1.
Процедуры и объекты
Серые! эллипсы на схеме в левой части рисунка представляют процедуры, некоторые из которых напрямую обращаются к данным, хранящимся в файле или в базе данных. Зачерненный эллипс представляет процедуру самого верхнего уровня (в языке С — это процедура main). Эта функция вызывает другие функции, которые в конце концов вызывают функции самого нижнего уровня, выполняющие операции ввода/вывода.
На правой схеме объекты объединяют данные и процедуры работы с ними. Объекты организованы в виде одной или нескольких иерархических структур — деревьев или решеток. Утолщенный прямоугольник на схеме представляет базовый абстрактный класс. Экземпляры этих объектов взаимодействуют друг с другом, обмениваясь сообщениями, и таким образом образуются связи, ортогональные иерархии наследования.
Рис. 7.1. Процедурно- и объектно-ориентированные парадигмы программирования. Незаполненные фигуры представляют данные, а фигуры с заливкой—процедуры
Языки LOOPS и FLAVORS
Объектно-ориентированный стиль программирования идеально подходит для решения проблем, требующих детального представления объектов реального мира и динамических отношений между ними. Классическим примером применения данного подхода являются задачи моделирования. В таких программах компоненты сложной системы представляются структурами, инкапсулирующими и данные, и функции, моделирующие поведение соответствующих компонентов. Первым языком, в котором была реализована такая идея, стал SmallTalk [Goldberg andRobson, 1983].
Для задач искусственного интеллекта были разработаны языки LOOPS и FLAVORS, причем оба представляли собой объектно-ориентированные расширения языка LISP. Хотя в настоящее время эти языки практически не используются, реализованные в них базовые идеи унаследованы множеством языков представления знаний, появившихся позже. В частности, это можно сказать о языках CLOS (Common LISP Object System) и CLIPS. Ниже мы кратко опишем основные функциональные возможности языков LOOPS и FLAVORS и обратим ваше внимание на некоторые сложности, связанные с реализацией объектно-ориентированного стиля программирования.
Передача сообщений
Идея объектно-ориентированного программирования состоит в том, что программа строится вокруг множества объектов, каждый из которых обладает собственным набором функций (операций). Вместо того чтобы представлять объект пассивным набором данных, объектно-ориентированная система позволяет объекту играть более активную роль, в частности взаимодействовать с другими объектами, обмениваясь с ними сообщениями. В результате основной упор переносится с разработки общей управляющей структуры программы, которая ответственна за порядок обращения к функциям, на конструирование самих объектов, выяснение их ролей и создание протоколов взаимодействия объектов. Эти протоколы, по существу, определяют интерфейс между объектами. Если один объект должен взаимодействовать с другим, он должен вызывать функции в строгом соответствии с этим интерфейсом.
Объекты располагают собственными данными, которые играют ту же роль, что и слоты фреймов, собственным механизмом обновления этих данных и использования хранящейся в них информации. Помимо функций интерфейса, объекты располагают собственными, "приватными" функциями, которые, как правило, представляют собой реализацию определенной родовой операции применительно к данному объекту. Помимо данных, передаваемых в качестве аргументов родовой операции, такие функции используют и локальные данные объекта — аналоги слотов фрейма.
Предположим, мы определили объект, представляющий класс ship (корабль), и наделили его свойствами x-velocity (скорость по х) и y-velocity (скорость по у). Теперь можно создать экземпляр класса ship, назвать его Titanic и одновременно присвоить свойствам x-velocity и y-velocity нового экземпляра исходные значения. Практически нет никаких отличий между этой процедурой и процедурой создания нового экземпляра фрейма, рассмотренной в предыдущей главе.
Предположим теперь, что нам понадобилось определить процедуру speed, которая будет вычислять скорость судна на основании значений свойств x-velocity и у-velocity (скорость вычисляется как корень квадратный из суммы квадратов компонентов). Такая процедура будет принадлежать абстрактному типу данных, представляющему любые суда (в терминологии языка SmallTalk speed — это метод класса ships, а в терминологии C++ — функция-член класса ships.)
Идея состоит в том, чтобы закодировать в объекте (классе) не только декларативные знания о судах, но и процедурные, т.е. методы использования декларативных знаний. Для того чтобы активизировать процедуру вычисления скорости определенного судна, в частности "Титаника", нужно передать объекту Titanic сообщение, которое побудит его обратиться к ассоциированной процедуре в контексте данных о компонентах скорости именно этого объекта. Titanic — это экземпляр класса ships, от которого он унаследовал процедуру speed. Все это представляется довольно очевидным, но описанный механизм срабатывает только в случае, если соблюдаются следующие соглашения.
Во-первых, программа, разработанная в расчете на этот механизм, должна "учредить" и следовать в дальнейшем определенному протоколу или "контракту", определяющему способ взаимодействия между объектами. Другими словами, интерфейс обмена сообщениями между объектами должен быть досконально продуман, а правила этого интерфейса жестко соблюдаться. Лучше всего продемонстрировать эту мысль на примере.
Для того чтобы определить компоненту X текущего положения "Титаника", программа должна послать запрос объекту Titanic, который имел бы следующий смысл: "передай текущее значение координаты X". Как в объекте формируется это значение или как оно хранится — дело только самого объекта и никого более не касается. Ни объекты других классов, ни какие-либо другие компоненты программы этого не знают. Более того, внутренний механизм доступа к этой информации должен быть скрыт, чтобы никто не мог добраться к ней, минуя сам объект. Это соглашение принято называть инкапсуляцией.
Во-вторых, совершенно очевидна избыточность определения своего метода вычисления скорости для каждого класса объектов, которые обладают возможностью перемещаться в двумерной системе координат. Метод, который мы только что определили для класса судов, с таким же успехом может быть использован и для других движущихся объектов, поскольку вычисление скорости представляет собой родовую операцию. Поэтому имеет смысл связать этот метод с каким-нибудь суперклассом транспортных средств, производными от которого будут классы судов, автомобилей, троллейбусов и т.п. Все эти подклассы унаследуют родовую операцию у своего базового класса.
Такое наследование — это уже нечто большее, чем когнитивная экономия или наследование свойств. Выполнение родовых операций встраивается в механизм обмена сообщениями. Отсылка сообщения— это отнюдь не вызов определенной процедуры, поскольку вызывающий объект не знает, каким именно методом отреагирует на это сообщение объект-получатель, от кого он унаследует этот метод, и будет ли вообще задействован механизм наследования в данном конкретном случае. Вызывающему объекту известны лишь наименование операции и ее внешние по отношению к преемнику аргументы. Все остальное — заботы объекта-реципиента сообщения.
7.2.
Формирование объекта класса на языке CLIPS
(defclass ship
(is-a INITIAL_OBJECT)
(slot x-velocity (create-accessor read-write))
(slot y-velocity (create-accessor read-write)) )
Теперь сформируем экземпляр этого класса,, которому будет дано наименование "Titanic". Проще всего это сделать с помощью функции definstaces, которая в качестве аргументов принимает список параметров формируемых экземпляров. Определенные таким способом экземпляры класса будут инициализироваться при каждом перезапуске интерпретатора CLIPS.
(definstances ships (titanic of ship (x-velocity 12) (y-velocity (10)
Завершается определение созданием обработчика событий для класса ship. Все экземпляры класса будут использовать этот обработчик для вычисления собственной скорости. Обратите внимание на то, что член в этом определении ссылается на значение слота того экземпляра класса, скорость которого требуется вычислить.
(defmessage-handler ship speed () (sqrt
( +
{ ?self:x-velocity ?self:x-velocity) ( ?self:y-velocity ?self:y-velocity))) )
Если файл со всеми представленными выше выражениями загрузить в среду CLIPS, а затем ввести с клавиатуры (send [titanic] speed), то в ответ интерпретатор CLIPS выведет скорость объекта titanic.
Проблема наложения методов
Та простая картина, которая вырисовывается из представленного выше механизма прямого наследования, несколько усложняется, если мы попытаемся заменить прямое наследование множественным. В главе 6 уже отмечалось, что это может привести к неоднозначности в наследовании свойств. Но в контексте объектно-ориентированного подхода при множественном наследовании появляется и неоднозначность поведения.
С этой проблемой впервые столкнулись при разработке объектно-ориентированного языка FLAVORS, который поддерживает множественное наследование и наложение методов [Cannon, 1982]. Язык FLAVORS позволяет объектам иметь несколько родителей и таким образом наследовать процедуры и данные из нескольких источников. Для FLAVORS характерна не иерархия объектов, а гетерархия. Если графически изобразить отношения между разными объектами в FLAVORS, то схема будет больше походить на решетку, чем на дерево. Каков во всем этом смысл? Рассмотрим следующий пример, взятый из статьи Кэннона.
Отображение окон на дисплее рабочей станции реализуется, как правило, с использованием объектно-ориентированного стиля программирования. Будем считать, что окна на экране дисплея представлены в виде LISP-объектов, в каждом из которых записаны свойства окна (размеры и положение на поле экрана) и процедуры работы с окном (открытие, закрытие, перерисовка и т.п.). Существует несколько разновидностей окон и соответственно объектов окон — с рамкой, без рамки, со строкой заголовка, без заголовка и т.д.
Класс окно с рамкой .является подклассом (или производным классом) класса окно. Точно так же подклассом класса окно является и класс окно с заголовком. В иерархической системе классы окно с рамкой и окно с заголовком представляют собой отдельные узлы одного и того же уровня иерархии. Они наследуют определенные методы, например refresh (освежить), от базового класса окно, но имеют и собственные методы выполнения таких операций, как перерисовка рамки или строки заголовка.
А теперь предположим, что нам потребовался еще один вид окна — окно с рамкой и строкой заголовка. Окно такого типа должно быть представлено новым классом окно с рамкой и заголовком. В иерархической системе новый класс будет наследником класса окно и независимым "близким родственником" уже существующих классов окно с рамкой и окно с заголовком на том же уровне иерархии (рис. 7.2). Но даже интуитивно чувствуется, что такая организация избыточна. Ведь фактически мы стремимся "смешать" два набора уже существующих качеств и получить в результате новый комбинированный набор. Кажется, что целесообразнее сделать новый класс "дитятей" двух родителей, — классов окно с рамкой и окно с заголовком (рис. 7.3).
Рис. 7.2.
Иерархическая система классов окон
найти подходящие методы в базовых классах;
скомбинировать их таким образом, чтобы получить желаемый эффект.
Рис. 7.3.
Гетерархическая система классов окон
Метаклассы
Отличительной чертой языка LOOPS является поддержка концепции метаклассов, т.е. классов, членами которых являются другие классы. Впервые метаклассы появились в языке SmallTalk. В первой системе реализации этого языка имелся единственный метакласс Class, членами которого были все прочие классы в системе, в том числе и Class. В более поздних реализациях SmallTalk метакласс формировался автоматически всякий раз, когда создавался новый класс, и этот класс становился экземпляром класса Metaclass. Метаклассы в SmallTalk-80 сами по себе не являлись экземплярами метаклассов, а принадлежали единственному метаклассу Metaclass. Во избежание путаницы мы в дальнейшем будем называть "классами объектов" те классы, которые не являются метаклассами.
Смысл существования метаклассов — поддержка создания и инициализации экземпляров классов. Обычно сообщение посылается экземпляру класса, а не самому классу. Экземпляр класса наследует поведение от своего класса объектов. Но иногда желательно передать сообщение именно классу, например сообщение "сформировать экземпляр с такими-то свойствами". Классы наследуют поведение от своих метаклассов и таким образом вся система обладает приятным с точки зрения пользователя единообразием. (Обычно сообщения метаклассам не посылаются, но если такое произойдет, то они будут наследовать поведение от класса Metaclass, к которому они все принадлежат, включая и сам Metaclass.)
В языке LOOPS метакласс не создается для каждого класса. Этот язык имеет более простую структуру классов, представленную схематически на рис. 7.4. Узлы в форме эллипсов представляют классы объектов и метаклассы, а узлы в форме прямоугольников представляют экземпляры классов объектов. Тонкие стрелки означают отношения вида "А является подклассом В", а толстые — отношения вида "А является экземпляром В".
Рис. 7.4.
Структура классов в языке LOOPS
Узлы, вычерченные утолщенными линиями — ship (корабль) и liner (лайнер), — типичные классы, определенные пользователем. Обратите внимание на то, что все классы такого рода являются членами класса Class, от которого они наследуют свое поведение. Таким образом, для того чтобы сформировать экземпляр Titanic, нужно переслать сообщение new классу liner, который наследует метод new от класса Class. Поведение экземпляра Titanic, естественно, будет унаследовано от его класса— в данном случае liner.
Использование метаклассов позволяет запрограммировать поведение по умолчанию и определенные виды структур в объектно-ориентированной системе на самом высоком уровне. Экземпляры классов объектов, таких как liner, обычно формируются стандартным способом, но если для одного или семейства классов желательно использовать какой-то более специфический вариант, нужно включить между liner и Class определенный пользователем метакласс (например, metaliner), в котором и "прописать" желаемые модификации (рис. 7.5). Таким образом, структура классов в языке LOOPS позволяет в одной системе объединить мощность и гибкость представления объектов реального мира.
Рис. 7.5. Вставка метакласса, определенного пользователем
Языки CLIPS и CLOS
Появление языка Common LISP было связано с попыткой стандартизировать многочисленные диалекты LISP и создать устраивающую большинство пользователей версию этого языка. Развитие объектно-ориентированного подхода в программировании привело к разработке объектно-ориентированной версии LISP — Common LISP Object System (CLOS), о которой и пойдет речь в этом разделе. Разработчики CLOS включили в свою систему поддержку всех новшеств, ранее хорошо себя зарекомендовавших в языках FLAVORS и LOOPS, таких как множественное наследование, объединение методов и структура метаклассов.
Объектно-ориентированная версия CLIPS, язык COOL, очень близок к CLOS, что мы и продемонстрируем на примерах в этом разделе.
Множественное наследование в CLOS и CLIPS
Механизм множественного наследования в языках CLOS и CLIPS работает практически так же, как и в языке LOOPS. Порядок, в котором базовые классы перечислены в определении подкласса, задает и порядок приоритетов наследования данных и процедур. Кроме того, существует правило, в соответствии с которым определение процедуры или свойства, сделанное в классе, всегда имеет приоритет перед унаследованными от суперклассов. Эти соглашения позволяют разрешить проблему неоднозначности при множественном наследовании путем формирования списка предшествования классов.
Рассмотрим фрагмент программы на языке CLIPS, представленный в листинге 7.1. Этот фрагмент описывает "Алмаз Никсона", о котором шла речь в главе 6. Класс person определен как объявленный пользователем, классы quaker и republican — производные от person, a republican-quaker — производный как от quaker, так и от republican. Класс USER является системным абстрактным классом, т.е. может быть использован только для создания подклассов. Если планируется создавать экземпляры любого класса, производного от USER, то этот класс нужно объявлять с квалификатором concrete, как это и сделано при объявлении класса republican-quaker.
Листинг
7.1. Объявление классов на языке CLIPS
(defclass quaker (is-a person)
(defclass republican (is-a person)
(defclass republican-quaker (is-a republican quaker) (role concrete)
Список предшествования классов для класса republican-quaker будет иметь вид (republican-quaker republican,quaker person).
Список формируется в результате прослеживания графа связей системы классов, который неявно представлен слотами is-a в определениях классов.
Роль списка предшествования классов становится ясной при разработке обработчика событий для производного класса. Определим поведение классов quaker и republican как "голубей" и "ястребов" соответственно:
(defmessage-handler quaker speak () (printout t crlf "Peace")
)
(defmessage-handler republican speak ()
(printout t crlf "War") )
Сформируем экземпляр класса republican-quaker:
(definstances people
(richard of republican-quaker))
Теперь загрузим все это в исполняющую систему CLIPS и введем запрос к экземпляру Richard:
(send [richard] speak)
В ответ интерпретатор выведет "War" (война). Оказывается, что "ястребиный" характер республиканцев возобладал у экземпляра richard, поскольку в списке предшествования классов republican стоит раньше, чем quaker. Изменим порядок перечисления этих классов в определении republican-quaker:
(defclass republican-quaker
(is-a quaker republican)
(role concrete) )
Теперь в характере экземпляра Richard миролюбие квакеров будет доминировать. Ничего не изменится в поведении экземпляра и в том случае, если добавить обработчик сообщения в класс person:
(defmessage-handler person speak ()
(printout t crlf "Beer") )
Эта реализация метода speak перекрывается другими, поскольку класс находится в списке предшествования на последнем месте.
Слоты данных в языке COOL также поддерживают фацеты, т.е. свойства, ответственные за доступ к слотам в процессе работы программы. Например, существует фацет visibility (видимость), который определяет, какие другие классы могут обратиться к слоту. Значение private означает, что только обработчик сообщения данного класса может получить доступ к данным, а значение public позволяет это сделать также обработчикам сообщений производных классов и суперклассов.
Другие фацеты позволяют реализовать следующие возможности:
автоматическое определение функций доступа и присвоения значений слотам;
хранение данных, к которым возможен доступ со стороны всех экземпляров класса, аналогично статическим членам классов в языке C++.
Наложение методов в CLOS и CLIPS
В языках FLAVORS и LOOPS реализованы разные механизмы комбинирования поведения, унаследованного от разных "родителей". В языке FLAVORS используется описанный выше механизм вставок, а в языке LOOPS производится дополнительное обращение к альтернативному коду.
В языке CLOS поддерживаются оба варианта. Обычно существует главный метод, который берет на себя основную часть работы по выполнению родовой операции (примером является метод refresh, о котором шла речь при обсуждении набора классов окон). Как и в языке FLAVORS, before-методы (предварительные методы) используются для подготовки данных для тех вычислений, которые должны быть выполнены primary-методом (основным методом), а after-методы (заключительные-методы) используются для выполнения заключительных операций.
Кроме того, в CLOS имеется возможность использовать так называемые around-методы (методы оболочки), которые образуют своего рода оболочку вокруг ядра (последовательности "before-метод— primary-метод— after-метод"). Такая методика предназначена для ситуаций, в которых ядро не позволяет достичь требуемого результата. Например, желательно, чтобы before-метод установил локальные переменные, которые должны быть использованы primary-методом, или когда нужно заключить primary-метод в какую-либо управляющую структуру. В ядре before- и after-методы используются только для того, чтобы сформировать побочные эффекты; возвращается же значение, сформированное primary-методом, причем это значение не ограничивается никакими внешними управляющими структурами.
Наиболее специфический around-метод связывается с сообщением, которое передается перед тем, как будут вызваны подходящие before-, primary- или after-методы. Обращение к ядру производится в процессе выполнения системной функции call-next-method, которая размещается в теле around-метода.
Стандартная методика наложения методов суммирована в схеме, представленной на рис. 7.6.
В CLOS предлагается множество дополнительных типов наложения методов. Более того, пользователь может самостоятельно создавать и собственные типы. Например, тип or-combination передает вызывающему объекту значение первого компонента, вернувшего значение, отличное от NIL. При создании собственных функций наложения пользователь может использовать операторы разных видов: логические, арифметические или манипулирования списками.
Следует отметить, что стандартная схема наложения методов удовлетворяет потребности программирования практически на 90%.
Методы в CLOS являются эффективными родовыми функциями, возможность применения которых зависит от специальных параметров, представляющих класс первого аргумента сообщения. Методы вызываются точно так же, как и функции LISP (т.е. не используются никакие функции отсылки, как это делается в LOOPS). Получатель сообщения представляется первым аргументом, а остальные'аргументы — обычные параметры функции.
Рис. 7.6.
Стандартная схема наложения методов в языках CLOS и CLIPS ([Keene,1989])
(prepare-meal X Y)
и его реализация зависит как от класса аргумента X (повара), так и от класса аргумента У (блюда). Аналогичные возможности в CLIPS обеспечиваются посредством родовых функций (подробнее об этом — в главе 17).
7.3.
Как сделать людей вежливыми
(defmessage-handler guaker speak () (printout t crlf "Peace")
)
(defmessage-handler republican speak () (printout t crlf "War")
)
(defmessage-handler person speak after() (printout t ", please" t crlf)
)
Теперь в ответ на запрос (send frichard] speak) последует ответ "War, please" Обработчики сообщений базовых классов имеют статус primary по умолчанию, а потому можно и не указывать это явно в объявлении обработчика, как это сделано, например, ниже:
(defmessage-handler republican speak primary () (printout t crlf "War")
Метаклассы в CLOS и CLIPS
В языке CLOS классы и метаклассы интегрированы в среду LISP. Фактически каждый объект LISP является экземпляром класса. Например, существует класс массивов array, соответствующий типу данных array в Common LISP.
В CLOS поддерживаются три базовых метакласса.
standard-class. Это метакласс по умолчанию для любого класса объектов, определенных пользователем с помощью функции defclass. Ниже приведено определение обычного класса father (отец), который имеет суперклассы man (мужчина) и parent (родитель), слоты для хранения имени (name) и рода занятий (occupation) экземпляров класса, а также краткого описания (documentation).
(defclass father (man parent) (:name) (:occupation) (:documentation "Класс родителя мужского пола"))
Большинство классов, определенных пользователем, имеет именно этот метакласс.
build-in-class. Это метакласс тех классов объектов, которые реализованы необычным способом. Например, некоторые из таких классов могут соответствовать типам данных Common LISP. Большинство системных классов имеет именно этот метакласс.
structure-class. Это метакласс тех классов объектов, которые определены с помощью функции defstruct, а не defclass. Функция defstruct используется в Common LISP для формирования фреймов, состоящих из слотов и наполнителей, но не поддерживающих множественное наследование.
Хотя функциональные возможности базовых метаклассов вполне приемлемы для большинства приложений, программист может воспользоваться и опциями формирования специализированного standard-class, который будет обладать каким-нибудь экзотическим поведением. В частности, можно использовать meta-object protocols (протоколы мета-объектов) и переопределить родовой алгоритм диспетчирования. В результате у разработчика появляется возможность создать собственный механизм наследования, более приемлемый для конкретного приложения, чем стандартный. Нужно отметить, что подобного рода возможности отсутствуют в языках, подобных C++, правда, как показал опыт, большинство пользователей обращаются к ним очень редко.
Язык COOL, включенный в состав CLIPS, имеет 17 системных классов, причем некоторые из них выполняют функции метаклассов. Верхние уровни структуры классов схематически представлены на рис. 7.7.
Рис. 7.7.
Верхние уровни иерархической системы классов языка COOL
Множественное наследование в C++
Так же, как язык CLOS представляет собой объектно-ориентированное расширение языка LISP, так и язык C++ создан на основе широко известного языка С и сохранил все его возможности, добавив к ним средства объектно-ориентированного программирования. Если отвлечься от того факта, что CLOS и C++ основаны на разных языках-прототипах, то основное отличие между ними заключается в реализации механизма наследования, в частности множественного наследования. В языке C++ множественное наследование трактуется совсем не так, как мы это делали в предшествующих разделах настоящей главы, а потому этот вопрос заслуживает подробного обсуждения.
В языке C++ родовые операции реализуются в виде виртуальных функций. Виртуальная функция, объявленная в классе X, это функция, которая может быть перегружена (переопределена) в классе, производном от X. При объявлении в классе X виртуальная функция вообще может не иметь тела, т.е. программного кода реализации. В таком случае функция называется чисто виртуальной, а класс, имеющий одну или более чисто виртуальных функций, является абстрактным базовым классом, экземпляры которого создать невозможно. В любом случае ключевое слово virtual говорит компилятору, что программный код функции будет уточнен в производных классах.
Те методы, которые вызываются на выполнение, являются невиртуальными членами-функциями, т.е. функциями, имеющими определенный программный код, который не перегружается в производных классах. В этом смысле C++ существенно отличается от языка CLOS, в котором практически все функции суперкласса в большей или меньшей степени модифицируются механизмом наложения методов. Поэтому в C++ существует множество синтаксических тонкостей, в которых не нуждается CLOS. Например, во всех классах иерархии виртуальная функция должна иметь квалификатор virtual до тех пор, пока в некотором производном классе не будет представлена ее конкретная реализация.
В чисто иерархической структуре классов, когда каждый производный класс имеет единственного "родителя", передача методов по наследству выполняется совершенно очевидным способом. Единственная тонкость в реализации этого механизма в C++ состоит в использовании квалификаторов наследования public и private. Если не вдаваться в подробности, то наследование вида public представляет собой отношение "is а" (является), которое мы использовали при обсуждении фреймов. Наследование вида private ближе к отношению "реализовано в терминах", которое позволяет скрыть определенные детали реализации интерфейсов объектов. Такое полезное разделение "выпало" в языке CLOS, в котором каждое отношение "класс-подкласс" несет семантический смысл.
Но если обратиться к множественному наследованию, то механизмы его реализации в C++ и CLOS существенно отличаются. Поскольку в языке C++ не существует такого понятия, как порядок предшествования классов, то даже такой простой случай, как в рассмотренном выше примере "Алмаз Никсона", приводит к неоднозначности. Будем считать, что отношения между классами Person, Quaker, Republican и Republican_Quaker, как и прежде, имеют вид, представленный на рис. 7.8.
Объявление классов Person, Quaker, Republican и Republican Quaker на языке C++ показано в листинге 7.2 (программный код объявления включен в файл nixon.h).
Рис. 7.8.
Отношения между классами в примере "Алмаз Никсона"
Листинг
7.2. Файл nixon.h. Объявление классов, версия 1
class Person
{ public:
Personf) {};
virtual "Person() {};
virtual void speak() = 0; };
class Republican : public Person
{ public:
Republican)) {};
virtual ~Republican)) {};
virtual void speak() { cout « "War";} };
class Quaker : public Person
{ public:
Quakerf) {};
virtual ~Quaker)) {};
virtual void speak)) { cout « "Peace";} };
class Republican_Quaker : public Republican, public Quaker
{ public:
Republican_Quaker() {};
virtual ~Republican_Quaker() {};
};
Создадим экземпляр richard класса Republican_Quaker.
#include "nixon.h" void main))
Republican_Quaker richard; richard.speak));
При обработке этого программного кода компилятор C++ обнаружит, что вызов richard.speak)) содержит неоднозначную ссылку. Оно и понятно, поскольку нельзя однозначно заключить, скажет ли Ричард "War" (война) или "Peace" (мир).
Если мы решим, что метод speak)) класса Republican_Quaker должен "брать пример" с класса Quaker, то проблему можно решить, определив этот метод одним из двух способов:
void S::speak(){ cout << "Peace"; }
или
void S::speak)({Quaker::speak)); }
Первый вариант просто перегружает оба наследованных определения метода, а второй в явном виде вызывает один из них, а именно тот вариант, который реализован в классе Quaker.
Однако совершенно незначительное на первый взгляд изменение в файле определения классов может разительно изменить поведение объекта. Предположим, решено удалить объявления методов speak)) из всех классов, кроме Person, как это показано в листинге 7.3.
Листинг
7.3. Файл nixon.h. Объявление классов, версия 2
public:
Person)) {};
virtual "Person)) {};
virtual void speak)){ cout « "Beer";}
};
class Republican : public Person
public:
Republican)) {}; virtual ~Republican)) {};
class Quaker : public Person
public:
Quaker)) {};
virtual ~Quaker)) {};
class Republican Quaker : public Republican, public Quaker
{
public:
Republican_Quaker( ) {} ;
virtual ~Republican_Quaker( ) {};
}
При обработке такого файла определения компилятор опять выдаст сообщение о неоднозначности ссылки на метод speak ( ). Это произойдет по той причине, что компилятор сформирует две копии объявления класса Person — по одной для каждого пути наследования, а это приведет к конфликту имен. Чтобы устранить эту неоднозначность, нужно объявить Person как виртуальный базовый класс и для Republican, и для Quaker. Тогда оба производных класса будут ссылаться на единственный объект суперкласса (листинг 7.4).
Листинг
7.4. Файл nixon.h. Объявление классов, версия 3
{ public:
Per son () {};
virtual "Person)) {};
virtual void speak(){ cout << "Beer";} И
class Republican : virtual public Person
{ public:
Republican)) {};
virtual ~Republican)) {};
};
class Quaker : virtual public Person
{
public:
Quaker)) {};
virtual ~Quaker)) .{};
}
class Republican_Quaker : public Republican, public Quaker
{
public:
Republican_Quaker { ) { } ; virtual "Republican_Quaker( ) {};
}
Объявление Person в качестве виртуального базового класса для Republican и Quaker имеет и еще одно преимущество. Предположим, что нам нужно сделать так, чтобы класс Republican_Quaker отдавал предпочтение стилю поведения квакеров, а все другие были индифферентны к вопросам войны и мира и следовали линии поведения, определенной классом Person. Тогда, поскольку Person является виртуальным базовым классом, можно заставить доминировать Quaker::speak)) над Person::speak)) для класса Republican_Quaker (листинг 7.5).
Листинг
7.5. Файл nixon.h. Объявление классов, версия 4
public:
Person)) {};
virtual ~Person)) {};
virtual void speak)){ cout « "Beer";}
class Republican : virtual public Person
public:
Republican)) {}; virtual ~Republican)) {};
class Quaker : virtual public Person
public:
Quaker)) {};
virtual ~Quaker() {};
virtual void speak)) { cout « "Peace";}
class Republican_Quaker : public Republican, public Quaker
public:
Republican_Quaker() {};
virtual "Republican_Quaker() {};
}
При создании языка C++ преследовалась цель не усложнять механизм множественного наследования по сравнению с единственным и разрешать все неоднозначности на стадии компиляции [Stromtrup, 1977]. В этом существенное различие между C++ и SmallTalk. В последнем такого рода конфликты разрешаются на стадии выполнения программы. Это также отличается и от метода, основанного на списке предшествования классов, который используется в CLOS. Кроме того, в языке CLOS конфликта имен, подобного тому, который мы наблюдали с классом Person, быть просто не может, поскольку все базовые классы с одинаковыми именами считаются идентичными.
Таким образом, за высокую эффективность языка C++ приходится платить, тщательно продумывая передачу свойств и поведения от классов родителей к производным классам с учетом всех нюансов функционирования механизма наследственности в C++.
В этом отношении C++ напоминает свой прототип — язык С, который требует гораздо более близкого знакомства с работой компьютера, чем язык LISP, поскольку позволяет напрямую обращаться к памяти компьютера, манипулировать адресами, формировать собственный механизм выделения памяти и т.д. Какую стратегию предпочесть — зависит от индивидуальных предпочтений разработчика, но если главным требованием к продукту является высокая производительность, то чем большими возможностями управления ресурсами обладает разработчик, тем лучше, тем более эффективную программу можно создать.
Суммируя все сказанное о языке C++, отметим, что он вполне может послужить базовым программным инструментом для создания экспертных систем. Если потребуется интерпретатор порождающих правил, то можно либо разработать его самостоятельно (хотя это и далеко не тривиальная задача), либо воспользоваться одним из имеющихся на рынке, которые допускают внедрение в среду C++. Если вам удастся избежать описанных выше сложностей в реализации множественного наследования, вы сможете в полной мере воспользоваться многочисленными преимуществами этого языка — проверкой статических типов, разделением между закрытым и общедоступным наследованием, множеством средств защиты данных от случайных изменений.
Объектно-ориентированный анализ и конструирование экспертных систем
Философия и технология объектно-ориентированного программирования могут весьма пригодиться проектировщику экспертных систем.
Философия представления знаний о реальном мире в терминах взаимодействия объектов и субъектов предоставляет достаточно удобную среду для решения большого класса проблем, предполагающих значительный объем моделирования (задачи планирования и составления расписаний).
Методика представления абстрактных данных и процедур позволяет программистам, занятым задачами искусственного интеллекта, на ранних этапах разработки сосредоточиться на выборе подходящих видов объектов и их поведении, не вдаваясь в подробности реализации функций и структур данных.
Существует довольно много литературы по объектно-ориентированному программированию, которая поможет конструктору экспертных систем.
Но существуют и определенные сложности внедрения объектно-ориентированного подхода в область задач искусственного интеллекта.
Объекты в основном являются средствами реализации вычислений.
Идея наследования поведения вленет за собой появление множества проблем при ее реализации, как мы видели это на примере с классами окон. Некоторые из появившихся в последнее время объектно-ориентированных схем допускают наследование только интерфейсов.
Определенные сложности возникают с реализацией наследования при использовании новых технологий многокомпонентных объектов, таких как СОМ (см. [Chappell, 1996]).
В идеальном случае желательно так организовать разработку системы, особенно экспертной, чтобы добавление новых возможностей сводилось к включению в систему новых модулей программного кода. Приведенные в этой главе примеры показывают, что это вряд ли возможно даже при использовании объектно-ориентированной среды. Добавление новых модулей знаний всегда связано с побочными эффектами, которые вовлекают в свою орбиту правила разрешения конфликтов и неопределенностей. В дальнейшем мы покажем, что эта проблема характерна и для технологии логического программирования (об этом речь пойдет в главе 8).
Таким образом, совершенно очевидно, что реализация идей объектно-ориентированного программирования не позволяет решить все проблемы и разработчику экспертной системы будет еще над чем поломать голову. Но объектно-центрическая парадигма упрощает принятие определенных видов решений в процессе проектирования системы и облегчает реализацию уже принятых.
Рекомендуемая литература
Для ознакомления с общими концепциями объектно-ориентированных вычислений и их практической реализацией я бы рекомендовал статьи из сборника [Peterson, 1987]. Обзор работ по объектно-ориентированной технологии применительно к задачам искусственного интеллекта читатель найдет в [Stefik and Bobrow, 1986]. В работе [Кеепе, 1989] довольно подробно описан язык CLOS.
Подробное описание C++ и методики программирования на этом языке содержится в книгах [Booch, 1994] и [Meyers, 1995], [Meyers, 1997], хотя я и не в восторге от двух последних.