Объектно-ориентированное проектирование с примерами

         

Логическая и физическая модели


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

При анализе мы должны задать следующие вопросы:

Каково требуемое поведение системы?

Каковы роли и обязанности объектов по поддержанию этого поведения?

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

При проектировании мы должны задать следующие вопросы относительно архитектуры системы:

Какие существуют классы и какие есть между ними связи?

Какие механизмы регулируют взаимодействие классов?

Где должен быть объявлен каждый класс?

Как распределить процессы по процессорам и как организовать работу каждого процессора, если требуется обработка нескольких процессов?

Чтобы ответить на эти вопросы, мы используем следующие диаграммы:

диаграммы классов

диаграммы объектов

диаграммы модулей

диаграммы процессов.

Статическая и динамическая модели

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

диаграммами переходов из одного состояния в другое

диаграммами взаимодействия.

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



Макроорганизация


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

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

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

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

Рис. 9-2. Категории классов в библиотеке.

Как видно из рис. 9-2, библиотека организована не в виде дерева, а в виде леса классов; здесь не существует единого базового класса, как этого требуют языки типа Smalltalk.

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



Механизм датчиков


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

На данном этапе разработки иерархия классов-датчиков, представленная на рис. 8-4, остается без изменений. Мы, однако, должны уточнить местонахождение некоторых полиморфных операций, чтобы добиться как можно более высокой степени общности классов в иерархии. Ранее, например, мы описали требования к операции currentValue, принадлежащей абстрактному базовому классу Sensor. Более полно конструкцию данного класса можно определить на C++ следующим образом:

class Sensor {


public:

Sensor(SensorName, unsigned int id = 0);


virtual ~Sensor();


virtual float currentValue = 0;


virtual float rawValue() = 0;


SensorName name() const;


unsigned int id() const;

protected:


...


};

Этот класс включает в себя чисто виртуальные функции-члены, и поэтому является абстрактным.

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

После того, как мы добавили новые свойства к классу датчиков, можно вернуться немного назад и упростить объявление функции DisplayManager::display, которая теперь может иметь только один аргумент, а именно ссылку на объект класса Sensor. От остальных аргументов можно отказаться, так как объект класса, производного от sensor, сам выдаст информацию о своем типе и идентификационном номере.

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


Объявление подкласса CalibratingSensor основывается на базовом классе Sensor:

class CalibratingSensor : public Sensor {

public:

CalibratingSensor(SensorName, unsigned int id = 0);

virtual ~CalibratingSensor();

void setHighValue(float, float);

void setLowValue(float, float);

virtual float currentValue();

virtual float rawValue() = 0;

protected:

...

};

Этот класс включает в себя две новые операции (setHighValue и setbowValue), и реализует виртуальную функцию currentValue базового класса.

Теперь рассмотрим объявление подкласса HistoricalSensor, базирующегося на классе CalibratingSensor:

class HistoricalSensor : public CalibratingSensor {

public:

HistoricalSensor(SensorName, unsigned int id = 0);

virtual ~HistoricalSensor();

float highValue() const;

float lowValue() const;

const char* timeOfHighValue() const;

const char* timeOfLowValue() const;

protected:

...

};

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

Класс TrendSensor является производным от HistoricalSensor; в нем добавлено одно новое свойство:

class TrendSensor : public HistoricalSensor {

public:

TrendSensor(SensorName, unsigned int id = 0);

virtual ~TrendSensor();

float trend() const;

protected:

...

};

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

И вот, наконец, мы переходим к конкретному классу TemperatureSensor:

class TemperatureSensor : public TrendSensor {

public:

TemperatureSensor(unsigned int id = 0);

virtual ~TemperatureSensor();

virtual float rawValue();

float currentTenperature();

protected:

...

};

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

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


Механизм опроса датчиков


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

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

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

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

12.3. Эволюция



Механизм передачи сообщений


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

между компьютерами и устройствами

между компьютерами.

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

Первый шаг при определении сообщений в системе - анализ взаимодействия каждой пары сообщающихся компьютеров (см. рис. 12-3). Для каждой такой пары мы должны задать три вопроса: (1) Какую информацию обрабатывает каждый компьютер? (2) Какая информация будет передаваться с одного компьютера на другой? (3) К какому уровню абстракции будет относиться эта информация? Эмпирического ответа на эти вопросы нет. Мы должны действовать итеративно, пока не придем к уверенности, что определены правильные сообщения и в системе связи нет "узких" мест (которые могут возникать из-за перегрузки линий связи или, например, из-за того, что сообщение разбивается на слишком мелкие пакеты).

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


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

На диаграмме классов на рис. 12-4 показаны некоторые наиболее важные сообщения в системе управления движением. Заметим, что все сообщения в конечном счете являются экземплярами абстрактного класса Message, который инкапсулирует поведение, общее для всех сообщений. Три класса следующего уровня представляют главные категории сообщений: сообщение о состоянии поезда, сообщение о плане движения поезда, сообщение путевого устройства. Каждый из этих трех классов будет детализирован в дальнейшем. В результате проектирования должны появиться десятки специализированных классов. Таким образом, существование обобщающих абстрактных классов чрезвычайно важно; без них мы получили бы сотни несвязанных между собой и, следовательно, сложных в использовании модулей, каждый из которых реализовывал бы специализированный класс, По мере проектирования мы будем выявлять другие важные группы сообщений и создавать для них специализированные промежуточные классы. К счастью, изменения в иерархии классов не должны волновать клиентов, использующих классы.
 


Рис. 12-4. Диаграмма классов сообщений.

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

//номер, обозначающий уникальный идентификатор пакета
typedef unsigned int PacketId;

//номер, обозначающий уникальный сетевой идентификатор
typedet unsigned Int NodeId;

Теперь дадим определение абстрактного класса Message:

class Message {
public: Message();
Message(NodeId sender);
Message(const Message&);
virtual ~Message();
virtual Message& operator=(const Message&);
virtual Boolean operator==(const Message&);
Boolean operator!=(const Message&);
PacketId id() const;
Time timeStamp() const;
NodeId sender() const;


virtual Boolean isIntact() const = 0;

};

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

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

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


Рис. 12-5. Передача сообщений.

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

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


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

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

При проектировании диспетчера мы располагаем его на прикладном уровне сетевой модели ISO OSI [4]. Это позволит всем клиентам, передатчикам и приемникам работать на самом высоком уровне абстракции и общаться друг с другом в терминах, специфических для данного приложения.

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


Механизм покадровой обработки


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

Начнем с разработки внешнего интерфейса для класса Timer, осуществляющего диспетчеризацию функции обратного вызова (все решения будут в дальнейшем реализовываться на языке C++). Во-первых, с помощью ключевого слова typedef определим новый тип переменной, Tick, соответствующий словарю нашей проблемной области.

// Временной промежуток, измеряемый в 1/60 долях секунды


typedef unsigned int Tick

Затем определим класс Timer:

class Timer {


public:

static setCallback(void (*)(Tick));


static startTiming();


static Tick numberOfTicks();

private:


...


};

Это - необычный класс хотя бы потому, что он содержит не совсем обычную информацию. Функция-член setCallback используется для передачи таймеру функции обратного вызова. Таймер запускается вызовом функции startTiming, после чего единственный экземпляр класса Timer начинает вызывать функцию обратного вызова каждую 1/60 секунды. Отметим, что функция запуска введена в явном виде, поскольку нельзя полагаться на то, как в частной реализации определяется порядок обработки объявлений.

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

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


enum SensorName {Direction, Speed, WindChill, Temperature, DewPoint, Humidity, Pressure};

Теперь можно определить интерфейс класса Sampler:

class Sampler {


public:

Sampler();


~Sampler();


void setSamplingRate(SensorName, Tick);


void sample(Tick);


Tick samplingRate() const;

protected:


...


};

Для того, чтобы клиент мог динамически изменять поведение сэмплера, мы определили модификатор setSamplingRate и селектор samplingRate.

Чтобы обеспечить связь между классами Timer и Sampler, придется еще приложить небольшие усилия. В следующем фрагменте кода создается объект класса Sampler и определяется "неклассовая" функция acquire:


Sampler sampler;

void acquire(Tick t)

{

sampler.sample(t);

}

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

main() {

Timer::setCallback(acquire);

Timer::startTiming();

while(1);

return 0;

}

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

Продолжим рассмотрение нашей задачи. Определим теперь внешний интерфейс класса Sensors (датчики). Мы предполагаем, что существуют различные конкретные классы датчиков:

class Sensors : protected Collection {

public:

Sensors();

virtual ~Sensors();

void addSensor(const Sensor& SensorName, unsigned int id = 0);

unsigned int numberOfSensors() const;

unsigned int numberOfSensors(SensorName);

Sensor& sensor(SensorName, unsigned int id = 0);

protected:

};

Это, в основном, класс-коллекция и поэтому он объявляется подклассом фундаментального класса Collection. Класс Collection указан как защищенный суперкласс; это сделано для того, чтобы скрыть детали его строения от клиентов класса Sensor. Обратите внимание на то, что набор операций, который мы определили для класса Sensors, крайне скуден - это вызвано ограниченностью задач класса. Мы, например, знаем, что датчики могут добавляться в коллекцию, но не удаляться из нее.

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

Вернемся к спецификации класса Sampler. Нам надо обеспечить его ассоциацию с классами Sensors и DisplayManager:

class Sampler {

public:

Sampler(Sensors&, DisplayManager&) ;

protected:

Sensors& repSensors;

DisplayManager& repDisplayManager;



};

Теперь следует изменить фрагмент кода, где происходит создание экземпляра класса Sampler:

Sensors sensors;

DisplayManager display;

Sampler sampler(sensors, display);

При порождении объекта Sampler устанавливается связь между ним, коллекцией датчиков sensors, и экземпляром класса DisplayManager, который будет использоваться системой.

Теперь можно заняться описанием ключевой операции класса Sampler, а именно, sample:

void Sampler::sample(Tick t)

{

for (SensorName name = Direction; name <= Pressure; name++)

for (unsigned int id = 0; id < repSensors.numberOfSensors(name); id++)

if (!(t % samplingRate(name)))

repDisplayManager.display(repSensors.sensor(name, id).currentValue(), name, id);

}

 



Рис. 8-14. Механизм покадровой обработки.

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

Семантика этой операции основывается на полиморфном поведении определенного метода, а именно:

virtual float currentValue();

определенного для базового класса sensor. Эта операция, кроме того, основывается на функции display класса DisplayManager:

void display(float, SensorName, unsigned int id = 0);

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

8.3. Эволюция


Механизм пользовательского интерфейса


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

Начнем с определения словаря проблемной области:

enum Key {kRun, kSelect, kCalibrate, kMode, kUp, kDown, kLeft, kRight, kTemperature, kPressure, kHumidity, kWind, kTime, kDate, kUnassigned};

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

Далее, определим класс Keypad следующим образом:

class Keypad {


public:

Keypad();


~Keypad();


int inputPending() const;


Key lastKeyPress() const;

protected:


...


};

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

Класс InputManager имеет во многом аналогичный интерфейс:

class InputManager {


public:

InputManager(Keypad&);


~InputManager();


void processKeyPress();

protected:

Keypad& repKeypad;

};

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

Рис. 8-13 иллюстрирует взаимодействие классов Sampler, InputManager и Keypad по обработке пользовательских команд. Чтобы интегрировать их, надо несколько видоизменить интерфейс класса Sampler, включив в его описание новый объект repInputManager:

class Sampler {


public:

Sampler(Sensor&, DisplayManager&, inputManager&);


...

protected:

Sensors& repSensors;


DisplayManager& repDisplayManager;


InputManager& replnputManager;

};

Теперь связь между экземплярами классов Sensors, DisplayManager и InputManager устанавливается в момент создания объекта класса Sampler.
Использование ссылок гарантирует, что каждый экземпляр Sampler получит соответствующий набор датчиков, менеджера экрана и менеджера ввода. Другая схема, в которой вместо ссылок используются указатели, обеспечила бы довольно слабую связь, позволяя создавать объект Sampler, у которого отсутствовали бы некоторые важные компоненты.

Ключевую функцию Sampler::sample надо модифицировать следующим образом:

void Sampler::sample(Tick t)

{

repInputManager.processKeyPress();

for (SensorName name = Direction; name <= Pressure; name++)

for (unsigned int id = 0; id < repSensors.numberOfSensors(name); id++)

if (!(t % samplingRate(name)))

repDisplayManager.display(repSensors.sensor (name, id));

}

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

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

enum InputState {Running, Selecting, Calibrating, Mode);

Затем определим некоторые защищенные функции класса:

class InputManager {

public:

...

protected:

Keypads repKeypad;

InputState repState;

void enterSelecting();

void enterCalibrating();

void enterMode();

};

И, наконец, начнем реализовывать переходы между состояниями (см. рис. 8-11):

void InputManager::process Keypress() {

if (repKeypad.inputPending()) {

Key key = repKeypad.lastKeyPress();

switch (repState) {

case Running:

if (key == kSelect)

enterSelecting();

else if (key == kCalibrate)

enterCalibrating();

else if (key == kMode)

enterMode();

break;

case Selecting: break;

case Mode: break;

}



}

}

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

8.4. Сопровождение

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

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

К счастью, нам не придется радикально менять нашу архитектуру, надо будет лишь дополнить ее. Используя в качестве основы архитектурный макет, представленный на рис. 8-13, можно выделить следующие необходимые изменения:

Создание нового класса-датчика RainFallSensor (датчика осадков); выявление его оптимального положения в иерархии датчиков (RainFallSensor

есть разновидность HistoricalSensor).

Обновление перечисления SensorName.

Модификация класса DisplayManager, обеспечивающая вывод на экран параметров, снимаемых с датчика нового типа.

Модификация класса InputManager, обеспечивающая обработку нажатия новой клавиши RainFall.

Правильное включение экземпляров класса RainFallSensor

в коллекцию Sensors.

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

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

Создание нового класса SerialPort, ответственного за управление последовательным портом RS232.

Разработка нового класса ReportManager, ответственного за подготовку информации к записи в определенном формате.


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

Изменение реализации функции Sampler::sample, дополнительно обеспечивающее периодическое обслуживание последовательного порта.

Признак хорошо продуманной объектно-ориентированной архитектуры - изменения не разрушают ее, а расширяют, сохраняя существующие механизмы.

Дополнительная литература

Проблемы синхронизации процессов, тупиков, конфликтов и т. п. подробно обсуждаются в работах Хансена (Hansen) [H 1977], Бен-Ари (Ben-Ari) [H 1982] и Холта и др. (Holt et al.) [H 1978]. Мелличамп (Mellichamp) [H 1983], Гласе (Glass) [H 1983] и Фостер (Foster) [H 1981] являются традиционными ссылками по вопросам разработки приложении реального времени. Параллельность с точки зрения взаимодействия аппаратуры и программы обсуждает Лорин (Lorin) [H 1972].






Механизм транзакций


Архитектура клиент/сервер построена на взаимодействии клиентской и серверной частей приложения, для реализации которого необходим определенный механизм. Берсон указал, что "существует три базовых вида взаимодействия между процессами в архитектуре клиент/сервер" [20]:

конвейеры (pipes)

удаленный вызов процедур (RPC)

взаимодействие клиент/сервер через SQL.

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

Мы ранее уже упомянули о классе транзакции, но не остановились подробно на его семантике. Берсон определяет транзакцию как "единицу обмена и обработки информации между локальной и удаленной программами, которая отражает логически законченную операцию или результат" [21]. Это и есть определение нужной нам абстракции: объект-транзакция является агентом, ответственным за выполнение некоторого удаленного действия, а, следовательно, отчетливо отделяет само действие от механизма его реализации.

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

С внешней стороны можно выделить следующие операции, описывающие суть поведения в проектируемой системе:

attachOperation

dispatch

commit

rollback

status

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


Интересно отметить, что такое объектно- ориентированное видение транзакций полностью согласуется с принципами, принятыми в практике работы с базами данных. Дэйт определил, что "транзакция представляет собой последовательность операторов SQL (возможно, не только SQL), которые должны быть неразделимы в смысле произведения отката и управления параллельным доступом" [Date, C.[E 1987], c.32].

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

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

Выполнение транзакции несколько усложняется при работе с распределенными базами данных. Как реализовать протокол завершения транзакций при работе с локальной базой достаточно понятно, а что необходимо сделать при работе с данными, размещенными на нескольких серверах? Для этого используется так называемый двухфазный протокол завершения транзакций [23]. В этом случае агент, то есть объект класса Transaction, разделяет транзакцию на несколько фрагментов и раздает их для выполнения различным серверам. Это называется фазой подготовки. Когда все серверы сообщили о том, что готовы к завершению, центральный агент транзакции передает им всем команду commit. Это называется фазой завершения. Только при правильном завершении всех разделенных компонент транзакции основная транзакция считается завершенной. Если хотя бы на одном сервере выполнение операций будет неполным, мы откатим всю транзакцию.


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


Рис. 10-8. Транзакции.

Изложенное выше представление о классе транзакций показано на рис. 10-8. Мы видим здесь иерархию транзакций. Класс Transaction является базовым для всех транзакций и содержит в себе все ключевые аспекты поведения. Производные специализированные классы вносят в общее поведение свои особенности. Мы различаем, например, классы UpdateTransaction и QueryTransaccion, потому что их семантика очень различна: первый из них модифицирует данные на сервере баз данных, а второй - нет. Различая эти и другие типы транзакций, мы собираем в базовом классе наиболее общие характеристики, и пополняем при этом наш словарь.

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

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

public:

Transaction();

virtual ~Transaction();

virtual void setOperation(const UnboundedCollection<SQLStatement>&);

virtual int dispatch();

virtual void commit();

virtual void rollback();

virtual int status() const;

protected:

...

};

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

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


Механизм вывода информации на экран


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

class DisplayManager {


public:

DisplayManager();


~DisplayManager();


void clear();


void refresh();


void display(Sensor&);


void drawStaticItems(TemperatureScale, SpeedScale);


void displayTime(const char*);


void displayDate(const char*);


void displayTemperature(float, unsigned int id = 0);


void displayHumidity(float, unsigned int id = 0);


void displayPressure(float, unsigned int id = 0);


void displayWindChill(float, unsigned int id = 0);


void displayDewPoint(float, unsigned int id = 0);


void displayWindSpeed(float, unsigned int id = 0);


void displayWindDirection(unsigned int, unsigned int id = 0);


void displayHighLow(float, const char*, SensorName, unsigned int id = 0);


void setTemperatureScale(TemperatureScale);


void setSpeedScale(SpeedScale);

protected:


// ...


};

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

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

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



