Лекция №

Вид материалаЛекция
3.4Вопросы по теме
Общие моменты разработки программы на основе объектно-ориентированного подхода
Подобный материал:
1   2   3   4   5   6   7   8   9   10   11

3.4Вопросы по теме

  1. Перечислить основные этапы разработки компонент программных систем.
  2. Особенности проектирования иерархии классов.
  3. Назначение реорганизации иерархии и структуры классов.

Общие моменты разработки программы на основе объектно-ориентированного подхода


Рассмотрим примеры, демонстрирующие использование концепции ООП и шаблонов.

Отметим особенности реализации, присущие всем примерам.

Чтобы использовать функцию, определенную в другом модуле программы или в библиотеке, ее нужно объявить. Для упрощения этого процесса для каждого модуля или библиотеки принято писать так называемые заголовочные файлы (.h), в которых объявляются все функции модуля (библиотеки). Так же в заголовочных файлах определяются специфические типы и константы. Далее, в начало модуля, где эти функции нужно использовать, включаются соответствующие заголовочные файлы при помощи директивы препроцессора #include. Использование этого механизма часто приводит к тому, что некоторый файл включается несколько раз косвенным образом (через другие файлы). Обычно такое повторное включение вызывает ошибки компиляции. Чтобы их избежать нужно предотвратить повторную компиляцию включаемого заголовка. Это достигается при помощи условной компиляции следующим образом:


// Если не определен макрос HeaderH, то включить код (до #endif).

// HeaderH - некоторое уникальное имя, обычно связанное с именем заголовочного файла.

#ifndef HeaderH


// Определим макрос HeaderH

#define HeaderH


// Объявление функций, определение типов и т. п.

...


#endif

При первом включении макрос HeaderH еще не определен, поэтому содержимое заголовка включается. При повторных включениях HeaderH уже определен, поэтому препроцессор выбрасывает код между #ifndef и #endif, т. е. повторная компиляция исключена.

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

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

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

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

Далее опишем рассматриваемые более детально примеры:
  1. Шаблоны функций. Понятие шаблона функции. Определение и использование шаблонов функций.
  2. Шаблоны классов: динамический массив. Понятие шаблона класса. Определение и использование шаблона класса. Показана перегрузка операций "ввода-вывода", индексации, равенства/неравенства.
  3. Шаблоны классов: связный список. Понятия контейнера и итератора. Определение и использование шаблона класса-контейнера и итератора для него. Показана перегрузка операций разыменовывания, инкремента и декремента.
  4. Иерархия классов: двухмерные фигуры. Абстрактный класс, виртуальные и чистые виртуальные функции, множественное наследование, использование информации о типах во время выполнения (RTTI). Перегрузка операций присваивание, сложение с присваиванием и запятая нестандартным образом.

2.2 Пример 1. Шаблоны функций


При работе с языком программирования Паскаль, нам известна процедура Inc. Она получает один обязательный аргумент - переменную X перечислимого типа, и один необязательный N - целое значение (по умолчанию 1). Результатом ее работы будет увеличение X на N (аналог X := X + N). Однако, если копнуть поглубже, Inc вовсе не является процедурой (несмотря на все уверения help-а). Вместо вызова этой процедуры подставляется специальный оптимизированный код, который в общем случае работает быстрее, чем конструкция X := X + N. Предположим, что в силу привычки, нам хотелось бы использовать эту конструкцию и в программе на C++ (вместо имеющейся именно для этих целей операции +=). Что же, при помощи встраиваемых функций, перегрузки функций, ссылок и параметров по умолчанию это можно легко сделать, причем эффективность будет как в паскале. Например, определим Inc для типов int и unsigned:


inline void Inc(int& x, int n = 1) { x += n; }

inline void Inc(unsigned& x, int n = 1) { x += n; }


...

// Теперь, например, можно написать:

int a;

Inc(a);

unsigned b;

Inc(b, 10);

...

А если нужно увеличить значение типа char? И как насчет float (хотя это не перечислимый тип)? Данный исходный код будет выглядеть так:


inline void Inc(char& x, int n = 1) { x += n; }

inline void Inc(float& x, int n = 1) { x += n; }


...

char c;

Inc(c, 3);

float x;

Inc(x);

...

