Программирование — страница 31 из 57

Проектирование графических классов

“Польза, прочность, красота”.

Витрувий (Vitruvius)


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

14.1. Принципы проектирования

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

14.1.1. Типы

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

 Цель проектирования — отразить понятия предметной области в тексте программы. Если вы хорошо разбираетесь в предметной области, то легко поймете код, и наоборот. Рассмотрим пример.

Window
— окно, открываемое операционной системой.

Line
— линия, которую вы видите на экране.

Point
— точка в системе координат.

Color
— цвет объекта на экране.

Shape
— общие свойства всех фигур в нашей модели графики или графического пользовательского интерфейса.


Последнее понятие,

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

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

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

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

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

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

 Мы решили использовать небольшие классы, содержащие несколько операций. Например, мы создали классы

Open_polyline
,
Closed_polyline
,
Polygon
,
Rectangle
,
Marked_polyline
,
Marks
и
Mark
вместо отдельного класса (который можно было бы назвать
Polyline
). В этих классах предусмотрено множество аргументов и операций, позволяющих задавать вид ломаной и даже изменять ее. Доводя эту идею до предела, можно было бы создать отдельные классы для каждой фигуры в качестве составных частей единого класса
Shape
. Мы считаем, что использование небольших классов наиболее точно и удобно моделирует нашу область графических приложений. Отдельный класс, содержащий “все”, завалил бы пользователя данными и возможностями, затруднив понимание, усложнив отладку и снизив производительность.

14.1.2. Операции

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

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

Point
.


Line ln(Point(100,200),Point(300,400));

Mark m(Point(100,200),'x'); // отображает отдельную точку

                            // в виде буквы "x"

Circle c(Point(200,200),250);


Все функции, работающие с точками, используют класс

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


void draw_line(Point p1,Point p2); // от p1 до p2 (наш стиль)

void draw_line(int x1,int y1,int x2,int y2); // от (x1,y1)

                                             // до (x2,y2)


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

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


draw_rectangle(Point(100,200),300,400); // наш стиль

draw_rectangle (100,200,300,400);       // альтернатива


При первом вызове функция рисует прямоугольник по заданной точке, ширине и высоте. Это легко угадать. А что можно сказать о втором вызове? Имеется в виду прямоугольник, определенный точками (100,200) и (300,400)? Или прямоугольник, определенный точкой (100,200), шириной 300 и высотой 400? А может быть, программист имел в виду нечто совершенно другое (хотя и разумное)? Последовательно используя класс

Point
, мы можем избежать таких недоразумений.

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

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

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

Такие коды называют обобщенными (generic); подробно мы рассмотрим их в главах 19–21. 

14.1.3. Именование

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

Shape
с объектом класса
Window
, но добавляем объект класса
Line
к объекту класса
Shape
? В обоих случаях мы “помещаем нечто во что-то”, так почему бы не назвать такие операции одинаково? Нет. За этой схожестью кроется фундаментальная разница. Рассмотрим пример.


Open_polyline opl;

opl.add(Point(100,100));

opl.add(Point(150,200));

opl.add(Point(250,250));


Здесь мы копируем три точки в объект

opl
. Фигуре
opl
безразлично, что будет с нашими точками после вызова функции
add()
; она хранит свои собственные копии этих точек. На самом деле мы редко храним копии точек, а просто передаем их фигуре. С другой стороны, посмотрим на следующую инструкцию:


win.attach(opl);


Здесь мы создаем связь между окном win и нашей фигурой

opl
; объект
win
не создает копию объекта
opl
, а вместо этого хранит ссылку на него. Итак, мы должны обеспечить корректность объекта
opl
, поскольку объект
win
использует его. Иначе говоря, когда окно
win
использует фигуру
opl
, оно должно находиться в ее области видимости. Мы можем обновить объект
opl
, и в следующий раз объект
win
будет рисовать фигуру
opl
с изменениями. Разницу между функциями
attach()
и
add()
можно изобразить графически.



Функция

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


void f(Simple_window& w)

{

  Rectangle r(Point(100,200),50,30);

  w.attach(r);

} // Ой, объекта r больше нет


int main()

{

  Simple_window win(Point(100,100),600,400,"Мое окно");

  // ...

  f(win); // возникают проблемы

  // ...

  win.wait_for_button();

}


 Пока мы выходили из функции

f()
и входили в функцию
wait_for_button()
, объект
r
для объекта win перестал существовать и соответственно выводиться на экран. В главе 17 мы покажем, как создать объект в функции и сохранить его между ее вызовами, а пока должны избежать связывания с объектом, который исчез до вызова функции
wait_for_button()
. Для этого можно использовать класс
Vector_ref
, который рассматривается в разделах 14.10 и Г.4.

Обратите внимание на то, что если бы мы объявили функцию

f()
так, чтобы она получала константную ссылку на объект класса
Window
(как было рекомендовано в разделе 8.5.6), то компилятор предотвратил бы ошибку: мы не можем выполнить вызов
attach(r)
с аргументом типа
const Window
, поскольку функция
attach()
должна изменить объект класса
Window
, чтобы зарегистрировать связь между ним и объектом
r