Метаклассы


Как было сказано, любой объект является экземпляром какого-либо класса. Что будет, если мы попробуем и с самими классами обращаться как с объектами? Для этого нам надо ответить на вопрос, что же такое класс класса? Ответ - это метакласс. Иными словами, метакласс - это класс, экземпляры которого суть классы. Метаклассы венчают объектную модель в чисто объектно-ориентированных языках. Соответственно, они есть в Smalltalk и CLOS, но не в C++.

Вот как Робсон мотивирует потребность в метаклассах: "классы доставляют программисту интерфейс для определения объектов. Если так, то желательно, чтобы и сами классы были объектами, так, чтобы ими можно было манипулировать, как всеми остальными описаниями" [49].

В языках типа Smalltalk первичное назначение метакласса - поддержка переменных класса (которые являются общими для всех экземпляров этого класса), операции инициализации переменных класса и создания единичного экземпляра метакласса [50]. По соглашению, метакласс Smalltalk обычно содержит примеры использования его классов. Например, как показано на рис. 3-11, мы могли бы задать переменную класса nextId для метакласса TelemetryData, чтобы вырабатывать идентифицирующие метки при создании каждого экземпляра TelemetryData. Аналогично, мы могли бы определить оператор порождения новых экземпляров класса, который изготавливал бы их, скажем, в некотором предварительно выделенном пуле памяти.

Хотя в C++ метаклассов нет, семантика его конструкторов и деструкторов служит целям, аналогичным тем, что вызвали к жизни метаклассы. C++ имеет средства поддержки и переменных класса, и операций метакласса. Конкретно, в C++ можно описать члены данных или функции класса как статические (static), что будет означать: этот элемент является общим для всех экземпляров класса. Статические члены класса в C++ эквивалентны переменным класса в Smalltalk. Статическая функция-член класса играет роль операций метакласса в Smalltalk.

Как мы уже отмечали, в CLOS аппарат метаклассов еще сильнее чем в Smalltalk.
Через него можно изменять саму семантику элементов: следование классов, обобщенные функции и методы. Главное преимущество - возможность экспериментировать с другими объектно-ориентированными парадигмами и создавать такие инструменты для разработчика, как броузеры классов и объектов.
 



Рис. 3-11. Метаклассы.

В CLOS есть предопределенный класс с именем standard-class, который является метаклассом для всех нетипизированных классов, определенных с помощью defclass. В этом метаклассе есть метод make-instance, который создает экземпляры. Кроме того, в нем определена вся техника работы со списком следования классов. Все это можно изменить.

Методы и обобщенные функции в CLOS тоже можно рассматривать как объекты. Так как они несколько отличаются от обычных объектов, то в совокупности объекты, соответствующие классам, методам и обобщенным функциям, называются метаобьектами. Каждый метод является экземпляром предопределенного класса standard-method, а каждая функция является экземпляром предопределенного класса standard-generic-function. Поскольку поведение этих предопределенных классов можно изменить, удается влиять на трактовку методов и обобщенных функций.

3.5. Взаимосвязь классов и объектов.


Метод


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


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

Генри Петроски (Henry Petroski)

Проектирование как человеческая деятельность

(То Engineer is Human)



Модели баз данных


Дэйт рассматривает базу данных как "вместилище хранимой информации. Она, как правило, одновременно является и интегрированной, и общедоступной. Под "интегрированностью" имеется в виду то, что базу данных можно представить как объединение нескольких отдельных файлов данных, избыточность информации в которых частично или полностью исключена... Под "общедоступностью" имеется в виду то, что информация, содержащаяся в базе, может одновременно использоваться сразу несколькими пользователями" [7]. При централизованном управлении базой данных можно "устранять несоответствия, устанавливать стандарты, накладывать ограничения на доступ к информации и поддерживать целостность базы данных" [8].

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

Между разработкой базы данных и созданием объектно-ориентированного приложения существует много параллелей. Проектирование баз данных часто рассматривается как процесс итеративного развития, в ходе которого надо принимать решения, касающиеся как программной логики, так и аппаратных аспектов [9]. ВTрковски и Кул указывают на то, что "Объекты, описывающие базу данных в терминах, которыми оперируют пользователи и разработчики, называются логическими. Объекты, отображающие физическое расположение данных в системе, называются физическими" [10]. Разработчики баз данных в процессе проектирования, напоминающем объектно-ориентированное, постоянно перескакивают от рассмотрения логических объектов к обсуждению физических аспектов их реализации. Кроме того, описание элементов базы данных очень напоминает перечисление ключевых абстракций объектно-ориентированного приложения.
Проектировщики баз данных часто используют для анализа так называемые диаграммы "сущность-связь" (entity-relationship diagrams). Диаграммы классов, как мы видели, могут быть организованы таким образом, что будут напрямую соответствовать диаграммам сущность-связь, но обладать при этом еще большей выразительностью.

Дэйт утверждает, что при проектировании любой базы данных нужно дать ответ на следующий вопрос: "Какие структуры данных и соответствующие им операторы должна поддерживать система?" [11]. Три различные модели баз данных, перечисленные ниже, дают три различных ответа на этот вопрос:

иерархическая;

сетевая;

реляционная.

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

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


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

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

Основными элементами реляционной базы данных являются "таблицы, в которых столбцы представляют собой предметы и их атрибуты, а строки описывают отдельные экземпляры предметов... Модель также подразумевает наличие операторов для генерации новых таблиц на базе старых: именно таким способом пользователи могут манипулировать данными и получать информацию из базы" [12].

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

Products

 

productId description
0081735 Resistor, 10 ае 1/4 watt
0081736 Resistor, 10 ае 1/4 watt
3891043 Capacitor, 100 pF
9074000 7400 1С quad NAND
9074001 74LS00 1С quad HAND
 
Мы видим таблицу с двумя столбцами, каждый из которых представляет определенный атрибут. В данном случае порядок, в котором расположены строки (столбцы), не важен; количество строк не ограничено, но каждая из них должна быть уникальной. Первый столбец, productID, является первичным ключом, то есть он может быть использован для однозначной идентификации детали.



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

Suppliers

 
SupplierID Company Address Telephone
00056 Interstate Supply 2222 Fannin, Amarillo, TX 806-555-0036
03107 Interstate Supply 3320 Scott, Santa Clara, CA 408-555-3600
78829 Universal Products 2171 Parfet Ct, Lakewood, CD 303-555-2405
 
supplierID - первичный ключ в том смысле, что им можно однозначно идентифицировать поставщика. Отметим, что все строки в этой таблице уникальны, однако у двух из них имя поставщика одинаково.

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

Prices

 
productID SupplierID Price
0081735 03107 $0.10
0081735 78829 $0.09
0156999 78829 $367.75
7775098 03107 $10.69
6889655 00056 $0.09
9074001 03107 $1.75
 
В этой таблице нет простого первичного ключа. Для однозначной идентификации строк мы должны использовать комбинацию ключей productID и supplierID. Ключ, образуемый из значений различных столбцов, называется составным. Заметьте, что мы не включили в эту таблицу названия деталей и поставщиков - это было бы излишним; данную информацию можно отыскать по значениям полей productID и supplierID в таблицах товаров и поставщиков. Поля productID и supplierID называются внешними ключами, так как они представляют первичные ключи других таблиц.

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


Рис. 10-5. Ассоциация с атрибутами.

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



Inventory

 
ProductId Quantity
0081735 1000
0097890 2000
0156999 34
7775098 46
6889655 1
9074001 92
 
Эта таблица показывает, что объектно- ориентированное представление данных системы может отличаться от их представления в базе данных. В схеме, представленной на рис. 10-4, quantity является атрибутом класса ProductRecord, a здесь, в целях обеспечения быстродействия, мы решили разместить quantity в отдельной таблице. Дело в том, что, как правило, описание товара (description) модифицируется очень редко, в то время как количество (quantity) меняется постоянно по мере того, как со склада отгружаются товары и на склад прибывают новые грузы. Для оптимизации доступа к количеству товара разумнее выделить его в отдельную таблицу.

Данная деталь реализации системы, как следует из рис. 10-4, не будет видна клиентам нашего приложения. Класс ProductRecord создает иллюзию того, что quantity является его частью.

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

Для достижения этой цели (важной, но не единственной [13]) была разработана специальная теория нормализации. Нормализация есть свойство таблицы; если таблица удовлетворяет определенным условиям, то мы говорим что она имеет нормальную форму. Существует несколько уровней нормализации, каждый из которых базируется на предыдущем [14]:
 
• Первая нормальная форма (1NF) Каждый атрибут представляет собой атомарное значение (неразложимые атрибуты).
• Вторая нормальная форма (2NF) Таблица приведена в 1NF, и при этом каждый атрибут целиком и полностью зависит от ключа (функционально независимые атрибуты).
• Третья нормальная форма (3NF) Таблица приведена в 2NF, и при этом ни один из атрибутов не предоставляет никаких сведений о другом атрибуте (взаимно независимые атрибуты).
Таблица в третьей нормальной форме "содержит свойства ключа, весь ключ и ничего кроме ключа" [15].

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

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


Модели и ракурсы


В главе 3 мы объяснили, что такое классы и объекты, а также какова связь между ними. Как показано на рис. 5-1, при принятии решений в анализе и проектировании полезно рассмотреть взаимодействие классов и объектов в двух измерениях: логическом/физическом и статическом/динамическом. Оба этих аспекта необходимы для определения структуры и поведения объектной системы.

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

Рассмотрим для примера систему, включающую в себя несколько сотен классов; эти классы образуют часть модели. Невозможно, а на самом деле и не нужно представлять все классы и их связи на единственной диаграмме. Вместо этого мы можем описать модель в нескольких диаграммах классов, каждая из которых представляет только один ее ракурс. Одна диаграмма может показывать структуру наследования некоторых ключевых классов; другая - транзитивное замыкание множества всех классов, используемых конкретным классом. Когда модель "устоится" (придет в устойчивое состояние), все такие диаграммы становятся семантически согласованными друг с другом и с моделью. Например, если по данному сценарию (который мы описываем на диаграмме объектов), объект A посылает сообщение M объекту B, то M должно быть прямо или косвенно определено в классе B. В соответствующей диаграмме классов должна быть надлежащая связь между классами A и B, так, чтобы экземпляры класса A действительно могли посылать сообщение M.

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

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



Модели взаимодействий


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

Анализ показывает, что существует определенный набор основных механизмов, необходимый для библиотеки базовых классов:

семантика времени и памяти;

управление хранением данных;

обработка исключений;

идиомы итерации;

синхронизация при многопоточности.

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

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

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

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

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

9.2. Проектирование


Модульная архитектура


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

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

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

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

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

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



Рис. 12-9. Диаграмма модулей верхнего уровня системы управления движением.


Модульность


Понятие модульности. По мнению Майерса "Разделение программы на модули до некоторой степени позволяет уменьшить ее сложность... Однако гораздо важнее тот факт, что внутри модульной программы создаются множества хорошо определенных и документированных интерфейсов. Эти интерфейсы неоценимы для исчерпывающего понимания программы в целом" [57]. В некоторых языках программирования, например в Smalltalk, модулей нет, и классы составляют единственную физическую основу декомпозиции. В других языках, включая Object Pascal, C++, Ada, CLOS, модуль - это самостоятельная языковая конструкция. В этих языках классы и объекты составляют логическую структуру системы, они помещаются в модули, образующие физическую структуру системы. Это свойство становится особенно полезным, когда система состоит из многих сотен классов.

Согласно Барбаре Лисков "модульность - это разделение программы на фрагменты, которые компилируются по отдельности, но могут устанавливать связи с другими модулями". Мы будем пользоваться определением Парнаса: "Связи между модулями - это их представления друг о друге" [58]. В большинстве языков, поддерживающих принцип модульности как самостоятельную концепцию, интерфейс модуля отделен от его реализации. Таким образом, модульность и инкапсуляция ходят рука об руку. В разных языках программирования модульность поддерживается по-разному. Например, в C++ модулями являются раздельно компилируемые файлы. Для C/C++ традиционным является помещение интерфейсной части модулей в отдельные файлы с расширением .h (так называемые файлы-заголовки). Реализация, то есть текст модуля, хранится в файлах с расширением .с (в программах на C++ часто используются расширения .ее, .ср и .срр). Связь между файлами объявляется директивой макропроцессора #include. Такой подход строится исключительно на соглашении и не является строгим требованием самого языка. В языке Object Pascal принцип модульности формализован несколько строже. В этом языке определен особый синтаксис для интерфейсной части и реализации модуля (unit).
Язык Ada идет еще на шаг дальше: модуль (называемый package) также имеет две части - спецификацию и тело. Но, в отличие от Object Pascal, допускается раздельное определение связей с модулями для спецификации и тела пакета. Таким образом, допускается, чтобы тело модуля имело связи с модулями, невидимыми для его спецификации.

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

Модули выполняют роль физических контейнеров, в которые помещаются определения классов и объектов при логическом проектировании системы. Такая же ситуация возникает у проектировщиков бортовых компьютеров. Логика электронного оборудования может быть построена на основе элементарных схем типа НЕ, И-НЕ, ИЛИ-НЕ, но можно объединить такие схемы в стандартные интегральные схемы (модули), например, серий 7400, 7402 или 7404.

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


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



Модульность позволяет хранить абстракции раздельно.

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

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


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

Таким образом, программист должен находить баланс между двумя противоположными тенденциями: стремлением скрыть информацию и необходимостью обеспечения видимости тех или иных абстракций в нескольких модулях. Парнас, Клеменс и Вейс предложили следующее правило: "Особенности системы, подверженные изменениям, следует скрывать в отдельных модулях; в качестве межмодульных можно использовать только те элементы, вероятность изменения которых мала. Все структуры данных должны быть обособлены в модуле; доступ к ним будет возможен для всех процедур этого модуля и закрыт для всех других. Доступ к данным из модуля должен осуществляться только через процедуры данного модуля" [62]. Другими словами, следует стремиться построить модули так, чтобы объединить логически связанные абстракции и минимизировать взаимные связи между модулями. Исходя из этого, приведем определение модульности:

Модульность - это свойство системы, которая была разложена на внутренне связные, но слабо связанные между собой модули.

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

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


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

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

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


Два этих процесса выполняются итеративно.

Примеры модульности. Посмотрим, как реализуется модульность в гидропонной огородной системе. Допустим, вместо закупки специализированного аппаратного обеспечения, решено использовать стандартную рабочую станцию с графическим интерфейсом пользователя GUI (Graphical User Interface). С помощью рабочей станции оператор может формировать новые планы выращивания, модифицировать имеющиеся планы и наблюдать за их исполнением. Так как абстракция плана выращивания - одна из ключевых, создадим модуль, содержащий все, относящееся к плану выращивания. На C++ нам понадобится примерно такой файл-заголовок (пусть он называется gplan.h).

// gplan.h

#ifndef _GPLAN_H #define _GPLAN_H 1
#include "gtypes.h"
#include "except.h"
#include "actions.h"
class GrowingPlan ...
class FruitGrowingPlan ...
class GrainGrowingPlan ...

#endif

Здесь мы импортируем в файл три других заголовочных файла с определением интерфейсов, на которые будем ссылаться: gtypes.h, except .h и actions.h. Собственно код классов мы поместим в модуль реализации, в файл с именем gplan.cpp.

Мы могли бы также собрать в один модуль все программы, относящиеся к окнам диалога, специфичным для данного приложения. Этот модуль наверняка будет зависеть от классов, объявленных в gplan.h, и от других файлов-заголовков с описанием классов GUI.

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


Наследование


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

class Time...

struct ElectricalData { Time timeStamp;
int id;
float fuelCell1Voltage, fuelCell2Voltage;
float fuelCell1Amperes, fuelCell2Amperes;
float currentPower;

};

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



Дочерний класс может унаследовать структуру и поведение родительских классов.

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

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

class TelemetryData {

public:

TelemetryData();

virtual ~TelemetryData();

virtual void transmit();

Time currentTime() const;

protected:

int id;

Time timeStamp;

};

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

Теперь разберемся с ElectricalData:

class ElectricalData : public TelemetryData {

public:

ElectricalData(float v1, float v2, float a1, float a2);

virtual ~ElectricalData();

virtual void.transmit();

float currentPower() const;

protected:

float fuelCell1Voltage, fuelCell2Voltage;

float fuelCell1Amperes, fuelCell2Amperes;

};

Этот класс - наследник класса TelemetryData, но исходная структура дополнена (четырьмя новыми элементами), а поведение - переопределено (изменена функция transmit). Кроме того, добавлена функция currentPower.

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


Так, TelemetryData. является суперклассом для ElectricalData. Производный от суперкласса класс называется подклассом. Это означает, что наследование устанавливает между классами иерархию общего и частного. В этом смысле ElectricalData является более специализированным классом более общего TelemetryData. Мы уже видели, что в подклассе структура и поведение исходного суперкласса дополняются и переопределяются. Наличие механизма наследования отличает объектно-ориентированные языки от объектных.

Подкласс обычно расширяет или ограничивает существующую структуру и поведение своего суперкласса. Например, подкласс GuardedQueue может добавлять к поведению суперкласса Queue операции, которые защищают состояние очереди от одновременного изменения несколькими независимыми потоками. Обратный пример: подкласс UnselectableDisplayItem может ограничить поведение своего суперкласса DisplayItem, запретив выделение объекта на экране. Часто подклассы делают и то, и другое.

Отношения одиночного наследования от суперкласса TelemetryData показаны на рис. 3-5. Стрелки обозначают отношения общего к частному. В частности, Cameradata - это разновидность класса SensorData, который в свою очередь является разновидностью класса TelemetryData. Такой же тип иерархии характерен для семантических сетей, которые часто используются специалистами по распознаванию образов и искусственному интеллекту для организации баз знаний [25]. В главе 4 мы покажем, что правильная организация иерархии абстракций - это вопрос логической классификации.
 


Рис. 3-5. Одиночное наследование.

Можно ожидать, что для некоторых классов на рис. 3-5 будут созданы экземпляры, а для других - нет. Наиболее вероятно образование объектов самых специализированных классов ElectricalData и SpectrometerData (такие классы называют конкретными классами, или листьями иерархического дерева). Образование объектов из классов, занимающих промежуточное положение (SensorData или тем более TelemetryData), менее вероятно. Классы, экземпляры которых не создаются, называются абстрактными.


