8.3. Эволюция

8.3. Эволюция

Планирование релизов

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

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

• Создание иерархии датчиков.

• Создание классов, ответственных за управление изображением на экране.

• Создание классов, ответственных за работу пользовательского интерфейса.

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

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

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

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

На данном этапе разработки иерархия классов-датчиков, представленная на рис. 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 более безопасна с точки зрения типов.

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

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

Подготовка следующего релиза, где должны быть окончательно определены классы 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 поднимает абстракцию до уровня понятий предметной области.

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

Последним основным элементом нашей системы является механизм пользовательского интерфейса, который должен быть реализован с помощью классов 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.