14.1.4. Изменяемость

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

public
и
private
, но мы продемонстрируем еще более гибкий и тонкий механизм, основанный на ключевом слове
protected
. Это значит, что мы не можем просто включить в класс какой-то член, скажем, переменную
label
типа
string
; мы должны также решить, следует ли открыть его для изменений после создания объекта, и если да, то как. Мы должны также решить, должен ли другой код, кроме данного класса, иметь доступ к переменной
label
, и если да, то как. Рассмотрим пример.


struct Circle {

  // ...

private:

  int r; // radius

};


Circle c(Point(100,200),50);

c.r = –9; // OK? Нет — ошибка компилирования: переменная Circle::r

          // закрыта


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

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

Мы интерпретируем экран (т.е. совокупность объектов класса

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

14.2. Класс Shape

Класс

Shape
отражает общее понятие о том, что может изображаться в объекте класса
Window
на экране.

• Понятие, которое связывает графические объекты с нашей абстракцией

Window
, которая в свою очередь обеспечивает связь с операционной системой и физическим экраном.

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

Line_style
и
Color
(для линий и заполнения).

• Может хранить последовательности объектов класса Point и информацию о том, как их рисовать.


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

Сначала опишем полный класс, а затем подробно его обсудим.

class Shape { // работает с цветом и стилем, хранит последователь -

              // ность точек

public:

  void draw() const; // работает с цветом и рисует линии

  virtual void move(int dx, int dy); // перемещает фигуры +=dx

                                     // и +=dy

  void set_color(Color col);

  Color color() const;


  void set_style(Line_style sty);

  Line_style style() const;


  void set_fill_color(Color col);

  Color fill_color() const;


  Point point(int i) const; // доступ к точкам только для чтения

  int number_of_points() const;


  virtual ~Shape() { }

protected:

  Shape();

  virtual void draw_lines() const; // рисует линии

  void add(Point p);               // добавляет объект p к точкам

  void set_point(int i, Point p);  // points[i]=p;

private:

  vector points;  // не используется всеми фигурами

  Color lcolor;          // цвет для линий и символов

  Line_style ls;

  Color fcolor;          // заполняет цветом

  Shape(const Shape&);   // копирующий конструктор

  Shape& operator=(const Shape&);

};


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

14.2.1. Абстрактный класс

Сначала рассмотрим конструктор класса

Shape
:


protected:

Shape();


который находится в разделе

protected
. Это значит, что его можно непосредственно использовать только в классах, производных от класса
Shape
(используя обозначение
:Shape
). Иначе говоря, класс
Shape
можно использовать только в качестве базы для других классов, таких как
Line
и
Open_polyline
. Цель ключевого слова
protected:
— гарантировать, что мы не сможем создать объекты класса
Shape
непосредственно.

Рассмотрим пример.


Shape ss; // ошибка: невозможно создать объект класса Shape


 Класс

Shape
может быть использован только в роли базового класса. В данном случае ничего страшного не произошло бы, если бы мы позволили создавать объекты класса
Shape
непосредственно, но, ограничив его применение, мы открыли возможность его модификации, что было бы невозможно, если бы кто-то мог его использовать непосредственно. Кроме того, запретив прямое создание объектов класса
Shape
, мы непосредственно моделируем идею о том, что абстрактной фигуры в природе не существует, а реальными являются лишь конкретные фигуры, такие как объекты класса
Circle
и
Closed_polyline
. Подумайте об этом! Как выглядит абстрактная фигура? Единственный разумный ответ на такой вопрос — встречный вопрос: какая фигура? Понятие о фигуре, воплощенное в классе
Shape
, носит абстрактный характер. Это важное и часто полезное свойство, поэтому мы не хотим компрометировать его в нашей программе. Позволить пользователям непосредственно создавать объекты класса Shape противоречило бы нашим представлениям о классах как о прямых воплощениях понятий. Конструктор определяется следующим образом:


Shape::Shape()

      :lcolor(fl_color()),     // цвет линий и символов по умолчанию

      ls(0),                   // стиль по умолчанию

      fcolor(Color::invisible) // без заполнения

{

}


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

Shape
,
Color
и
Line_style
.

Объект класса

vector
по умолчанию считается пустым вектором.

 Класс является абстрактным (abstract), если его можно использовать только в качестве базового класса. Для того чтобы класс стал абстрактным, в нем часто объявляют чисто виртуальную функцию (pure virtual function), которую мы рассмотрим в разделе 14.3.5. Класс, который можно использовать для создания объектов, т.е. не абстрактный класс, называется конкретным (concrete). Обратите внимание на то, что слова абстрактный и конкретный часто используются и в быту. Представим себе, что мы идем в магазин покупать фотоаппарат. Однако мы не можем просто попросить какой-то фотоаппарат и принести его домой. Какую торговую марку вы предпочитаете? Какую модель фотоаппарата хотите купить? Слово фотоаппарат — это обобщение; оно ссылается на абстрактное понятие. Название “Olympus E-3” означает конкретную разновидность фотоаппарата, конкретный экземпляр которого с уникальным серийным номером мы можем купить (в обмен на большую сумму денег). Итак, фотоаппарат — это абстрактный (базовый) класс, “Olimpus E-3” — конкретный (производный) класс, а реальный фотоаппарат в моей руке (если я его купил) — это объект.

Объявление


virtual ~Shape() { }


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

14.2.2. Управление доступом

Класс

Shape
объявляет все данные-члены закрытыми.


private:

  vector points;

  Color lcolor;

  Line_style ls;

  Color fcolor; 


 Поскольку данные-члены класса

Shape
объявлены закрытыми, нам нужно предусмотреть функции доступа. Существует несколько стилей решения этой задачи. Мы выбрали простой, удобный и понятный. Если у нас есть член, представляющий свойство
X
, то мы предусмотрели пару функций,
X()
и
set_X()
, для чтения и записи соответственно. Рассмотрим пример.


void Shape::set_color(Color col)

{

  lcolor = col;

}


Color Shape::color() const

{

  return lcolor; 

}


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

const
, чтобы подчеркнуть, что функция чтения не может модифицировать члены своего класса
Shape
(см. раздел 9.7.4).

В классе

Shape
хранится вектор объектов класса
Point
с именем
points
, которые предназначены для его производных классов. Для добавления объектов класса
Point
в вектор
points
предусмотрена функция
add()
.


void Shape::add(Point p) // защищенный

{

  points.push_back(p);

}


Естественно, сначала вектор

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

Классы, производные от класса

Shape
, например
Circle
и
Polygon
, “понимают”, что означают их точки. Базовый класс
Shape
этого “не понимает”, он просто хранит точки. Следовательно, производные классы должны иметь контроль над тем, как добавляются точки. Рассмотрим пример.

• Классы

Circle
и
Rectangle
не позволяют пользователю добавлять точки, они просто “не видят” в этом смысла. Что такое прямоугольник с дополнительной точкой? (См. раздел 12.7.6.)

• Класс

Lines
позволяет добавлять любые пары точек (но не отдельные точки; см. раздел 13.3).

• Классы

Open_polyline
и
Marks
позволяют добавлять любое количество точек.

• Класс

Polygon
позволяет добавлять точки только с помощью функции
add()
, проверяющей пересечения (раздел 13.8).


 Мы поместили функцию

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

По аналогичным причинам мы поместили функцию

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

Например, если класс

Regular_hexagon
объявлен как множество, состоящее из шести точек, то изменение даже одной точки может породить фигуру, не являющуюся правильным шестиугольником. С другой стороны, если мы изменим одну из точек прямоугольника, то в результате все равно получим прямоугольник. Фактически функция
set_point()
в этом случае оказывается ненужной, поэтому мы включили ее просто для того, чтобы обеспечить выполнение правил чтения и записи каждого атрибута класса
Shape
. Например, если бы мы захотели создать класс
Mutable_rectangle
, то могли бы вывести его из класса
Rectangle
и снабдить операциями, изменяющими точки.

Мы поместили вектор

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


void Shape::set_point(int i, Point p) // не используется

{

  points[i] = p;

}


Point Shape::point(int i) const

{

  return points[i];

}


int Shape::number_of_points() const

{

  return points.size();

}


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


void Lines::draw_lines() const

// рисует линии, соединяющие пары точек

{

  for (int i=1; i

    fl_line(point(i–1).x,point(i–1).y,point(i).x,point(i).y);

}


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

number_of_points()
занимает столько же байтов памяти и выполняет точно столько же инструкций, сколько и непосредственный вызов функции
points.size()
.

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

Shape
.


struct Shape { // слишком простое определение — не используется

  Shape();

  void draw() const; // работает с цветом и вызывает функцию

                     // draw_lines

  virtual void draw_lines() const;   // рисует линии

  virtual void move(int dx, int dy); // перемещает фигуры +=dx

                                     // и +=dy

  vector points; // не используется всеми фигурами

  Color lcolor;

  Line_style ls;

  Color fcolor;

}


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

private:
и
protected:
)? Главный ответ состоит в том, что защита класса от нежелательного изменения позволяет разработчику создавать лучшие классы с меньшими усилиями. Этот же аргумент относится и к инвариантам (см. раздел 9.4.3). Подчеркнем эти преимущества на примере определения классов, производных от класса
Shape
. В более ранних вариантах класса
Shape
мы использовали следующие переменные:


Fl_Color lcolor;

int line_style;


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

int
, не позволяет элегантно задавать ширину линии, а класс
Fl_Color
не предусматривает невидимые линии) и приводит к довольно запутанному коду. Если бы эти две переменные были открытыми и использовались в пользовательской программе, то мы могли бы улучшить интерфейсную библиотеку только за счет взлома этого кода (поскольку в нем упоминаются имена
lcolor
и
line_style
).

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