Ну а теперь вспомним, что в C++ есть и такие типы: double, long double, unsigned char, short, unsigned short, long и так далее, плюс указатели на эти типы, плюс указатели на указатели... Довольно трудная вышла задачка.

Как можно заметить, у написанных выше функций (с одинаковым именем) всего одно различие - в типе первого аргумента. Однако, благодаря этому различию, компилятор знает какую функцию нужно вызвать (механизм перегрузки функций). А раз он такой умный, почему бы не заставить его самого писать эти функции по мере необходимости? К сожалению, компилятор не на столько умен и писать функции с "чистого листа" не станет. Вот тут то и пригодятся шаблоны! Пишем (для компилятора) один шаблон функции Inc и забываем про нее, а всю "грязную работу" по написанию отдельных функций Inc для int-ов, char-ов и прочих безвозмездно передаем компилятору.

Пример:

// Ключевым словом template сообщаем компилятору, что пишем шаблон.

// Конструкция class XXX в угловых скобках говорит о том, что мы хотим

// использовать один "произвольный" тип, который (в пределах данного шаблона)

// будет называться XXX.

template

inline void Inc(XXX& x, int n = 1) { x += n; }

Вот и все. Две простые строчки на все случаи функции Inc.

...

// Теперь мы пишем:

char c;

Inc(c, 2);

// а компилятор делает и использует функцию:

inline void Inc(char& x, int n = 1) { x += n; }

...


// Мы пишем:

float *p;

Inc(p);

// а компилятор делает и использует функцию:

inline void Inc(float*& x, int n = 1) { x += n; }

...

На паскале написать свою функцию Inc таким образом будет проблематично.

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

Шаблонные функции определены в заголовочном файле "Algo.h". Все они работают с одним "произвольным" типом, который обозначен как T. Указаны ограничения, налагаемые на тип T. На них стоит обратить внимание, если T является классом. Для встроенных типов эти условия выполняются.

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

Создаем консольное Win32-приложение, используя этот модуль как главный. Непосредственно в примере используются в качестве типа T встроенные типы int и float.


2.3 Пример 2. Шаблоны классов: динамический массив.


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

type Vector = array [1..N] of real;

В C и C++ массивы индексируются только с нуля. Поэтому при реализации алгоритма, рассчитанного на индексацию с единицы, в программе всегда нужно помнить об уменьшении индекса на единицу при обращении к элементу массива. Этого можно избежать, добавив в массив лишний элемент (массив содержит (N + 1) элементов, элемент с индексом 0 не используется).

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


template

class Array {

private:

// Элемент-данное: массив из (H - L + 1) элементов типа T

T Elements[H - L + 1];

public:

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

// Операция возвращает не константную ссылку на элемент массива Elements, позволяя как считывать так и

// модифицировать данные. При этом исходный индекс корректируется для получения соответствующего C++ индекса.

T& operator [](int index)

{

return Elements[index - L];

}

// Также для этого класса будут корректно работать конструктор копии и операция присваивания,

// определяемые компилятором по умолчанию.

};

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


typedef Array Vector;


// И аналогично паскалю использовать его:

Vector a, b, c;

for (int i = 1; i <= N; i++) {

a[i] = i * 0.5; // обращение по индексам

b[i] = a[i] + i; // от 1 до N

}

c = b; // присваивание


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


// Определение следующих переменных:

Array a;

Array b;

Array c;

Array d;


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

class Array {

private:

int Elements[N - 1 + 1];

public:

int& operator [](int index) { return Elements[index - 1]; }

};


class Array {

private:

int Elements[N - 0 + 1];

public:

int& operator [](int index) { return Elements[index - 0]; }

};


class Array {

private:

char Elements[5 - -5 + 1];

public:

char& operator [](int index) { return Elements[index - -5]; }

};


class Array {

private:

float Elements[5 - -5 + 1];

public:

float& operator [](int index) { return Elements[index - -5]; }

};

Так же стоит отметить, что паскаль позволяет проверять корректность индексов при обращении к элементам массива. C и C++ этого не делают. Однако, при необходимости, это легко сделать при помощи средств препроцессора. Стандартная библиотека содержит заголовочный файл "assert.h", в котором определяется макрос assert, использующийся для выявления ошибок на этапе разработки программы. Единственным аргументом макроса является условие (выражение), которое должно выполняться для дальнейшей безошибочной работы программы. После обработки исходного текста препроцессором на место макроса вставляется код, который проверяет данное условие. Если условие не выполняется (то есть значение определяющего его выражения равно нулю), то печатается сообщение об ошибке и программа аварийно завершается вызовом функции abort(). Модифицируем шаблон класса Array с использованием макроса assert, т. о. чтобы выполнялась проверка индексов:


#include

...

template

class Array {

...

T& operator [](int index)

{

assert(L <= index && index <= H);

return Elements[index - L];

}

};


Очевидно, что эта проверка увеличивает как размер программы, так и время ее работы. То есть она допустима только на этапе разработки. В паскале она отключается в настройках компилятора. В C и C++ опять придется воспользоваться средствами препроцессра. Если до включения "assert.h" определить макрос NDEBUG, то макрос assert будет определен как пустой (при его определении используется условная компиляция). Таким образом на его место в программе препроцессор не вставит никакого кода и проверки будут "отключены".

Теперь, когда рассмотрены ключевые моменты использования шаблонов классов и макроса assert, можно перейти к написанию шаблона класса. Как пример берется шаблон класса Array одномерного динамического массива. Шаблон имеет один параметр-тип - тип элементов массива. Он определен в заголовочном файле "Array.h". Используются шаблоны функций из Примера 1.

Ограничения, налагаемые на тип T, (как для шаблонов функций в "Algo.h") не указаны. Это связано с тем, что их полное описание будет очень объемным, так как зависит от того, какие функции-элементы класса мы захотим использовать. Так, например, если мы будем присваивать массивы, то для T должна быть корректно определена операция присваивания. Чтобы применять к массивам операции равенства и неравенства, для T должна быть определена операция неравенства.

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


2.4 Пример 3. Шаблоны классов: связный список.


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

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

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

Отметим еще несколько особенностей программирования контейнеров/итераторов.

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

class Container {

...


public:

Container(unsigned number_of_elements) { ... }

...


class Iterator { ... };


Iterator Begin() { return ...; }

Iterator End() { return ...; }

...

};


...

const unsigned Size = 10;

{

int с[Size];

// Итераторы для массива.

int *i, *begin, *end;

begin = с;

end = с + Size;

...

// Вывод на печать элементов.

for (i = begin; i != end; i++)

cout << *i;

}

{

Container c(Size);

// Итераторы для контейнера.

Container::Iterator i, begin, end;

begin = c.Begin();

end = c.End();

// Вывод на печать элементов.

for (i = begin; i != end; i++)

cout << *i;

}

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

Итератор списка (класс Iterator) также определен как вложенный класс для List, но является открытым. Итератор хранит адрес узла списка, в котором хранится элемент, на который этот итератор указывает. Для класса Iterator определены следующие операции:

Разыменовывание. Возвращает ссылку на элемент, хранящийся в узле.

Инкремент. Переход к следующему элементу (берет из узла и сохраняет адрес следующего узла).

Декремент. Переход к предыдущему элементу (берет из узла и сохраняет адрес предыдущего узла).

Равенство и неравенство (простое сравнение указателей).

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

Определение структуры Node и класса Iterator расположены в заголовочных файлах "ListNode.h" и "ListIterator.h" соответсвенно (листинг модулей в приложении). Эти файлы включаются в заголовке "List.h" при определении шаблона List. Используется механизм условной компиляции для предотвращения использования этих заголовочных файлов самих по себе. Такая методика (разделение определения класса на несколько заголовочных файлов) не используется. Здесь она применена только для улучшения читабельности программы; попутно демонстрируется и условная компиляция.

Использование шаблона List показано в программе "Ex_List.cpp" (листинг модуля в приложении). Создаем консольное Win32-приложение, используя этот модуль как главный.


2.5 Пример 4. Иерархия классов: двухмерные фигуры.


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

Пример реализован в обычном Windows-приложении (Application). Создаем новый проект. Добавляем в него модуль "Classes.cpp", в котором содержится реализация классов-фигур (листинг модуля в приложении). Далее вносим изменения в модуль " OOPExample.cpp", содержащий форму frmOOPExample. Основные пояснения внесены в комментарии самого исходного кода программы (код программы написан в C++ Builder).

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

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



Рисунок 1 – Главная форма приложения


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

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

Рассмотрим иерархию классов, показанную на рисунке 2.


Рисунок 2 – Иерархия классов