Ожидается, что подклассы абстрактных классов доопределят их до жизнеспособной абстракции, наполняя класс содержанием. В языке Smalltalk разработчик может заставить подкласс переопределить метод, помещая в реализацию метода суперкласса вызов метода SubclassResponsibility. Если метод не переопределен, то при попытке выполнить его генерируется ошибка. Аналогично, в C++ существует возможность объявлять функции чисто виртуальными. Если они не переопределены, экземпляр такого класса невозможно создать.

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

У класса обычно бывает два вида клиентов [26]:

экземпляры;

подклассы.

Часто полезно иметь для них разные интерфейсы [27]. В частности, мы хотим показать только внешне видимое поведение для клиентов-экземпляров, но нам нужно открыть служебные функции и представления клиентам-подклассам. Этим объясняется наличие открытой, защищенной и закрытой частей описания класса в языке C++: разработчик может четко разделить, какие элементы класса доступны Для экземпляров, а какие для подклассов. В языке Smalltalk степень такого разделения меньше: данные видимы для подклассов, но не для экземпляров, а методы общедоступны (можно ввести закрытые методы, но язык не обеспечивает их защиту).

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



Наследование подразумевает, что подклассы повторяют структуры их суперклассов. В предыдущем примере экземпляры класса ElectricalData содержат элементы структуры суперкласса (id и timeStamp) и более специализированные элементы (fuelCell1Voltage, fuelCell2Voltage, fuelCell1Amperes, fuelCell2Amperes) [Некоторые языки объектно-ориентированного программирования, главным образом экспериментальные, позволяют подклассу сокращать структуру его суперкласса].

Поведение суперклассов также наследуется. Применительно к объектам класса ElectricalData можно использовать операции currentTime (унаследована от суперкласса), currentPower (определена в классе) и transmit (переопределена в подклассе). В большинстве языков допускается не только наследование методов суперкласса, но также добавление новых и переопределение существующих методов. В Smalltalk любой метод суперкласса можно переопределить в подклассе.

В C++ степень контроля за этим несколько выше. Функция, объявленная виртуальной (функция transmit в предыдущем примере), может быть в подклассе переопределена, а остальные (currentTime) - нет.

Одиночный полиморфизм. Пусть функция transmit класса TelemetryData реализована следующим образом:

void TelemetryData::transmit()

{

// передать id

// передать timeStamp

};

В классе ElectricalData та же функция переопределена:

void ElectricalData::transmit()

{

TelemetryData::transmit();

// передать напряжение

// передать силу тока

};

Эта функция сначала вызывает одноименную функцию суперкласса с помощью ее явно квалифицированного имени TelemetryData::transmit(). Та передаст заголовок пакета (id и timeStamp), после чего в подклассе передаются его собственные данные.

Определим теперь экземпляры двух описанных выше классов:

TelemetryData telemetry;

ElectricalData electrical(5.0, -5.0, 3.0, 7.0);

Теперь определим свободную процедуру:

void transmitFreshData (TelemetryData& d, const Time& t)

{

if (d.currentTime() >= t)

d.transmit();

);

Что произойдет, если выполнить следующие два оператора?



transmitFreshData(telemetry, Time(60));

transmitFreshData(electrical, Time(120));

В первом операторе будет передан уже известный нам заголовок. Во втором будет передан он же, плюс четыре числа в формате с плавающей точкой, содержащие результаты измерений электрических параметров. Почему это так? Ведь функция transmitFreshData ничего не знает о классе объекта, она просто выполняет d.transmit()! Это был пример полиморфизма. Переменная d может обозначать объекты разных классов. У этих классов есть общий суперкласс и они, хотя и по разному, могут реагировать на одно и то же сообщение, одинаково понимая его смысл.

Карделли и Вегнер заметили, что "традиционные типизированные языки типа Pascal основаны на той идее, что функции и процедуры, а следовательно, и операнды должны иметь определенный тип. Это свойство называется мономорфизмом, то есть каждая переменная и каждое значение относятся к одному определенному типу. В противоположность мономорфизму полиморфизм допускает отнесение значений и переменных к нескольким типам" [28]. Впервые идею полиморфизма ad hoc описал Страчи [29], имея в виду возможность переопределять смысл символов, таких, как "+", сообразно потребности. В современном программировании мы называем это перегрузкой. Например, в C++ можно определить несколько функций с одним и тем же именем, и они будут автоматически различаться по количеству и типам своих аргументов. Совокупность этих признаков называется сигнатурой функции; в языке Ada к этому списку добавляется тип возвращаемого значения. Страчи говорил также о параметрическом полиморфизме, который мы сейчас называем просто полиморфизмом.

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


На языке Pascal процедура TransmitFreshData может быть написана следующим образом:

const

Electrical = 1;

Propulsion = 2;

Spectrometer = 3;

Procedure Transmit_Presh_Data(TheData: Data; The_Time: Time);

begin

if (The_Data.Current_Time >= The_Time)

then

case TheData.Kind of

Electrical: Transmit_Electrical_Data(The_Data);

Propulsion: Transmit_Propulsion_Data(The_Data);

end;

end;

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

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

Наследование без полиморфизма возможно, но не очень полезно. Это видно на примере Ada, где можно объявлять производные типы, но из-за мономорфизма языка операции жестко задаются на стадии компиляции.

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

Наследование и типизация. Рассмотрим еще раз переопределение функции transmit:

void ElectricalData::transmit()

{

TelemetryData::transmit();

// передать напряжение

// передать силу тока

};

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


В Smalltalk метод вышестоящего класса вызывают с помощью ключевого слова super, при этом вызывающий может указывать на самого себя с помощью ключевого слова self. В C++ метод любого достижимого вышестоящего класса можно вызывать, добавляя имя класса в качестве префикса, формируя квалифицированное имя метода (как TelemetryData::transmit() в нашем примере). Вызывающий объект может ссылаться на себя с помощью предопределенного указателя this.

На практике метод суперкласса вызывается до или после дополнительных действий. Метод подкласса уточняет или дополняет поведение суперкласса [В CLOS эти различные роли метода выражаются явно с помощью дополнительных квалификаторов :before, :after или :around. Метод без дополнительного квалификатора считается первичным и выполняет основную работу, обеспечивающую требуемое поведение. Before-метод вызывается до первичного, after-метод - после первичного, around-метод действует как оболочка вокруг первичного метода, которая вызывается изнутри этого метода функцией call-next-method].

Все подклассы на рис. 3-5 являются также подтипами вышестоящего класса. В частности, ElectricalData является подтипом TelemetryData. Система типов, развивающаяся параллельно наследованию, обычна для объектно-ориентированных языков с сильной типизацией, включая C++. Для Smalltalk, который едва ли вообще можно считать типизированным, типы не имеют значения.
 
Поиск метода

Рассмотрим иерархию (рис. 3-6), в которой имеется базовый класс и три подкласса с именами circle, Triangle и Rectangle. Для класса Rectangle определен в свою очередь подкласс SolidRectangle. Предположим, что в классе DisplayItem определена переменная экземпляра theCenter (задающая координаты центра изображения), а также следующие операции:

draw - нарисовать изображение;

move - передвинуть изображение;

location - вернуть координаты изображения.

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


Рис. 3-6. Диаграмма класса DisplayItem.

Класс Circle имеет переменную theRadius и соответственно операции для установки (set) и чтения значения этой переменной. Для этого класса операция draw формирует изображение окружности заданного радиуса с центром в точке theCenter. В классе Rectangle есть переменные theHeight и theWidth и соответствующие операции установки и чтения их значений. Операция draw в данном случае формирует изображение прямоугольника заданной высоты и ширины с центром в заданной точке theCenter. Подкласс SolidRectangle наследует все особенности класса Rectangle, но операция draw в этом подклассе переопределена. Сначала вызывается draw вышестоящего класса, а затем полученный контур заполняется цветом.

Теперь рассмотрим следующий фрагмент программы:

DisplayItem* items[10]; 
for (unsigned index = 0; index < 10; index++) items[index]->draw();

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

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

получатель ищет селектор сообщения в словаре методов своего класса;

если метод найден, то запускается его код;

если нет, поиск производится в словаре методов суперкласса.

Таким образом, поиск распространяется вверх по иерархии и заканчивается на классе object, который является "предком" всех классов. Если метод не найден и там, посылается сообщение doesNotUnderstand, то есть, генерируется ошибка.

Главным действующим лицом в этом алгоритме является словарь методов. Он формируется при создании класса и, являясь частью его реализации, скрыт от клиентов. Вызов метода в Smalltalk требует примерно в 1.5 раза больше времени, чем вызов простой подпрограммы. В коммерческих версиях Smalltalk вызов методов ускорен на 20-30% за счет кеширования доступа к словарю [31].

Операция draw в подклассе solidRectangle представляет собой особый случай. Мы уже отмечали, что вначале вызывается одноименный метод суперкласса Rectangle. В Smalltalk для вызова метода суперкласса используется ключевое слово super. Поиск метода super draw начинается сразу с суперкласса.

Исследования Дейча дают основание полагать, что полиморфизм в 85% случаев не нужен, так что вызов метода часто можно свести к обычному вызову процедуры [32]. Дафф замечает, что в таких ситуациях программист часто делает неявные предположения, которые бы позволили раннее связывание [33]. К сожалению, в нетипизированных языках у него нет способа сообщить об этом компилятору.

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

В C++ операции для позднего связывания объявляются виртуальными (virtual), а все остальные обрабатываются компилятором как обычные вызовы подпрограмм. В нашем примере draw - виртуальная функция, a location - обычная. Есть еще одно средство, используя которое можно выиграть в скорости. Невиртуальные методы могут быть объявлены подставляемыми (inline), при этом соответствующая подпрограмма не вызывается, а явно включается в код на манер макроподстановки.

Для управления виртуальными функциями в C++ используется концепция vtable (виртуальных таблиц), которые формируются для каждого объекта при его создании (то есть когда класс объекта уже известен). Такая таблица содержит список указателей на виртуальные функции. Например, при создании объекта класса Rectangle виртуальная таблица будет содержать запись для виртуальной функции draw, содержащую указатель на ближайшую в иерархии реализацию функции draw. Если в классе DisplayItem есть виртуальная функция rotate, которая в классе Rectangle не переопределена, то соответствующий указатель для rotate останется связан с классом DisplayItem. Во время исполнения программы происходит косвенное обращение через соответствующий указатель и сразу выполняется нужный код без всякого поиска [34].

Операция draw в классе SolidRectangle представляет собой особый случай и в языке C++. Чтобы вызвать метод draw из суперкласса, применяется специальный префикс, указывающий на место определения функции. Это выглядит следующим образом:

Rectangle::Draw(); 

Исследование Страуструпа показало, что вызов виртуальной функции по эффективности мало уступает вызову обычной функции [35]. Для одиночного наследования вызов виртуальной функции требует дополнительно выполнения трех-четырех операций доступа к памяти по отношению к обычному вызову; при множественном наследовании число таких дополнительных операций составляет пять или шесть.

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

Определяется тип аргументов. 

Вычисляется множество допустимых методов. 

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

Выполняются вызовы всех методов с квалификатором : before. 

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

Выполняются вызовы всех методов с квалификаторами : after. 

Возвращается значение первичного метода [36]. 

В CLOS есть протокол для метаобъектов, который позволяет переопределять в том числе и этот алгоритм. На практике, однако, мало кто заходит так далеко. Как справедливо отметили Винстон и Хорн: "Алгоритмы, используемые в языке CLOS, сложны, и даже кудесники программирования стараются не вникать в их детали, так же как физики предпочитают иметь дело с механикой Ньютона, а не с квантовой механикой" [37]. 
<


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

TelenetryData telemetry;

ElectrycalData electrical(5.0, -5.0, 3.0, 7.0);

Следующий оператор присваивания правомочен:

telemetry = electrical; //electrical - это подтип telemetry

Хотя он формально правилен, он опасен: любые дополнения в состоянии подкласса по сравнению с состоянием суперкласса просто срезаются. Таким образом, дополнительные четыре параметра, определенные в подклассе electrical, будут потеряны при копировании, поскольку их просто некуда записать в объекте telemetry клacca TelemetryData.

Следующий оператор неправилен:

electrical = telemetry; //неправильно: telemetry - это не подтип electrical

Можно сделать заключение, что присвоение объекту y значения объекта x допустимо, если тип объекта x совпадает с типом объекта y или является его подтипом.

В большинстве строго типизированных языков программирования допускается преобразование значений из одного типа в другой, но только в тех случаях, когда между двумя типами существует отношение класс/подкласс. В языке C++ есть оператор явного преобразования, называемый приведением типов. Как правило, такие преобразования используются по отношению к объекту специализированного класса, чтобы присвоить его значение объекту более общего класса. Такое приведение типов считается безопасным, поскольку во время компиляции осуществляется семантический контроль. Иногда необходимы операции приведения объектов более общего класса к специализированным классам. Эти операции не являются надежными с точки зрения строгой типизации, так как во время выполнения программы может возникнуть несоответствие (несовместимость) приводимого объекта с новым типом [Новейшие усовершенствования C++, направленные на динамическое определение типа, смягчили эту проблему]. Однако такие преобразования достаточно часто используются в тех случаях, когда программист хорошо представляет себе все типы объектов.


Например, если нет параметризованных типов, часто создаются классы set или bag, представляющие собой наборы произвольных объектов. Их определяют для некоторого базового класса (это гораздо безопаснее, чем использовать идиому void*, как мы делали, определяя класс Queue). Итерационные операции, определенные для такого класса, умеют возвращать только объекты этого базового класса. Внутри конкретного приложения разработчик может использовать этот класс, создавая объекты только какого-то специализированного подкласса, и, зная, что именно он собирается помещать в этот класс, может написать соответствующий преобразователь. Но вся эта стройная конструкция рухнет во время выполнения, если в наборе встретится какой-либо объект неожиданного типа.

Большинство сильно типизированных языков позволяют приложениям оптимизировать технику вызова методов, зачастую сводя пересылку сообщения к простому вызову процедуры. Если, как в C++, иерархия типов совпадает с иерархией классов, такая оптимизация очевидна. Но у нее есть недостатки. Изменение структуры или поведения какого-нибудь суперкласса может поставить вне закона его подклассы. Вот что об этом пишет Микаллеф: "Если правила образования типов основаны на наследовании и мы переделываем какой-нибудь класс так, что меняется его положение в иерархии наследования, клиенты этого класса могут оказаться вне закона с точки зрения типов, несмотря на то, что внешний интерфейс класса остается прежним" [38].

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


В Smalltalk и CLOS эти два аспекта неразделимы. C++ более гибок. В частности, при определении класса его суперкласс можно объявить public (как ElectricalData в нашем примере). В этом случае подкласс считается также и подтипом, то есть обязуется выполнять все обязательства суперкласса, в частности обеспечивая совместимое с суперклассом подмножество интерфейса и обладая неразличимым с точки зрения клиентов суперкласса поведением. Но если при определении класса объявить его суперкласс как private, это будет означать, что, наследуя структуру и поведение суперкласса, подкласс уже не будет его подтипом [Мы можем также объявить суперкласс защищенным, что даст ту же семантику, что и в случае закрытого суперкласса, но открытые и защищенные элементы такого суперкласса будут доступны подклассам]. Это означает, что открытые и защищенные члены суперкласса станут закрытыми членами подкласса, и следовательно они будут недоступны подклассам более низкого уровня. Кроме того, тот факт, что подкласс не будет подтипом, означает, что класс и суперкласс обладают несовместимыми (вообще говоря) интерфейсами с точки зрения клиента. Определим новый класс:

class InternalElectricalData: private ElectricalData {

public:

InternalElectricalData(float v1, float v2, float a1, float a2);

virtual ~InternalElectricalData();

ElectricalData::currentPower;

};

Здесь суперкласс ElectricalData объявлен закрытым. Следовательно, его методы, такие, например, как transmit, недоступны клиентам. Поскольку класс InternalElectricalData не является подтипом ElectricalData, мы уже не сможем присваивать экземпляры подкласса объектам суперкласса, как в случае объявления суперкласса в качестве открытого. Отметим, что функция currentPower сделана видимой путем ее явной квалификации. Иначе она осталась бы закрытой. Как можно было ожидать, правила C++ запрещают делать унаследованный элемент в подклассе "более открытым", чем в суперклассе. Так, член timeStamp, объявленный в классе TelemetryData защищенным, не может быть сделан в подклассе открытым путем явного упоминания (как это было сделано для функции currentpower).



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

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

Множественное наследование. Мы рассмотрели вопросы, связанные с одиночным наследованием, то есть, когда подкласс имеет ровно один суперкласс. Однако, как указали Влиссидес и Линтон: "одиночное наследование при всей своей полезности часто заставляет программиста выбирать между двумя равно привлекательными классами. Это ограничивает возможность повторного использования предопределенных классов и заставляет дублировать уже имеющиеся коды. Например, нельзя унаследовать графический элемент, который был бы одновременно окружностью и картинкой; приходится наследовать что-то одно и добавлять необходимое от второго" [40].

Множественное наследование прямо поддерживается в языках C++ и CLOS, а также, до некоторой степени, в Smalltalk. Необходимость множественного наследования в OOP остается предметом горячих споров. По нашему опыту, множественное наследование - как парашют: как правило, он не нужен, но, когда вдруг он понадобится, будет жаль, если его не окажется под рукой.

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

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


Другой аспект - способность имущества приносить дивиденды; это общее свойство банковских счетов и ценных бумаг.

Очевидно, одиночное наследование в данном случае не отражает реальности, так что придется прибегнуть к множественному [В действительности, это - "лакмусовая бумажка" для множественного наследования. Если мы составим структуру классов, в которой конечные классы (листья) могут быть сгруппированы в множества по разным ортогональным признакам (как в нашем примере, где такими признаками были способность приносить дивиденды и возможность страховки) и эти множества перекрываются, то это служит признаком невозможности обойтись одной структурой наследования, в которой бы существовали какие-то промежуточные классы с нужным поведением. Мы можем исправить ситуацию, используя множественное наследование, чтобы соединить два нужных поведения там, где это необходимо]. Получившаяся структура классов показана на рис. 3-7. На нем класс Security (ценные бумаги) наследует одновременно от классов InterestBearingItem (источник дивидендов) и Asset (имущество). Сходным образом, BankAccount (банковский счет) наследует сразу от трех классов: InsurableItem (страхуемое) и уже известным Asset и InterestBearingItem.

Вот как это выражается на C++. Сначала базовые классы:

class Asset ...
class InsurableItem ...
class InterestBearingItem ...

Теперь промежуточные классы; каждый наследует от нескольких суперклассов:

class BankAccount: public Asset, public InsurableItem, public InterestBearingItem ...
class RealEstate: public Asset, public InsurableItem ...
class Security: public Asset, public InterestBearingItem ...