s.add(p)
читается и записывается легче, чем
s.points.push_back(p)

14.2.3. Рисование фигур

Мы описали почти все, кроме ядра класса

Shape
.


void draw() const; // работает с цветом и вызывает функцию

                   // draw_lines

virtual void draw_lines() const; // рисует линии


Основная задача класса

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

• Функция

draw()
интерпретирует стиль и цвет, а затем вызывает функцию
draw_lines()
.

• Функция

draw_lines()
подсвечивает пиксели на экране.


Функция

draw()
не использует никаких новаторских методов. Она просто вызывает функции библиотеки FLTK, чтобы задать цвет и стиль фигуры, вызывает функцию
draw_lines()
, чтобы выполнить реальное рисование на экране, а затем пытается восстановить цвет и фигуру, заданные до ее вызова.


void Shape::draw() const

{

  Fl_Color oldc = fl_color();

  // универсального способа идентифицировать текущий стиль

  // не существует

  fl_color(lcolor.as_int());            // задаем цвет

  fl_line_style(ls.style(),ls.width()); // задаем стиль

  draw_lines();

  fl_color(oldc);   // восстанавливаем цвет (предыдущий)

  fl_line_style(0); // восстанавливаем стиль линии (заданный

                    // по умолчанию)

}


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

 Обратите внимание на то, что функция

Shape::draw()
не работает с цветом заливки фигуры и не управляет видимостью линий. Эти свойства обрабатывают отдельные функции
draw_lines()
, которые лучше “знают”, как их интерпретировать. В принципе всю обработку цвета и стиля можно было бы перепоручить отдельным функциям
draw_lines()
, но для этого пришлось бы повторять много одних и тех же фрагментов кода.

Рассмотрим теперь, как организовать работу с функцией

draw_lines()
. Если немного подумать, то можно прийти к выводу, что функции-члену класса
Shape
было бы трудно рисовать все, что необходимо для создания любой разновидности фигуры. Для этого пришлось бы хранить в объекте класса Shape каждый пиксель каждой фигуры. Если мы используем вектор
vector
, то вынуждены хранить огромное количество точек. И что еще хуже, экран (т.е. устройство для вывода графических изображений) лучше “знает”, как это делать.

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

Shape
, возможность самому определить, что он будет рисовать. Классы
Text
,
Rectangle
и
Circle
лучше “знают”, как нарисовать свои объекты. На самом деле все такие классы это “знают”. Помимо всего прочего, такие классы точно “знают” внутреннее представление информации. Например, объект класса
Circle
определяется точкой и радиусом, а не, скажем, отрезком линии. Генерирование требуемых битов для объекта класса
Circle
на основе точки и радиуса там, где это необходимо, и тогда, когда это необходимо, не слишком сложная и затратная работа. По этой причине в классе
Circle
определяется своя собственная функция
draw_lines()
, которую мы хотим вызывать, а не функция
draw_lines()
из класса
Shape
. Именно это означает слово
virtual
в объявлении функции
Shape::draw_lines()
.


struct Shape {

  // ...

  virtual void draw_lines() const;

  // пусть каждый производный класс

  // сам определяет свою собственную функцию draw_lines(),

  // если это необходимо

  // ...

};


struct Circle : Shape {

  // ...

  void draw_lines() const; // " замещение " функции

  // Shape::draw_lines()

  // ...

};


Итак, функция

draw_lines()
из класса
Shape
должна как-то вызывать одну из функций-членов класса
Circle
, если фигурой является объект класса
Shape
, и одну из функций-членов класса
Rectangle
, если фигура является объектом класса
Rectangle
. Вот что означает слово
virtual
в объявлении функции
draw_lines()
: если класс является производным от класса
Shape
, то он должен самостоятельно объявить свою собственную функцию
draw_lines()
(с таким же именем, как функция
draw_lines()
в классе
Shape
), которая будет вызвана вместо функции
draw_lines()
из класса. В главе 13 показано, как это сделано в классах
Text
,
Circle
,
Closed_polyline
и т.д. Определение функции в производном классе, используемой с помощью интерфейса базового класса, называют замещением (overriding).

Обратите внимание на то, что, несмотря на свою главную роль в классе

Shape
, функция
draw_lines()
находится в разделе
protected
. Это сделано не для того, чтобы подчеркнуть, что она предназначена для вызова “общим пользователем” — для этого есть функция
draw()
. Просто тем самым мы указали, что функция
draw_lines()
— это “деталь реализации”, используемая функцией
draw()
и классами, производными от класса
Shape
.

На этом завершается описание нашей графической модели, начатое в разделе 12.2. Система, управляющая экраном, “знает” о классе

Window
. Класс
Window
“знает” о классе
Shape
и может вызывать его функцию-член
draw()
. В заключение функция
draw()
вызывает функцию
draw_lines()
, чтобы нарисовать конкретную фигуру. Вызов функции
gui_main()
в нашем пользовательском коде запускает драйвер экрана.



Что делает функция

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

Функция

move()
класса
Shape
просто перемещает каждую хранимую точку на определенное расстояние относительно текущей позиции.