Наконец, листья:

class SavingsAccount: public BankAccount ...
class CheckingAccount: public BankAccount ...
class Stock: public Security ...
class Bond: public Security ...



Рис. 3-7. Множественное наследование.

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



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

Борются с этим конфликтом тремя способами. Во-первых, можно считать конфликт имен ошибкой и отвергать его при компиляции (так делают Smalltalk и Eiffel, хотя в Eiffel конфликт можно разрешить, исправив имя). Во-вторых, можно считать, что одинаковые имена означают одинаковый атрибут (так делает CLOS). В третьих, для устранения конфликта разрешается добавить к именам префиксы, указывающие имена классов, откуда они пришли. Такой подход принят в C++ [В C++ конфликт имен элементов подкласса может быть разрешен полной квалификацией имени члена класса. Функции-члены с одинаковыми именами и сигнатурами семантическими считаются идентичными].

О второй проблеме, повторном наследовании, Мейер пишет следующее: "Одно тонкое затруднение при использовании множественного наследования встречается, когда один класс является наследником другого по нескольким линиям. Если в языке разрешено множественное наследование, рано или поздно кто-нибудь напишет класс D, который наследует от B и C, которые, в свою очередь, наследуют от A. Эта ситуация называется повторным наследованием, и с ней нужно корректно обращаться" [41]. Рассмотрим следующий класс:

class MutualFund: public Stock, public Bond ...

который дважды наследует от класса security.

Проблема повторного наследования решается тремя способами. Во-первых, можно его запретить, отслеживая при компиляции. Так сделано в языках Smalltalk и Eiffel (но в Eiffel, опять-таки допускается переименование для устранения неопределенности).


Во-вторых, можно явно развести две копии унаследованного элемента, добавляя к именам префиксы в виде имени класса-источника (это один из подходов, принятых в C++). В-третьих, можно рассматривать множественные ссылки на один и тот же класс, как обозначающие один и тот же класс. Так поступают в C++, где повторяющийся суперкласс определяется как виртуальный базовый класс. Виртуальный базовый класс появляется, когда какой-либо подкласс именует другой класс своим суперклассом и отмечает этот суперкласс как виртуальный, чтобы показать, что это - общий (shared) класс. Аналогично, в языке CLOS повторно наследуемые классы "обобществляются" с использованием механизма, называемого список следования классов. Этот список заводят для каждого нового класса, помещая в него сам этот класс и все его суперклассы без повторений на основе следующих правил:

класс всегда предшествует своему суперклассу;

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

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

При множественном наследовании часто используется прием создания примесей (mixin). Идея примесей происходит из языка Flavors: можно комбинировать (смешивать) небольшие классы, чтобы строить классы с более сложным поведением. Хендлер пишет об этом так: "примесь синтаксически ничем не отличается от класса, но назначение их разное. Примесь не предназначена для порождения самостоятельно используемых экземпляров - она смешивается с другими классами" [44]. На рис. 3-7 классы InsurableItem и interestBearingItem - это примеси.


Ни один из них не может существовать сам по себе, они используются для придания смысла другим классам [Для языка CLOS при обогащении поведения существующих первичных методов обычной практикой является строить примесь, используя только :before- и :after-методы]. Таким образом, примесь - это класс, выражающий не поведение, а одну какую-то хорошо определенную повадку, которую можно привить другим классам через наследование. При этом повадка эта обычно ортогональна собственному поведению наследующего ее класса. Классы, сконструированные целиком из примесей, называют агрегатными.

Множественный полиморфизм. Вернемся к одной из функций-членов класса DisplayItem:

virtual void draw();

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

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

В CLOS есть так называемые мультиметоды. Они полиморфны, то есть их смысл зависит от множества параметров (например, от графического режима и от объекта). В C++ мультиметодов нет, поэтому там используется идиома так называемый двойной диспетчеризации.

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

virtual void draw(DisplayDevice&);

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


Необходимость разных точек зрения


Невозможно охватить все тонкие детали сложной программной системы одним взглядом. Как отмечают Клейн и Джингрич: "Необходимо понять как функциональные, так и структурные свойства объектов. Следует уяснить также таксономическую структуру классов объектов, используемые механизмы наследования, индивидуальное поведение объектов и динамическое поведение системы в целом. Эта задача в чем-то аналогична показу футбольного или теннисного матча, когда для вразумительной передачи происходящего действия требуется несколько камер, расположенных в разных углах спортивной площадки. Каждая камера передает свой аспект игры, недоступный другим камерам" [4].

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

Наша система обозначений богата деталями, но это не означает, что в каждом проекте необходимо использовать все ее аспекты. На практике для описания большей части итогов анализа и проектирования достаточно некоторого малого подмножества этой системы; один наш коллега называет такие облегченные обозначения "нотацией Booch Lite" (сокращенная нотация Буча). В процессе знакомства с нашей системой обозначений мы четко выделим это подмножество. Зачем же тогда беспокоиться о деталях системы обозначений, не вошедших в минимальное подмножество? В основном они нужны, чтобы выражать некоторые важные тактические решения; а некоторые из них предназначены для создания программных инструментов CASE.
Мы будем называть их дополнениями.

Как отмечает Вайнберг: "В некоторых областях (таких, как архитектура), в процессе проектирования графический план чаще всего представлен в виде общих набросков, а строго детализированные описания, пока не окончена творческая часть становления проекта, используются редко" [5]. Следует помнить, что обозначения - только средство выразить итоги размышлений над архитектурой и поведением системы, а никак не самоцель. Надо пользоваться исключительно теми элементами обозначений, которые необходимы, и не более того. Также, как опасно ставить слишком жесткие требования, нехорошо чересчур подробно описывать решение задачи. Например, на чертеже архитектор может показать расположение выключателя света в комнате, но его точное место не будет определяться, пока заказчик и прораб не произведут осмотр уже сооруженного здания для уточнения деталей отделки. Глупо было бы заранее указывать на чертеже трехмерные координаты этого выключателя (конечно, если это не является функционально важной деталью для заказчика: может быть, все члены его семьи имеют рост намного выше или ниже среднего). Если разработчики большой программной системы - высококвалифицированные специалисты и уже сработались друг с другом, им будет достаточно и грубых набросков, хотя и в этом случае они должны оставить свое творческое наследие эксплуатационщикам в удобоваримом виде. Если же разработчики неопытны, отделены друг от друга во времени или пространстве или если так установлено контрактом, то на протяжении всего процесса проектирования необходимы более детальные эскизы проекта. Система обозначений, которую мы представляем в этой главе, учитывает эти ситуации.



 Рис. 5-1. Объектные модели.

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


Например, в Smalltalk не бывает свободных подпрограмм, следовательно, в проект нет смысла закладывать утилиты классов. Аналогично, C++ не поддерживает метаклассы, следовательно, этот элемент должен быть исключен. Но нет ничего предосудительного в подгонке обозначений под конструкции выбранного языка. Например, в CLOS операции можно снабдить специальными пометками, чтобы определить методы :before, :after и :around. Подобным же образом, в системе автоматического проектирования для C++ вместо спецификаций класса можно пользоваться прямо заголовочными файлами.

Цель этой главы - определить синтаксис и семантику наших обозначений для объектно-ориентированного анализа и проектирования. В качестве примера мы будем использовать гидропонную теплицу, которая рассматривалась в главе 2. В настоящей главе не обсуждается, как, собственно, были получены представленные в примерах решения: это задача главы 6. В главе 7 обсуждаются практические аспекты этого процесса, а в главах 8-12 система обозначений демонстрируется в деле на серии примеров разработки приложений.


Объектная модель


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

Объектно-ориентированный анализ и проектирование принципиально отличаются от традиционных подходов структурного проектирования: здесь нужно по-другому представлять себе процесс декомпозиции, а архитектура получающегося программного продукта в значительной степени выходит за рамки представлений, традиционных для структурного программирования. Отличия обусловлены тем, что структурное проектирование основано на структурном программировании, тогда как в основе объектно-ориентированного проектирования лежит методология объектно-ориентированного программирования, К сожалению, для разных людей термин "объектно-ориентированное программирование" означает разное. Ренч правильно предсказал: "В 1980-х годах объектно-ориентированное программирование будет занимать такое же место, какое занимало структурное программирование в 1970-х. но всем будет нравиться. Каждая фирма будет рекламировать свой продукт как зданный по этой технологии. Все программисты будут писать в этом стиле, причем все по-разному. Все менеджеры будут рассуждать о нем. И никто не будет знать, что же это такое" [Wegner, P. [J 1981]] [1]. Данные предсказания продолжают сбываться и в 1990-х годах.

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

2.1. Эволюция объектной модели



Объектно-ориентированные языки программирования


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

это инструмент проектирования;

это средство человеческого восприятия;

это средство управления компьютером [1].

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

А.1. Концепции

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

Рис. А-1. Генеалогия объектных и объектно-ориентированных языков.

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

Общим предком практически всех используемых сегодня объектных и объектно-ориентированных языков является язык Simula, созданный в 1960 году Далем, Мюрхогом и Ныгардом [2]. Язык Simula основывался на идеях ALGOL, но был дополнен механизмом наследования и инкапсуляции. Но еще более существенно то, что Simula, предназначенная для описания систем и моделирования, ввела дисциплину написания программ, отражающих словарь предметной области.

Рис. А-1, заимствованный у Шмукера [3], демонстрирует генеалогию пяти наиболее влиятельных и популярных объектных или объектно-ориентированных языков программирования: Smalltalk. Object Pascal, C++, CLOS и Ada. В следующих разделах мы проанализируем некоторые из этих языков с точки зрения их "объектности".

А.2. Smalltalk



Объектно-ориентированный анализ


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

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

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

Например, Шлаер и Меллор предлагают следующих кандидатов в классы и объекты [32]:
 

• Осязаемые предметы Автомобили, телеметрические данные, датчики давления
• Роли Мать, учитель, политик
• События Посадка, прерывание, запрос
• Взаимодействие Заем, встреча, пересечение

 
Что-то в этом роде предлагает Росс, исходя из перспектив моделирования баз данных [33]:
 

• Люди Человеческие существа, выполняющие некоторые функции
• Места Области, связанные с людьми или предметами
• Предметы Осязаемый материальный объект или группа объектов
• Организации Формально организованная совокупность людей, ресурсов, оборудования, которая имеет определенную цель и существование которой в целом не зависит от индивидуумов
• Концепции Принципы и идеи, сами по себе неосязаемые, но предназначенные для организации деятельности и/или общения, или же для наблюдения за ними
• События Нечто случающееся с чем-то в заданное время или последовательно

 
Коад и Иордан предложили свой список [34]:


 

• Структуры Отношения "целое-часть" и "общее-частное"
• Другие системы Внешние системы, с которыми взаимодействует приложение
• Устройства Устройства, с которыми взаимодействует приложение
• События Происшествия, которые должны быть запомнены
• Разыгрываемые роли Роли, которые исполняют пользователи, работающие с приложением
• Места Здания, офисы и другие места, существенные для работы приложения
• Организационные единицы Группы, к которым принадлежат пользователи
 
На более высоком уровне абстракции Коад вводит понятие предметной области, которая в сущности является логически связанной группой классов, относящейся к высокоуровневым функциям системы.

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

Вирфс-Брок предлагает понятие ответственности объекта, под которыми следует понимать "его знания и умения. Ответственность - это способ выразить цель объекта и его место в системе. Ответственность объекта есть совокупность всех услуг, которые он может предоставлять по всем его контрактам" [36]. То есть, мы объединяем вместе те объекты, которые имеют сходные ответственности и строим иерархию классов, в которой каждый подкласс, выполняя обязательства суперкласса, привносит свои дополнительные услуги.

Рубин и Гольдберг предлагают идентифицировать классы и объекты, анализируя функционирование системы: "Наш подход основан на изучении поведения системы. Мы сопоставляем формы поведения с частями системы и пытаемся понять, какая часть инициирует поведение и какие части в нем участвуют...


Инициаторы и участники, играющие существенные роли, опознаются как объекты и делаются ответственными за эти роли" [37].

Идеи Рубина тесно связаны с предложенным в 1979 году Альбрехтом подходом с точки зрения функций. По его определению, функция "определяется как отдельное бизнес-действие конечного пользователя" [38], то есть: ввод/вывод, запрос, файл или интерфейс. Очевидно, что эта концепция происходит из области информационных систем. Однако, она может быть применена к любой автоматизированной системе. По существу, функция - это любое достоверно видимое извне и имеющее отношение к делу поведение системы.

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

Идею анализа предметной области впервые предложил Нейборс. Мы определим такой анализ как "попытку выделить те объекты, операции и связи, которые эксперты данной области считают наиболее важными" [39]. Мур и Байлин определяют следующие этапы в анализе области:

"Построение скелетной модели предметной области при консультациях с экспертами в этой области.

Изучение существующих в данной области систем и представление результатов в стандартном виде.

Определение сходства и различий между системами при участии экспертов.

Уточнение общей модели для приспособления к нуждам конкретной системы" [40].



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

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

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

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


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

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

Коротко говоря, этот вид анализа можно начинать вместе с анализом требований. В этот момент пользователи, эксперты и разработчики перечисляют сценарии, наиболее существенные для работы системы (пока не углубляясь в детали). Затем они тщательно прорабатывают сценарии, раскладывая их по кадрам, как делают телевизионщики и кинематографисты [42]. При этом они устанавливают, какие объекты участвуют в сценарии, каковы обязанности каждого объекта и как они взаимодействуют в терминах операций. Тем самым группа разработчиков вынуждена четко распределить области влияния абстракций. Далее набор сценариев расширяется, чтобы учесть исключительные ситуации и вторичное поведение (Гольдстейн и Алджер называют это периферийными аспектами [43]). В результате появляются новые или уточняются существующие абстракции. Позже, в главе 6, мы покажем, как сценарии используются для тестирования.
 
CRC-карточки. CRC обозначает Class-Responsibilities-Collaborators (Класс/Ответственности/Участники). Это простой и замечательно эффективный способ анализа сценариев. Карты CRC впервые предложили Бек и Каннингхэм для обучения объектно-ориентированному программированию, но такие карточки оказались отличным инструментом для мозговых атак и общения разработчиков между собой.

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


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

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

Неформальное описание. Радикальная альтернатива классическому анализу была предложена в чрезвычайно простом методе Аббота. Согласно этому методу надо описать задачу или ее часть на простом английском языке, а потом подчеркнуть существительные и глаголы [45]. Существительные - кандидаты на роль классов, а глаголы могут стать именами операций. Метод можно автоматизировать, и такая система была построена в Токийском технологическом институте и в Fujitsu [46].

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

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



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

МакМенамин и Палмер предлагают сначала приступить к формированию словаря данных и затем к анализу контекстных диаграмм модели. Они говорят:

"рассматривая список основных структур данных, следует подумать, о чем они говорят или что описывают. Например, если они прилагательные, то какие существительные они описывают? Ответы на такие вопросы могут пополнить ваш список объектов" [47]. Эти кандидаты в объекты происходят из окружающей среды, из существенных входных и выходных данных, а также продуктов, услуг и других ресурсов, которыми она управляет.

Следующие два способа основаны на анализе отдельных диаграмм потоков данных. Если взять какую-нибудь диаграмму потоков (в терминологии Барда и Меллора [48]), то кандидаты в объекты это:

внешние сущности;

хранилища данных;

хранилища управляющих сущностей;

управляющие преобразования.

Кандидаты в классы:

потоки данных;

потоки управления.

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

Зайдевиц и Старк предлагают еще один метод, который они называют анализом абстракций. Метод базируется на идентификации основных сущностей, которые по своей природе аналогичны основным преобразованиям в структурном проектировании. Как они говорят, "в структурном анализе входные и выходные данные изучаются до тех пор, пока не достигнут высшего уровня абстракции. Процесс преобразования входных данных в выходные есть основное преобразование. В абстрактном анализе разработчик делает то же самое, а также изучает основное преобразование для того, чтобы определить, какие процессы и состояния представляют наилучшую абстрактную модель системы" [49].


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

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

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

4.3. Ключевые абстракции и механизмы


Обозначения


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

Однако, хорошо продуманная и выразительная система обозначений очень важна для разработки. Во-первых, общепринятая система позволяет разработчику описать сценарий или сформулировать архитектуру и доходчиво изложить свои решения коллегам. Символ транзистора понятен всем электронщикам мира. Аналогично, над проектом жилого дома, разработанным архитекторами в Нью-Йорке, строителям из Сан-Франциско скорее всего не придется долго ломать голову, решая, как надо расположить двери, окна или электрическую проводку. Во-вторых, как подметил Уайтхед в своей основополагающей работе по математике, "Освобождая мозг от лишней работы, хорошая система обозначений позволяет ему сосредоточиться на задачах более высокого порядка" [2]. В-третьих, четкая система обозначений позволяет автоматизировать большую часть утомительной проверки на полноту и правильность. Как говорится в отчете управления оборонных исследований:

"Разработка программного обеспечения всегда будет кропотливой работой... Хотя наши машины могут выполнять рутинную работу и помогать нам держать нить рассуждений, разработка концепций останется прерогативой человека... Что всегда будет в цене - это искусство построения концептуальной структуры, а заботы об ее описании уйдут в прошлое" [3].

5.1. Элементы обозначений



Обзор


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

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

В микропроцессе традиционные фазы анализа и проектирования умышленно перемешаны, а управление осуществляется "по возможности". Как отмечает Страуструп, "не существует рецептов, которые могли бы заменить ум, опыт и хороший вкус в проектировании и программировании... Различные фазы программного проекта, такие, как проектирование, программирование и тестирование, неотделимы друг от друга" [11].

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

выявление классов и объектов на данном уровне абстракции;

выяснение семантики этих классов и объектов;

выявление связей между этими классами и объектами;

спецификация интерфейса и реализация этих классов и объектов.

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


Как показано на рис. 6-2, макропроцесс обычно включает следующие действия:

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

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

Создание архитектуры для реализации (проектирование).

Итеративное выполнение реализации (эволюция).

Управление эволюцией продукта в ходе эксплуатации (сопровождение).



Рис. 6-2. Макропроцесс.

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

Основная философия макропроцесса состоит в постепенном развитии. Как его определяет Вонк, "при разработке методом последовательного развития, система выстраивается шаг за шагом, причем каждая новая версия содержит функциональность предыдущей, плюс новые функции" [14]. Этот подход чрезвычайно хорошо сочетается с объектно-ориентированной парадигмой и дает много возможностей для управления риском. Как утверждает Гилб: "Постепенная передача программ заказчику изобретена для того, чтобы заранее предупредить нас о надвигающихся неприятностях" [15].

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