void Shape::move(int dx, int dy) // перемещает фигуру +=dx and +=dy

{

  for (int i = 0; i

    points[i].x+=dx;

    points[i].y+=dy;

  }

}


Подобно функции

draw_lines()
, функция
move()
является виртуальной, поскольку производный класс может иметь данные, которые необходимо переместить и о которых может “не знать” класс
Shape
. В качестве примера можно привести класс
Axis
(см. разделы 12.7.3 и 15.4).

Функция

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

14.2.4. Копирование и изменчивость

 Класс Shape содержит закрытые объявления копирующего конструктора (copy constructor) и оператора копирующего присваивания (copy assignment constructor).


private:

  Shape(const Shape&); // prevent copying

  Shape& operator=(const Shape&);


В результате только члены класса

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


void my_fct(const Open_polyline& op, const Circle& c)

{

  Open_polyline op2 = op; // ошибка: копирующий конструктор

                          // класса Shape закрыт

  vector v;

  v.push_back(c);         // ошибка: копирующий конструктор

                          // класса Shape закрыт

  // ...

  op = op2;               // ошибка: присваивание в классе

  // Shape закрыто


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

push_back()
; без копирования было бы трудно использовать векторы (функция
push_back()
помещает в вектор копию своего аргумента). Почему надо беспокоиться о непредвиденном копировании? Если операция копирования по умолчанию может вызывать проблемы, ее следует запретить. В качестве основного примера такой проблемы рассмотрим функцию
my_fct()
. Мы не можем копировать объект класса
Circle
в вектор
v
, содержащий объекты типа
Shape
; объект класса
Circle
имеет радиус, а объект класса
Shape
— нет, поэтому
sizeof(Shape) 
. Если бы мы допустили операцию
v.push_back(c)
, то объект класса
Circle
был бы “обрезан” и любое последующее использование элемента вектора
v
привело бы к краху; операции класса
Circle
предполагают наличие радиуса (члена
r
), который не был скопирован.



Конструктор копирования объекта

op2
и оператор присваивания объекту
op
имеют тот же самый недостаток. Рассмотрим пример.


Marked_polyline mp("x");

Circle c(p,10);

my_fct(mp,c); // аргумент типа Open_polyline ссылается

              // на Marked_polyline


Теперь операции копирования класса

Open_polyline
приведут к “срезке” объекта
mark
, имеющего тип
string
.

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

Shape
.

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

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

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

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

14.3. Базовые и производные классы

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

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

Circle
является производным от класса
Shape
, иначе говоря, класс
Circle
является разновидностью класса
Shape
или класс
Shape
является базовым по отношению к классу
Circle
. Производный класс (в данном случае
Circle
) получает все члены базового класса (в данном случае
Shape
) в дополнение к своим собственным. Это свойство часто называют наследованием (inheritance), потому что производный класс наследует все члены базового класса. Иногда производный класс называют подклассом (subclass), а базовый — суперклассом (superclass).

• Виртуальные функции. В языке С++ можно определить функцию в базовом классе и функцию в производном классе с точно таким же именем и типами аргументов, чтобы при вызове пользователем функции базового класса на самом деле вызывалась функция из производного класса. Например, когда класс Window вызывает функцию

draw_lines()
из класса
Circle
, выполняется именно функция
draw_lines()
из класса
Circle
, а не функция
draw_lines()
из класса
Shape
. Это свойство часто называют динамическим полиморфизмом (run-time polymorphism) или динамической диспетчеризацией (run-time dispatch), потому что вызываемые функции определяются на этапе выполнения программы по типу объекта, из которого они вызываются.

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


Наследование, динамический полиморфизм и инкапсуляция — наиболее распространенные характеристики объектно-ориентированного программирования (object-oriented programming). Таким образом, язык C++ непосредственно поддерживает объектно-ориентированное программирование наряду с другими стилями программирования. Например, в главах 20-21 мы увидим, как язык C++ поддерживает обобщенное программирование. Язык C++ позаимствовал эти ключевые механизмы из языка Simula67, первого языка, непосредственно поддерживавшего объектно-ориентированное программирование (подробно об этом речь пойдет в главе 22).

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



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

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

14.3.1. Схема объекта

Как объекты размещаются в памяти? Как было показано в разделе 9.4.1, схема объекта определяется членами класса: данные-члены хранятся в памяти один за другим. Если используется наследование, то данные-члены производного класса просто добавляются после членов базового класса. Рассмотрим пример.



Класс

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

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

Shape
: информация о том, какая функция будет на самом деле вызываться при обращении к функции
draw_lines()
из класса
Shape
. Для этого обычно в таблицу функций заносится ее адрес. Эта таблица обычно называется
vtbl
(таблица виртуальных функций), а ее адрес часто имеет имя
vptr
(виртуальный указатель). Указатели обсуждаются в главах 17-18; здесь они действуют как ссылки. В конкретных реализациях языка таблица виртуальных функций и виртуальный показатель могут называться иначе. Добавив таблицу
vptr
и указатели
vtbl
к нашему рисунку, получим следующую диаграмму.

Поскольку функция

draw_lines()
— первая виртуальная функция, она занимает первую ячейку в таблице
vtbl
, за ней следует функция
move()
, вторая виртуальная функция. Класс может иметь сколько угодно виртуальных функций; его таблица
vtbl
может быть сколь угодно большой (по одной ячейке на каждую виртуальную функцию). Теперь, когда мы вызовем функцию
x.draw_lines()
, компилятор сгенерирует вызов функции, найденной в ячейке
draw_lines()
таблицы
vtbl
, соответствующей объекту
x
. В принципе код просто следует по стрелкам на диаграмме.



Итак, если объект

x
относится к классу
Circle
, будет вызвана функция
Circle::draw_lines()
. Если объект
x
относится к типу, скажем,
Open_polyline
, который использует таблицу
vtbl
точно в том виде, в каком ее определил класс
Shape
, то будет вызвана функция
Shape::draw_lines()
. Аналогично, поскольку в классе
Circle
не определена его собственная функция
move()
, при вызове
x.move()
будет выполнена функция
Shape::move()
, если объект
x
относится к классу
Circle
. В принципе код, сгенерированный для вызова виртуальной функции, может просто найти указатель
vptr
и использовать его для поиска соответствующей таблицы
vtbl
и вызова нужной функции оттуда. Для этого понадобятся два обращения к памяти и обычный вызов функции, — быстро и просто.

Класс

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

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

Circle::draw_lines()
), при котором функция из производного класса записывается в таблицу
vtbl
вместо соответствующей функции из базового класса, называется замещением (overriding). Например, функция
Circle::draw_lines()
замещает функцию
Shape::draw_lines()
.