OOP, OOD и ООА


Унаследовав от многих предшественников, объектный подход, к сожалению, перенял и запутанную терминологию. Программист в Smalltalk пользуется термином метод, в C++ - термином виртуальная функция, в CLOS - обобщенная функция.

Рис. 2-5. Топология больших приложений в объектных и объектно-ориентированных языках.

В Object Pascal используется термин приведение типов, а в языке Ada то же самое называется преобразование типов. Чтобы уменьшить путаницу, следует определить, что является объектно-ориентированным, а что - нет. Определение наиболее употребительных терминов и понятий вы найдете в глоссарии в конце книги.

Термин объектно-ориентированный, по мнению Бхаскара, "затаскан до потери смысла, как "материнство", "яблочный пирог" и "структурное программирование"" [7]. Можно согласиться, что понятие объекта является центральным во всем, что относится к объектно-ориентированной методологии. В главе 1 мы определили объект как осязаемую сущность, которая четко проявляет свое поведение. Стефик и Бобров определяют объекты как "сущности, объединяющие процедуры и данные, так как они производят вычисления и сохраняют свое локальное состояние" [8]. Определение объекта как сущности в какой-то мере отвечает на вопрос, но все же главным в понятии объекта является объединение идей абстракции данных и алгоритмов. Джонс уточняет это понятие следующим образом: "В объектном подходе акцент переносится на конкретные характеристики физической или абстрактной системы, являющейся предметом программного моделирования... Объекты обладают целостностью, которая не должна - а, в действительности, не может - быть нарушена. Объект может только менять состояние, вести себя, управляться или становиться в определенное отношение к другим объектам. Иначе говоря, свойства, которые характеризуют объект и его поведение, остаются неизменными. Например, лифт характеризуется теми неизменными свойствами, что он может двигаться вверх и вниз, оставаясь в пределах шахты...
Любая модель должна учитывать эти свойства лифта, так как они входят в его определение" [32].
 

Основные положения объектной модели

Йонесава и Токоро свидетельствуют: "термин "объект" появился практически независимо в различных областях, связанных с компьютерами, и почти одновременно в начале 70-х годов для обозначения того, что может иметь различные проявления, оставаясь целостным. Для того, чтобы уменьшить сложность программных систем, объектами назывались компоненты системы или фрагменты представляемых знании" [9]. По мнению Леви, объектно-ориентированный подход был связан со следующими событиями:

"прогресс в области архитектуры ЭВМ;

развитие языков программирования, таких как Simula, Smalltalk, CLU, Ada;

развитие методологии программирования, включая принципы модульности и скрытия данных" [10].

К этому еще следует добавить три момента, оказавшие влияние на становление объектного подхода:

развитие теории баз данных;

исследования в области искусственного интеллекта;

достижения философии и теории познания.

Понятие "объект" впервые было использовано более 20 лет назад при конструировании компьютеров с descriptor-based и capability-based архитектурами [11]. В этих работах делались попытки отойти от традиционной архитектуры фон Неймана и преодолеть барьер между высоким уровнем программной абстракции и низким уровнем ЭВМ [12]. По мнению сторонников этих подходов, тогда были созданы более качественные средства, обеспечивающие: лучшее выявление ошибок, большую эффективность реализации программ, сокращение набора инструкций, упрощение компиляции, снижение объема требуемой памяти. Ряд компьютеров имеет объектно-ориентированную архитектуру: Burroughs 5000, Plessey 250, Cambridge CAP [13], SWARD [14], Intel 432 [15], Caltech's СОМ [16], IBM System/38 [17], Rational R1000, BiiN 40 и 60.

С объектно-ориентированной архитектурой тесно связаны объектно-ориентированные операционные системы (ОС). Дейкстра, работая над мультипрограммной системой THE, впервые ввел понятие машины с уровнями состояния в качестве средства построения системы [18]. Среди первых объектно-ориентированных ОС следует отметить: Plessey/System 250 (для мультипроцессора Plessey 250), Hydra (для CMU C.mmp), CALTSS (для CDC 6400), CAP (для компьютера Cambridge CAP), UCLA Secure UNIX (для PDP 11/45 и 11/70), StarOS (для CMU Cm*), Medusa (также для CMU Cm*) и iMAX (для Intel 432) [19]. Следующее поколение операционных систем, таких, как Microsoft Cairo и Taligent Pink, будет, по всей видимости, объектно-ориентированным.

Наиболее значительный вклад в объектный подход внесен объектными и объектно-ориентированными языками программирования. Впервые понятия классов и объектов введены в языке Simula 67. Система Flex и последовавшие за ней диалекты Smalltalk-72, -74, -76 и, наконец, -80, взяв за основу методы Simula, довели их до логического завершения, выполняя все действия на основе классов. В 1970-х годах создан ряд языков, реализующих идею абстракции данных: Alphard, CLU, Euclid, Gypsy, Mesa и Modula. Затем методы, используемые в языках Simula и Smalltalk, были использованы в традиционных языках высокого уровня. Внесение объектно-ориентированного подхода в С привело к возникновению языков C++ и Objective С. На основе языка Pascal возникли Object Pascal, Eiffel и Ada. Появились диалекты LISP, такие, как Flavors, LOOPS и CLOS (Common LISP Object System), с возможностями языков Simula и Smalltalk. Более подробно особенности этих языков изложены в приложении.

Первым, кто указал на необходимость построения систем в виде структурированных абстракций, был Дейкстра. Позднее Парнас ввел идею скрытия информации [20], а в 70-х годах ряд исследователей, главным образом Лисков и Жиль [21], Гуттаг [22], и Шоу [23], разработал механизмы абстрактных типов данных. Хоар дополнил эти подходы теорией типов и подклассов [24].

Развивавшиеся достаточно независимо технологии построения баз данных также оказали влияние на объектный подход [25], в первую очередь благодаря так называемой модели "сущность-отношение" (ER, entity-relationship) [26]. В моделях ER, впервые предложенных Ченом [27], моделирование происходит в терминах сущностей, их атрибутов и взаимоотношений.

Разработчики способов представления данных в области искусственного интеллекта также внесли свой вклад в понимание объектно-ориентированных абстракций. В 1975 г. Мински выдвинул теорию фреймов для представления реальных объектов в системах распознавания образов и естественных языков [28]. Фреймы стали использоваться в качестве архитектурной основы в различных интеллектуальных системах.

Объектный подход известен еще издавна. Грекам принадлежит идея о том, что мир можно рассматривать в терминах как объектов, так и событий. А в XVII веке Декарт отмечал, что люди обычно имеют объектно-ориентированный взгляд на мир [29]. В XX веке эту тему развивала Рэнд в своей философии объективистской эпистемологии [30]. Позднее Мински предложил модель человеческого мышления, в которой разум человека рассматривается как общность различно мыслящих агентов [31]. Он доказывает, что только совместное действие таких агентов приводит к осмысленному поведению человека.
<



Объектно-ориентированное программирование. Что же такое объектно-ориентированное программирование (object-oriented programming, OOP)? Мы определяем его следующим образом:

Объектно-ориентированное программирование - это методология программирования, основанная на представлении программы в виде совокупности объектов, каждый из которых является экземпляром определенного класса, а классы образуют иерархию наследования.

В данном определении можно выделить три части: 1) OOP использует в качестве базовых элементов объекты, а не алгоритмы (иерархия "быть частью", которая была определена в главе 1); 2) каждый объект является экземпляром какого-либо определенного класса; 3) классы организованы иерархически (см. понятие об иерархии "is а" там же). Программа будет объектно-ориентированной только при соблюдении всех трех указанных требований. В частности, программирование, не основанное на иерархических отношениях, не относится к OOP, а называется программированием на основе абстрактных типов данных.

В соответствии с этим определением не все языки программирования являются объектно-ориентированными. Страуструп определил так: "если термин объектно-ориентированный язык вообще что-либо означает, то он должен означать язык, имеющий средства хорошей поддержки объектно-ориентированного стиля программирования... Обеспечение такого стиля в свою очередь означает, что в языке удобно пользоваться этим стилем. Если написание программ в стиле OOP требует специальных усилий или оно невозможно совсем, то этот язык не отвечает требованиям OOP" [33]. Теоретически возможна имитация объектно-ориентированного программирования на обычных языках, таких, как Pascal и даже COBOL или ассемблер, но это крайне затруднительно. Карделли и Вегнер говорят, что: "язык программирования является объектно-ориентированным тогда и только тогда, когда выполняются следующие условия:

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



Объекты относятся к соответствующим типам (классам).

Типы (классы) могут наследовать атрибуты супертипов (суперклассов)" [34].

Поддержка наследования в таких языках означает возможность установления отношения "is-a" ("есть", "это есть", " - это"), например, красная роза - это цветок, а цветок - это растение. Языки, не имеющие таких механизмов, нельзя отнести к объектно-ориентированным. Карделли и Вегнер назвали такие языки объектными, но не объектно-ориентированными. Согласно этому определению объектно-ориентированными языками являются Smalltalk, Object Pascal, C++ и CLOS, a Ada - объектный язык. Но, поскольку объекты и классы являются элементами обеих групп языков, желательно использовать и в тех, и в других методы объектно-ориентированного проектирования.

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

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

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

Именно объектно-ориентированная декомпозиция отличает объектно-ориентированное проектирование от структурного; в первом случае логическая структура системы отражается абстракциями в виде классов и объектов, во втором - алгоритмами. Иногда мы будем использовать аббревиатуру OOD, object-oriented design, для обозначения метода объектно-ориентированного проектирования, изложенного в этой книге.



Объектно-ориентированный анализ. На объектную модель повлияла более ранняя модель жизненного цикла программного обеспечения. Традиционная техника структурного анализа, описанная в работах Де Марко [35], Иордана [36], Гейна и Сарсона [37], а с уточнениями для режимов реального времени у Варда и Меллора [38] и Хотли и Пирбхая [39], основана на потоках данных в системе. Объектно-ориентированный анализ (или OOA, object-oriented analysis) направлен на создание моделей реальной действительности на основе объектно-ориентированного мировоззрения.

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

Как соотносятся ООА, OOD и OOP? На результатах ООА формируются модели, на которых основывается OOD; OOD в свою очередь создает фундамент для окончательной реализации системы с использованием методологии OOP.

2.2. Составные части объектного подхода


Определение границ предметной области


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

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

Q AZWS DSSC KAS DXZNN DASNN

Подсказка: буква w соответствует букве v исходного текста. Перебор всех возможных вариантов совершенно лишен смысла. Предполагая, что алфавит содержит 26 прописных английских букв, получим 26! (около 4.03х1026) возможных комбинаций. Следовательно, нужно искать другой метод решения, например, использовать знания о структуре слов и предложений и делать правдоподобные допущения. Как только мы исчерпаем явные решения, мы сделаем наиболее вероятное предположение и будем продвигаться дальше. Если обнаружится, что предположение приводит к противоречию или заводит в тупик, мы вернемся назад и сделаем другую попытку.

Требования к системе криптоанализа

Криптография "изучает методы сокрытия данных от посторонних" [3]. Криптографические алгоритмы преобразовывают сообщения (исходный текст) в зашифрованный текст (криптограмму) и наоборот.

Одним из наиболее общеупотребительных (еще со времен Древнего Рима) криптографических алгоритмов является подстановка. Каждая буква в алфавите исходного текста заменяется другой буквой. Например, можно циклически сдвинуть все буквы алфавита: буква A заменяется на B, B на C, a Z на A. Тогда следующий исходный текст:

CLOS is an object-oriented programming language

превращается в криптограмму:

DMPT jt bo pckfdu-psjfoufe qsphsbnnjoh mbohvbhf 

Чаще всего замена делается менее тривиальным образом. Например, A заменяется на G, B на J и т.д. Рассмотрим следующую криптограмму:

PDG TBCER CQ TCK AL S NGELCH QZBBR SBAJG 

Подсказка: буква C в этой криптограмме соответствует букве O

исходного текста.

Существенно упрощает задачу предположение о том, что для шифрования текста использован алгоритм подстановки, поскольку в общем случае процесс дешифровки не будет столь тривиальным. В процессе расшифровки приходится использовать метод проб и ошибок, когда мы делаем предположение о замене и рассматриваем его следствия. Удобно, например, начать расшифровку с предположения о том, что одно- и двухбуквенные слова в криптограмме соответствуют наиболее употребительным словам английского языка (I, a, or, it, in, of, on). Подставляя эти предполагаемые буквы в другие слова, мы можем догадаться о вероятном значении других букв. Например, если трехбуквенное слово начинается с литеры O, то это могут быть слова one, our, off.

Знание фонетики и грамматики также может способствовать дешифровке. Например, следование подряд двух одинаковых литер с очень малой вероятностью может означать qq. Наличие в окончании слова буквы g позволяет сделать предположение о наличии суффикса ing. На еще более высоком уровне абстракции логично предположить, что словосочетание it is более вероятно, чем if is. Необходимо учитывать и структуру предложения: существительные и глаголы. Если выясняется, что в предложении есть глагол, но нет существительного, которое с ним связано, то нужно отвергнуть сделанные ранее предположения н начать поиск заново.

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

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

<

Вот наше решение, шаг за шагом:

1. Используя подсказку, заменим w на v.

Q AZVS DSSC KAS DXZNN DASNN

2. Первое слово из одной буквы, вероятна, A или I; предположим, что это A:

A AZVS DSEC KAS DXZNN DASNN

3. В третьем слове должны быть гласные звуки и вероятно, что это двойные буквы. Это не могут быть UU или II, а также AA (буква A уже использована). Попробуем вариант EE.

A AZVE DEEC KAE DXZNN DAENN

4. Четвертое слово состоит из трех букв и оканчивается на E, это очень похоже на слово THE.

A HZVE DEEC THE DXZNN DHENN

5. Во втором слове нужна гласная, и здесь подходят только I, O, U (буква A уже использована). Только вариант с буквой I дает осмысленное слово.

A HIVE DEEC THE DXINN DHENN

6. Можно найти несколько слов с двойной буквой E из четырех букв (DEER, BEER, SEEN). Грамматика требует, чтобы третье слово было глаголом, поэтому остановимся на SEEN.

A HIVE SEEN THE SXINN SHENN

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

I HAVE SEEN THE SXANN SHENN

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

I HAVE SEEN THE SXALL

SHELL

9. Из грамматических соображений очевидно, что оставшееся слово - прилагательное. Анализируя шаблон S?ALL, находим SMALL.

I HAVE SEEN THE SMALL SHELL

Таким образом, решение найдено. Анализируя процесс решения, мы можем сделать три наблюдения:

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

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



Мы подходили к делу наугад, приспосабливаясь к обстановке. Иногда делались выводы от общего к частному (словом из трех букв, оканчивающимся на E будет, вероятно, THE), а иногда от частного к общему (?EE? может соответствовать DEER, BEER, SEEN, но глаголом из них является только SEEN).

Изложенный подход известен как метод информационной доски. Он впервые был предложен Ньюэллом в 1962 году, а позднее был использован Редди и Ерманом в проектах Hearsay и Hearsay II по распознаванию речи [4]. Эффективность метода подтвердилась, и он был использован в других областях, включая интерпретацию сигналов, трехмерное моделирование молекулярных структур, распознавание образов и планирование [5]. Метод показал хорошие результаты в представлении описательных знаний; он более эффективен с точки зрения памяти и времени по сравнению с другими подходами [6].

Информационная доска вполне подходит на роль среды разработки (см. главу 9). Попробуем теперь зафиксировать архитектуру этого метода в виде системы классов и механизмов их взаимодействия.



Система управления движением выполняет две главные функции: выбор маршрутов железнодорожных перевозок и контроль систем, обеспечивающих перевозки. Эти функции включают: планирование перевозок, контроль местонахождения поездов, контроль за перевозками, предотвращение конфликтов, прогнозирование нарушении, регистрацию всех операций. На рис. 12-1 показана схема основных элементов системы управления движением [2].



Рис. 12-1. Система управления движением

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

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

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


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

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

Отслеживание маршрутов движения поездов осуществляется с помощью подключенных к сети передачи данных ответчиков местоположения и глобальной спутниковой системы указания местоположения (GPS, Global Positioning System) Navstar. Система анализа и отображения информации на локомотиве может вычислять пройденный путь с помощью счетчика, подсчитывающего число оборотов колеса. Эта информация дополняется данными ответчиков местоположения, которые размещены через каждый километр пути или чаще (на важнейших развилках). Ответчики передают информацию о себе на проходящие поезда (используя блок управления данными), что позволяет более точно определить местоположение. Кроме того, поезд может быть оснащен приемниками GPS, с помощью которых его географическое положение может быть определено с точностью до метра.

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

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


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

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

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

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


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

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

Архитектура должна быть открыта для развития.

Реализация должна опираться на существующие стандарты.

Наш опыт разработки больших систем показывает, что первоначальная формулировка требований никогда не бывает полной, она всегда в некоторой степени неопределенна и противоречива. Соответственно, мы должны быть готовы управлять возникающими в процессе разработки неопределенностями. Мы настоятельно рекомендуем осуществлять эволюцию подобных систем в виде пошагового, итеративного процесса. Как уже говорилось в главе 7, сам цикл разработки дает пользователям и разработчикам возможность понять, какие требования на самом деле существенны; именно процесс разработки, а не упражнения в чистописании спецификаций в отсутствии готовой частичной реализации или прототипа. Кроме того, необходимо учитывать, что на создание большой системы может быть затрачено несколько лет. За это время сильно изменится аппаратная часть [В действительности для многих систем такого уровня сложности характерно, что в них входят компьютеры самых разнообразных типов. Хорошо продуманная и стабильная архитектура смягчает риск смены техники в процессе разработки, которая сплошь и рядом происходит в быстро меняющемся компьютерном мире. Новые модели приходят и уходят, поэтому важно четко представлять границу между техникой и программами, чтобы можно было ввести в систему новые компьютеры или контроллеры, снижающие затраты или улучшающие характеристики работы, и сохранить при этом целостность архитектуры]. Поэтому требования к программе должны предусматривать адаптацию к новой технике. Бессмысленно создавать элегантную архитектуру для аппаратуры, которая гарантированно устареет за время разработки. Мы считаем, что в архитектуру программной системы следует включать только те аппаратные особенности, которые непосредственно опираются на существующие стандарты: связь, сети передачи данных, графику и протокол работы датчиков.Для совершенно новых систем иногда Приходится становиться первопроходцами аппаратных и программных средств. Это приводит к повышению риска, который для большинства систем и без того высок. Разработка программного обеспечения, особенно, когда речь идет об успешном завершении большого приложения, неизбежно связана с риском, и наша цель - снизить этот риск до минимума.

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