Почему мы говорим о таблицах

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

14.3.2. Вывод классов и определение виртуальных функций

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


struct Circle:Shape { /* ... */ }; 


 По умолчанию члены структуры, объявляемой с помощью ключевого слова

struct
, являются открытыми (см. раздел 9.3) и наследуют открытые члены класса. Можно было бы написать эквивалентный код следующим образом:


class Circle : public Shape { public: /* ... */ };


Эти два объявления класса

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

Не забудьте указать слово

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


class Circle : Shape { public: /* ... */ }; // возможно, ошибка


В этом случае класс

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

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

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


struct Shape {

  // ...

  virtual void draw_lines() const;

  virtual void move();

  // ...

};


  virtual void Shape::draw_lines() const { /* ... */ } // ошибка

  void Shape::move() { /* ... */ } // OK

14.3.3. Замещение

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


struct Circle:Shape {

  void draw_lines(int) const; // возможно, ошибка (аргумент int?)

  void drawlines() const;     // возможно, ошибка (опечатка 
в имени?)

  void draw_lines();          // возможно, ошибка (нет const?)

  // ...

};


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

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

Пример функции

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


struct B {

  virtual void f() const { cout << "B::f "; }

  void g() const { cout << "B::g "; } // невиртуальная

};


struct D : B {

  void f() const { cout << "D::f "; } // замещает функцию B::f

  void g() { cout << "D::g "; }

};


struct DD : D {

  void f() { cout << "DD::f "; } // не замещает функцию D::f
 (нет const)

  void g() const { cout << "DD::g "; }

};


Здесь мы описали небольшую иерархию классов с одной виртуальной функцией

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


void call(const B& b)

  // класс D — разновидность класса B,

  // поэтому функция call() может

  // получить объект класса D

  // класс DD — разновидность класса D,

  // а класс D — разновидность класса B,

  // поэтому функция call() может получать объект класса DD

{

  b.f();

  b.g();

}


int main()

{

  B b;

  D d;

  DD dd;

  call(b);

  call(d);

  call(dd);

  b.f();

  b.g();

  d.f();

  d.g();

  dd.f();

  dd.g();

}


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


B::f B::g D::f B::g D::f B::g B::f B::g D::f D::g DD::f DD::g


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

14.3.4. Доступ

 Язык С++ реализует простую модель доступа к членам класса. Члены класса могут относиться к следующим категориям.

• Закрытые (private). Если член класса объявлен с помощью ключевого слова

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

• Защищенные (protected). Если член класса объявлен с помощью ключевого слова

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

• Открытые (public). Если член класса объявлен с помощью ключевого слова

public
, то его имя могут использовать все функции.


Изобразим это на рисунке.



Базовый класс также может иметь атрибут

private
,
protected
или
public
.

• Если базовый класс для класса

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

• Если базовый класс для класса

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

• Если базовый класс для класса

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


Эти определения игнорируют понятие дружественной функции или класса и другие детали, которые выходят за рамки рассмотрения нашей книги. Если хотите стать крючкотвором, читайте книги Stroustrup, The Design and Evolution of C++ (Страуструп, “Дизайн и эволюция языка С++”), The C++ Programming Language (Страуструп, “Язык программирования С++”) и стандарт 2003 ISO C++. Мы не рекомендуем вам становиться крючкотвором (т.е. вникать в мельчайшие детали языковых определений) — быть программистом (разработчиком программного обеспечения, инженером, пользователем, назовите как хотите) намного увлекательнее и полезнее для общества. 