Определение границ рассматриваемой задачи


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

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

Используется компьютер с одним процессором i486 [Это решение может показаться избыточным, но сейчас платформы на базе 486-го процессора ненамного дороже компьютеров с процессорами предыдущих поколений. Закладывая в требования избыточную производительность компьютера, мы обеспечиваем больший срок жизни разрабатываемой системы].

Системные время и дата поддерживаются встроенными часами, соответствующие значения отображаются в оперативную память.

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

Направление ветра измеряется с помощью флюгера с точностью до одного из 16 направлений; скорость ветра определяется анемометром со счетчиком оборотов.

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

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

Встроенный таймер посылает прерывания через каждую 1/60 долю секунды.

На рис. 8-1 приведена диаграмма, иллюстрирующая состав аппаратной части системы.
 



Рис. 8-1. Аппаратное обеспечение системы мониторинга погоды.

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

Что касается особенностей организации ввода/вывода посредством отображения в память, то нам не хотелось бы подробно на них останавливаться, так как эти детали в большой степени зависят от способа реализации проекта. Мы можем легко изолировать наши программные абстракции от этих "неинтересных" подробностей, скрыв их в реализациях соответствующих классов. Например, имеет смысл создать простой класс для определения текущего времени и даты: для этого надо провести небольшой анализ, в процессе которого придется рассмотреть роли и обязанности данной абстракции [На самом деле, прежде чем создавать класс, полезно порыться в доступных вам библиотеках и постараться найти там что-нибудь похожее. Класс времени и даты - хороший кандидат на повторное использование, и скорее всего кто-нибудь уже разработал и отладил подобную абстракцию. Однако, для целей нашего изложения лучше предположить, что такого класса в готовом виде не нашлось].


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

13:56:42

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

3-20-98

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

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

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


Рис. 8-2. Жизненный цикл класса TimeDate.

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


Более изощренная реализация может использовать микросхему времени/даты с питанием от батарейки. В любом случае, внешний вид этого класса (контракт с клиентами) должен быть одним и тем же. Наша реализация класса отвечает за поддержание этого контракта на данной аппаратной платформе]. Тогда наш класс времени и даты должен обеспечивать пересчет этой сырой информации в полезные значения, что, в свою очередь, диктует необходимость добавления новых операций setHour, setSecond, setDay, setMonth и setYear, определяющих на основе первичных данных текущие час, секунду, день, месяц и год.

Теперь подведем итоги. Абстракция класса времени и даты выглядит так:

Имя: TimeDate

Ответственность: Поддержание информации о текущем времени и о текущей дате.

Операции: CurrentTime - текущее время
currentDate - текущая дата
setFormat - установка формата
setHour - установка часа
setMinute - установка минут
setSecond - установка секунд
setMonth - установка месяца
setDay - установка дня
setYear - установка года

Атрибуты: time - время
date - дата

Экземпляры этого класса имеют динамический жизненный цикл, который отражен в диаграмме состояний и переходов на рис. 8-2. Мы видим, что при инициализации экземпляра класса происходит обнуление значений атрибутов и безусловный переход в рабочее состояние в режиме 24-часового формата. В рабочем состоянии можно переключать режимы с помощью операции setFormat. Операция переустановки времени и даты нормализует его атрибуты вне зависимости от текущего вложенного состояния объекта. Запрос относительно текущего времени или даты приводит к выполнению необходимых вычислений и генерации текстовой строки.

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

Класс TemperatureSensor (температурный датчик) служит аналогом аппаратного температурного датчика нашей системы. Изолированный анализ поведения этого класса дает в первом приближении следующий результат:



Имя: TemperatureSensor

Ответственность: Поддержание информации о текущей температуре.

Операции: currentTemperature - текущая температура
setLowTemperature - установка минимальной температуры
setHighTemperature - установка максимальной температуры

Атрибуты: temperature - температура

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

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

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

Имя: PressureSensor

Отвественность: Поддержание информации о текущем барометрическом давлении.

Операции: currentPressure - текущее давление
setLowPressure - установка минимального давления
setHighPressure - установка максимального давления

Атрибуты: pressure - давление

 


Рис. 8-3. Калибровка класса-датчика TemperatureSensor.

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


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

И для температурного датчика, и для датчика давления тренд может быть определен как вещественное число, изменяющееся в диапазоне от -1 до 1 и представляющее собой наклон графика изменений температуры и давления на некотором интервале времени [Значение 0 показывает, что температура или давление стабильно. Значение 0.1 указывает на небольшой рост, значение -0.3 соответствует резкому уменьшению. Значения, близкие к -1 и 1 намекают на природный катаклизм, выходящий за рамки тех сценариев, в которых наша система должна исправно работать]. Таким образом, к описанию двух вышеупомянутых классов можно добавить еще одну ответственность и соответствующую ей операцию:

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

Операции: trend - тренд

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

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

Абстракцию, соответствующую датчику влажности, можно определить следующим образом:

Имя: HumiditySensor

Ответственность: Поддержание информации о текущей влажности, выраженной в процентах от 0% до 100%.

Операции: currentHumidity - текущая влажность


setLowHumidity - установка минимальной влажности
setHighHumidity - установка максимальной влажности

Атрибуты: humidity - влажность

Нам не ставится задача определения тренда влажности, поэтому класс HumiditySensor, в отличие от классов TemperatureSensor

и PressureSensor, не является потомком класса TrendSensor.

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

Отвественность: Генерация сообщений о максимальных и минимальных значениях параметров за 24-часа.

Операции: highValue - максимальное значение
lowValue - минимальное значение
timeOf HighValue - время, соответствующее максимальному значению
timeOfLowValue - время, соответствующее минимальному значению

Пока отложим решение вопроса о том, как реализовать эту ответственность; мы вернемся к нему на этапе проектирования. Однако, учитывая то, что данное поведение является общим для всех трех датчиков, представляется целесообразным организация еще одного суперкласса, который мы назовем HistoricalSensor. Класс HumiditySensor является прямым потомком класса HistoricalSensor, так же как и TrendSensor. Последний служит промежуточным абстрактным классом, переходным между абстрактным HistoricalSensor и конкретными TemperatureSensor и PressureSensor.

Абстракция для датчика скорости ветра может выглядеть следующим образом:

Имя: WindSpeedSensor

Ответственность: Поддержание информации о текущей скорости ветра.

Операции: currentSpeed - текущая скорость
setLowSpeed - установка минимальной скорости
setHighSpeed - установка максимальной скорости

Атрибуты: speed - скорость

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


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

Краткий анализ последних четырех классов системы (TemperatureSensor, PressureSensor, HumiditySensor и WindSpeedSensor) показывает, что у них имеется еще одна общая черта: калибровка измеренных значений посредством линейной интерполяции. Вместо того, чтобы реализовывать эту способность по отдельности в каждом из классов, можно выделить особый суперкласс CalibratingSensor, ответственный за выполнение калибровки.

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

Операции: currentValue - текущее значение
setLowValue - установка минимального значения
setHighValue - установка максимального значения

CalibratingSensor является непосредственным суперклассом для класса HistoricalSensor.

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

Имя: WindDirectionSensor

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

Операции: currentDirection - текущее направление

Атрибуты: direction - направление

Чтобы объединить все классы, относящиеся к датчикам, в одну иерархию, имеет смысл создать еще один абстрактный базовый класс Sensor, который является непосредственным суперклассом для WindDirectionSensor и CalibratingSensor. Рис. 8-4 иллюстрирует полную иерархию классов датчиков.
 


Рис. 8-4. Иерархия классов датчиков.

Абстракция ввода информации с клавиатуры имеет следующий простой вид:

Имя: Keypad

Ответственность: Поддержание информации о коде последней клавиши, нажатой на клавиатуре.



Операции: lastKeyPress - последняя нажатая клавиша

Атрибуты: key - клавиша

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

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


Рис. 8-5. Дисплей метеорологической станции.

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

Требования к графике можно выразить через следующую абстракцию:

Имя: LCDDevice

Ответственность: Управление выводом на экран графических элементов.

Операции: drawText - рисовать текст


Имя: Timer

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

Операции: setCallback() - установка функции обратного вызова

Сценарии

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

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

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

Показ максимальных и минимальных значений выбранных параметров.

Установка времени и даты.

Калибровка выбранных датчиков.

Включение системы.

Добавим еще две дополнительные ситуации:

Отказ питания.

Отказ датчика.

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

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

каждые 0.1 секунды направление ветра

каждые 0.5 секунды скорость ветра

каждые 5 минут температура, барометрическое давление и влажность

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


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


Рис. 8-7. Сценарий измерений.

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

Имя: DisplayManager

Ответственность: Организация отображения параметров на экране дисплея.

Операции: drawStaticItems - рисование статических элементов
displayTime - вывод времени
displayDate - вывод даты
displayTemperature - вывод температуры
displayHumidity - вывод влажности
displayPressure - вывод давления
displayWindChill - вывод коэффициента жесткости погоды
displayDewPoint - вывод точки росы
displayWindSpeed - вывод скорости ветра
displayWindDirection - вывод направления ветра
displayHighLow - вывод максимальных и минимальных значений

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



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

Отметим еще одно важное преимущество нашего решения о выделении отдельного класса DisplayManager. Задача локализации системы для различных стран предполагает изменение языка, на котором информация выводится на дисплей. Наличие отдельного класса, ответственного за вывод сообщений на экран, существенно облегчает процесс локализации, так как имена всех сообщений (например, ТЕМПЕРАТУРА, или скорость) находятся, в этом случае, в ведении единственного класса; они не разбросаны по множеству различных абстракций.
 


Рис. 8-8. Классы, ответственные за вывод данных.

Рассмотрение задачи локализации ставит перед разработчиком ряд дополнительных вопросов, не выраженных явным образом в требованиях к системе. Как следует показывать температуру, по Цельсию или по Фаренгейту? В чем отображать скорость ветра, в километрах в час или в милях в час? Ясно, что наше программное обеспечение не должно нас жестко ограничивать. Для обеспечения гибкости в использовании системы конечным пользователем необходимо добавить к описаниям классов TemperatureSensor и WindSpeedSensor еще одну операцию, setMode, устанавливающую нужную систему измерений. Также следует добавить в описание этих классов новую обязанность, предусматривающую возможность установки вновь создаваемых объектов в известное состояние. И, наконец, мы должны изменить описание операции DisplayManager::drawStaticItems таким образом, чтобы при изменении единиц измерений соответствующим образом менялась панель дисплея.

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

Установка единиц измерения температуры и скорости ветра.

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


Однако, чтобы полностью определить сценарий мониторинга, придется добавить еще два класса (назовем их WindChill и DewPoint), предназначенных для определения коэффициента жесткости погоды и точки образования росы. Эти абстракции не отождествляются с датчиками и вообще с чем-либо осязаемым. Их задача - вычисление значений параметров. Они выступают в роли агентов, сотрудничающих с другими классами. Именно класс WindChill использует для вычислений информацию, содержащуюся в TemperatureSensor и WindSpeedSensor, а класс DewPoint сотрудничает с классами TemperatureSensor и HimiditySensor. Классы Windchill и DewPoint сотрудничают и с классом Sampler, так как они используют аналогичный механизм опроса датчиков. Рис. 8-9 иллюстрирует набор классов и связи между ними, необходимые для реализации рассмотренного сценария. Он почти не отличается от диаграммы классов, приведенной ранее на рис. 8-8.
 


Рис. 8-9. Вторичные параметры.

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

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


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

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

Вывод на экран максимальных и минимальных значений выбранного параметра.

1. Пользователь нажимает клавишу SELECT.
2. Система выводит на экран сообщение SELECTING.
3. Пользователь нажимает одну из следующих клавиш: WIND SPEED, TEMPERATURE, PRESSURE или HUMIDITY; нажатие всех остальных клавиш (кроме клавиши RUN) игнорируется.
4. Название выбранного параметра начинает мигать на экране.
5. Пользователь нажимает одну из клавиш UP или DOWN, выбирая тем самым, какое значение - максимальное или минимальное - будет выведено на экран; нажатие всех остальных клавиш (кроме клавиши Run) игнорируется.
6. Система выводит на экран выбранное значение, а также время его замера.
7. Переход управления к пункту 3 или 5.

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

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

Установка времени и даты подчиняется аналогичному сценарию:

Установка времени и даты.

1. Пользователь нажимает клавишу SELECT.
2. Система выводит на экран сообщение SELECTING.
3. Пользователь нажимает одну из следующих клавиш: TIME или DATE; нажатия всех остальных клавиш (кроме клавиши RUN и клавиш, перечисленных в пункте 3 предыдущего сценария) игнорируются.
4. Название выбранного параметра, а также первое поле его значения (для времени - это час, для даты - месяц) начинают мигать на экране.


5. Пользователь нажимает одну из клавиш LEFT или RIGHT для перехода на другое поле; пользователь нажимает одну из клавиш UР или DOWN для увеличения или уменьшения значения выделенной величины.
6. Переход управления к пункту 3 или 5.

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

Сценарий калибровки датчика следует той же схеме:

Калибровка датчика.

1. Пользователь нажимает клавишу CALIBRATE.
2. Система выводит на экран сообщение CALIBRATING.
3. Пользователь нажимает одну из следующих клавиш: WIND SPEED, TEMPERATURE, PRESSURE или HUMIDITY; нажатия всех остальных клавиш (кроме клавиши RUM) игнорируются.
4. Название выбранного параметра начинает мигать на экране.
5. Пользователь нажимает одну из клавиш Up или DOWN, задавая тем самым, какое калибровочное значение, максимальное или минимальное, будет переопределяться.
6. Соответствующее калибровочное значение начинает мигать на экране.
7. Пользователь нажимает клавиши ПР или DOWN для изменения значения выделенной величины.
8. Переход управления к пункту 3 или 5.
 


Рис. 8-10. Клавиатура метеорологической станции.

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

На время калибровки все экземпляры класса Sampler должны прекратить считывание параметров, в противном случае будут показаны ошибочные данные. Таким образом, мы должны добавить в описание класса sampler еще две операции:

inhibitSample и resumeSample, приостанавливающие и возобновляющие процесс.

Последний сценарий касается установки единиц измерений:

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

1. Пользователь нажимает клавишу MODE.
2. Система выводит на экран сообщение MODE .
3. Пользователь нажимает одну из клавиш WIND SPEED или TEMPERATURE; нажатия всех остальных клавиш (кроме клавиши RUN) игнорируются.


4. Название выбранного параметра начинает мигать на экране.
5. Пользователь нажимает одну из клавиш UР или DOWN, изменяя при этом единицу измерения параметра.
6. Система изменяет единицу измерения выбранного параметра.
7. Переход управления к пункту 3 или 5.

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

После изучения сценариев работы можно определить состав и расположение клавиш на клавиатуре (системное решение). На рис. 8-10 представлен один из вариантов такого решения.

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

Имя: InputManager

Ответственность: Диспетчеризация команд пользователя.

Операции: processKeyPress обработка сигналов с клавиатуры

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

Как видно из рис. 8-11, на котором представлена диаграмма состояний класса InputManager, есть четыре состояния: Running, Calibrating, Selecting, и Mode (работа, калибровка, выбор и режим). Эти состояния соответствуют вышеприведенным сценариям. Переход в новое состояние определяется первой клавишей, нажатой в состоянии Running. Мы возвращаемся в состояние Running после нажатия клавиши Run, при этом происходит очистка дисплея.
 


Рис. 8-11. Диаграмма состояний для InputManager.

Мы более детально расписали поведение системы в состоянии Mode (правая часть диаграммы), чтобы показать, как можно формализовать динамику сценария. При переходе в это состояние на экране появляется соответствующее сообщение. Затем система входит в состояние waiting (ожидание) до тех пор, пока пользователь не нажмет одну из клавиш Temperature или WindSpeed, которые переводят систему во вложенное состояние Processing.


Если пользователь нажимает клавишу Run, система возвращается в основное эксплуатационное состояние. Каждый раз при переходе в состояние Processing соответствующий параметр начинает мигать. При последующих входах мы сразу попадаем в то подсостояние (Temp или wind), из которого вышли в прошлый раз.

Находясь в состояниях Temp или wind, система может реагировать на нажатие пяти клавиш: up или Down (переход между режимами), Temp или wind (переход к другому вложенному состоянию) и Run (выход из состояния Mode).

Состояния selecting и calibrating можно расписать подобным же образом. Мы не приводим их здесь, потому что они мало добавляют к пониманию метода [Естественно, при создании реального продукта детальный анализ должен завершиться составлением диаграммы переходов. Мы можем опустить здесь эту часть работы, потому что она достаточно скучна и не добавляет ничего нового к нашим знаниям о системе].

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

Включение системы.

1. Включение питания.
2. Создание датчиков; датчики с историей очищают "исторические" данные; датчики с трендом инициализируют алгоритм вычисления тренда.
3. Инициализация буфера клавиатуры, очистка его от случайной информации, вызванной помехами при включении питания.
4. Прорисовка статических элементов экрана.
5. Инициализация процесса опроса датчиков.

Постусловия:

Последние минимаксные значения параметров устанавливаются равными их первому замеру.

Время достижения микимакса считается равным времени первого замера.

Тренды температуры и давления равны нулю.

InputManager находится в состоянии Running.

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

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

8.2. Проектирование


Определение границ задачи


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

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

 

Требования к системе складского учета

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

Учет товаров, приходящих от разных поставщиков, при их приеме на склад.

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

Генерация указаний персоналу, в частности, об упаковке товаров.

Генерация счетов и отслеживание оплат.

Генерация запросов о поставке и отслеживание платежей поставщикам.

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

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

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


Рис. 10-1. Сеть системы складского учета.

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

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


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

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

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

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


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

виды хранимых данных;

аппаратная часть системы.

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



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

Перед дальнейшим обсуждением задачи отметим две важные вещи. Во-первых, при разработке будет использоваться стандартная реляционная база данных (СУРБД), вокруг которой строится программное приложение. Заниматься созданием своей СУБД в данной ситуации просто бессмысленно; нам придется реализовать большинство основных свойств стандартной базы данных, что резко увеличит расходы, а полученный в результате продукт окажется функционально куда менее гибким. Преимущество стандартной реляционной СУБД заключается также в ее относительной переносимости. Большинство распространенных баз данных адаптировано к различным платформам, от персональных компьютеров до мэйнфреймов. Во-вторых, как видно из рис. 10-1, мы хотим, чтобы система складского учета функционировала в распределенной компьютерной сети.Мы планируем разместить всю базу данных на одном компьютере, к которому будут иметь доступ все компьютеры сети. Такая схема и реализует архитектуру клиент-сервер; компьютер, на котором установлена база, является сервером. К нему подключаются несколько клиентов. Конкретный компьютер, на котором работает пользователь, не имеет для сервера никакого значения. Таким образом, наше приложение должно работать на любом компьютере сети, и внедрение новых аппаратных технологий будет оказывать минимальное влияние на функционирование системы.