14.3.5. Чисто виртуальные функции

Абстрактный класс — это класс, который можно использовать только в качестве базового. Абстрактные классы используются для представления абстрактных понятий; иначе говоря, мы используем абстрактные классы для описания понятий, которые являются обобщением общих характеристик связанных между собой сущностей. Описанию абстрактного понятия (abstract concept), абстракции (abstraction) и обобщению (generalization) посвящены толстенные книги по философии. Однако философское определение абстрактного понятия мало полезно. Примерами являются понятие “животное” (в противоположность конкретному виду животного), “драйвер устройства” (в противоположность драйверу конкретного вида устройств) и “публикация” (в противоположность конкретному виду книг или журналов). В программах абстрактные классы обычно определяют интерфейсы групп связанных между собой классов (иерархии классов).

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


class B {             // абстрактный базовый класс

public:

  virtual void f()=0; // чисто виртуальная функция

  virtual void g()=0;

};


B b; // ошибка: класс B — абстрактный


Интересное обозначение

=0
указывает на то, что виртуальные функции
B::f()
и
B::g()
являются чистыми, т.е. они должны быть замещены в каком-то производном классе. Поскольку класс B содержит чисто виртуальную функцию, мы не можем создать объект этого класса. Замещение чисто виртуальных функций устраняет эту проблему.


class D1:public B {

public:

  void f();

  void g();

};


D1 d1; // OK


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


class D2:public B {

public:

  void f();

  // no g()

};


D2 d2; // ошибка: класс D2 — (по-прежнему) абстрактный


class D3:public D2 {

  public:

    void g();

};


D3 d3; // OK


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

14.4. Преимущества объектно-ориентированного программирования

 Когда мы говорим, что класс

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

Наследование интерфейса. Функция, ожидающая аргумент класса

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

Наследование реализации. Когда мы определяем класс

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


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

Never_do_this
, относительно которого класс
Shape
является открытым базовым классом. Затем мы могли бы заместить функцию
Shape::draw_lines()
функцией, которая не рисует фигуру, а просто перемещает ее центр на 100 пикселей влево. Этот проект фатально неверен, поскольку, несмотря на то, что класс
Never_do_this
может предоставить интерфейс класса
Shape
, его реализация не поддерживает семантику (т.е. поведение), требуемое классом
Shape
. Никогда так не делайте!

 Преимущества наследования интерфейса проявляются в использовании интерфейса базового класса (в данном случае класса

Shape
) без информации о реализациях (в данном случае классах, производных от класса
Shape
).

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

Circle
), которое обеспечивается возможностями базового класса (например, класса
Shape
).

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

Shape::draw()
, которая в свою очередь вызывает виртуальную функцию
draw_lines()
класса
Shape
, чтобы она выполнила реальную работу, связанную с выводом изображений на экран. Ни “графический движок”, ни класс
Shape
не знают, какие виды фигур существуют. В частности, наш “графический движок” (библиотека FLTK и графические средства операционной системы) написан и скомпилирован за много лет до создания наших графических классов! Мы просто определяем конкретные фигуры и вызываем функцию
attach()
, чтобы связать их с объектами класса
Window
в качестве объектов класса
Shape
(функция
Window::attach()
получает аргумент типа
Shape&
; см. раздел Г.3). Более того, поскольку класс
Shape
не знает о наших графических классах, нам не нужно перекомпилировать класс
Shape
каждый раз, когда мы хотим определить новый класс графического интерфейса.

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

Shape
предусматривает довольно ограниченный набор операций), и этот метод не может решить все проблемы программирования (например, в главах 17–19 определяется класс
vector
; наследование здесь мало может помочь). Однако наследование интерфейса — один из мощных методов проектирования и реализации систем, устойчивых к изменениям. 

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

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


Задание

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

1. Определите класс

B1
с виртуальной функцией
vf()
и невиртуальной функцией
f()
. Определите эти функции в классе
B1
. Реализуйте каждую функцию так, чтобы она выводила свое имя (например, “
B1::vf()
”). Сделайте эти функции открытыми. Создайте объект
B1
и вызовите каждую из функций.

2. Определите класс

D1
, производный от класса
B1
, и заместите функцию
vf()
. Создайте объект класса
D1
и вызовите функции
vf()
и
f()
из него.

3. Определите ссылку на объект класса

B1
(т.е.
B1&
) и инициализируйте ею только что определенный объект класса
D1
. Вызовите функции
vf()
и
f()
для этой ссылки.

4. Теперь определите функцию

f()
в классе
D1
и повторите пп. 1–3. Объясните результаты.

5. Добавьте в класс

B1
чисто виртуальную функцию
pvf()
и попытайтесь повторить пп. 1–4. Объясните результат.

6. Определите класс

D2
, производный от класса
D1
, и заместите в нем функцию
pvf()
. Создайте объект класса
D2
и вызовите из него функции
f()
,
vf()
и
pvf()
.