Организованная и неорганизованная сложность


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

Этот пример наводит на мысль, что мы обращались с термином иерархия в весьма приблизительном смысле. Наиболее интересные сложные системы содержат много разных иерархий. В самолете, например, можно выделить несколько систем: питания, управления полетом и т.д. Такое разбиение дает структурную иерархию типа "быть частью". Эту же систему можно разложить совершенно другим способом. Например, турбореактивный двигатель - особый тип реактивного двигателя, a "Pratt and Whitney TF30" - особый тип турбореактивного двигателя. С другой стороны, понятие "реактивный двигатель" обобщает свойства, присущие всем реактивным двигателям; "турбореактивный двигатель" - это просто особый тип реактивного двигателя со свойствами, которые отличают его, например, от прямоточного.

Эта вторая иерархия представляет собой иерархию типа "is-a". Исходя из нашего опыта, мы сочли необходимым рассмотреть систему с двух точек зрения, как иерархию первого и второго типа. По причинам, изложенным в главе 2, мы назовем эти иерархии соответственно структурой классов и структурой объектов [Сложные программные системы включают также и другие типы иерархии. Особое значение имеют их модульная структура, которая описывает отношения между физическими компонентами системы, и иерархия процессов, которая описывает отношения между динамическими компонентами].


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

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

Структуры классов и объектов системы вместе мы называем архитектурой системы.

Человеческие возможности и сложные системы. Если мы знаем, как должны быть спроектированы сложные программные системы, то почему при создании таких систем мы сталкиваемся с серьезными проблемами? Как показано в главе 2, идея о том, как бороться со сложностью программ (эту идею мы будем называть объектный подход) относительно нова.


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



Рис. 1-1. Каноническая форма сложной системы.

Когда мы начинаем анализировать сложную программную систему, в ней обнаруживается много составных частей, которые взаимодействуют друг с другом различными способами, причем ни сами части системы, ни способы их взаимодействия не обнаруживают никакого сходства. Это пример неорганизованной сложности. Когда мы начинаем организовывать систему в процессе ее проектирования, необходимо думать сразу о многом. Например, в системе управления движением самолетов приходится одновременно контролировать состояние многих летательных аппаратов, учитывая такие их параметры, как местоположение, скорость и курс. При анализе дискретных систем необходимо рассматривать большие, сложные и не всегда детерминированные пространства состояний. К сожалению, один человек не может следить за всем этим одновременно. Эксперименты психологов, например Миллера, показывают, что максимальное количество структурных единиц информации, за которыми человеческий мозг может одновременно следить, приблизительно равно семи плюс-минус два [14]. Вероятно, это связано с объемом краткосрочной памяти у человека. Саймон также отмечает, что дополнительным ограничивающим фактором является скорость обработки мозгом поступающей информации: на восприятие каждой новой единицы информации ему требуется около 5 секунд [15].

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

1.3. Внесение порядка в хаос


Основные положения объектной модели


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

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

Рис. 2-4. Топология малых и средних приложений в объектных и объектно-ориентированных языках.

Объектно-ориентированный анализ и проектирование отражают эволюционное, а не революционное развитие проектирования; новая методология не порывает с прежними методами, а строится с учетом предшествующего опыта. К сожалению, большинство программистов в настоящее время формально и неформально натренированы на применение только методов структурного проектирования. Разумеется, многие хорошие проектировщики создали и продолжают совершенствовать большое количество программных систем на основе этой методологии. Однако алгоритмическая декомпозиция помогает только до определенного предела, и обращение к объектно-ориентированной декомпозиции необходимо. Более того, при попытках использовать такие языки, как C++ или Ada, в качестве традиционных, алгоритмически ориентированных, мы не только теряем их внутренний потенциал - скорее всего результат будет даже хуже, чем при использовании обычных языков С и Pascal. Дать электродрель плотнику, который не слышал об электричестве, значит использовать ее в качестве молотка. Он согнет несколько гвоздей и разобьет себе пальцы, потому что электродрель мало пригодна для замены молотка.



Открытые вопросы


Чтобы успешно использовать объектный подход, нам предстоит ответить на следующие вопросы:

Что такое классы и объекты?

Как идентифицировать классы и объекты в конкретных приложениях?

Как описать схему объектно-ориентированной системы?

Как создавать хорошо структурированные объектно-ориентированные системы?

Как организовать управление процессом разработки согласно OOD? Этим вопросам посвящены следующие пять глав.

Выводы

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

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

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

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

<
Рис. 2-6. Применения объектной модели.

Модульность - это состояние системы, разложенной на внутренне связные и слабо связанные между собой модули.

Иерархия - это ранжирование или упорядочение абстракций.

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

Параллелизм - это свойство, отличающее активные объекты от пассивных.

Сохраняемость - способность объекта существовать во времени и (или) в пространстве.

Дополнительная литература

Концепция объектной модели была впервые введена Джонсом (Jones) [F 1979] и Вильям-сом (Williams) [F 1986]. Диссертация Кэя (Кау) [F 1969] дала направление большой части дальнейших работ по объектно-ориентированному программированию.

Шоу (Shaw) [J 1984] дала блестящий обзор механизмов абстракции в языках программирования высокого уровня. Теоретические обоснования абстракции можно найти в работах Лисков и Гуттага (Liskov and Guttag) [H 1986], Гуттага (Guttag) [J 1980] и Хилфингера Hilfinger [J 1982]. Работа Парнаса (Parnas) [F 1979] по скрытию информации была очень плодотворна. Смысл и значение иерархии обсуждается в работе под редакцией Пати (Pattee) [J 1973].

Есть масса литературы по объектно-ориентированному программированию. Карделли и Вегнер (Cardelli and Wegner) [J 1985] и Вегнер (Wegner) Х[J 1987] подготовили замечательный обзор объектных и объектно-ориентированных языков. Методические статьи Стефика и Боброва (Stefik and Bobrow) [G 1986], Страуструпа (Stroustrup) [G 1988], Hюгарта (Nygaard) [G 1986] и Грогоно (Grogono) [G 1991] полезны для начала изучения всех вопросов объектно-ориентированного программирования. В книгах Кокса (Сох) [G 1986], Мейера(Меуеr)[F 1988], Шмукера(Schmucker) [G 1986] и Кима и Лочовского (Kim and Lochovsky) [F 1989] эти же вопросы рассматриваются более подробно.

Методы объектно-ориентированного проектирования впервые ввел Буч (Booch) [F 1981, 1982, 1986, 1987, 1989]. Методы объектно-ориентированного анализа впервые ввели Шлэер и Меллор (Shiaer and Mellor) [В 1988] и Бэилин (Bailin) [В 1988].


После этого было предложено много методов объектно-ориентированного анализа и проектирования; среди них наиболее значительны были, изложенные в работах Румбаха (Rumbaugh) [F 1991], Коада и Йордона (Coad and Yourdon) [В 1991], Константайна (Constantine) [F 1989], Шлэра и Меллора (Shlaer and Mellor) [В 1992], Мартина и Одела (Martin and Odell) [В 1992], Вассермана (Wasserman) [В 1991], Джекобсона (Jacobson) [F 1992], Рубина и Голдберга (Rubin and Goldberg) [В 1992], Эмбли (Embly) [В 1992], Верфс-Брока (Wirfs-Brock) [F 1990], Голдстейна и Адлера (Goldstein and Adler) [С 1992], Хендерсон-Селлерс (Henderson-Sellers) [F 1992], Файерсмита (Firesmith) [F 1992] и Фьюжина (Fusion) [F 1992].

Разбор конкретных случаев может быть найден в работах Тэйлора (Taylor) [H 1990, С 1992], Берарда (Berard) [H 1993], Лова (Love) [С 1993] и Пинсона с Вейнером (Pinson and Weiner) [С 1990].

Замечательная подборка работ по всем аспектам объектно-ориентированной технологии может быть найдена в трудах Петерсона (Peterson) [G 1987], Шривера и Вегнера (Schriver and Wegner) [G 1987] и Хошафяна и Абнуа (Khoshafian and Abnous) [I 1990]. Труды нескольких ежегодных конференций по объектно-ориентированной технологии - это еще один богатый источник материала. Наиболее интересные форумы - OOPSLA, ЕСООР, TOOLS, Object Word и ObjectExpo.

Организации, отвечающие за стандарты по объектной технологии: Object Management Group (OMG) и комитет ANSI X3J7.

Главный источник сведений по C++ - это книга Эллис и Страуструпа (Ellis and Stroustrup) [G 1990]. Другие полезные ссылки: Страуструп (Stroustrup) [G 1991], Липпман (Lippman) I [G 1991] и Коплиен (Coplien) [1992].








Отношения между классами и объектами


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

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

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



Отображение информации


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

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

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

Pиc. 12-8. Отображение информации.

На рис. 12-8 показано проектное решение о реализации всех отображаемых объектов с помощью общих утилит класса. Эти утилиты построены на основе низкоуровневого интерфейса Windows, который скрыт от всех высокоуровневых классов. На самом деле, процедуры Windows API трудно воплотить в одном классе или утилите. Наша диаграмма немного упрощена; вероятно, реализация потребует услуг нескольких классов Windows API и утилит отображения на дисплее компьютера в поезде.

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



Парадигмы программирования


Дженкинс и Глазго считают, что "в большинстве своем программисты используют в работе один язык программирования и следуют одному стилю. Они программируют в парадигме, навязанной используемым ими языком. Часто они оставляют в стороне альтернативные подходы к цели, а следовательно, им трудно увидеть преимущества стиля, более соответствующего решаемой задаче" [40]. Бобров и Стефик так определили понятие стиля программирования: "Это способ построения программ, основанный на определенных принципах программирования, и выбор подходящего языка, который делает понятными программы, написанные в этом стиле" [41]. Эти же авторы выявили пять основных разновидностей стилей программирования, которые перечислены ниже вместе с присущими им видами абстракций:
 


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

Каждый стиль программирования имеет свою концептуальную базу. Каждый стиль требует своего умонастроения и способа восприятия решаемой задачи. Для объектно-ориентированного стиля концептуальная база - это объектная модель. Она имеет четыре главных элемента:

абстрагирование;

инкапсуляция;

модульность;

иерархия.

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

типизация;

параллелизм;

сохраняемость.

Называя их дополнительными, мы имеем в виду, что они полезны в объектной модели, но не обязательны.

Без такой концептуальной основы вы можете программировать на языке типа Smalltalk, Object Pascal, C++, CLOS, Eiffel или Ada, но из-под внешней красоты будет выглядывать стиль FORTRAN, Pascal или С. Выразительная способность объектно-ориентированного языка будет либо потеряна, либо искажена. Но еще более существенно, что при этом будет мало шансов справиться со сложностью решаемых задач.



Параллелизм


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

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

Многие современные операционные системы предусматривают прямую поддержку параллелизма, и это обстоятельство очень благоприятно сказывается на возможности обеспечения параллелизма в объектно-ориентированных системах. Например, системы UNIX предусматривают системный вызов fork, который порождает новый процесс. Системы Windows NT и OS/2 - многопоточные; кроме того они обеспечивают программные интерфейсы для создания процессов и манипулирования с ними.

Лим и Джонсон отмечают, что "возможности проектирования параллельности в объектно-ориентированных языках не сильно отличаются от любых других, - на нижних уровнях абстракции параллелизм и OOP развиваются совершенно независимо.
С OOP или без, все традиционные проблемы параллельного программирования сохраняются" [75]. Действительно, создавать большие программы и так непросто, а если они еще и параллельные, то надо думать о возможном простое одного из потоков, неполучении данных, взаимной блокировке и т.д.

К счастью, как отмечают те же авторы далее: "на верхних уровнях OOP упрощает параллельное программирование для рядовых разработчиков, пряча его в повторно-используемые абстракции" [76]. Блэк и др. сделали следующий вывод: "объектная модель хороша для распределенных систем, поскольку она неявно разбивает программу на (1) распределенные единицы и (2) сообщающиеся субъекты" [77].

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



Параллелизм позволяет различным объектам действовать одновременно.

Параллелизм - это свойство, отличающее активные объекты от пассивных.

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



Во-первых, параллелизм - это внутреннее свойство некоторых языков программирования. Так, для языка Ada механизм параллельных процессов реализуется как задача. В Smalltalk есть класс process, которому наследуют все активные объекты. Есть много других языков со встроенными механизмами для параллельного выполнения и синхронизации процессов - Actors, Orient 84/K, ABCL/1, которые предусматривают сходные механизмы параллелизма и синхронизации. Во всех этих языках можно создавать активные объекты, код которых постоянно выполняется параллельно с другими активными объектами.

Во-вторых, можно использовать библиотеку классов, реализующих какую-нибудь разновидность "легкого" параллелизма. Например, библиотека AT&T для C++ содержит классы Shed, Timer, Task и т.д. Ее реализация, естественно, зависит от платформы, хотя интерфейс достаточно хорошо переносим. При этом подходе механизмы параллельного выполнения не встраиваются в язык (и, значит, не влияют на системы без параллельности), но в то же время практически воспринимаются как встроенные.

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

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


Пять признаков сложной системы


Исходя из такого способа изучения, можно вывести пять общих признаков любой сложной системы. Основываясь на работе Саймона и Эндо, Куртуа предлагает следующее наблюдение [7]:

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

Саймон отмечает: "тот факт, что многие сложные системы имеют почти разложимую иерархическую структуру, является главным фактором, позволяющим нам понять, описать и даже "увидеть" такие системы и их части" [8]. В самом деле, скорее всего, мы можем понять лишь те системы, которые имеют иерархическую структуру.

Важно осознать, что архитектура сложных систем складывается и из компонентов, и из иерархических отношений этих компонентов. Речтин отмечает: "Все системы имеют подсистемы, и все системы являются частями более крупных систем... Особенности системы обусловлены отношениями между ее частями, а не частями как таковыми" [9].

Что же следует считать простейшими элементами системы? Опыт подсказывает нам следующий ответ:

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

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

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

3. "Внутрикомпонентная связь обычно сильнее, чем связь между компонентами. Это обстоятельство позволяет отделять "высокочастотные" взаимодействия внутри компонентов от "низкочастотной" динамики взаимодействия между компонентами" [10].

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


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

4. "Иерархические системы обычно состоят из немногих типов подсистем, по-разному скомбинированных и организованных" [11].

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

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

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

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


Планирование расписания поездов


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

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

На рис. 12-6 приведены стратегические проектные решения, касающиеся структуры класса TrainPlan. Как и в главе 10, мы используем диаграмму классов, чтобы показать части, из которых состоит план движения поезда (подобно тому, как это делается на традиционных диаграммах "сущность-связь"). Мы видим, что каждый план содержит одну бригаду, но может включать в себя много приказов и действий. Мы ожидаем, что эти действия будут упорядочены во времени и что с каждым действием связана такая информация, как время, местоположение, скорость, ответственное лицо, приказы. Например, план может содержать следующие действия:

Время Положение Скорость Ответственное лицо Приказ
0800 Pueblo Как указано Начальник депо Покинуть депо
1100 Colorado Springs 40 миль/ч   Отцепить 30 вагонов
1300 Denver 40 миль/ч   Отцепить 20 вагонов
1600 Pueblo Как указано   Вернуться в депо

 
Из рис. 12-6 видно, что класс TrainPlan имеет один статический объект типа UniqueId, так называемое магическое число, однозначно идентифицирующее каждый экземпляр класса TrainPlan.

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


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

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

Рис. 12-6. Диаграмма классов TrainPlan (план движения поезда).

 
На рис. 12-7 показано, как происходит передача и обновление копий плана. Первичная копия плана движения находится в централизованной базе данных в диспетчерском центре и может быть разослана по любому числу узлов сети. Когда какой-либо клиент (используя операцию get с уникальным UniqueId в качестве аргумента) запрашивает план, первичная версия копируется и посылается клиенту.


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

Рис. 12- 7. План движения поезда.

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

Использование готовой коммерческой СУБД на диспетчерских компьютерах позволит обеспечить резервирование данных, восстановление, ведение контрольного журнала и секретность информации.>


Планирование релизов


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

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

Создание иерархии датчиков.

Создание классов, ответственных за управление изображением на экране.

Создание классов, ответственных за работу пользовательского интерфейса.

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

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



Планирование задач


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

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

Планирование задач связано с построением графика представления результатов макропроцесса. В промежутках между очередными релизами менеджеры команды должны оценить трудности, угрожающие проекту [Гилб замечает: "если вы не идете в атаку на трудности, трудности идут в атаку на вас" [5]], сконцентрировать ресурсы, чтобы разрешить возникшие проблемы, и далее заниматься новой итерацией микропроцесса, в результате которой нужно получить стабильную систему, удовлетворяющую сценариям, запланированным для нового релиза. Планирование задач на этом уровне очень часто оказывается неудачным из-за чрезмерно оптимистических графиков [4]. Разработка, которая рассматривалась как "просто вопрос программирования", растягивается на многие дни работы; графики выбрасываются в корзину, когда разработчик, занимаясь частью системы, предполагает определенные протоколы для других частей системы, а потом получает неполно или неправильно изготовленные классы.
Смертельную опасность могут представлять внезапно обнаружившиеся ошибки в компиляторе или то, что программа не укладывается в заданное время исполнения. И то и другое часто приходится преодолевать, жертвуя принятыми ранее тактическими решениями.

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

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


Почему программному обеспечению присуща сложность?


Как говорит Брукс, "сложность программного обеспечения - отнюдь не случайное его свойство" [3]. Сложность вызывается четырьмя основными причинами:

сложностью реальной предметной области, из которой исходит заказ на разработку;

трудностью управления процессом разработки;

необходимостью обеспечить достаточную гибкость программы;

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

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

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


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

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

под сопровождением понимается устранение ошибок;

под эволюцией - внесение изменений в систему в ответ на изменившиеся требования к ней;

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

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

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


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



Задача разработчиков программной системы - создать иллюзию простоты.

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



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

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


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


Последствия неограниченной сложности


"Чем сложнее система, тем легче ее полностью развалить" [5]. Строитель едва ли согласится расширить фундамент уже построенного 100-этажного здания. Это не просто дорого: делать такие вещи значит напрашиваться на неприятности. Но что удивительно, пользователи программных систем, не задумываясь, ставят подобные задачи перед разработчиками. Это, утверждают они, всего лишь технический вопрос для программистов.

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

Как мы можем изменить положение дел? Так как проблема возникает в результате сложности структуры программных продуктов, мы предлагаем сначала рассмотреть способы работы со сложными структурами в других областях. В самом деле, можно привести множество примеров успешно функционирующих сложных систем. Некоторые из них созданы человеком, например: космический челнок Space Shuttle, туннель под Ла-Маншем, большие фирмы типа Microsoft или General Electric. В природе существуют еще более сложные системы, например система кровообращения у человека или растение.

1.2. Структура сложных систем



Поведение


Что такое поведение. Объекты не существуют изолированно, а подвергаются воздействию или сами воздействуют на другие объекты.

Поведение - это то, как объект действует и реагирует; поведение выражается в терминах состояния объекта и передачи сообщений.

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

Операцией называется определенное воздействие одного объекта на другой с целью вызвать соответствующую реакцию. Например, клиент может активизировать операции append и pop для того, чтобы управлять объектом-очередью (добавить или изъять элемент). Существует также операция length, которая позволяет определить размер очереди, но не может изменить это значение. В чисто объектно-ориентированном языке, таком как Smalltalk, принято говорить о передаче сообщений между объектами. В языках типа C++, в которых четче ощущается процедурное прошлое, мы говорим, что один объект вызывает функцию-член другого. В основном понятие сообщение совпадает с понятием операции над объектами, хотя механизм передачи различен. Для наших целей эти два термина могут использоваться как синонимы.

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

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


Состояние объекта представляет суммарный результат его поведения.

Наиболее интересны те объекты, состояние которых не статично: их состояние изменяется и запрашивается операциями.

Примеры. Опишем на языке C++ класс Queue (очередь):

class Queue {
public: Queue();
Queue(const Queue&);
virtual ~Queue();
virtual Queue& operator=(const Queue&);
virtual int operator==(const Queue&) const;
int operator!=(const Queue&) const;
virtual void clear();
virtual void append(const void*);
virtual void pop();
virtual void remove(int at);
virtual int length() const;
virtual int isEmpty() const;
virtual const void* front() const;
virtual int location(const void*);

protected:
...
};


В определении класса используется обычная для С идиома ссылки на данные неопределенного типа с помощью void*, благодаря чему в очередь можно вставлять объекты разных классов. Эта техника не безопасна - клиент должен ясно понимать, с каким (какого класса) объектом он имеет дело. Кроме того, при использовании void* очередь не "владеет" объектами, которые в нее помещены. Деструктор ~Queue() уничтожает очередь, но не ее участников. В следующем разделе мы рассмотрим параметризованные типы, которые помогают справляться с такими проблемами.

Так как определение Queue задает класс, а не объект, мы должны объявить экземпляры класса, с которыми могут работать клиенты:

Queue a, b, c, d;

Мы можем выполнять операции над объектами:

a.append(&deb);
a.append(&karen);
a.append (&denise);
b = a;
a.pop();


Теперь очередь а содержит двух сотрудников (первой стоит karen), а очередь b - троих (первой стоит deb). Таким образом, очереди имеют определенное состояние, которое влияет на их будущее поведение - например, одну очередь можно безопасно продвинуть (pop) еще два раза, а вторую - три.

Операции. Операция - это услуга, которую класс может предоставить своим клиентам. На практике типичный клиент совершает над объектами операции пяти видов [Липпман предложил несколько иную классификацию: функции управления, функции реализации, вспомогательные функции (все виды модификаторов) и функции доступа (эквивалентные селекторам) [7]].


Ниже приведены три наиболее распространенные операции:

• Модификатор Операция, которая изменяет состояние объекта
• Селектор Операция, считывающая состояние объекта, но не меняющая состояния
• Итератор Операция, позволяющая организовать доступ ко всем частям объекта в строго определенной последовательности
 
Поскольку логика этих операций весьма различна, полезно выбрать такой стиль программирования, который учитывает эти различия в коде программы. В нашей спецификации класса Queue мы вначале перечислили все модификаторы (функции-члены без спецификаторов const - clear, append, pop, remove), а потом все селекторы (функции со спецификаторами const - length, isEmpty, front и location). Позднее в главе 9, следуя нашему стилю, мы определим отдельный класс, который действует как агент, отвечающий за итеративный просмотр очередей.

Две операции являются универсальными; они обеспечивают инфраструктуру, необходимую для создания и уничтожения экземпляров класса:
 
• Конструктор Операция создания объекта и/или его инициализации
• Деструктор Операция, освобождающая состояние объекта и/или разрушающая сам объект
 
В языке C++ конструктор и деструктор составляют часть описания класса, тогда как в Smalltalk и CLOS эти операторы определены в протоколе метакласса (то есть класса класса).

В чисто объектно-ориентированных языках, таких как Smalltalk, операции могут быть только методами, так как процедуры и функции вне классов в этом языке определять не допускается. Напротив, в языках Object Pascal, C++, CLOS и Ada допускается описывать операции как независимые от объектов подпрограммы. В C++ они называются функциями-нечленами; мы же будем здесь называть их свободными подпрограммами. Свободные подпрограммы - это процедуры и функции, которые выполняют роль операций высокого уровня над объектом или объектами одного или разных классов. Свободные процедуры группируются в соответствии с классами, для которых они создаются. Это дает основание называть такие пакеты процедур утилитами класса.


Например, для определенного выше класса Queue можно написать следующую свободную процедуру:

void copyUntilFound(Queue& from, Queue& to, void* item)
{ while ((!from.isEmpty()) && (from.front() != item))
{ to.append(from.front());
from.pop();

}

}


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

В C++ (и Smalltalk) принято собирать все логически связанные свободные подпрограммы и объявлять их частью некоторого класса, не имеющего состояния. Все такие функции будут статическими.

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

Роли и ответственности. Совокупность всех методов и свободных процедур, относящихся к конкретному объекту, образует протокол этого объекта. Протокол, таким образом, определяет поведение объекта, охватывающее все его статические и динамические аспекты. В самых нетривиальных абстракциях полезно подразделять протокол на частные аспекты поведения, которые мы будет называть ролями. Адамс говорит, что роль - это маска, которую носит объект [8]; она определяет контракт абстракции с ее клиентами.

Объединяя наши определения состояния и поведения объекта, Вирфс-Брок вводит понятие ответственности. "Ответственности объекта имеют две стороны - знания, которые объект поддерживает, и действия, которые объект может исполнить. Они выражают смысл его предназначения и место в системе. Ответственность понимается как совокупность всех услуг и всех контрактных обязательств объекта" [9].


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

Действительно большинство интересных объектов исполняют в своей жизни разные роли, например [10]:

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

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

В течении дня одна и та же персона может играть роль матери, врача, садовника и кинокритика.

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

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

Объекты как автоматы. Наличие внутреннего состояния объектов означает, что порядок выполнения операций имеет существенное значение. Это наводит на мысль представить объект в качестве маленькой независимой машины [11]. Действительно, для ряда объектов такой временной порядок настолько существен, что наилучшим способом их формального описания будет конечный автомат. В главе 5 мы введем обозначения для описания иерархических конечных автоматов, которые можно использовать для выражения соответствующей семантики.

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


Практические вопросы


Разработка программ пока остается чрезвычайно трудоемким делом, в значительной степени она по-прежнему больше напоминает строительство коттеджей, чем промышленное возведение зданий [1]. Доклад Кишиды и др. свидетельствует, что даже в Японии на начальной стадии проектов "все еще по большей части полагаются на неформальный подход - карандаш и бумагу" [2].

Ситуация усугубляется тем обстоятельством, что проектирование - никак не точная наука. Возьмем проектирование баз данных, одну из технологий, предшествовавших объектно-ориентированному проектированию. Как замечает Хаврис-кевич: "Хотя все выглядит просто и ясно, неизбежно примешивается изрядная доля личного представления о важности различных объектов на предприятии. В результате процесс проектирования не воспроизводим: разные проектировщики могут создать разные модели одного и того же предприятия" [3].

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

7.1. Управление и планирование

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



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


Как программисты- профессионалы мы стремимся делать свои системы полезными и работоспособными. Как инженеры-разработчики мы должны создавать сложные системы при ограниченных компьютерных и кадровых ресурсах. За последние несколько лет объектно-ориентированная технология проникла в различные разделы компьютерных наук. К ней относятся как к средству преодоления сложности, присущей многим реальным системам. Объектная модель показала себя мощной объединяющей концепцией.
Что изменилось по сравнению с первым изданием
Со времени выхода в свет первого издания книги "Объектно-ориентированное проектирование с примерами применения" ("Object-Oriented Design with Applications") объектно-ориентированная технология стала одной из основных при разработке программного обеспечения промышленного масштаба. Мы видим, что во всем мире объектная парадигма применяется в таких различных областях, как управление банковскими транзакциями, автоматизация кегельбанов, управление коммунальным хозяйством и исследование генов человека. Во многих случаях новые поколения операционных систем, систем управления базами данных, телефонных служб, систем авионики и мультимедиа-программ пишутся в объектно-ориентированном стиле. В большинстве таких проектов предпочли использовать объектно-ориентированную технологию просто потому, что не было другой возможности создать достаточно надежную и жизнеспособную систему.
За последние годы в сотнях проектов применяли нотацию и процесс разработки, предложенные в нашей книге [Включая мои собственные проекты. Я все же разработчик, а не методолог. Первый вопрос, который нужно задавать каждому методологу: "Используете ли вы ваши методы при разработке собственных программ?"]. В процессе собственной разработки проектов и с учетом опыта многих других, кто пожертвовал своим временем, чтобы поделиться с нами, мы нашли много способов усовершенствовать наш метод. Усовершенствование достигается за счет лучшего изложения процесса проектирования, введения семантики, которая ранее не была отражена в нашей нотации, и упрощения этой нотации там, где возможно.


За истекшее время появились многие другие методы, изложенные в работах Джекобсона (Jacobson), Румбаха (Rumbaugh), Гоада и Иордана (Goad and Yourdon), Константайна (Constantine), Шлера и Меллора (Shiaer and Mellor), Мартина и Одел-ла (Martin and Odell), Вассермана (Wasserman), Голдберга и Рубина (Goldberg and Rubin), Эмбли (Embley), Вирфс-Брока (Wirfs-Brock), Голдстейна и Алгера (Goldstein and Alger), Хендерсон-Селлерса (Henderson-Sellers), Файесмита (Firesmith) и др. Особенно интересна работа Румбаха, который отмечает, что в наших подходах больше сходства чем различий. Мы провели анализ многих из этих методов, разговаривали с разработчиками и менеджерами, которые их использовали, и, когда это было возможно, пытались сами их применять. Так как мы больше заинтересованы в реальной помощи по разработке проектов в объектно-ориентированной технологии, чем в догматическом следовании (будь то по эмоциональным или историческим причинам) нашим идеям, мы пытались включить все лучшее, что нашли в новых методах, в нашу собственную работу. Мы с благодарностью отмечаем фундаментальный и уникальный вклад каждого из этих лиц в данную область.
Индустрии программных средств и объектно-ориентированной технологии полезно было бы иметь, в частности, стандартную систему обозначений. Поэтому в данном издании представлена унифицированная система обозначений, в которой, где возможно, устранены косметические различия между нашей нотацией и другими, особенно Джекобсона и Румбаха. Как и раньше, чтобы обеспечить ее неограниченное использование, система обозначений сделана общедоступным продуктом (public domain).
Цели, аудитория и структура этого издания остаются теми же, что и в первом. Однако, есть пять существенных различий между вторым и первым изданиями.
Во-первых, глава 5 была расширена с тем, чтобы изложить унифицированную систему обозначений значительно подробнее. Чтобы сделать ее более понятной, были явно разделены существенные и дополнительные элементы обозначений. Кроме того, особое внимание уделено взаимному согласованию разных представлений в этой системе.


Во-вторых, значительно расширены главы 6 и 7, в которых рассматривается практика объектно-ориентированного анализа и проектирования. Мы даже сменили в этом издании заглавие книги, отразив тот факт, что наш метод объединяет анализ и проектирование.
В-третьих, мы решили приводить примеры всех программных текстов в основной части книги на одном языке, а именно на C++. Этот язык быстро становится фактическим стандартом для многих областей, кроме того, большинство профессиональных разработчиков, "сочиняющих" на других языках, могут "читать" на C++. Это не значит, что мы считаем другие языки - такие, как Smalltalk, CLOS, Ada или Eiffel - менее важными. Главная цель этой книги - анализ и проектирование, и так как нам нужны конкретные примеры, мы решили писать их на достаточно общем языке программирования. Где возможно, мы описываем особенности семантики других языков и их влияние на наш метод.
В-четвертых, в это издание включены несколько новых примеров приложений. Некоторые интересные идиомы и среды разработки применялись для написания целого ряда приложений и наши примеры отражают эти достижения. Например, концепция "клиент/сервер" послужила основой для одного пересмотренного прикладного примера.
Наконец, почти в каждую главу добавлены ссылки на литературу. Кроме того, мы обсуждаем новые проблемные вопросы объектно-ориентированной технологии, возникшие после выхода первого издания.
Цели
Эта книга призвана служить практическим руководством по созданию объектно-ориентированных систем. Особое внимание мы уделяем следующим целям:
обеспечить отчетливое понимание основных концепций объектной модели;
помочь освоить систему обозначений и процесс объектно-ориентированного анализа и проектирования;
научить читателя практическому применению объектно-ориентированного подхода в различных предметных областях.
Изложенные здесь понятия имеют серьезное теоретическое обоснование, но эта книга прежде всего призвана удовлетворить практические потребности и интересы сообщества разработчиков программных продуктов.


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

Преимущества объектной модели


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

Во-первых, объектная модель позволяет в полной мере использовать выразительные возможности объектных и объектно-ориентированных языков программирования. Страуструп отмечает: "Не всегда очевидно, как в полной мере использовать преимущества такого языка, как C++. Существенно повысить эффективность и качество кода можно просто за счет использования C++ в качестве "улучшенного C" с элементами абстракции данных. Однако гораздо более значительным достижением является введение иерархии классов в процессе проектирования. Именно это называется OOD и именно здесь преимущества C++ демонстрируются наилучшим образом" [82]. Опыт показал, что при использовании таких языков, как Smalltalk, Object Pascal, C++, CLOS и Ada вне объектной модели, их наиболее сильные стороны либо игнорируются, либо применяются неправильно.

Сохраняемость поддерживает состояние и класс объекта в пространстве и во времени.

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

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

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

Наконец, объектная модель ориентирована на человеческое восприятие мира, или, по словам Робсона, "многие люди, не имеющие понятия о том, как работает компьютер, находят вполне естественным объектно-ориентированный подход к системам" [84].



Примеры приложений


Заключительная часть посвящена пяти нетривиальным примерам, охватывающим широкий круг приложений: сбору данных, прикладным средам разработки, архитектуре клиент/сервер, искусственному интеллекту и управлению технической системой. Мы выбрали эти области, так как они хорошо представляют те разновидности сложных задач, с которыми может столкнуться программист. Легко можно продемонстрировать успех любых принципов на простых задачах, но поскольку мы фокусируем свое внимание на создании систем реальной жизни, нам было интереснее показать, как объектная модель доходит до сложных приложений. Некоторые читатели могут быть незнакомы со спецификой выбранного приложения, поэтому мы начинаем каждый пример с краткого обсуждения присущих ему технологических особенностей (таких, как проектирование базы данных и понятия информационной доски). Разработку программных систем нельзя свести к набору рецептов, поэтому мы подчеркиваем необходимость постепенного развития приложений на основе соблюдения ряда четких принципов и следования ясным моделям.


Чтобы построить теорию, необходимо как следует знать основные феномены изучаемой области. Что касается теории вычислений, то мы не знаем этого предмета настолько, чтобы изложить его на высоком уровне абстракции. Напротив, нам следует объяснять хорошо изученные конкретные примеры, на основе которых мы сможем догадаться об общих принципах и обосновать их.
Марвин Минский (Marvin Minsky)
Форма и содержание в компьютерной науке
(Form and Content in Computer Science)

Примеры сложных систем


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

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

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

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


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


Ядерщики имеют дело со структурной иерархией физических тел совсем другого масштаба. Атомы состоят из электронов, протонов и нейтронов; электроны, по-видимому, являются элементарными частицами, но протоны, нейтроны и другие тяжелые частицы формируются из еще более мелких компонентов, называемых кварками.
Мы опять обнаруживаем общность форм механизмов в этих сложных иерархиях. На самом деле оказывается, что во Вселенной работают всего четыре типа сил: гравитационное, электромагнитное, сильное и слабое взаимодействия. Многие законы физики универсальны, например, закон сохранения энергии и импульса можно применить и к галактикам, и к кваркам.
Структура социальных институтов. Как последний пример сложных систем рассмотрим структуру общественных институтов. Люди объединяются в группы для решения задач, которые не могут быть решены индивидуально. Одни организации быстро распадаются, другие функционируют на протяжении нескольких поколений. Чем больше организация, тем отчетливее проявляется в ней иерархическая структура. Транснациональные корпорации состоят из компаний, которые в свою очередь состоят из отделений, содержащих различные филиалы. Последним принадлежат уже отдельные офисы и т.д. Границы между частями организации могут изменяться, и с течением времени может возникнуть новая, более стабильная иерархия.
Отношения между разными частями большой организации подобны отношениям между компонентами компьютера, растения или галактики. Характерно, что степень взаимодействия между сотрудниками одного учреждения несомненно выше, чем между сотрудниками двух разных учреждений. Клерк, например, обычно не общается с исполнительным директором компании, а в основном обслуживает посетителей. Но и здесь различные уровни имеют единые механизмы функционирования. Работа и клерка и директора оплачивается одной финансовой организацией, и оба они для своих целей используют общую аппаратуру, в частности, телефонную систему компании.

Процесс


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

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

6.1. Основные принципы



Проектирование


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

Результаты. Имеется два основных результата проектирования: описание архитектуры и выработка общих тактических приемов.

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

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

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

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

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

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

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

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

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

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

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

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


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

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

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

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

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

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

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

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

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

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

Основным признаком совершенства является простота. Хорошая архитектура имеет характеристики организованной сложной системы (см.главу 1).

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


Проектирование интерфейса классов


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

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

 
Подобным же образом можно определить протокол класса BinaryTree:
 

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