7. Определите класс

B2
с чисто виртуальной функцией
pvf()
. Определите класс
D21
с членом типа
string
и функцией-членом, замещающей функцию
pvf()
; функция
D21::pvf()
должна выводить значение члена типа
string
. Определите класс
D22
, аналогичный классу
D21
, за исключением того, что его член имеет тип
int
. Определите функцию
f()
, получающую аргумент типа
B2&
и вызывающую функцию
pvf()
из этого аргумента. Вызовите функцию
f()
с аргументами класса
D21
и
D22
.


Контрольные вопросы

1. Что такое предметная область?

2. Назовите цели именования.

3. Что такое имя?

4. Какие возможности предоставляет класс

Shape
?

5. Чем абстрактный класс отличается от других классов?

6. Как создать абстрактный класс?

7. Как управлять доступом?

8. Зачем нужен раздел

private
?

9. Что такое виртуальная функция и чем она отличается от невиртуальных функций?

10. Что такое базовый класс?

11. Как объявляется производный класс?

12. Что мы подразумеваем под схемой объекта?

13. Что можно сделать, чтобы класс было легче тестировать?

14. Что такое диаграмма наследования?

15. В чем заключается разница между защищенными и закрытыми членами класса?

16. К каким членам класса имеют доступ члены производного класса?

17. Чем чисто виртуальная функция отличается от других виртуальных функций?

18. Зачем делать функции-члены виртуальными?

19. Зачем делать функции-члены чисто виртуальными?

20. Что такое замещение?

21. Чем наследование интерфейса отличается от наследования реализации?

22. Что такое объектно-ориентированное программирование?


Термины


Упражнения

1. Определите два класса,

Smiley
и
Frowny
, производные от класса
Circle
и рисующие два глаза и рот. Затем создайте классы, производные от классов
Smiley
и
Frowny
, добавляющие к каждому из них свою шляпу.

2. Попытайтесь скопировать объект класса

Shape
. Что произошло?

3. Определите абстрактный класс и попытайтесь определить объект его типа. Что произошло?

4. Определите класс

Immobile_Circle
, напоминающий класс
Circle
, объекты которого не способны перемещаться.

5. Определите класс

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

6. Определите класс

Striped_circle
, используя приемы из класса
Striped_rectangle
.

7. Определите класс

Striped_closed_polyline
, используя приемы из класса
Striped_rectangle
(для этого придется потрудиться).

8. Определите класс

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

9. Определите класс

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

10. Определите класс

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

11. Определите класс

Binary_tree
, производный от класса
Shape
. Введите параметр, задающий количество уровней (
levels==0
означает, что в дереве нет ни одного узла,
levels==1
означает, что в дереве есть один узел,
levels==2
означает, что дерево состоит из вершины и двух узлов,
levels==3
означает, что дерево состоит из вершины и двух дочерних узлов, которые в свою очередь имеют по два дочерних узла, и т.д.). Пусть узел изображается маленьким кружочком. Соедините узлы линиями (как это принято). P.S. В компьютерных науках деревья изображаются растущими вниз от вершины (забавно, но нелогично, что ее часто называют корнем).

12. Модифицируйте класс

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

13. Модифицируйте класс

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

14. Добавьте в класс

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

15. Большинство иерархий классов не связано с графикой. Определите класс

Iterator
, содержащий чисто виртуальную функцию
next()
, возвращающую указатель типа
double*
(см. главу 17). Теперь выведите из класса
Iterator
классы
Vector_iterator
и
List_iterator
так, чтобы функция
next()
для класса
Vector_iterator
возвращала указатель на следующий элемент вектора типа
vector
, а для класса
List_iterator
делала то же самое для списка типа
list
. Инициализируйте объект класса
Vector_iterator
вектором
vector
и сначала вызовите функцию
next()
, возвращающую указатель на первый элемент, если он существует. Если такого элемента нет, верните нуль. Проверьте этот класс с помощью функции
void print(Iterator&)
, выводящей на печать элементы вектора типа
vector
и списка типа
list

16. Определите класс

Controller
, содержащий четыре виртуальные функции:
on()
,
off()
,
set_level(int)
и
show()
. Выведите из класса
Controller
как минимум два класса. Один из них должен быть простым тестовым классом, в котором функция
show()
выводит на печать информацию, включен или выключен контроллер, а также текущий уровень. Второй производный класс должен управлять цветом объекта класса
Shape
; точный смысл понятия “уровень” определите сами. Попробуйте найти третий объект для управления с помощью класса
Controller
.

17. Исключения, определенные в стандартной библиотеке языка C++, такие как

exception
,
runtime_error
и
out_of_range
(см. раздел 5.6.3), организованы в виде иерархии классов (с полезной виртуальной функцией
what()
, возвращающей строку, которая предположительно содержит объяснение ошибки). Найдите источники информации об иерархии стандартных исключений в языке C++ и нарисуйте диаграмму этой иерархии классов.


Послесловие

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

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

Глава 15