Часть II Ввод и вывод

Глава 10 Потоки ввода и вывода

“Наука — это знания о том, как не дать себя одурачить”.

Ричард Фейнман (Richard P. Feynman)


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

10.1. Ввод и вывод

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

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

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

1. Настроить потоки ввода-вывода на соответствующие источники и адресаты данных.

2. Прочитать и записать их потоки.



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

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

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

• Взаимодействие с пользователем посредством клавиатуры.

• Взаимодействие с пользователем посредством графического интерфейса (вывод объектов, обработка щелчков мыши и т.д.).


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

iostream
и будем использовать ее в данной и следующей главах. Графический вывод и взаимодействие с пользователем посредством графического интерфейса обеспечиваются разнообразными библиотеками. Этот вид ввода-вывода мы рассмотрим в главах 12–16.

10.2. Модель потока ввода-вывода

Стандартная библиотека языка С++ содержит определение типов

istream
для потоков ввода и
ostream
— для потоков вывода. В наших программах мы использовали стандартный поток
istream
с именем
cin
и стандартный поток
ostream
с именем
cout
, поэтому эта часть стандартной библиотеки (которую часто называют библиотекой
iostream
) нам уже в принципе знакома.

Поток

ostream
делает следующее.

• Превращает значения разных типов в последовательности символов.

• Посылает эти символы “куда-то” (например, на консоль, в файл, основную память или на другой компьютер).


Поток

ostream
можно изобразить следующим образом.



Буфер — это структура данных, которую поток

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

Поток

istream
делает следующее.

• Превращает последовательности символов в значения разных типов.

• Получает эти символы “откуда-то” (например, с консоли, из файла, из основной памяти или от другого компьютера).


Поток

istream
можно изобразить следующим образом.



Как и поток

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

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

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

10.3. Файлы

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



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

При работе с файлами поток

ostream
преобразует объекты, хранящиеся в основной памяти, в потоки байтов и записывает их на диск. Поток
istream
действует наоборот; иначе говоря, он считывает поток байтов с диска и составляет из них объект.



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

Для того чтобы прочитать файл, мы должны

• знать его имя;

• открыть его (для чтения);

• считать символы;

• закрыть файл (хотя это обычно выполняется неявно).


Для того чтобы записать файл, мы должны

• назвать его;

• открыть файл (для записи) или создать новый файл с таким именем;

• записать наши объекты;

• закрыть файл (хотя это обычно выполняется неявно).


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

ostream
, связанный с файлом, ведет себя точно так же, как поток
cout
, а поток
istream
, связанный с файлом, ведет себя точно так же, как объект
cin
. Операции, характерные только для файлов, мы рассмотрим позднее (в разделе 11.3.3), а пока посмотрим, как открыть файлы, и сосредоточим свое внимание на операциях и приемах, которые можно применить ко всем потокам
ostream
и
istream
.

10.4. Открытие файла

Если хотите считать данные из файла или записать их в файл, то должны открыть поток специально для этого файла. Поток ifstream — это поток istream для чтения из файла, поток ofstream — это поток ostream для записи в файл, а поток fstream — это поток iostream, который можно использовать как для чтения, так и для записи. Перед использованием файлового потока его следует связать с файлом. Рассмотрим пример.


cout << "Пожалуйста, введите имя файла: ";

string name;

cin >> name;

ifstream ist(name.c_str()); // ist — это поток ввода для файла,

               // имя которого задано строкой name

if (!ist) error(" Невозможно открыть файл для ввода ",name);


Определение потока ifstream с именем, заданным строкой name, открывает файл с этим именем для чтения. Функция

c_str()
— это член класса
string
, создающий низкоуровневую строку в стиле языка С из объекта класса
string
. Такие строки в стиле языка С требуются во многих системных интерфейсах. Проверка
!ist
позволяет выяснить, был ли файл открыт корректно. После этого можно считывать данные из файла точно так же, как из любого другого потока istream. Например, предположим, что оператор ввода
>>
определен для типа
Point
. Тогда мы могли бы написать следующий фрагмент программы:


vector points;

Point p;

while (ist>>p) points.push_back(p);


Вывод в файлы аналогичным образом можно выполнить с помощью потоков

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


cout << "Пожалуйста, введите имя файла для вывода: ";

string oname;

cin >> oname;

ofstream ost(oname.c_str()); // ost — это поток вывода для файла,

               // имя которого задано строкой name

if (!ost) error("Невозможно открыть файл вывода ",oname);


Определение потока

ofstream
с именем, заданным строкой
name
, открывает файл с этим именем для чтения. Проверка
!ost
позволяет выяснить, был ли файл открыт корректно. После этого можно записывать данные в файл точно так же, как в любой другой поток
ostream
. Рассмотрим пример.


for (int i=0; i

  ost << '(' << points[i].x << ',' << points[i].y << ")\n";


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

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

Открытие файла неявно является частью процесса создания потоков

ostream
и
istream
. В идеале при закрытии файла следует полагаться на его область видимости.

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


void fill_from_file(vector& points, string& name)

{

 ifstream ist(name.c_str()); // открываем файл для чтения

 if (!ist) error("Невозможно открыть файл для ввода",name);

   // ...используем поток ist...

   // файл неявно закроется, когда мы выйдем из функции

}


Кроме того, можно явно выполнить операции

open()
и
close()
(раздел B.7.1). Однако ориентация на область видимости минимизирует шансы того, что вы попытаетесь использовать файловый поток до того, как файл будет связан с потоком, или после того, как он был закрыт. Рассмотрим пример.


ifstream ifs;

// ...

ifs >> foo; // не выполнено: для потока its не открыт ни один файл

// ...

ifs.open(name,ios_base::in); // открываем файл, имя которого задано

               // строкой name

// ...

ifs.close(); // закрываем файл

// ...

ifs >> bar;  // невыполнено: файл, связанный с потоком ifs, закрыт

// ...


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


fstream fs;

fs.open("foo", ios_base::in);  // открываем файл для ввода

                // пропущена функция close()

fs.open("foo", ios_base::out); // невыполнено: поток ifs уже открыт

if (!fs) error("невозможно");


Не забывайте проверять поток после его открытия.

Почему допускается явное использование функций

open()
и
close()
? Дело в том, что иногда время жизни соединения с файлом не ограничивается его областью видимости. Однако это событие происходит так редко, что о нем можно не беспокоиться. Более важно то, что такой код можно встретить в программах, в которых используются стили и идиомы языков и библиотек, отличающихся от стилей и идиом, используемых в потоках
iostream
(и в остальной части стандартной библиотеки C++).

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

10.5. Чтение и запись файла

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


0 60.7

1 60.6

2 60.3

3 59.22

...


Этот файл содержит последовательность пар (час, температура). Часы пронумерованы от

0
до
23
, а температура измерена по шкале Фаренгейта. Дальнейшее форматирование не предусмотрено; иначе говоря, файл не содержит никаких заголовков (например, информации об источнике данных), единиц измерений, знаков пунктуации (например, скобок вокруг каждой пары значений) или признак конца файла. Это простейший вариант.

Представим информацию в виде структуры

Reading
.


struct Reading {    // данные о температуре воздуха

  int hour;      // часы после полуночи [0:23]

  double temperature; // по Фаренгейту

 Reading(int h, double t) :hour(h), temperature(t) { }

};


В таком случае данные можно считать следующим образом:


vector temps; // здесь хранится считанная информация

int hour;

double temperature;

while (ist >> hour >> temperature) {

 if (hour < 0 || 23 

  temps.push_back(Reading(hour,temperature));

}


Это типичный цикл ввода. Поток

istream
с именем
ist
мог бы быть файловым потоком ввода (
ifstream
), как в предыдущем разделе, стандартным потоком ввода (
cin
) или любым другим потоком
istream
. Для кода, подобного приведенному выше, не имеет значения, откуда поток
istream
получает данные. Все, что требуется знать нашей программе, — это то, что поток
ist
относится к классу
istream
и что данные имеют ожидаемый формат. Следующий раздел посвящен интересному вопросу: как выявлять ошибки в наборе входных данных и что можно сделать после выявления ошибки форматирования.

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

ofstream
) из предыдущего раздела наравне с любым другим потоком
ostream
.

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


for (int i=0; i

  ost << '(' << temps[i].hour << ',' << temps[i].temperature << ")\n";


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

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


#include "std_lib_facilities.h"


struct Reading {    // данные о температуре воздуха

 int hour;       // часы после полуночи [0:23]

 double temperature; // по Фаренгейту

 Reading(int h, double t):hour(h), temperature(t) { }

};


int main()

{

 cout << "Пожалуйста, введите имя файла для ввода: ";

 string name;

 cin >> name;

 ifstream ist(name.c_str()); // поток ist считывает данные

                // из файла,

                // имя которого задано строкой name

 if (!ist) error("Невозможно открыть файл для ввода ",name);

 cout << "Пожалуйста, введите имя файла для вывода: ";

 cin >> name;

 ofstream ost(name.c_str()); // поток ost записывает данные

               // в файл, имя которого задано

                // строкой name

 if (!ost) error("Невозможно открыть файл для вывода ",name);

 vector temps;    // здесь хранится считанная информация

 int hour;

 double temperature;

  while (ist >> hour >> temperature) {

   if (hour < 0 || 23 

   temps.push_back(Reading(hour,temperature));

  }

  for (int i=0; i

   ost << '(' << temps[i].hour << ','

     << temps[i].temperature << ")\n";

}

10.6. Обработка ошибок ввода-вывода

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

istream
сводит их все к четырем возможным классам, которые называют состояниями потока (stream state)



К сожалению, различия между состояниями

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


int i = 0;

cin >> i;

if (!cin) { // мы окажемся здесь (и только здесь),

       // если операция ввода не выполнена

 if (cin.bad()) error("cin испорчен "); // поток поврежден: стоп!

  if (cin.eof()) {

   // входных данных больше нет

   // именно так мы хотели бы завершить ввод данных

  }

  if (cin.fail()) { // с потоком что-то случилось

   cin.clear();   // приготовиться к дальнейшему вводу

           // исправление ситуации

  }

}


Выражение

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

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

*
или признаком конца файла ( в системе Windows или в системе Unix). Например, пусть в файле записаны следующие числа:


1 2 3 4 5 *


Ввести их можно с помощью такой функции:


void fill_vector(istream& ist, vector& v, char terminator)

 // считывает целые числа из потока ist в вектор v,

 // пока не будет достигнут признак eof() или символ завершения

{

 int i = 0;

 while (ist >> i) v.push_back(i);

 if (ist.eof()) return; // отлично: мы достигли конца файла

 if (ist.bad()) error("Поток ist поврежден."); // поток поврежден;

                         // стоп!

  if (ist.fail()) { // очищаем путаницу как можем и сообщаем

           // об ошибке

   ist.clear();   // очищаем состояние потока

           // и теперь снова можем искать признак

           // завершения

   char c;

   ist>>c;     // считываем символ, возможно, признак

           // завершения

   if (c != terminator) {      // неожиданный символ

    ist.unget();          // возвращаем этот символ назад

    ist.clear(ios_base::failbit); // переводим поток

                   // в состояние fail()

   }

  }

}


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

fill_vector()
, может попытаться вывести поток из состояния
fail()
. Поскольку мы очистили состояние, то, для того чтобы проверить символ, должны вернуть поток обратно в состояние
fail()
. Для этого выполняется инструкция
ist.clear(ios_base::failbit)
. Обратите внимание на потенциально опасное использование функции
clear()
: на самом деле функция
clear()
с аргументом устанавливает указанные флаги (биты) состояния потока
iostream
, сбрасывая (только) не указанные. Переводя поток в состояние
fail()
, мы указываем, что обнаружили ошибку форматирования, а не нечто более серьезное. Мы возвращаем символ обратно в поток
ist
, используя функцию
unget()
; функция, вызывающая функцию
fill_vector()
, может использовать его по своему усмотрению. Функция
unget()
представляет собой более короткий вариант функции
putback()
, который основывается на предположении, что поток помнит, какой символ был последним, и поэтому его не обязательно указывать явно.

Если вы вызвали функцию

fill_vector()
и хотите знать, что вызвало прекращение ввода, то можно проверить состояния
fail()
и
eof()
. Кроме того, можно перехватить исключение
runtime_error
, сгенерированное функцией
error()
, но понятно, что маловероятно получить больше данных из потока
istream
, находящегося в состоянии
bad()
. Большинство вызывающих функций не предусматривает сложной обработки ошибок. По этой причине практически во всех случаях единственное, чего мы хотим сделать, обнаружив состояние
bad()
, — сгенерировать исключение.

Для того чтобы облегчить себе жизнь, можем поручить потоку

istream
сделать это за нас.


// поток ist генерирует исключение, если попадает в состояние bad

ist.exceptions(ist.exceptions()|ios_base::badbit);


Эти обозначения могут показаться странными, но результат простой: если поток

ist
окажется в состоянии
bad()
, он сгенерирует стандартное библиотечное исключение
ios_base::failure
. Вызвать функцию
exceptions()
можно только один раз. Все это позволяет упростить циклы ввода, игнорируя состояние
bad()
.


void fill_vector(istream& ist, vector& v, char terminator)

 // считываем целые числа из потока ist в вектор v, пока не

 // достигнем конца файла eof() или признака завершения

{

 int i = 0;

 while (ist >> i) v.push_back(i);

 if (ist.eof()) return; // отлично: обнаружен конец файла

        // не good(), не bad() и не eof(),

        // поток ist должен быть переведен в состояние fail()

 ist.clear(); // сбрасываем состояние потока

 char c;

 ist>>c; // считываем символ в поисках признака завершения ввода

 if (c != terminator) { // Ох: это не признак завершения ввода,

             // значит, нужно вызывать функцию fail()

   ist.unget();     // может быть, вызывающая функция

             // может использовать этот символ

   ist.clear(ios_base::failbit); // установить состояние fail()

 }

}


Класс

ios_base
является частью потока
iostream
, в котором хранятся константы, такие как
badbit
, исключения, такие как
failure
, и другие полезные вещи. Для обращения к нему необходим оператор
::
, например
ios_base::badbit
(раздел B.7.2). Мы не планируем подробно описывать библиотеку
iostream;
для этого понадобился бы отдельный курс лекций. Например, потоки
iostream
могут обрабатывать разные наборы символов, реализовывать разные стратегии буферизации, а также содержат средства форматирования представлений денежных средств на разных языках (однажды мы даже получили сообщение об ошибке, связанной с форматированием представления украинской валюты). Все, что вам необходимо знать о потоках
iostream,
можно найти в книгах Страуструп (Stroustrup), The C++ Programming Language Страуструпа и Лангер (Langer), Standard C++ IOStreams and Locales.

Поток ostream имеет точно такие же состояния, как и поток

istream: good()
,
fail()
,
eof()
и
bad()
. Однако в таких программах, которые мы описываем в этой книге, ошибки при выводе встречаются намного реже, чем при вводе, поэтому мы редко их проверяем. Если вероятность того, что устройство вывода недоступно, переполнено или сломано, является значительной, то в программе следует предусмотреть проверку состояния потока вывода после каждой операции вывода, так как мы сделали выше по отношению к операции ввода.

10.7. Считывание отдельного значения

Итак, мы знаем, как считать последовательность значений, завершающихся признаком конца файла или завершения ввода. Впоследствии мы рассмотрим еще несколько примеров, а сейчас обсудим все еще популярную идею о том, чтобы несколько раз запрашивать значение, пока не будет введен его приемлемый вариант. Это позволит нам проверить несколько распространенных проектных решений. Мы обсудим эти альтернативы на примерах нескольких решений простой проблемы — как получить от пользователя приемлемое значение. Начнем с очевидного, но скучного и запутанного варианта под названием “сначала попытайся”, а затем станем его постепенно совершенствовать. Наше основное предположение заключается в том, что мы имеем дело с интерактивным вводом, в ходе которого человек набирает на клавиатуре входные данные и читает сообщения, поступающие от программы. Давайте предложим пользователю ввести целое число от 1 до 10 (включительно).


cout << "Пожалуйста, введите целое число от 1 до 10:\n";

int n = 0;

while (cin>>n) {       // читаем

 if (1<=n && n<=10) break; // проверяем диапазон

 cout << "Извините " << n

 << " выходит за пределы интервала [1:10]; попробуйте еще \n";

}


Этот код довольно уродлив, но отчасти работоспособен. Если вы не любите использовать оператор

break
(раздел А.6), то можете объединить считывание и проверку диапазона.


cout << "Пожалуйста, введите целое число от 1 до 10:\n";

int n = 0;

while (cin>>n && !(1<=n && n<=10)) // read and check range

 cout << "Извините, "

 << n << " выходит за пределы интервала [1:10];

попробуйте еще \n";


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

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

1. Что делать, если пользователь вводит число, находящееся за пределами допустимого диапазона?

2. Что делать, если пользователь не вводит никакого числа (признак конца файла)?

3. Что делать, если пользователь вводит неправильные данные (в данном случае не целое число)?


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

1. Решить проблему в коде при вводе данных.

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

3. Игнорировать проблему.


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

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

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

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

10.7.1. Разделение задачи на управляемые части

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


cout << "Пожалуйста, введите целое число от 1 до 10:\n";

int n = 0;

while (true) {

 cin >> n;

 if (cin) { // мы ввели целое число; теперь проверим его

   if (1<=n && n<=10) break;

   cout << "Извините, "

   << n << " выходит за пределы интервала [1:10];

        попробуйте еще \n";

 }

 else if (cin.fail()) { // обнаружено нечто, что является

             // целым числом

   cin.clear();     // возвращаем поток в состояние good();

             // мы хотим взглянуть на символы

   cout << "Извините, это не число; попробуйте еще раз \n";

   char ch;

   while (cin>>ch && !isdigit(ch));  // отбрасываем не цифры

    if (!cin) error(" ввода нет "); // цифры не обнаружены:

                   // прекратить

    cin.unget();    // возвращаем цифру назад,

             // чтобы можно было считать число

 }

 else {

   error(" ввода нет "); // состояние eof или bad: прекратить

 }

}

// если мы добрались до этой точки, значит, число n лежит

// в диапазоне [1:10]


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

• Считывание значения.

• Предложение к вводу.

• Вывод сообщений об ошибках.

• Пропуск “плохих” входных символов.

• Проверка диапазона входных чисел.


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


void skip_to_int()

{

 if (cin.fail()) {  // обнаружено нечто, что является целым числом

   cin.clear();    // возвращаем поток в состояние good();

            // мы хотим взглянуть на символы

   char ch;

   while (cin>>ch){  // отбрасываем не цифры

    if (isdigit(ch) || ch == '-')

     cin.unget(); // возвращаем цифру назад,

           // чтобы можно было считать число

    }

   }

  }

 error(" ввода нет "); // состояние eof или bad: прекратить

}


Имея вспомогательную функцию

skip_to_int()
, можем написать следующий код:


cout << "Пожалуйста, введите целое число от 1 до 10:\n";

int n = 0;

while (true) {

 if (cin>>n) { // мы ввели целое число; теперь проверим его

   if (1<=n && n<=10) break;

   cout << "Извините, " << n

   << " выходит за пределы интервала [1:10]; попробуйте еще раз.\n";

 }

 else {

   cout << "Извините, это не число; попробуйте еще раз.\n";

   skip_to_int();

 }

}

// если мы добрались до этой точки, значит, число n лежит

// в диапазоне [1:10]


Этот код лучше, но остается слишком длинным и запутанным для того, чтобы много раз применять его в программе. Мы никогда не добьемся желаемого результата, разве что после (слишком) долгой проверки. Какие операции мы бы хотели иметь на самом деле? Один из разумных ответов звучит так: “Нам нужны две функции: одна должна считывать любое число типа int, а другая — целое число из заданного диапазона”.


int get_int(); // считывает число типа int из потока cin

int get_int(int low, int high); // считывает из потока cin число int,

        // находящееся в диапазоне [low:high]


Если бы у нас были эти функции, то мы могли бы, по крайней мере, использовать их просто и правильно. Их несложно написать.


int get_int()

{

 int n = 0;

 while (true) {

   if (cin >> n) return n;

   cout << "Извините, это не число; попробуйте еще раз \n";

   skip_to_int();

 }

} 


В принципе функция

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

Используя такую общую функцию

get_int()
, можем написать проверку выхода за пределы диапазона
get_int()
:


int get_int(int low, int high)

{

 cout << "Пожалуйста, введите целое число из от "

 << low << " до " << high << " ( включительно ):\n";

 while (true) {

   int n = get_int();

   if (low<=n && n<=high) return n;

   cout << "Извините, " << n

   << " выходит за пределы интервала ["<< low << ':' << high

   << "]; попробуйте еще \n";

 }

}


Этот вариант функции

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

Теперь можем написать код для ввода целых чисел.


int n = get_int(1,10);

cout << "n: " << n << endl;

int m = get_int(2,300);

cout << "m: " << m << endl;


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

get_int()
на самом деле не может ввести ни одного числа.

10.7.2. Отделение диалога от функции

Разные варианты функции

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


int strength = get_int(1,10,"Введите силу",

        "Вне диапазона, попробуйте еще");

cout << " сила: " << strength << endl;

int altitude = get_int(0,50000,

        "Пожалуйста, введите высоту в футах",

        "Вне диапазона, пожалуйста, попробуйте еще");

cout << "высота: " << altitude << " футов над уровнем моря \n";


Эту задачу можно решить так:


int get_int(int low, int high, const string& greeting,

       const string& sorry)

{

  cout << greeting << ": [" << low << ':' << high << "]\n";

 while (true) {

   int n = get_int();

   if (low<=n && n<=high) return n;

   cout << sorry << ": [" << low << ':' << high << "]\n";

 }

}


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

Обратите внимание на то, что наше решение осталось незавершенным: функция

get_int()
без указания диапазона осталась “болтушкой”. Более тонкий аспект этой проблемы заключается в том, что вспомогательные функции, используемые в разных частях программы, не должны содержать “вшитых” сообщений. Далее, библиотечные функции, которые по своей сути предназначены для использования во многих программах, вообще не должны выдавать никаких сообщений для пользователя, — помимо всего прочего, автор библиотеки может даже не предполагать, что программа, в которой используется его библиотека, будет выполняться на машине под чьим-то наблюдением. Это одна из причин, по которым наша функция
error()
не выводит никаких сообщений об ошибках (см. раздел 5.6.3); в общем, мы не можем знать, куда их писать.

10.8. Операторы вывода, определенные пользователем

Определение оператора вывода

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

Рассмотрим простой оператор вывода для типа

Date
из раздела 9.8, который просто печатает год, месяц и день, разделенные запятыми.


ostream& operator<<(ostream& os, const Date& d)

{

 return os << '(' << d.year()

      << ',' << d.month()

       << ',' << d.day() << ')';

}


Таким образом, дата 30 августа 2004 года будет представлена как

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

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

<<
для типа
Date
, то инструкция


cout<


где объект

d1
имеет тип
Date
, эквивалентна вызову функции


operator<<(cout,d1);


Обратите внимание на то, что первый аргумент

ostream&
функции
operator<<()
одновременно является ее возвращаемым значением. Это позволяет создавать “цепочки” операторов вывода. Например, мы могли бы вывести сразу две даты.


cout<


В этом случае сначала был бы выполнен первый оператор

<<
, а затем второй.


cout << d1 << d2; // т.е. operator<<(cout,d1) << d2;

          // т.е. operator<<(operator<<(cout,d1),d2);


Иначе говоря, сначала происходит первый вывод объекта

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

10.9. Операторы ввода, определенные пользователем

Определение оператора ввода

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

Рассмотрим простой оператор ввода для типа

Date
из раздела 9.8, который считывает даты, ранее записанные с помощью оператора
<<
, определенного выше.


istream& operator>>(istream& is, Date& dd)

{

 int y, m, d;

 char ch1, ch2, ch3, ch4;

 is >> ch1 >> y >> ch2 >> m >> ch3 >> d >> ch4;

 if (!is) return is;

 if (ch1!='(' || ch2!=',' || ch3!=',' || ch4!=')') { // ошибка

                            // формата

   is.clear(ios_base::failbit);

   return is;

 }

 dd = Date(y,Date::Month(m),d); // обновляем объект dd

  return is;

}


Этот оператор

>>
вводит такие тройки, как
(2004,8,20)
, и пытается создать объект типа
Date
из заданных трех чисел. Как правило, выполнить ввод данных намного труднее, чем их вывод. Просто при вводе данных намного больше возможностей для появления ошибок, чем при выводе.

Если данный оператор

>>
не находит трех чисел, заданных в формате (целое, целое, целое), то поток ввода перейдет в одно из состояний,
fail
,
eof
или
bad
, а целевой объект типа
Date
останется неизмененным. Для установки состояния потока
istream
используется функция-член
clear()
. Очевидно, что флаг
ios_base::failbit
переводит поток в состояние
fail()
. В идеале при сбое во время чтения следовало бы оставить объект класса
Date
без изменений; это привело бы к более ясному коду. В идеале хотелось бы, чтобы функция
operator>>()
отбрасывала любые символы, которые она не использует, но в данном случае это было бы слишком трудно сделать: мы должны были бы прочитать слишком много символов, пока не обнаружится ошибка формата. В качестве примера рассмотрим тройку
(2004, 8, 30}
. Только когда мы увидим закрывающую фигурную скобку,
}
, обнаружится ошибка формата, и нам придется вернуть в поток много символов. Функция
unget()
позволяет вернуть только один символ. Если функция
operator>>()
считывает неправильный объект класса
Date
, например
(2004,8,32)
, конструктор класса
Date
сгенерирует исключение, которое приведет к прекращению выполнения оператора
operator>>()
.

10.10. Стандартный цикл ввода

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

ist
относится к классу
istream
.


My_type var;

while (ist>>var) { // читаем до конца файла

    // тут можно было бы проверить,

    // является ли переменная var корректной

    // тут мы что-нибудь делаем с переменной var

}

// выйти из состояния bad удается довольно редко;

// не делайте этого без крайней необходимости:

if (ist.bad()) error(" плохой поток ввода ");

if (ist.fail()) {

    // правильно ли выполнен ввод ?

}

// продолжаем: обнаружен конец файла


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

istream
генерировать исключение типа failure в случае сбоя. Это позволит нам не постоянно выполнять проверку.


// где-то: пусть поток ist генерирует исключение при сбое

ist.exceptions(ist.exceptions()|ios_base::badbit);


Можно также назначить признаком завершения ввода (terminator) какой-нибудь символ.


My_type var;

while (ist>>var) { // читаем до конца файла

 // тут можно было бы проверить,

  // является ли переменная var корректной

  // тут мы что-нибудь делаем с переменной var

}

if (ist.fail()) { // в качестве признака завершения ввода используем

          // символ '|' и / или разделитель

  ist.clear();

  char ch;

  if (!(ist>>ch && ch=='|'))

   error(" неправильное завершение ввода ");

}

// продолжаем: обнаружен конец файла или признак завершения ввода


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

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

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


// где-то: пусть поток ist генерирует исключение при сбое

ist.exceptions(ist.exceptions()|ios_base::badbit);


void end_of_loop(istream& ist, char term, const string& message)

{

 if (ist.fail()) { // используем символ завершения ввода

           // и/или разделитель

   ist.clear();

   char ch;

   if (ist>>ch && ch==term) return; // все хорошо

   error(message);

 }

}


Это позволяет нам сократить цикл ввода.


My_type var;

while (ist>>var) { // читаем до конца файла

 // тут можно было бы проверить, является ли переменная var

 // корректной

 // тут мы что-нибудь делаем с переменной var

}

end_of_loop(ist,'|'," неправильное завершение файла "); // проверяем,

                             // можно ли

                             // продолжать

// продолжаем: обнаружен конец файла или признак завершения ввода


Функция

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

10.11. Чтение структурированного файла

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

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

• Запись о годе начинается символами

{ year
, за которыми следует целое число, обозначающее год, например 1900, и заканчивается символом
}
.

• Год состоит из месяцев, в течение которых производились измерения.

• Запись о месяце начинается символами

{ month
, за которыми следует трехбуквенное название месяца, например jan, и заканчивается символом
}
.

• Данные содержат показания времени и температуры.

• Показания начинаются с символа

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


{ year 1990 }

{year 1991 { month jun }}

{ year 1992 { month jan ( 1 0 61.5) } {month feb (1 1 64) (2 2 
65.2)}}

{year 2000

{ month feb (1 1 68 ) (2 3 66.66 ) ( 1 0 67.2)}

{month dec (15 15 –9.2 ) (15 14 –8.8) (14 0 –2) }


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

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

10.11.1. Представление в памяти

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

Year
,
Month
и
Reading
, точно соответствующие входной информации. Классы
Year
и
Month
очевидным образом могли бы оказаться полезными при обработке данных; мы хотим сравнивать температуры разных лет, вычислять среднемесячные температуры, сравнивать разные месяцы одного года, одинаковые месяцы разных лет, показания температуры с записями о солнечном излучении и влажности и т.д. В принципе классы
Year
и
Month
точно отображают наши представления о температуре и погоде: класс
Month
содержит ежемесячную информацию, а класс
Year
— ежегодную. А как насчет класса
Reading
? Это понятие низкого уровня, связанное с частью аппаратного обеспечения (сенсором). Данные в классе
Reading
(день месяца, час и температура) являются случайными и имеют смысл только в рамках класса
Month
. Кроме того, они не структурированы: никто не обещал, что данные будут записаны по дням или по часам. В общем, для того чтобы сделать с данными что-то полезное, сначала их необходимо упорядочить. Для представления данных о температуре в памяти сделаем следующие предположения.

• Если есть показания для какого-то месяца, то их обычно бывает много.

• Если есть показания для какого-то дня, то их обычно бывает много.


В этом случае целесообразно представить класс

Year
как вектор, состоящий из 12 объектов класса
Month
, класс
Month
— как вектор, состоящий из 30 объектов класса
Day
, а класс
Day
— как 24 показания температуры (по одному в час). Это позволяет просто и легко манипулировать данными при решении самых разных задач. Итак, классы
Day
,
Month
и
Year
— это простые структуры данных, каждая из которых имеет конструктор. Поскольку мы планируем создавать объекты классов
Month
и
Day
как часть объектов класса Year еще до того, как узнаем, какие показания температуры у нас есть, то должны сформулировать, что означает “пропущены данные” для часа дня, до считывания которых еще не подошла очередь.


const int not_a_reading = –7777; // ниже абсолютного нуля


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


const int not_a_month = –1;


Три основных класса принимают следующий вид:


struct Day {

  vector hour;

 Day(); // инициализируем массив hour значениями "нет данных"

};


Day::Day()

   : hour(24)

{

 for (int i = 0; i

}


struct Month {    // месяц

 int month;    // [0:11] январю соответствует 0

 vector day; // [1:31] один вектор для всех данных по дням

 Month()     // не больше 31 дня в месяце (day[0]

          // не используется)

   :month(not_a_month), day(32) { }

};


struct Year {       // год состоит из месяцев

 int year;       // положительный == н.э.

 vector month;  // [0:11] январю соответствует 0

 Year() :month(12) { } // 12 месяцев в году

};


В принципе каждый класс — это просто вектор, а классы

Month
и
Year
содержат идентифицирующие члены
month
и
year
соответственно.

В этом примере существует несколько “волшебных констант” (например,

24
,
32
и
12
). Как правило, мы пытаемся избегать таких литеральных констант в коде. Эти константы носят фундаментальный характер (количество месяцев в году изменяется редко) и в остальной части кода не используются. Однако мы оставили их в коде в основном для того, чтобы напомнить вам о проблеме “волшебных чисел”, хотя намного предпочтительнее использовать символьные константы (см. раздел 7.6.1). Использование числа
32
для обозначения количества дней в месяце определенно требует объяснений; в таком случае число
32
действительно становится “волшебным”.

10.11.2. Считывание структурированных значений

Класс

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


struct Reading {

 int day;

  int hour;

 double temperature;

};


istream& operator>>(istream& is, Reading& r)

 // считываем показания температуры из потока is в объект r

  // формат: (3 4 9.7)

  // проверяем формат, но не корректность данных

{

  char ch1;

  if (is>>ch1 && ch1!='('){ // можно это превратить в объект типа

                // Reading?

   is.unget();

    is.clear(ios_base::failbit);

   return is;

  }


 char ch2;

  int d;

 int h;

  double t;

  is >> d >> h >> t >> ch2;

  if (!is || ch2!=')') error("Плохая запись"); // перепутанные

                        // показания

 r.day = d;

  r.hour = h;

  r.temperature = t;

  return is;

}


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

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

Операции ввода в классе

Month
почти такие же, за исключением того, что в нем вводится произвольное количество объектов класса
Reading
, а не фиксированный набор значений (как делает оператор
>>
в классе
Reading
).


istream& operator>>(istream& is, Month& m)

 // считываем объект класса Month из потока is в объект m

 // формат: { month feb... }

{

 char ch = 0;

 if (is >> ch && ch!='{') {

   is.unget();

   is.clear(ios_base::failbit); // ошибка при вводе Month

   return is;

 }


 string month_marker;

 string mm;

 is >> month_marker >> mm;

 if (!is || month_marker!="month") error("Неверное начало Month");

 m.month = month_to_int(mm);


 Reading r;

 int duplicates = 0;

 int invalids = 0;

 while (is >> r) {

   if (is_valid(r)) {

    if (m.day[r.day].hour[r.hour] != not_a_reading)

    ++duplicates;

    m.day[r.day].hour[r.hour] = r.temperature;

   }

   else

    ++invalids;

 }

 if (invalids) error("Неверные показания в Month", invalids);

 if (duplicates) error("Повторяющиеся показания в Month",
duplicates);

 end_of_loop(is,'}',"Неправильный конец Month");

 return is;

}


Позднее мы еще вернемся к функции

month_to_int();
она преобразовывает символические обозначения месяцев, такие как
jun
, в число из диапазона
[0:11]
. Обратите внимание на использование функции
end_of_loop()
из раздела 10.10 для проверки признака завершения ввода. Мы подсчитываем количество неправильных и повторяющихся объектов класса
Readings
(эта информация может кому-нибудь понадобиться).

Оператор

>>
в классе
Month
выполняет грубую проверку корректности объекта класса
Reading
, прежде чем записать его в память.


const int implausible_min = –200;

const int implausible_max = 200;

bool is_valid(const Reading& r)

// грубая проверка

{

 if (r.day<1 || 31

 if (r.hour<0 || 23

 if (r.temperature
implausible_max

   return false;

 return true;

}


В заключение можем прочитать объекты класса

Year
. Оператор
>>
в классе
Year
аналогичен оператору
>>
в классе
Month
.


istream& operator>>(istream& is, Year& y)

 // считывает объект класса Year из потока is в объект y

 // формат: { year 1972... }

{

 char ch;

 is >> ch;

 if (ch!='{') {

   is.unget();

   is.clear(ios::failbit);

   return is;

 }


 string year_marker;

 int yy;

 is >> year_marker >> yy;

 if (!is || year_marker!="year")

   error("Неправильное начало Year");

 y.year = yy;

 while(true) {

   Month m; // каждый раз создаем новый объект m

   if(!(is >> m)) break;

   y.month[m.month] = m;

 }

 end_of_loop(is,'}',"Неправильный конец Year");

 return is;

}


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


Month m;

while (is >> m)

y.month[m.month] = m;


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

operator>>(istream& is, Month& m)
не присваивает объекту m совершенно новое значение; она просто добавляет в него данные из объектов класса
Reading
. Таким образом, повторяющаяся инструкция
is>>m
добавляла бы данные в один и тот же объект
m
. К сожалению, в этом случае каждый новый объект класса
Month
содержал бы все показания всех предшествующих месяцев текущего года. Для того чтобы считывать данные с помощью инструкции
is>>m
, нам нужен совершенно новый объект класса
Month
. Проще всего поместить определение объекта m в цикл так, чтобы он инициализировался на каждой итерации.

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

operator>>(istream& is, Month& m)
перед считыванием в цикле присваивала бы объекту
m
пустой объект.


Month m;

while (is >> m) {

 y.month[m.month] = m;

 m = Month(); // "Повторная инициализация" объекта m

}


Попробуем применить это.


// открываем файл для ввода:

cout << "Пожалуйста, введите имя файла для ввода \n";

string name;

cin >> name;

ifstream ifs(name.c_str());

if (!ifs) error(" невозможно открыть файл для ввода ",name);

ifs.exceptions(ifs.exceptions()|ios_base::badbit); // генерируем bad()


// открываем файл для вывода:

cout << "Пожалуйста, введите имя файла для ввода \n";

cin >> name;

ofstream ofs(name.c_str());

if (!ofs) error(" невозможно открыть файл для ввода ",name);


// считываем произвольное количество объектов класса Year:

vector ys;

while(true) {

 Year y; // объект класса Year каждый раз очищается

 if (!(ifs>>y)) break;

 ys.push_back(y);

}

cout << " считано " << ys.size() << " записей по годам.\n";

for (int i = 0; i


Функцию

print_year()
мы оставляем в качестве упражнения.

10.11.3. Изменение представления

Для того чтобы оператор

>>
класса
Month
работал, необходимо предусмотреть способ для ввода символьных представлений месяца. Для симметрии мы описываем способ сравнения с помощью символьного представления. Было бы слишком утомительно писать инструкции
if
, подобные следующей:


if (s=="jan")

  m = 1;

else if (s=="feb")

 m = 2;

...


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

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


vector month_input_tbl; // month_input_tbl[0]=="jan"

void init_input_tbl(vector& tbl)

// инициализирует вектор входных представлений

{

 tbl.push_back("jan");

 tbl.push_back("feb");

 tbl.push_back("mar");

 tbl.push_back("apr");

 tbl.push_back("may");

 tbl.push_back("jun");

 tbl.push_back("jul");

 tbl.push_back("aug");

 tbl.push_back("sep");

 tbl.push_back("oct");

 tbl.push_back("nov");

 tbl.push_back("dec");

}


int month_to_int(string s)

// Является ли строка s названием месяца? Если да, то возвращаем ее

// индекс из диапазона [0:11], в противном случае возвращаем –1

{

 for (int i=0; i<12; ++i) if (month_input_tbl[i]==s) return i;

  return –1;

}


На всякий случай заметим, что стандартная библиотека С++ предусматривает более простой способ решения этой задачи. См. тип

map
в разделе 21.6.1.

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

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


vector month_print_tbl; // month_print_tbl[0]=="January"

void init_print_tbl(vector& tbl)

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

{

 tbl.push_back("January");

 tbl.push_back("February");

 tbl.push_back("March");

 tbl.push_back("April");

 tbl.push_back("May");

 tbl.push_back("June");

 tbl.push_back("July");

 tbl.push_back("August");

 tbl.push_back("September");

 tbl.push_back("October");

 tbl.push_back("November");

 tbl.push_back("December");

}


string int_to_month(int i)

// месяцы [0:11]

{

 if (i<0 || 12<=i) error("Неправильный индекс месяца.");

 return month_print_tbl[i];

}


Для того чтобы этот подход работал, необходимо где-то вызвать функции инициализации, такие как указаны в начале функции main().


// первая инициализация таблиц представлений:

init_print_tbl(month_print_tbl);

init_input_tbl(month_input_tbl);


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


Задание

1. Разработайте программу, работающую с точками (см. раздел 10.4). Начните с определения типа данных

Point
, имеющего два члена — координаты
x
и
y
.

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

Point
с именем
original_points
.

3. Выведите на печать данные из объекта

original_points
, чтобы увидеть, как они выглядят.

4. Откройте поток

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

5. Закройте поток

ofstream
, а затем откройте поток
ifstream
для файла
mydata.txt
. Введите данные из файла
mydata.txt
и запишите их в новый вектор с именем
processed_points
.

6. Выведите на печать данные из обоих векторов.

7. Сравните эти два вектора и выведите на печать сообщение Что-то не так

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


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

1. Насколько разнообразными являются средства ввода и вывода у современных компьютеров?

2. Что делает поток

istream
?

3. Что делает поток

ostream
?

4. Что такое файл?

5. Что такое формат файла?

6. Назовите четыре разных типа устройств для ввода и вывода данных из программ.

7. Перечислите четыре этапа чтения файла.

8. Перечислите четыре этапа записи файлов.

9. Назовите и определите четыре состояния потоков.

10. Обсудите возможные способы решения следующих задач ввода.

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

 10.2. Данные исчерпаны (конец файла).

 10.3. Пользователь набрал значение неправильного типа.

11. В чем ввод сложнее вывода?

12. В чем вывод сложнее ввода?

13. Почему мы (часто) хотим отделить ввод и вывод от вычислений?

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

clear()
класса
istream
.

15. Как определить операторы

<<
и
>>
для пользовательского типа
X
?


Термины


Упражнения

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

2. Напишите программу, создающую файл из данных, записанных в виде объектов класса

Reading
, определенного в разделе 10.5. Заполните файл как минимум 50 показаниями температуры. Назовите эту программу
store_temps.cpp
, а файл —
raw_temps.txt
.

3. Напишите программу, считывающую данные из файла

raw_temps.txt
, созданного в упр. 2, в вектор, а затем вычислите среднее и медиану температур. Назовите программу
temp_stats.cpp
.

4. Модифицируйте программу store_temps.cpp из упр. 2, включив в нее суффикс c для шкалы Цельсия и суффикс

f
для шкалы Фаренгейта. Затем модифицируйте программу
temp_stats.cpp
, чтобы перед записью в вектор проверить каждое показание, преобразовать показание из шкалы Цельсия в шкалу Фаренгейта.

5. Напишите функцию

print_year()
, упомянутую в разделе 10.11.2.

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

Roman_int
для хранения римских цифр (как чисел типа
int
) с операторами
<<
и
>>
. Включите в класс
Roman_int
функцию
as_int()
, возвращающую значение типа
int
, так, чтобы, если объект
r
имеет тип
Roman_int
, мы могли написать
cout << "Roman" << r << " равен " << r.as_int() << '\n';
.

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

XXI+CIV==CXXV
.

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

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

10. Добавьте в калькулятор из главы 7 команду

from x
, осуществляющую ввод данных из файла
x
. Добавьте в калькулятор команду
to y
, выполняющую вывод (как обычных данных, так и сообщений об ошибках) в файл
y
. Напишите набор тестов, основанных на идеях из раздела 7.3, и примените его для проверки калькулятора. Объясните, как вы используете эти команды для тестирования.

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

bears: 17 elephants 9 end
” результат должен быть равен
26
.

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

getline()
.


Послесловие

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

Глава 11 Настройка ввода и вывода

“Все должно быть как можно более простым,

но не проще”.

Альберт Эйнштейн (Albert Einstein)


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

11.1. Регулярность и нерегулярность

Библиотека ввода-вывода является частью стандартной библиотеки языка С++. Она обеспечивает единообразную и расширяемую базу для ввода и вывода текста. Под словом “текст” мы подразумеваем нечто, что можно представить в виде последовательности символов. Таким образом, когда мы говорим о вводе и выводе, то целое число

1234
рассматривается как текст, поскольку его можно записать с помощью четырех символов:
1
,
2
,
3
и
4
.

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

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

11.2. Форматирование вывода

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

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

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

true
(или
vrai
or
sandt
), так и числом
1
, если в вашем распоряжении находятся только символы, не входящие в набор ASCII (например, символы в системе Unicode). Кроме того, существуют разные способы ограничения символов, записываемых в строку. Эти возможности не интересны, пока они вам не нужны, поэтому мы отсылаем читателей к справочникам и специализированным книгам, таким как Langer Standard C++ IOStreams and Locales; главе 21 и приложению D в книге The C++ Programming Language Страуструпа; а также к §22 и 27 стандарта ISO C++. В настоящей книге мы рассмотрим лишь самые распространенные варианты вывода и некоторые общие понятия.

11.2.1. Вывод целых чисел

Целые числа можно вывести как восьмеричные (в системе счисления с основанием 8), десятичные (в обычной системе счисления с основанием 10) и шестнадцатеричные (в системе счисления с основанием 16). Если вы ничего не знаете об этих системах, сначала прочитайте раздел A.2.1.1. В большинстве случаев при выводе используется десятичная система. Шестнадцатеричная система широко распространена при выводе информации, связанной с аппаратным обеспечением.

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

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

1234
при выводе должно трактоваться как десятичное, шестнадцатеричное или восьмеричное.


cout << 1234 << "\t(decimal)\n"

    << hex << 1234 << "\t(hexadecimal)\n"

   << oct << 1234 << "\t(octal)\n";


Символ

'\t'
означает “символ табуляции”. Он обеспечивает следующее представление выходной информации:


1234 (decimal)

4d2 (hexadecimal)

2322 (octal)


Обозначения

<< hex
и
<< oct
не являются значениями, предназначенными для вывода. Выражение
<< hex
сообщает потоку, что любое целое число в дальнейшем должно быть представлено как шестнадцатеричное, а выражение
<< oc
t означает, что любое целое число в дальнейшем должно быть представлено как восьмеричное. Рассмотрим пример.


cout << 1234 << '\t' << hex << 1234 << '\t' << oct << 1234 << '\n';

cout << 1234 << '\n'; // восьмеричная основа продолжает действовать


В итоге получаем следующий вывод:


1234 4d2 2322

2322 // целые числа продолжают трактоваться как восьмеричные


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

oct
,
hex
и
dec
(для десятичных чисел) являются персистентными (инертными) — они применяются к каждому целому числу, пока мы не дадим потоку другое указание. Термины
hex
и
oct
используются для изменения поведения потока и называются манипуляторами (manipulators).


ПОПРОБУЙТЕ

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


Представление чисел в системе счисления, отличной от десятичной, может ввести читателя в заблуждение. Например, если заранее не знать, в какой системе представлено число, то строка 11 может означать десятичное число 11, а не восьмеричное число 9 (т.е. 11 в восьмеричной системе) или шестнадцатеричное число 17 (т.е. 11 в шестнадцатеричной системе). Для того чтобы избежать таких проблем, можно попросить поток показать базу, в которой представлено целое число. Рассмотрим пример.


cout << 1234 << '\t' << hex << 1234 << '\t' << oct << 1234 << '\n';

cout << showbase << dec; // показывать базы

cout << 1234 << '\t' << hex << 1234 << '\t' << oct << 1234 << '\n';


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


1234 4d2 2322

1234 0x4d2 02322


Итак, десятичные числа не имеют префиксов, восьмеричные числа имеют префикс 0, а шестнадцатеричные числа имеют префикс

0x
(или
0X
). Именно так обозначаются целочисленные литералы в языке С++. Рассмотрим пример.


cout << 1234 << '\t' << 0x4d2 << '\t' << 02322 << '\n';


В десятичном виде эти числа выглядели бы так:


1234 1234 1234


Как вы могли заметить, манипулятор

showbase
является персистентным, как и манипуляторы
oct
и
hex
. Манипулятор
noshowbase
отменяет действие манипулятора
showbase
, возвращая поток в состояние по умолчанию, в котором любое число выводится без указания его базы счисления.

Итак, существует несколько манипуляторов вывода.



11.2.2. Ввод целых чисел

По умолчанию оператор

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


int a;

int b;

int c;

int d;

cin >> a >> hex >> b >> oct >> c >> d;

cout << a << '\t' << b << '\t' << c << '\t' << d << '\n';


Если набрать на клавиатуре числа


1234 4d2 2322 2322


то программа выведет их так:


1234 1234 1234 1234


Обратите внимание на то, что при вводе манипуляторы

oct
,
dec
и
hex
являются персистентными, как и при выводе.


ПОПРОБУЙТЕ

Завершите фрагмент кода, приведенный выше, и преобразуйте его в программу. Попробуйте ввести предлагаемые числа; затем введите числа


1234 1234 1234 1234


Объясните результат. Попробуйте ввести другие числа, чтобы увидеть, что произойдет.


Для того чтобы принять и правильно интерпретировать префиксы

0
и
0x
, можно использовать оператор
>>
. Для этого необходимо отменить установки, принятые по умолчанию. Рассмотрим пример.


cin.unsetf(ios::dec); // не считать десятичным

           // (т.е. 0x может означать

            // шестнадцатеричное число)

cin.unsetf(ios::oct); // не считать восьмеричным

            // (т.е. 12 может означать двенадцать)

cin.unsetf(ios::hex); // не считать шестнадцатеричным

            // (т.е. 12 может означать двенадцать)


Функция-член потока

unsetf()
сбрасывает флаг (или флаги), указанный как аргумент. Итак, если вы напишете


cin >>a >> b >> c >> d;


и введете


1234 0x4d2 02322 02322


то получите


1234 1234 1234 1234

11.2.3. Вывод чисел с плавающей точкой

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

iostream
почти так же, как и целые числа. Рассмотрим пример.


cout << 1234.56789 << "\t\t(общий)\n"  // \t\t — выравнивание столбцов

 << fixed << 1234.56789 << "\t(фиксированный)\n"

 << scientific << 1234.56789 << "\t(научный)\n";


В итоге получим следующие строки:


1234.57    (общий)

1234.567890  (фиксированный)

1.234568e+003 (научный)


Манипуляторы

fixed
и
scientific
используются для выбора форматов для представления чисел с плавающей точкой. Интересно, что в стандартной библиотеке нет манипулятора
general
, который устанавливал бы формат, принятый по умолчанию. Однако мы можем определить его сами, как это сделано в заголовочном файле
std_lib_facilities.h
. Для этого не требуются знания о внутреннем устройстве библиотеки ввода-вывода.


inline ios_base& general(ios_base& b) // фиксированный и научный

 // формат

 // сбрасывает все флаги формата с плавающей точкой

{

 b.setf(ios_base::fmtflags(0), ios_base::floatfield);

 return b;

}


Теперь можем написать следующий код:


cout << 1234.56789 << '\t'

   << fixed << 1234.56789 << '\t'

    << scientific << 1234.56789 << '\n';

cout << 1234.56789 << '\n';      // действует формат

                    // с плавающей точкой

cout << general << 1234.56789 << '\t' // предупреждение:

   << fixed << 1234.56789 << '\t'  // general — нестандартный

                    // манипулятор

   << scientific << 1234.56789 << '\n';


В итоге получим следующие числа:


1234.57 1234.567890 1.234568e+003

1.234568e+003  // манипулятор научного формата является

         // персистентным

1234.57 1234.567890 1.234568e+003


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



11.2.4. Точность

По умолчанию число с плавающей точкой выводится на печать с помощью шести цифр в формате

general
. Формат, состоящий из шести цифр (точность формата general по умолчанию), считается наиболее подходящим, а такое округление числа — наилучшим. Рассмотрим пример.


1234.567
выводится на печать как
1234.57

1.2345678
выводится на печать как
1.23457


Округление, как правило, выполняется по правилу 4/5: от 0 до 4 — округление вниз, а от 5 до 9 — вверх. Обратите внимание на то, что такое форматирование относится только к числам с плавающей точкой.


1234567
выводится на печать как
1234567
(поскольку число целое)

1234567.0
выводится на печать как
1.23457e+006


В последнем случае поток

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


ПОПРОБУЙТЕ

Напишите программу, три раза выводящую на печать число

1234567.89
, сначала в формате
general
, затем — в
fixed
, потом — в
scientific
. Какая форма вывода обеспечивает наиболее точное представление числа и почему?


Программист может установить точность представления числа, используя манипулятор

setprecision()
. Рассмотрим пример.


cout << 1234.56789 << '\t'

    << fixed << 1234.56789 << '\t'

    << scientific << 1234.56789 << '\n';

cout << general << setprecision(5)

    << 1234.56789 << '\t'

   << fixed << 1234.56789 << '\t'

    << scientific << 1234.56789 << '\n';

cout << general << setprecision(8)

    << 1234.56789 << '\t'

    << fixed << 1234.56789 << '\t'

    << scientific << 1234.56789 << '\n';


Этот код выводит на печать следующие числа (обратите внимание на округление):


1234.57 1234.567890 1.234568e+003

1234.6 1234.56789 1.23457e+003

1234.5679 1234.56789000 1.23456789e+003


Точность определятся по правилам, приведенным ниже.

Мы рекомендуем использовать формат, принятый по умолчанию (формат

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

11.2.5. Поля

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

setw()
. Рассмотрим пример.


cout << 123456      // поля не используются

   <<'|'<< setw(4) << 123456 << '|' // число 123456

             // не помещается в поле

   << setw(8) << 123456 << '|' // из 4 символов,

             // расширим до 8

   << 123456 << "|\n"; // размеры полей не инертны


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


123456|123456| 123456|123456|


Обратите внимание на два пробела перед третьим появлением числа

123456
. Это является результатом того, что мы выводим шесть цифр в поле, состоящее из восьми символов. Однако число
123456
невозможно усечь так, чтобы оно помещалось в поле, состоящем из четырех символов. Почему? Конечно, числа
|1234|
или
|3456|
можно интерпретировать как вполне допустимые для поля, состоящего из четырех символов. Однако в этом случае на печать будут выведены числа, которые совершенно не соответствуют ожиданиям программиста, причем он не получит об этом никакого предупреждения. Поток
ostream
не сделает этого; вместо этого он аннулирует неправильный формат вывода. Плохое форматирование почти всегда лучше, чем “плохие результаты”. В большинстве случаев (например, при выводе таблиц) переполнение полей сразу бросается в глаза и может быть исправлено.

Поля также можно использовать при выводе строк и чисел с плавающей точкой. Рассмотрим пример.


cout << 12345 <<'|'<< setw(4) << 12345 << '|'

   << setw(8) << 12345 << '|' << 12345 << "|\n";

cout << 1234.5 <<'|'<< setw(4) << 1234.5 << '|'

   << setw(8) << 1234.5 << '|' << 1234.5 << "|\n";

cout << "asdfg" <<'|'<< setw(4) << "asdfg" << '|'

    << setw(8) << "asdfg" << '|' << "asdfg" << "|\n";


Этот код выводит на печать следующие числа:


12345|12345| 12345|12345|

1234.5|1234.5| 1234.5|1234.5|

asdfg|asdfg| asdfg|asdfg|


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


ПОПРОБУЙТЕ

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

11.3. Открытие файла и позиционирование

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



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

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

11.3.1. Режимы открытия файлов

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

ifstream
открывает файлы для чтения, а поток
ofstream
— для записи. Эти операции удовлетворяют большинство наших потребностей. Однако существует несколько альтернатив.



Режим открытия файла можно указать после его имени. Рассмотрим пример.


ofstream of1(name1); // по умолчанию ios_base::out

ifstream if1(name2); // по умолчанию ios_base::in

ofstream ofs(name, ios_base::app); // по умолчанию ofstream —

                   // для записи

fstream fs("myfile", ios_base::in|ios_base::out); // для ввода и вывода


Символ

|
в последнем примере — это побитовый оператор ИЛИ (раздел A.5.5), который можно использовать для объединения режимов. Опция
app
часто используется для записи регистрационных файлов, в которых записи всегда добавляются в конец.

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


if (!fs) // Ой: мы не можем открыть файл в таком режиме


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


ifstream ifs("redungs");

if (!ifs) // ошибка: невозможно открыть файл readings для чтения


В данном случае причиной ошибки стала опечатка.

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


ofstream ofs("no-such-file");     // создает новый файл no-such-file

ifstream ifs("no-file-of-this-name");  // ошибка: поток ifs не нахо-

                    // дится в состоянии good()

11.3.2. Бинарные файлы

В памяти мы можем представить значение 123 как целое или как строку. Рассмотрим пример.


int n = 123;

string s = "123";


В первом случае число

123
интерпретируется как (двоичное) число. Объем памяти, который оно занимает, совпадает с объемом памяти, который занимает любое другое целое число (
4
байта, т.е.
32
бита на персональном компьютере). Если вместо числа
123
мы выберем число
12345
, то оно по-прежнему будет занимать те же самые четыре байта. Во втором варианте значение
123
хранится как строка из трех символов. Если мы выберем строку
"12345"
, то для ее хранения нам потребуются пять символов (плюс накладные расходы памяти на управление объектом класса
string
). Проиллюстрируем сказанное, используя обычные десятичное и символьное представления, а не двоичное, как в памяти компьютера.



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



Разница между хранением двоичного представления фиксированного размера (например, в виде типа

int
) и символьного представления переменного размера (например, в виде типа
string
) проявляется и при работе с файлами. По умолчанию потоки
iostream
работают с символьными представлениями; иначе говоря, поток
istream
считывает последовательность символов и превращает их в объект заданного типа. Поток
ostream
принимает объект заданного типа и преобразует их в последовательность записываемых символов. Однако можно потребовать, чтобы потоки
istream
и
ostream
просто копировали байты из файла в файл. Такой ввод-вывод называется двоичным (binary I/O). В этом случае файл необходимо открыть в режиме
ios_base::binary
. Рассмотрим пример, в котором считываются и записываются двоичные файлы, содержащие целые числа. Главные сроки, предназначенные для обработки двоичных файлов, объясняются ниже.


int main()

{

 // открываем поток istream для двоичного ввода из файла:

 cout << "Пожалуйста, введите имя файла для ввода \n";

 string name;

 cin >> name;

 ifstream ifs(name.c_str(),ios_base::binary); // примечание: опция

    // binary сообщает потоку, чтобы он ничего не делал

    // с байтами

 if (!ifs) error("Невозможно открыть файл для ввода ", name);

    // открываем поток ostream для двоичного вывода в файл:

 cout << "Пожалуйста, введите имя файла для вывода \n";

 cin >> name;

 ofstream ofs(name.c_str(),ios_base::binary); // примечание: опция

    // binary сообщает потоку, чтобы он ничего не делал

    // с байтами

 if (!ofs) error("Невозможно открыть файл для ввода ",name);

 vector v;

    // чтение из бинарного файла:

 int i;

 while (ifs.read(as_bytes(i),sizeof(int))) // примечание:

                       // читаем байты

    v.push_back(i);

    // ...что-то делаем с вектором v...

    // записываем в двоичный файл:

 for(int i=0; i

   ofs.write(as_bytes(v[i]),sizeof(int)); // примечание:

                      // запись байтов

  return 0;

}


Мы открыли эти файлы с помощью опции

ios_base::binary
.


ifstream ifs(name.c_str(), ios_base::binary);

ofstream ofs(name.c_str(), ios_base::binary);


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

>>
и
<<
. Эти операторы преобразуют значения в последовательности символов, руководствуясь установленными по умолчанию правилами (например, строка
"asdf"
превращается в символы
a
,
s
,
d
,
f
, а число
123
превращается в символы
1
,
2
,
3
). Если вы не хотите работать с двоичным представлением чисел, достаточно ничего не делать и использовать режим, заданный по умолчанию. Мы рекомендуем применять опцию
binary
, только если вы (или кто-нибудь еще) считаете, что так будет лучше. Например, с помощью опции
binary
можно сообщить потоку, что он ничего не должен делать с байтами.

А что вообще мы могли бы сделать с типом

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


ifs.read(as_bytes(i),sizeof(int))   // чтение байтов

ofs.write(as_bytes(v[i]),sizeof(int)) // запись байтов


Функция

write()
потока
ostream
и функция
read()
потока
istream
принимают адрес (с помощью функции
as_bytes()
) и количество байтов (символов), полученное с помощью оператора
sizeof
. Этот адрес должен ссылаться на первый байт в памяти, хранящей значение, которое мы хотим прочитать или записать. Например, если у нас есть объект типа
int
со значением
1234
, то мы могли бы получить четыре байта (используя шестнадцатеричную систему обозначений) —
00
,
00
,
04
,
d2
:



Функция

as_bytes()
позволяет получить адрес первого байта объекта. Ее определение выглядит так (некоторые особенности языка, использованные здесь, будут рассмотрены в разделах 17.8 и 19.3):


template

char* as_bytes(T& i) // рассматривает объект T как последовательность

           // байтов

{

   void* addr = &i;  // получаем адрес первого байта

            // памяти, использованной для хранения объекта

   return static_cast(addr); // трактуем эту память как байты

}


Небезопасное преобразование типа с помощью оператора static_cast необходимо для того, чтобы получить переменную в виде совокупности байтов. Понятие адреса будет подробно изучено в главах 17 и 18. Здесь мы просто показываем, как представить любой объект, хранящийся в памяти, в виде совокупности байтов, чтобы прочитать или записать его с помощью функций

read()
и
write()
.

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

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

11.3.3. Позиционирование в файлах

При малейшей возможности считывайте и записывайте файлы от начала до конца. Это проще всего и открывает меньше возможностей для совершения ошибок. Каждый раз, когда вы понимаете, что пора изменить файл, лучше создайте новый и запишите в него все изменения. Однако, если вы должны поступить иначе, то можно выполнить позиционирование и указать конкретное место для чтения и записи в файле. В принципе в любом файле, открытом для чтения, существует позиция для считывания/ввода (“read/get position”), а в любом файле, открытом для записи, есть позиция для записи/вывода (“write/put position”).



Эти позиции можно использовать следующим образом.


fstream fs(name.c_str()); // открыть для ввода и вывода

if (!fs) error("Невозможно открыть файл ",name);


fs.seekg(5); // перенести позицию считывания (буква g означает "get")

       // на пять ячеек вперед (шестой символ)

char ch;

fs>>ch;    // считать и увеличить номер позиции для считывания

cout << " шестой символ — это " << ch << '(' << int(ch) << ")\n";

fs.seekp(1); // перенести позицию для записи (буква p означает "put")

       // на одну ячейку вперед

fs<<'y';   // записать и увеличить позицию для записи


Будьте осторожны: ошибки позиционирования не распознаются. В частности, если вы попытаетесь выйти за пределы файла (используя функцию

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

11.4. Потоки строк

В качестве источника ввода для потока

istream
или цели вывода для потока
ostream
можно использовать объект класса
string
. Поток
istream
, считывающий данные из объекта класса
string
, называется
istringstream
, а поток
ostream
, записывающий символы в объект класса
string
, называется
ostringstream
. Например, поток
istringstream
полезен для извлечения числовых значений из строк.


double str_to_double(string s)

 // если это возможно, преобразовывает символы из строки s

 // в число с плавающей точкой

{

 istringstream is(s); // создаем поток для ввода из строки s

 double d;

 is >> d;

 if (!is) error("Ошибка форматирования типа double: ",s);

 return d;

}

 double d1 = str_to_double("12.4");        // проверка

 double d2 = str_to_double("1.34e–3");

 double d3 = str_to_double("twelve point three"); // вызывается

                           // error()


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

istringstream
, то он перейдет в состояние
eof()
. Это значит, что для потока
istringstream
можно использовать обычный цикл ввода; поток
istringstream
на самом деле является разновидностью потока
istream
.

Поток

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


void my_code(string label, Temperature temp)

{

 // ...

 ostringstream os; // поток для составления сообщения

 os << setw(8) << label << ": "

   << fixed << setprecision(5) << temp.temp << temp.unit;

 someobject.display(Point(100,100), os.str().c_str());

 // ...

}


Функция-член

str()
класса
ostringstream
возвращает объект класса
string
, составленный операторами вывода, в поток
ostringstream
. Функция
c_str()
— это функция-член класса
string
, возвращающая строки в стиле языка C, которые ожидаются интерфейсами многих систем.

Потоки

stringstream
обычно используются, когда мы хотим отделить собственно ввод-вывод от обработки данных. Например, аргумент типа
string
в функции
str_to_double()
обычно поступает из файла (например, из журнала событий веб) или с клавиатуры. Аналогично, сообщение, составленное функцией
my_code()
, в конце концов выводится на экран. Например, в разделе 11.7 мы используем поток
stringstream
при выводе для фильтрации нежелательных символов. Таким образом, потоки
stringstream
можно интерпретировать как механизм настройки ввода-вывода для особых потребностей и вкусов.

Продемонстрируем использование потока

ostringstream
на простом примере конкатенации строк.


int seq_no = get_next_number(); // вводим число из системного журнала

ostringstream name;

name << "myfile" << seq_no;      // например, myfile17

ofstream logfile(name.str().c_str()); // например, открыть myfile17


Как правило, поток

istringstream
инициализируется объектом класса
string
, а затем считывает из него символы, используя операторы ввода. И наоборот, поток
ostringstream
инициализируется пустым объектом класса
string
, а затем заполняется с помощью операторов вывода. Существует более простой способ доступа к символам в потоке
stringstream
, который иногда оказывается полезным: функция
ss.str()
возвращает копию строки из объекта
ss
, а функция
ss.str(s)
присваивает строке в объекте
ss
копию строки
s
. В разделе 11.7 приведен пример, в котором функция
ss.str(s)
играет существенную роль.

11.5. Ввод, ориентированный на строки

Оператор

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


string name;

cin >> name;      // ввод: Dennis Ritchie

cout << name << '\n'; // вывод: Dennis


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

getline()
. Рассмотрим пример.


string name;

getline(cin,name);   // ввод: Dennis Ritchie

cout << name << '\n'; // вывод: Dennis Ritchie


Теперь мы считали целую строку. Зачем нам это было нужно? Например, неплохой ответ: “Потому что мы сделали то, чего не может оператор

>>
”. Часто можно слышать совершенно неудачное объяснение: “Потому что пользователь набрал полную строку”. Если это все, что вы можете сказать, то используйте оператор
>>
, потому что, если вы ввели строку, то должны как-то ее разобрать на части. Рассмотрим пример.


string first_name;

string second_name;

stringstream ss(name);

ss>>first_name;  // ввод строки Dennis

ss>>second_name; // ввод строки Ritchie


Непосредственный ввод данных в строки

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


идти налево, пока не увидишь картину справа на стене

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


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


string command;

getline(cin,command);       // вводим строку

stringstream ss(command);

vector words;

string s;

while (ss>>s) words.push_back(s); // извлекаем отдельные слова


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

11.6. Классификация символов

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

Допустим, мы хотим разделить выражение

1+4*x<=y/z*5
на одиннадцать лексем.


1 + 4 * x <= y / z * 5


Для ввода чисел мы могли бы использовать оператор

>>
, но, пытаясь ввести идентификаторы как строки, должны были бы прочитать фразу
x<=y
как целую строку (поскольку символы
<
и
=
не являются разделителями). Сочетание символов
z*
мы также должны были бы ввести как целую строку (поскольку символ
*
также не является разделителем).

Вместо этого можно сделать следующее:


char ch;

while (cin.get(ch)) {

 if (isspace(ch)) { // если символ ch является разделителем,

           // ничего не делаем (так как разделители

           // игнорируются)

  }

  if (isdigit(ch)) {

           // вводим число

  }

  else if (isalpha(ch)) {

           // вводим идентификатор

  }

  else {

           // обрабатываем операторы

  }

}


Функция

istream::get()
считывает отдельный символ в свой аргумент. Разделители при этом не игнорируются. Как и оператор
>>
, функция
get()
возвращает ссылку на свой поток
istream
, так что можно проверить его состояние.

При вводе отдельных символов мы обычно хотим классифицировать их: это символ или цифра? В верхнем регистре или в нижнем? И так далее. Для этого существует набор стандартных библиотечных функций.



Обратите внимание на то, что категории классификации можно объединять с помощью оператора ИЛИ (

||
). Например, выражение
isalnum(c)
означает
isalpha(c)||isdigit(c);
иначе говоря, “является ли символ c буквой или цифрой?”

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



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

Right
,
right
и
rigHT
, то, скорее всего, он имел в виду одно и то же (например, слово
rigHT
чаще всего является результатом нечаянного нажатия клавиши ). Применив функцию
tolower()
к каждому символу в каждой из строк, мы можем получить одно и то же значение:
right
. Эту операцию можно выполнить с любым объектом класса
string
.


void tolower(string& s) // вводит строку s в нижнем регистре

{

 for (int i=0; i

}


Для того чтобы действительно изменить объект класса

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

11.7. Использование нестандартных разделителей

В этом разделе мы рассмотрим гипотетические примеры использования потоков i

ostream
для решения реальных задач. При вводе строк слова по умолчанию разделяются пробелами или другими специальными символами (whitespace). К сожалению, поток
istream
не имеет средств, позволяющих определять, какие символы должны играть роль разделителей, или непосредственно изменять способ, с помощью которого оператор
>>
считывает строки. Итак, что делать, если мы хотим дать другое определение разделителю? Рассмотрим пример из раздела 4.6.3, в котором мы считывали слова и сравнивали их друг с другом. Между этими словами стояли разделители, поэтому если мы вводили строку


As planned, the guests arrived; then


то получали слова


As

planned,

the

guests

arrived;

then,


Это слова невозможно найти в словаре: “planned,” и “arrived;” — это вообще не слова. Это набор букв, состоящий из слов, к которым присоединены лишние и не относящиеся к делу знаки пунктуации. В большинстве случаев мы должны рассматривать знаки пунктуации как разделители. Как же избавиться от этих знаков пунктуации? Мы могли бы считать символы, удалить знаки пунктуации или преобразовать их в пробелы, а затем ввести “очищенные” данные снова.


string line;

getline(cin,line);         // вводим строку line

for (int i=0; i

                  // пробелами

  switch(line[i]) {

  case ';': case '.': case ',': case '?': case '!':

   line[i] = ' ';

  }

stringstream ss(line); // создаем поток istream ss, вводя в него

            // строку line

vector vs;

string word;

while (ss>>word)    // считываем слова без знаков пунктуации

  vs.push_back(word);


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


As

planned

the

guests

arrived

then


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


ps.whitespace(";:,."); // точка с запятой, двоеточие, запятая и точка

             // считаются разделителями

string word;

while (ps>>word) vs.push_back(word);


Как определить поток, работающий так, как поток

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


as.not


Слова

as
и
not
должны быть двумя самостоятельными словами


as

not


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

istream
и содержать оператор
>>
, работающий так же, как оператор ввода потока
istream
, за исключением того, что мы сами можем указывать, какие символы являются разделителями. Для простоты будем считать существующие символы-разделители (пробел, символ перехода на новую строку и т.д.) обычными символами; мы просто позволим пользователю указать дополнительные разделители. Кроме того, мы не будем удалять указанные символы из потока; как и прежде, мы превратим их в разделители. Назовем наш класс
Punct_stream
.


class Punct_stream { // аналогичен потоку istream, но пользователь

            // может самостоятельно задавать разделители

public:

 Punct_stream(istream& is)

    :source(is), sensitive(true) { }

 void whitespace(const string& s) // создает строку

                  // разделителей s

 { white = s; }

 void add_white(char c) { white += c; } // добавляет символ

                      // в набор разделителей

  bool is_whitespace(char c); // является ли c набором

                // разделителей?

 void case_sensitive(bool b) { sensitive = b; }

 bool is_case_sensitive() { return sensitive; }

 Punct_stream& operator>>(string& s);

 operator bool();

private:

 istream& source;    // источник символов

 istringstream buffer; // буфер для форматирования

 string white;     // символы–разделители

 bool sensitive;    // является ли поток чувствительным

             // к регистру?

};


Как и в предыдущем примере, основная идея — ввести строку из потока

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

Например, можно приказать объекту класса

Punct_stream
прочитать строку


Man bites dog!


как


man

bites

dog


Конструктор класса

Punct_stream
получает поток
istream
, используемый как источник символов, и присваивает ему локальное имя
source
. Кроме того, конструктор по умолчанию делает поток чувствительным к регистру, как обычно. Можно создать объект класса
Punct_stream
, считывающий данные из потока
cin
, рассматривающий точку с запятой, двоеточие и точку как разделители, а также переводящий все символы в нижний регистр.


Punct_stream ps(cin);   // объект ps считывает данные из потока cin

ps.whitespace(";:.");   // точка с запятой, двоеточие и точка

              // также являются разделителями

ps.case_sensitive(false); // нечувствительный к регистру


Очевидно, что наиболее интересной операцией является оператор ввода

>>
. Он также является самым сложным для определения. Наша общая стратегия состоит в том, чтобы считать всю строку из потока
istream
в строку
line
. Затем мы превратим все наши разделители в пробелы (
' '
). После этого отправим строку в поток i
stringstream
с именем
buffer
. Теперь для считывания данных из потока
buffer
можно использовать обычные разделители и оператор
>>
. Код будет выглядеть немного сложнее, поскольку мы только пытаемся считать данные из потока
buffer
и заполняем его, только если он пуст.


Punct_stream& Punct_stream::operator>>(string& s)

{

 while (!(buffer>>s)) { // попытка прочитать данные

             // из потока buffer

  if (buffer.bad() || !source.good()) return *this;

 buffer.clear();


 string line;

 getline(source,line); // считываем строку line

             // из потока source

             // при необходимости заменяем символы

  for (int i =0; i

   if (is_whitespace(line[i]))

     line[i]= ' ';        // в пробел

   else if (!sensitive)

    line[i] = tolower(line[i]); // в нижний регистр

   buffer.str(line);       // записываем строку в поток

  }

 return *this;

}


Рассмотрим этот код шаг за шагом. Сначала обратим внимание не нечто необычное.


while (!(buffer>>s)) {


Если в потоке

buffer
класса
istringstream
есть символы, то выполняется инструкция
buffer>>s
и объект
s
получит слово, разделенное разделителями; больше эта инструкция ничего не делает. Эта инструкция будет выполняться, пока в объекте
buffer
есть символы для ввода. Однако, когда инструкция
buffer>>s
не сможет выполнить свою работу, т.е. если выполняется условие
!(buffer>>s)
, мы должны наполнить объект
buffer
символами из потока
source
. Обратите внимание на то, что инструкция
buffer>>s
выполняется в цикле; после попытки заполнить объект
buffer
мы должны снова попытаться выполнить ввод.


while (!(buffer>>s)) { // попытка прочитать символы из буфера

  if (buffer.bad() || !source.good()) return *this;

  buffer.clear();

  // заполняем объект buffer

}


Если объект

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


string line;

getline(source,line); // вводим строку line из потока source

            // при необходимости выполняем замену символов

for (int i =0; i

  if (is_whitespace(line[i]))

   line[i]= ' ';        // в пробел

  else if (!sensitive)

   line[i] = tolower(line[i]); // в нижний регистр

  buffer.str(line);       // вводим строку в поток


Считываем строку в объект

buffer
, затем просматриваем каждый символ строки в поисках кандидатов на замену. Функция
is_whitespace()
является членом класса
Punct_stream
, который мы определим позднее. Функция
tolower()
— это стандартная библиотечная функция, выполняющая очевидное задание, например превращает символ
A
в символ
a
(см. раздел 11.6).

После правильной обработки строки

line
ее необходимо записать в поток
istringstream
. Эту задачу выполняет функция
buffer.str(line);
эту команду можно прочитать так: “Поместить строку из объекта
buffer
класса
istringstream
в объект
line
”.

Обратите внимание на то, что мы “забыли” проверить состояние объекта

source
после чтения данных с помощью функции
getline()
. Это не обязательно, поскольку в начале цикла выполняется проверка условия
!source.good()
.

Как всегда, оператор

>>
возвращает ссылку на поток
*this
(раздел 17.10).

Проверка разделителей проста; мы сравниваем символ с каждым символом из строки, в которой записаны разделители.


bool Punct_stream::is_whitespace(char c)

{

 for (int i = 0; i

   if (c==white[i]) return true;

  return false;

}


Напомним, что поток

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

Осталась одна загадочная функция.


Punct_stream::operator bool()

{

 return !(source.fail() || source.bad()) && source.good();

}


Обычное использование потока

istream
сводится к проверке результата оператора
>>
. Рассмотрим пример.


while (ps>>s) { /* ... */ }


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

ps>>s
, представленного в виде булевого значения. Результатом инструкции
ps>>s
является объект класса
Punct_stream
, поэтому нам нужен способ неявного преобразования класса
Punct_stream
в тип
bool
. Эту задачу решает функция operator
bool()
в классе
Punct_stream
.

Функция-член operator

bool()
определяет преобразование класса
Punct_stream
в тип
bool
. В частности, она возвращает значение
true
, если эта операция над классом
Punct_stream
прошла успешно.

Теперь можем написать программу.


int main()

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

 // из заданного текста, игнорируя знаки пунктуации и регистры,

 // а также удаляя дубликаты из полученного результата

{

 Punct_stream ps(cin);

 ps.whitespace(";:,.?!()\"{}<>/&$@#%^*|~"); // \" в строке

                       // означает "

 ps.case_sensitive(false);

 cout << "Пожалуйста, введите слова \n";

 vector vs;

 string word;

 while (ps>>word) vs.push_back(word); // ввод слов

  sort(vs.begin(),vs.end());      // сортировка в лексикографическом

                    // порядке

  for (int i=0; i

   if (i==0 || vs[i]!=vs[i–1]) cout << vs[i] << endl;

}


Этот код создает упорядоченный список введенных слов. Инструкция


if (i==0 || vs[i]!=vs[i–1])


удаляет дубликаты. Если в программу ввести слова


There are only two kinds of languages: languages that people complain

about, and languages that people don’t use.


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


about

and

are

complain

don’t

kind

languages

of

only

people

that

there

two

use


Почему мы получили на выходе

don’t
, а не
dont
? Потому что оставили апостроф за пределами списка разделителей
whitespace()
.

Внимание: класс

Punct_stream
во многом похож на класс
istream
, но на самом деле отличается от него. Например, мы не можем проверить его состояние с помощью функции
rdstate()
, функция
eof()
не определена, и нет оператора
>>
, который вводит целые числа. Важно отметить, что мы не можем передать объект класса
Punct_stream
в качестве аргумента функции, ожидающей поток
istream
. Можно ли определить класс
Punct_istream
, который в точности повторял бы поведение класса
istream
? Можно, но у вас пока нет достаточного опыта программирования, вы еще не освоили основы проектирования и не знаете всех возможностей языка (если впоследствии вы вернетесь к этой задаче, то сможете реализовать буферы потоков на уровне профессионала).

Легко ли читать определение класса

Punct_stream
? Понятны ли вам объяснения? Могли бы вы самостоятельно написать такую программу? Еще несколько дней назад вы были новичком и честно закричали бы: “Нет, нет! Никогда!” или “Нет, нет! Вы что, с ума сошли? Очевидно, что ответ на поставленный вопрос отрицательный”. Цель нашего примера заключается в следующем:

• показать реальную задачу и способ ее решения;

• доказать, что это решение можно найти с помощью вполне доступных средств;

• описать простое решение простой задачи;

• продемонстрировать разницу между интерфейсом и реализацией.


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

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

11.8. И еще много чего

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

12.35
, в большинстве европейских языков означает
12,35
. Естественно, стандартная библиотека С++ предоставляет возможности для устранения этих и многих других проблем. А как записать китайские иероглифы? Как сравнивать строки, записанные символами малайского языка? Ответы на эти вопросы существуют, но они выходят далеко за рамки нашей книги. Если вам потребуется более детальная информация, можете обратиться к более специализированным книгам (например, Langer, Standard C++ IOStreams and Locales и Stroustrup, The C++ Programming Language), а также к библиотечной и системной документации. Ищите ключевое слово locale (местная специфика); этот термин обычно применяется к функциональным возможностям для обработки различий между естественными языками.

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

iostream
основаны на концепции под названием
streambuf
. Для сложных задач, связанных с потоками
iostream
, при решении которых важна производительность или функциональность, без объектов класса
streambuf
обойтись нельзя. Если хотите определить свой собственный класс
iostream 
или настроить объекты класса
iostream
на новые источники данных, см. главу 21 книги The C++ Programming Language Страуструпа или системную документацию.

При программировании на языке С++ вы можете обнаружить семейство стандартных функций ввода-вывода

printf()
/
scanf()
, определенных в языке С. В этом случае прочитайте разделы 27.6, B.10.2, или прекрасный учебник Кернигана и Ритчи Язык программирования С (Kernighan and Ritchie, The C Programming Language), или же любой из многочисленных источников информации в веб. Каждый язык имеет свои собственные средства ввода-вывода; все они изменяются, иногда неправильно, но в большинстве случаев правильно (совершенно по-разному) отражая основные понятия, изложенные в главах 10 и 11.

Стандартная библиотека ввода-вывода описана в приложении Б, а связанные с ней графические пользовательские интерфейсы — в главах 12–16.


Задание

1. Напишите программу с именем

Test_output.cpp
. Объявите целочисленную переменную
birth_year
и присвойте ей год своего рождения.

2. Выведите переменную

birth_year
в десятичном, шестнадцатеричном и восьмеричном виде.

3. Выведите основание системы счисления для каждого числа.

4. Выровняли ли вы результаты по столбцам с помощью символа табуляции? Если нет, то сделайте это.

5. Теперь выведите год вашего рождения.

6. Были ли какие-то проблемы? Что произошло? Замените ваш вывод на десятичный.

7. Вернитесь к упр. 2 и выведите основание системы счисления для каждого числа.

8. Попытайтесь прочитать данные как восьмеричные, шестнадцатеричные и т.д.


cin >> a >>oct >> b >> hex >> c >> d;

cout << a << '\t'<< b << '\t'<< c << '\t'<< d << '\n' ;


Запустите программу со следующими входными данными:


1234 1234 1234 1234


Объясните результаты.

9. Напишите программу, три раза выводящую на печать число

1234567.89:
сначала в формате
general
, затем — в
fixed
и в
scientific
. Какой способ представления обеспечивает наибольшую точность? Почему?

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


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

1. Почему ввод-вывод является сложной задачей для программиста?

2. Что означает выражение

<< hex
?

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

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

5. Что такое манипулятор?

6. Назовите префикс десятичного, восьмеричного и шестнадцатеричного числа.

7. Какой формат по умолчанию применяется при выводе чисел с плавающей точкой?

8. Что такое поле вывода?

9. Объясните, что делают функции

setprecision()
и
setw()
.

10. Для чего нужны разные режимы при открытии файлов?

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

hex
,
scientific
,
setprecision
,
showbase
,
setw
?

12. Укажите разницу между символьным и двоичным вводом.

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

14. Приведите два примера, в которых может оказаться полезным класс

stringstream
.

15. Что такое позиция в файле?

16. Что произойдет, если позиция в файле будет установлена за его пределами?

17. Когда ввод строк предпочтительнее, чем ввод, ориентированный на тип?

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

isalnum(c)
?


Термины


Упражнения

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

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

Once upon a time!
принимает вид
nc pn tm!
. Удивительно часто результат остается вполне читабельным; проверьте это на своих друзьях.

3. Напишите программу под названием

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



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

x
— это и буквенный, и буквенно-цифровой символ).

5. Напишите программу, заменяющую знаки пунктуации пробелами. Например, строка

“- don’t use the as-if rule.”
принимает вид
“dont use the asif rule”
.

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

don’t
словами
do not
,
can’t
cannot
и т.д.; дефисы внутри слов не трогайте (таким образом, мы получим строку
“do not use the as-if rule”
); переведите все символы в нижний регистр.

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

8. Разделите программы ввода-вывода из раздела 11.3.2 на две части: одна программа пусть конвертирует обычный текстовый файл в двоичный, а другая — считывает двоичный файл и преобразует его в текстовый. Протестируйте эти программы, сравнивая текстовые файлы до и после преобразования в двоичный файл.

9. Напишите функцию

vector split(const string& s)
, возвращающую вектор подстрок аргумента
s
, разделенных пробелами.

10. Напишите функцию

vector split(const string& s, const string& w)
, возвращающую вектор подстрок аргумента
s
, между которыми стоят разделители, при условии, что в качестве разделителя может использоваться как обычный пробел, так и символы из строки
w
.

11. Измените порядок следования символов в текстовом файле. Например, строка

asdfghjkl
примет вид
lkjhgfdsa
. Подсказка: вспомните о режимах открытия файлов.

12. Измените порядок следования слов (определенных как строки, разделенные пробелами). Например, строка

Norwegian Blue parrot
примет вид
parrot Blue Norwegian
. Вы можете предположить, что все строки из файла могут поместиться в памяти одновременно.

13. Напишите программу, считывающую текстовый файл и записывающую в другой файл количество символов каждой категории (см. раздел 11.6).

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

15. Напишите программу, считывающую из файла числа, разделенные пробелами, и выводящую их в порядке возрастания по одному числу в строке. Каждое число должно быть записано только один раз, если обнаружится дубликат, то необходимо вывести количество таких дубликатов в строке. Например, срока “7 5 5 7 3 117 5” примет следующий вид:


3

5 3

7 2

117


Послесловие

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

Глава 12 Вывод на экран

“Сначала мир был черным, а затем белым.

а в 1930-х годах появился цвет”.

Папаша Кальвина (Calvin’s dad)[8]


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

Line
,
Lines
,
Polygon
,
Axis
и
Tex
t, являющиеся подклассами класса
Shape
. Объект класса
Shape
хранится в памяти, отображается на экране и допускает манипуляции с ним. В следующих двух главах мы глубже исследуем эти классы. В главе 13 рассмотрим их реализацию, а в главе 14 — вопросы, связанные с проектированием.

12.1. Почему графика?

Почему мы посвящаем четыре главы графике и одну главу — графическим пользовательским интерфейсам (graphical user interface — GUI)? Как никак, эта книга о программировании, а не о графике. Существует огромное количество интересных тем, связанных с программированием, которые мы не обсуждаем и в лучшем случае можем сделать лишь краткий обзор вопросов, касающихся графики. Итак, почему графика? В основном потому, что графика — это предмет, позволяющий исследовать важные вопросы, относящиеся к проектированию программного обеспечения, программирования, а также к инструментам программирования.

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

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

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

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

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

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

12.2. Вывод на дисплей

Библиотека ввода-вывода ориентирована на чтение и запись потоков символов. Единственными символами, непосредственно связанными с понятием графической позиции, являются символы перехода на новую строку и табуляции. Кроме того, в одномерный поток символов можно внедрить также понятия цвета и двумерных позиций. Именно так устроены такие языки разметки, как Troff, Tex, Word, HTML и XML (а также связанные с ними графические пакеты). Рассмотрим пример.



Организация


Этот список состоит из трех частей:


 
  • Предложения , пронумерованные EPddd, ...
  •  
  • Пункты , пронумерованные EIddd, ...
  •  
  • Предположения , пронумерованные ESddd, ...
  • Мы пытаемся ...


    Это фрагмент кода на языке HTML, в котором указан заголовок

    (

    ...

    ), список
    (
      ...
    ) с пунктами
    (
  • ...
  • ) и параграфы
    (

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

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

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



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

    12.3. Первый пример

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


    #include "Simple_window.h" // открывает доступ к оконной библиотеке

    #include "Graph.h" // открывает доступ к графической библиотеке

    int main()

    {

     using namespace Graph_lib;  // наши графические средства

                    // находятся в пространстве

                    // имен Graph_lib


     Point tl(100,100);      // задаем левый верхний угол экрана


     Simple_window win(tl,600,400,"Canvas"); // создаем простое окно


     Polygon poly;         // создаем фигуру (многоугольник)


     poly.add(Point(300,200));   // добавляем точку

     poly.add(Point(350,100));   // добавляем другую точку

     poly.add(Point(400,200));  // добавляем третью точку


     poly.set_color(Color::red); // уточняем свойства объекта poly


     win.attach (poly);     // связываем объект poly с окном


      win.wait_for_button();    // передаем управление драйверу

                    // дисплея

    }


    Запустив эту программу, мы увидим примерно такую картину.



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


    #include "Simple_window.h" // открывает доступ к оконной библиотеке

    #include "Graph.h"   // открывает доступ к графической библиотеке


    Затем в функции

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

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


    Point tl(100,100); // задаем координаты левого верхнего угла экрана


    Затем создаем окно на экране.


    Simple_window win(tl,600,400,"Canvas"); // создаем простое окно


    Для этого мы используем класс

    Simple_window
    , представляющий окно в нашей библиотеке Graph_lib. Конкретный объект класса
    Simple_window
    носит имя
    win
    ; иначе говоря,
    win
    — это переменная класса
    Simple_window
    . Список инициализации объекта win начинается с точки, которая будет использована в качестве левого верхнего угла
    tl
    , за ней следуют числа 600 и 400. Это ширина и высота окна соответственно, измеренные в пикселях. Мы объясним их смысл позднее, а пока лишь укажем, что они позволяют задать прямоугольник с заданными шириной и высотой. Строка
    Canvas
    используется для пометки окна. Если присмотритесь, то увидите слово
    Canvas
    в левом верхнем углу рамки окна.

    Далее помещаем в окно некий объект.


    Polygon poly;       // создаем фигуру (многоугольник)

    poly.add(Point(300,200)); // добавляем точку

    poly.add(Point(350,100)); // добавляем другую точку

    poly.add(Point(400,200)); // добавляем третью точку


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

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

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


    poly.set_color(Color::red); // уточняем свойства объекта poly


    В заключение связываем объект

    poly
    с нашим окном
    win
    .


    win.attach(poly); // связываем объект poly с окном


    Легко заметить, что на экране пока не происходит вообще ничего. Мы создали окно (точнее, объект класса

    Simple_window
    ) и многоугольник (с именем
    poly
    ), окрасили многоугольник в красный цвет (
    Color::red
    ) и связали его с окном
    win
    , но мы не дали команду отобразить это окно на экране. Это делает последняя строка в программе.


    win.wait_for_button(); // передаем управление драйверу дисплея


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

    wait_for_button()
    , которая заставляет систему ждать, пока вы не щелкнете на кнопке Next в окне
    Simple_window
    .

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

    Наше окно выглядит так.



    Обратите внимание на то, что мы немного схитрили. А где же кнопка Next? Мы встроили ее в классе

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

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

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

    Canvas
    ).

    12.4. Использование библиотеки графического пользовательского интерфейса

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

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

    Набор инструментов для создания графического пользовательского интерфейса, который мы используем в нашей книге, называется FLTK (Fast Light Tool Kit, произносится как “full tick”) и находится по адресу www.fltk.org. Наш код можно выполнять везде, где выполняется код библиотеки (под управлением операционных систем Windows, Unix, Mac, Linux и др.). Наши интерфейсные классы можно было бы реализовать с помощью другой библиотеки, так что программы стали бы еще более мобильными.

    Модель программирования, представленная в наших интерфейсных классах, намного проще, чем предлагает обычный набор инструментальных средств. Например, наша полная библиотека графических средств и графического пользовательского интерфейса содержит около 600 строк кода на языке С++, в то время как чрезвычайно немногословная документация библиотеки FLTK содержит 370 страниц. Вы можете загрузить ее с веб-сайта www.fltk.org, но мы пока не рекомендуем делать это. Можно вообще обойтись без этой документации. Для создания любого популярного графического пользовательского интерфейса можно использовать идеи, изложенные в главах 12–16. Разумеется, мы объясним, как наши интерфейсные классы связаны с библиотекой FLTK, так что, если захотите, сможете (в конце концов) применить эту библиотеку непосредственно.

    Части нашего “мира графики” можно представить следующим образом.



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

    12.5. Координаты

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



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

    Количество пикселей зависит от экрана: самыми распространенными являются 1024×768, 1280×1024, 1450×1050 и 1600×1200. В контексте взаимодействия с компьютером окно рассматривается как прямоугольная область экрана, имеющая определенное предназначение и управляемая программой. Окно размечается точно так же, как и экран. В принципе окно можно интерпретировать как маленький экран.

    Например, если программа содержит инструкцию


    Simple_window win(tl,600,400,"Canvas");


    то это значит, что мы хотим создать прямоугольную область, ширина которой равна 600 пикселям, а высота — 400, чтобы адресовать ее от 0 до 599 слева направо и от 0 до 399 сверху вниз. Область окна, которую можно изобразить на экране, называется канвой (canvas). Область 600×400 считается внутренней областью окна, т.е. область, расположенной в системном кадре; она не содержит строки заголовка, кнопок выхода и пр.

    12.6. Класс Shape

    Наш основной набор инструментов для рисования на экране состоит из двенадцати классов.



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

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

    Сначала опишем использование следующих классов:

    Simple_window
    ,
    Window

    Shape
    ,
    Text
    ,
    Polygon
    ,
    Line
    ,
    Lines
    ,
    Rectangle
    ,
    Function
    и т.д.

    Color
    ,
    Line_style
    ,
    Point

    Axis


    Позднее (в главе 16) добавим к ним классы графического пользовательского интерфейса:

    Button
    ,
    In_box
    ,
    Menu
    и т.д.


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

    Spline
    ,
    Grid
    ,
    Block_chart
    ,
    Pie_chart
    и т.д.


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

    12.7. Использование графических примитивов

    В этом разделе мы рассмотрим некоторые элементарные примитивы нашей графической библиотеки:

    Simple_window
    ,
    Window
    ,
    Shape
    ,
    Text
    ,
    Polygon
    ,
    Line
    ,
    Lines
    ,
    Rectangle
    ,
    Color
    ,
    Line_style
    ,
    Point
    ,
    Axis
    . Цель этого обзора — дать читателям представление о том, что можно сделать с помощью этих средств без углубления в детали реализации этих классов. Каждый из этих классов будет подробно изучен в следующих главах.

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

    12.7.1. Графические заголовочные файлы и функция main

    Во-первых, включим заголовочные файлы, в которых определены графические классы и класс графического пользовательского интерфейса.


    #include "Window.h" // обычное окно

    #include "Graph.h"


    или


    #include "Simple_window.h" // если нам нужна кнопка Next

    #include "Graph.h"


    Как вы, возможно, уже догадались, файл

    Window.h
    содержит средства, связанные с окнами, а файл
    Graph.h
    — инструменты, связанные с рисованием фигур (включая текст) в окне. Эти средства определены в пространстве имен
    Graph_lib
    . Для упрощения обозначений мы используем директиву
    using namespace
    , чтобы получить доступ к именам из пространства
    Graph_lib
    .


    using namespace Graph_lib;


    Как обычно, функция

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


    int main ()

    try

    {

     // ...здесь находится наш код...

    }

    catch(exception& e) {

     // сообщения об ошибках

     return 1;

    }

    catch(...) {

     // другие сообщения об ошибках

     return 2;

    }

    12.7.2. Почти пустое окно

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

    main()
    :


    Point tl(100,100); // левый верхний угол нашего окна

    Simple_window win(tl,600,400,"Canvas");

     // координаты окна tl задают положение левого верхнего угла

     // размер окна 600*400

     // заголовок: Canvas

    win.wait_for_button(); // изобразить!


    Этот фрагмент программы создает объект класса

    Simple_window
    , т.е. окно с кнопкой
    Next
    , и выводит его на экран. Очевидно, что для создания объекта класса Simple_window нам необходима директива
    #include
    , включающая в программу заголовочный файл
    Simple_window.h
    , а не
    Window.h
    . Здесь мы указываем, в каком месте экрана должно появиться окно: его левый верхний угол должен находиться в точке
    Point(100,100)
    . Это близко, но не очень близко к левому верхнему углу экрана. Очевидно, что
    Point
    — это класс, конструктор которого получает пару целых чисел и интерпретирует их как пару координат (x, y). Эту инструкцию можно было бы написать так:

    Simple_window win(Point(100,100),600,400,"Canvas"); 

    Однако мы хотим использовать точку (100,100) несколько раз, поэтому удобнее присвоить ей символическое имя. Число 600 — это ширина окна, 400 — его высота, а строка "

    Canvas
    " — метка, которую мы хотим поместить на рамке окна.

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

    win.wait_for_button()
    . Результат показан на следующем рисунке.



    На фоне нашего окна мы видим экран ноутбука (на всякий случай очищенный от лишних пиктограмм). Для любопытных людей, интересующихся деталями, не относящимися к делу, сообщаю, что эту фотографию я сделал, стоя возле библиотеки Пикассо в Антибе и глядя через залив на Ниццу. Черное консольное окно, частично скрытое нашим окном, автоматически открывается при запуске нашей программы. Консольное окно выглядит некрасиво, но позволяет эффективно закрыть наше окно при отладке программы, если мы попадем в бесконечный цикл и не сможем выйти из программы обычным способом. Если внимательно присмотреться, то можно заметить, что мы использовали компилятор Microsoft C++, но вместо него можно было бы использовать любой другой компилятор (например, Borland или GNU).

    Для дальнейшей демонстрации нашей программы мы удалили с экрана все лишнее, оставив только само окно (см. ниже).

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



    12.7.3. Оси координат

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


    Axis xa(Axis::x, Point(20,300), 280, 10, "x axis"); // создаем

      // объект Axis

     // класс Axis — разновидность класса Shape

     // Axis::x означает горизонтальную ось

     // начало оси — в точке (20,300)

      // длина оси — 280 пикселей 10 делений

      // "Ось x" — метка оси

    win.attach(xa);       // связываем объект xa с окном win

    win.set_label("Canvas #2"); // изменяем метку окна

    win.wait_for_button();    // изобразить!


    Последовательность действий такова: создаем объект класса

    Axis
    , добавляем его в окне и выводим на экран.



    Как видим, параметр

    Axis::x
    задает горизонтальную линию. Кроме того, ось имеет десять делений и метку “
    x axis
    ”. Как правило, метка объясняет, что представляет собой ось и ее деления. Естественно, ось х следует выбирать где-то ближе к нижнему краю окна. В реальной программе мы обозначили бы ширину и высоту какими-нибудь символическими константами, чтобы придать фразе “где-то ближе к нижнему краю окна” конкретный смысл, например, выраженный в виде инструкции
    y_max-bottom_margin
    , и не использовали бы “магические константы”, такие как 300 (см. раздел 4.3.1, раздел 15.6.2).

    Для того чтобы идентифицировать результаты, мы изменили метку экрана на строку "

    Canvas #2
    " с помощью функции-члена
    set_label()
    класса
    Window
    .

    Теперь добавим ось

    y
    .


    Axis ya(Axis::y, Point(20,300), 280, 10, "y axis");

    ya.set_color(Color::cyan);      // выбираем цвет

    ya.label.set_color(Color::dark_red); // выбираем цвет текста

    win.attach(ya);

    win.set_label("Canvas #3");

    win.wait_for_button();        // изобразить!


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

    y
    в голубой цвет (cyan), а метку сделали темно-красной.



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

    12.7.4. График функции

    Что дальше? Теперь у нас есть окно с координатными осями, и кажется естественным нарисовать на нем график функции. Создадим фигуру, представляющую график синуса, и свяжем ее с окном.


    Function sine(sin,0,100,Point(20,150),1000,50,50); // график синуса

     // рисуем sin() в диапазоне [0:100) от (0,0) до (20,150),

     // используя 1000 точек; для масштабирования координаты

      // умножаются на 50

    win.attach(sine);

    win.set_label("Canvas #4");

    win.wait_for_button();


    Здесь объект класса

    Function
    с именем
    sine
    рисует график синуса, используя стандартную библиотечную функцию
    sin()
    . Детали построения графиков функций излагаются в разделе 15.3. А пока отметим, что для построения такого графика необходимо выбрать отправную точку (объект класса
    Point
    ), диапазон изменения входных значений, а также указать некоторую информацию, чтобы график поместился в окне (масштабирование).

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



    12.7.5. Многоугольники

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

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


    sine.set_color(Color::blue);  // мы изменили цвет графика синуса

    Polygon poly;  // класс Polygon - это разновидность класса Shape

    poly.add(Point(300,200));    // три точки образуют треугольник

    poly.add(Point(350,100));

    poly.add(Point(400,200));


    poly.set_color(Color::red);

    poly.set_style(Line_style::dash);

    win.attach(poly);

    win.set_label("Canvas #5");

    win.wait_for_button();


    На этот раз мы изменили цвет графика синуса (

    sine
    ) просто для того, чтобы показать, как это делается. Затем мы добавили треугольник, так же как в первом примере из раздела 12.3, представляющий собой разновидность многоугольника. Здесь мы также задали цвет и стиль. Линии в классе
    Polygon
    имеют стиль. По умолчанию они сплошные, но их можно сделать пунктирными, точечными и т.п. (подробнее об этом — в разделе 13.5). Итак, мы получаем следующий результат.



    12.7.6. Прямоугольник

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

    Rectangle
    , отделив его от класса
    Polygon
    . Класс
    Rectangle
    характеризуется координатами верхнего левого угла, шириной и высотой.


    Rectangle r(Point(200,200), 100, 50); // левый верхний угол,

                        // ширина, высота

    win.attach(r);

    win.set_label("Canvas #6");

    win.wait_for_button();


    Этот фрагмент открывает на экране следующее окно.



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

    Rectangle
    еще недостаточно. Легко можно создать объект класса
    Closed_polyline
    , который на экране выглядит как объект класса
    Rectangle
    (можно даже создать объект класса
    Open_polyline
    , который будет выглядеть точно так же).


    Closed_polyline poly_rect;

    poly_rect.add(Point(100,50));

    poly_rect.add(Point(200,50));

    poly_rect.add(Point(200,100));

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

    win.attach(poly_rect);


    Изображение (image) объекта

    poly_rect
    на экране действительно является прямоугольником. Однако объект класса
    poly_rect
    в памяти не является объектом класса
    Rectangle
    и не “знает” ничего о прямоугольниках. Проще всего это доказать, попытавшись добавить новую точку.


    poly_rect.add(Point(50,75));


    Прямоугольник не может состоять из пяти точек.



    Важно понимать, что объект класса

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

    12.7.7. Заполнение

    До сих пор наши фигуры были нарисованы схематично. Их можно заполнить цветом.


    r.set_fill_color(Color::yellow); // цвет внутри прямоугольника

    poly.set_style(Line_style(Line_style::dash,4));

    poly_rect.set_style(Line_style(Line_style::dash,2));

    poly_rect.set_fill_color(Color::green);

    win.set_label("Canvas #7");

    win.wait_for_button();


    Мы также решили, что прежний стиль линии в нашем треугольнике (

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



    Если внимательно присмотреться к объекту

    poly_rect
    , то можно увидеть, что он рисуется поверх заполнения.

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

    12.7.8. Текст

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

    Text
    .


    Text t(Point(150,150), "Hello, graphical world!");

    win.attach(t);

    win.set_label("Canvas #8");

    win.wait_for_button();



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

    Мы видели, как можно управлять цветом текста: метка оси (см. раздел 12.7.3) просто представляет собой объект класса

    Text
    . Кроме того, мы можем выбирать шрифт и размер символов.


    t.set_font(Font::times_bold);

    t.set_font_size(20);

    win.set_label("Canvas #9");

    win.wait_for_button();


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

    Hello, graphical world!
    " до 20 пунктов и выбрали жирный шрифт Times.

    12.7.9. Изображения

    Мы можем также загружать изображения из файлов.


    Image ii(Point(100,50),"image.jpg"); // файл 400×212 пикселей

                        // в формате jpg

    win.attach(ii);

    win.set_label("Canvas #10");

    win.wait_for_button();


    Файл

    image.jpg
    — это фотография двух самолетов, преодолевающих звуковой барьер.

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



    ii.move(100,200);

    win.set_label("Canvas #11");

    win.wait_for_button();


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



    12.7.10. И многое другое

    Приведем без объяснений еще один фрагмент кода


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

    Ellipse e(Point(100,200), 75,25);

    e.set_color(Color::dark_red);

    Mark m(Point(100,200),'x');

    ostringstream oss;

    oss << "screen size: " << x_max() << "*" << y_max()

       << "; window size: " << win.x_max() << "*" << win.y_max();

    Text sizes(Point(100,20),oss.str());

    Image cal(Point(225,225),"snow_cpp.gif"); // 320×240 пикселей,

                          // формат gif

    cal.set_mask(Point(40,40),200,150);    // отобразить рисунок

                          // в центре

    win.attach(c);

    win.attach(m);

    win.attach(e);

    win.attach(sizes);

    win.attach(cal);

    win.set_label("Canvas #12");

    win.wait_for_button();


    Можете ли вы догадаться, что делает этот фрагмент?



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

    istringstream
    (см. раздел 11.4).

    12.8. Запуск программы

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

    Shape
    и его подклассы, а также как их использовать.

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

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

    Итак, можно сказать, что наша программа состоит из четырех частей.

    • Код нашей программы (

    main()
    и т.д.).

    • Наша интерфейсная библиотека (

    Window
    ,
    Shape
    ,
    Polygon
    и т.д.).

    • Библиотека FLTK.

    • Стандартная библиотека языка C++.


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



    Как заставить эту программу работать, объясняется в приложении Г.

    12.8.1. Исходные файлы

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

    •Заголовки

     •Point.h

     •Window.h

     •Simple_window.h

     •Graph.h

     •GUI.h

    •Исходные файлы

     •Window.cpp

     •Graph.cpp

     •GUI.cpp


    До главы 16 мы можем игнорировать файлы графического пользовательского интерфейса.


    Задание

    Это задание напоминает программу “Привет, мир!”. Его цель — ознакомить вас с простейшими графическими средствами.

    1. Напишите программу, создающую пустой объект класса

    Simple_window
    размером 600×400 пикселей с меткой Мое окно, скомпилируйте ее, отредактируйте связи и выполните. Помните о том, что вы должны подключить библиотеку FLTK, описанную в приложении Г, вставить заголовочные файлы
    Graph.h
    ,
    Window.h
    ,
    GUI.h
    и
    Simple_Window.h
    в ваш код, а также включить в проект файлы
    Graph.cpp
    и
    Window.cpp
    .

    2. Добавьте примеры из раздела 12.7 один за другим, сравнивая их друг с другом.

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


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

    1. Зачем нужна графика?

    2. Почему нельзя обойтись без графики?

    3. Чем графика интересна программисту?

    4. Что такое окно?

    5. В каком пространстве имен находятся наши классы графического интерфейса (наша графическая библиотека)?

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

    7. Что представляет собой простейшее окно?

    8. Что представляет собой минимальное окно?

    9. Что такое метка окна?

    10. Как задать метку окна?

    11. Что собой представляют экранные, оконные и математические координаты?

    12. Приведите примеры простых фигур, которые можно отобразить на экране.

    13. Какие команды связывают фигуру с окном?

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

    15. Как вывести текст в окне?

    16. Как поместить в окне фотографию вашего лучшего друга или подруги? Напишите свою программу.

    17. Представьте, что вы создали объект класса

    Window
    , но на экране ничего не появилось. Перечислите возможные причины.

    18. Представьте, что вы создали объект класса

    Shape
    , но на экране ничего не появилось. Перечислите возможные причины.


    Термины


    Упражнения

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

    Simple_window
    .

    1. Нарисуйте прямоугольник как объект класса

    Rectangle
    и как объект класса
    Polygon
    . Сделайте линии объекта класса
    Polygon
    красными, а линии объекта класса
    Rectangle
    синими.

    2. Нарисуйте объект класса

    Rectangle
    с размерами 100×300 и поместите в него слово “Привет!”.

    3. Нарисуйте ваши инициалы высотой 150 пикселей. Используйте толстую линию. Нарисуйте каждый инициал другим цветом.

    4. Нарисуйте доску для игры в крестики-нолики размером 3×3, чередуя белые и красные квадраты.

    5. Нарисуйте красную рамку шириной один дюйм вокруг прямоугольника, высота которого составляет три четверти высоты вашего экрана, а ширина — две трети ширины экрана.

    6. Что произойдет, если вы нарисуете фигуру, которая не помещается в окне? Что произойдет, если вы нарисуете окно, которое не помещается на экране? Напишите две программы, иллюстрирующие эти эффекты.

    7. Нарисуйте двумерный дом анфас, как это делают дети: дверь, два окна и крыша с дымовой трубой. Детали можете выбрать сами, можете даже нарисовать дымок из трубы.

    8. Нарисуйте пять олимпийских колец. Если помните их цвета, то раскрасьте их.

    9. Выведите на экран фотографию вашего друга. Напишите его имя в заголовке окна и в заголовке внутри окна.

    10. Нарисуйте диаграмму файлов из раздела 12.8.

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

    N
    - многоугольника касаются сторон
    (N+1)
    - многоугольника.

    12. Суперэллипс — это двумерная фигура, определенная уравнением



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

    a
    ,
    b
    ,
    m
    ,
    n
    и
    N
    вводятся как аргументы. Выберите
    N
    точек на суперэллипсе, определенном параметрами
    a
    ,
    b
    ,
    m
    и
    n
    . Пусть эти точки лежат на равном расстоянии друг от друга. Соедините каждую из этих
    N
    точек с одной или несколькими другими точками (если хотите, можете задать количество таких точек с помощью дополнительного аргумента или использовать число
    N–1
    , т.е. все другие точки).

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


    Послесловие

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

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

    Глава 13 Графические классы

    “Язык, не изменяющий ваш образ мышления,

    изучать не стоит”.

    Расхожее мнение


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

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

    13.1. Обзор графических классов

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

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

    Основные интерфейсные классы перечислены в следующей таблице.



    Классы

    Function
    и
    Axis
    описываются в главе 15. В главе 16 рассматриваются основные интерфейсные классы.



    Исходный код состоит из следующих файлов.



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

    Shape
    или
    Widget
    .



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

    • Продемонстрировать связь между кодом и создаваемыми рисунками.

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

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


    Итак, пожалуйста, не торопитесь, иначе пропустите нечто важное и не сможете выполнить упражнения.

    13.2. Классы Point и Line

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

    x_max()
    (правого края экрана); координаты y изменяются от нуля (верхнего края экрана) до
    y_max()
    (нижнего края экрана).

    Как определено в файле

    Point.h
    , класс
    Point
    — это просто пара чисел типа
    int
    (координаты).


    struct Point {

     int x, y;

     Point(int xx, int yy):x(xx), y(yy) { }

     Point() :x(0), y(0) { }

    };


    bool operator==(Point a, Point b) { return a.x==b.x && a.y==b.y; }

    bool operator!=(Point a, Point b) { return !(a==b); }


    В файле

    Graph.h
    определены также класс
    Shape
    , подробно описанный в главе 14, и класс
    Line
    .


    struct Line:Shape {     // класс Line — это класс Shape,

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

    Line(Point p1, Point p2); // создаем объект класса Line

                  // из двух объектов класса Points

    };


    Класс

    Line
    — это разновидность класса
    Shape
    . Именно это означает строка
    “:Shape”
    . Класс
    Shape
    называют базовым (base class) по отношению к классу
    Line
    . В принципе класс
    Shape
    содержит возможности, чтобы упростить определение класса
    Line
    . Как только мы столкнемся с конкретными фигурами, например
    Line
    или
    Open_polyline
    , то увидим, что это значит (см. главу 14).

    Класс

    Line
    определяется двумя объектами класса
    Point
    . Оставляя в стороне “леса” (директивы #include и прочие детали, описанные в разделе 12.3), мы можем создать линию и нарисовать ее на экране.


    // рисуем две линии

    Simple_window win1(Point(100,100),600,400,"Two lines");

    Line horizontal(Point(100,100),Point(200,100)); // горизонтальная 

                             // линия

    Line vertical(Point(150,50),Point(150,150));   // вертикальная

                             // линия

    win1.attach(horizontal);             // связываем их

                            // с экраном

    win1.attach(vertical);

    win1.wait_for_button();             // изобразить!


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

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


    Line vertical(Point(150,50),Point(150,150));


    создает (вертикальную) линию, соединяющую точки (150,50) и (150,150). Разумеется, существуют детали реализации, но вам необязательно знать их, чтобы создавать линии. Реализация конструктора класса

    Line
    довольно проста.



    Line::Line(Point p1, Point p2) // создаем линию по двум точкам

    {

     add(p1); // добавляем точку p1

     add(p2); // добавляем точку p2

    }


    Иначе говоря, конструктор просто добавляет две точки. Добавляет куда? И как объект класса

    Line
    рисуется в окне? Ответ кроется в классе
    Shape
    . Как будет описано в главе 14, класс
    Shape
    может хранить точки, определяющие линии, знает, как рисовать линии, определенные парами точек, и имеет функцию
    add()
    , позволяющую добавлять объекты в объекты класса
    Point
    . Основной момент здесь заключается в том, что определение класса
    Line
    тривиально. Большая часть работы по реализации выполняется системой, поэтому программист может сосредоточиться на создании простых классов, которые легко использовать.

    С этого момента оставим в стороне определение класса

    Simple_window
    и вызовы функции
    attach()
    . Они не более чем “леса”, необходимые для завершения программы, но ничего не добавляющие к специфике объектов класса
    Shape
    .

    13.3. Класс Lines

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

    Lines
    .


    struct Lines:Shape {       // связанные друг с другом линии

     void draw_lines() const;

     void add(Point p1, Point p2); // добавляем линию, заданную

                     // двумя точками

    };


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

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


    Lines x;

    x.add(Point(100,100), Point(200,100)); // первая линия: 
    горизонтальная

    x.add(Point(150,50), Point(150,150));  // вторая линия: вертикальная


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

    Line
    .



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

    Разница между совокупностью объектов класса

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


    int x_size = win3.x_max(); // определяем размер нашего окна

    int y_size = win3.y_max();

    int x_grid = 80;

    int y_grid = 40;


    Lines grid;

    for (int x=x_grid; x

    grid.add(Point(x,0),Point(x,y_size)); // вертикальная линия

    for (int y = y_grid; y

    grid.add(Point(0,y),Point(x_size,y)); // горизонтальная линия


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

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



    Вернемся к классу

    Lines
    . Как реализованы функции-члены класса
    Lines
    ? Класс
    Lines
    выполняет только две операции. Функция
    add()
    просто добавляет линию, определенную парой точек, к набору линий, которые будут выведены на экран.


    void Lines::add(Point p1, Point p2)

    {

     Shape::add(p1);

     Shape::add(p2);

    }


    Да, квалификатор

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

    Функция

    draw_lines()
    рисует линии, определенные с помощью функции
    add()
    .


    void Lines::draw_lines() const

    {

     if (color().visibility())

     for (int i=1; i

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

     point(i).x,point(i).y);

    }


    Иначе говоря, функция

    Lines::draw_lines()
    на каждом шаге цикла получает две точки (начиная с точек
    0
    и
    1
    ) и рисует линию, соединяющую эти точки с помощью библиотечной функции
    fl_line()
    . Видимость (visibility) — это свойство объекта класса
    Color
    (раздел 13.4), поэтому, прежде чем рисовать эти линии, мы должны проверить, что они являются видимыми.

    Как будет показано в главе 14, функция

    draw_lines()
    вызывается системой. Мы не обязаны проверять, является ли количество точек четным, так как функция
    add()
    класса
    Lines
    может добавлять только пары точек. Функции
    number_of_points()
    и
    point()
    определены в классе
    Shape
    (см. раздел 14.2), и их смысл очевиден. Эти две функции обеспечивают доступ к точкам объекта класса
    Shape
    только для чтения. Функция-член
    draw_lines()
    определена как
    const
    (см. раздел 9.7.4), поскольку она не изменяет фигуру.

    Мы не предусмотрели в классе

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

    13.4. Класс Color

    Color
    — это тип, описывающий цвет. Его можно использовать примерно так:


    grid.set_color(Color::red);


    Эта инструкция окрашивает линии, определенные в объекте

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

    Класс

    Color
    определяет понятие цвета и приписывает символические имена нескольким наиболее распространенным цветам.



    struct Color {

      enum Color_type {

       red=FL_RED,

       blue=FL_BLUE,

       green=FL_GREEN,

       yellow=FL_YELLOW,

       white=FL_WHITE,

       black=FL_BLACK,

       magenta=FL_MAGENTA,

       cyan=FL_CYAN,

       dark_red=FL_DARK_RED,

       dark_green=FL_DARK_GREEN,

       dark_yellow=FL_DARK_YELLOW,

       dark_blue=FL_DARK_BLUE,

       dark_magenta=FL_DARK_MAGENTA,

       dark_cyan=FL_DARK_CYAN

     };


     enum Transparency { invisible = 0, visible=255 };


     Color(Color_type cc) :c(Fl_Color(cc)), v(visible) { }

     Color(Color_type cc, Transparency vv) :c(Fl_Color(cc)), v(vv)
     { }

     Color(int cc) :c(Fl_Color(cc)), v(visible) { }

     Color(Transparency vv) :c(Fl_Color()), v(vv) { } // цвет по

                              // умолчанию

     int as_int() const { return c; }


     char visibility() const { return v; }

     void set_visibility(Transparency vv) { v=vv; }

    private:

     char v; // видимый или невидимый

     Fl_Color c;

    }; 


    Предназначение класса

    Color
    заключается в следующем.

    • Скрыть реализацию цвета в классе

    Fl_Color
    из библиотеки FLTK.

    • Задать константы, соответствующие разным цветам.

    • Обеспечить простую реализацию прозрачности (видимый или невидимый).


    Цвет можно выбрать следующим образом.

    • Выбрать константу из списка, например

    Color::dark_blue
    .

    • Выбрать цвет из небольшой палитры, которую большинство программ выводит на экран (им соответствуют значения в диапазоне от 0–255; например, выражение

    Color(99)
    означает темно-зеленый цвет). Пример такой программы приведен в разделе 13.9.

    • Выбрать значение в системе RGB (Red, Green, Blue — красный, зеленый, синий), которую мы здесь обсуждать не будем. При необходимости читатели сами в ней разберутся. В частности, можно просто ввести запрос “RGB color” в поисковую веб-машину. Среди прочих вы получите ссылки www.hyperso-lutions.org/rgb.html и www.pitt.edu/~nisg/cis/web/cgi/rgb.html. См. также упр. 13 и 14.


    Обратите внимание на конструкторы класса

    Color
    , позволяющие создавать объекты как из объектов типа
    Color_type
    , так и из обычных чисел типа
    int
    . Каждый конструктор инициализирует член
    c
    . Вы можете возразить, что переменная c названа слишком коротко и непонятно, но, поскольку она используется в очень небольшой части класса
    Color
    и не предназначена для широкого использования, это не является недостатком. Мы поместили член
    c
    в закрытый раздел, чтобы защитить его от непосредственного обращения пользователей. Для представления члена c мы используем тип
    Fl_Color
    , определенный в библиотеке FLTK, который хотели бы скрыть от пользователей. Однако очень часто этот тип интерпретируется как целочисленное представление значения RGB (или другого значения), поэтому на этот случай мы предусмотрели функцию
    as_int()
    . Обратите внимание на то, что функция
    as_int()
    является константной функцией-членом, поскольку она не изменяет объект класса
    Color
    , который ее использует.

    Прозрачность задается членом

    v
    , который может принимать значения
    Color::visible
    и
    Color::invisible
    , имеющие очевидный смысл. Вы можете удивиться: зачем нужен “невидимый цвет”. Оказывается, он может быть очень полезен для того, чтобы скрыть часть фигуры на экране.

    13.5. Класс Line_style

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

    Line_style
    используется приблизительно так:


    grid.set_style(Line_style::dot);


    Эта инструкция выводит на экран линии, заданные в объекте

    grid
    , как последовательность точек, а не как сплошную линию.



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

    Класс

    Line_style
    выглядит так:


    struct Line_style {

      enum Line_style_type {

       solid=FL_SOLID,      // -------

       dash=FL_DASH,       // - - - -

       dot=FL_DOT,        // .......

       dashdot=FL_DASHDOT,    // - . - .

       dashdotdot=FL_DASHDOTDOT, // -..-..

     };


     Line_style(Line_style_type ss):s(ss), w(0) { }

     Line_style(Line_style_type lst, int ww):s(lst), w(ww) { }

     Line_style(int ss):s(ss), w(0) { }

     int width() const { return w; }

     int style() const { return s; }

    private:

     int s;

     int w;

    };


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

    Line_style
    , ничем не отличаются от методов, использованных для класса
    Color
    . Здесь мы снова скрываем тот факт, что для представления стилей линии библиотека FKTK использует тип
    int
    . Почему стоит скрывать эту информацию? Потому что эти способы представления при модификации библиотеки могут измениться. В следующей версии библиотеки FLTK может появиться тип
    Fl_linestyle
    , да и мы сами можем перенастроить наш интерфейс на другую библиотеку. В любом случае не стоит замусоривать свой код переменными типа
    int
    только потому, что мы знаем, как они задают стиль линий.

    Как правило, мы не заботимся о стиле вообще; мы просто полагаемся на параметры, заданные по умолчанию (сплошные линии, ширина которых задана по умолчанию). Если мы не указываем ширину линии явно, то она задается конструктором. Установка значений по умолчанию — это одно из предназначений конструктора, а правильно выбранные значения, задаваемые по умолчанию, могут значительно облегчить работу пользователей.

    Класс

    Line_style
    состоит из двух “компонентов”: характеристики стиля (например, пунктирные или сплошные линии) и ширины (толщина линий). Ширина измеряется в целых числах. По умолчанию ширина равна единице. Если нам нужна более широкая линия, то ее толщину можно задать следующим образом:


    grid.set_style(Line_style(Line_style::dash,2));


    В итоге получим следующее изображение:



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

    Lines
    ,
    Open_polyline
    или
    Polygon
    . Если мы хотим управлять цветом или стилем линий по отдельности, то их следует задать как отдельные объекты класса
    Line
    . Рассмотрим пример.


    horizontal.set_color(Color::red);

    vertical.set_color(Color::green);


    На экране откроется окно, приведенное ниже.



    13.6. Класс Open_polyline

    Класс

    Open_polyline
    определяет фигуру, состоящую из ряда отрезков линий, соединенных между собой и заданных последовательностью точек. Слово
    poly
    имеет греческое происхождение и означает “много”, а
    polyline
    — это удобное имя для фигуры, состоящей из многих линий. Рассмотрим пример.


    Open_polyline opl;

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

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

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

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



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

    В принципе

    Open_polyline
    — это выдуманное слово, которое мы позаимствовали из детской игры “Connect the Dots” (“Соедини точки”).

    Класс

    Open_polyline
    определен следующим образом:


    struct Open_polyline:Shape { // открытая последовательность линий

     void add(Point p) { Shape::add(p); }

    };


    Да-да, это все определение. В нем практически ничего нет, кроме указания имени класса и того факта, что он является наследником класса

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

    13.7. Класс Closed_polyline

    Класс

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


    Closed_polyline cpl;

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

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

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

    cpl.add(Point(300,200));


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

    Определение класса

    Closed_polyline
    приведено ниже.


    struct Closed_polyline:Open_polyline { // замкнутый ряд линий

     void draw_lines() const;

    };


    void Closed_polyline::draw_lines() const

    {

     Open_polyline::draw_lines(); // сначала рисуем открытый ряд линий,

                    // затем рисуем замыкающую линию:

     if (color().visibility())

       fl_line(point(number_of_points()–1).x,

           point(number_of_points()–1).y,

           point(0).x,

           point(0).y);

    }



    В классе

    Closed_polyline
    нужна отдельная функция
    draw_lines()
    , рисующая замыкающую линию, которая соединяет последнюю точку с первой. К счастью, для этого достаточно реализовать небольшую деталь, которая отличает класс
    Closed_polyline
    от класса
    Shape
    . Этот важный прием иногда называют “программированием различий“ (“programming by difference”). Нам нужно запрограммировать лишь то, что отличает наш производный класс (
    Closed_polyline
    ) от базового (
    Open_polyline
    ).

    Итак, как же нарисовать замыкающую линию? Воспользуемся функцией

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

    13.8. Класс Polygon

    Класс Polygon очень похож на класс

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


    cpl.add(Point(100,250));


    Результат изображен ниже.



    В соответствии с классическими определениями объект класса

    Closed_polyline
    многоугольником не является. Как определить класс
    Polygon
    так, чтобы он правильно отображал связь с классом
    Closed_polyline
    , не нарушая правил геометрии? Подсказка содержится в предыдущем описании. Класс
    Polygon
    — это класс
    Closed_polyline
    , в котором линии не пересекаются. Иначе говоря, мы могли бы подчеркнуть способ образования фигуры из точек и сказать, что класс
    Polygon
    — это класс
    Closed_polyline
    , в который невозможно добавить объект класса
    Point
    , определяющий отрезок линии, пересекающийся с одной из существующих линий в объекте класса
    Polygon
    .

    Эта идея позволяет описать класс

    Polygon
    следующим образом:


    struct Polygon:Closed_polyline { // замкнутая последовательность

                     // непересекающихся линий

     void add(Point p);

     void draw_lines() const;

    };


    void Polygon::add(Point p)

    {

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

     // (код скрыт)

     Closed_polyline::add(p);

    }


    Здесь мы унаследовали определение функции

    draw_lines()
    из класса
    Closed_polyline
    , сэкономив усилия и избежав дублирования кода. К сожалению, мы должны проверить каждый вызов функции
    add()
    . Это приводит нас к неэффективному алгоритму, сложность которого оценивается как N в квадрате, — определение объекта класса
    Polygon
    , состоящего из N точек, требует N*(N–1)/2 вызовов функции
    intersect()
    . По существу, мы сделали предположение, что класс
    Polygon
    будет использоваться для создания многоугольников с меньшим количеством точек.

    Например, для того чтобы создать объект класса

    Polygon
    , состоящего из 24 точек, потребуется 24*(24–1)/2 == 276 вызовов функции
    intersect()
    . Вероятно, это допустимо, но если бы мы захотели создать многоугольник, состоящий из 2000 точек, то вынуждены были бы сделать около 2 000 000 вызовов. Мы должны поискать более эффективный алгоритм, который может вынудить нас модифицировать интерфейс.

    В любом случае можем создать следующий многоугольник:


    Polygon poly;

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

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

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

    poly.add(Point(300,200));


    Очевидно, что этот фрагмент создает объект класса

    Polygon
    , идентичный (вплоть до последнего пикселя) исходному объекту класса
    Closed_polyline
    .

    Проверка того, что объект класса

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


    void Polygon::draw_lines() const

    {

     if (number_of_points() < 3)

       error("Меньше трех точек вводить нельзя.");

      Closed_polyline::draw_lines();
    }



    Проблема заключается в том, что инвариант класса

    Polygon
    — “точки образуют многоугольник” — невозможно проверить, пока не будут определены все точки. Иначе говоря, в соответствии с настоятельными рекомендациями мы не задаем проверку инварианта в конструкторе класса
    Polygon
    . И все же “предупреждение о трех точках” в классе
    Polygon::draw_lines()
    — совершенно недопустимый трюк. (См. также упр. 18.)

    13.9. Класс Rectangle

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


    struct Rectangle:Shape {

     Rectangle(Point xy, int ww, int hh);

     Rectangle(Point x, Point y);

     void draw_lines() const;


     int height() const { return h; }

     int width() const { return w; }

    private:

     int h; // высота

     int w; // ширина

    };


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


    Rectangle::Rectangle(Point xy,int ww,int hh)
    :w(ww),h(hh)

    {

     if (h<=0 || w<=0)

       error("Ошибка: отрицательная величина");

     add(xy);

    }


    Rectangle::Rectangle(Point x,Point y)
    :w(y.x–x.x),h(y.y–x.y)

    {

     if (h<=0 || w<=0)

       error("Ошибка: отрицательная ширина или длина.");

     add(x);

    }


    Каждый конструктор соответствующим образом инициализирует члены

    h
    и
    w
    (используя синтаксис списка инициализации; см. раздел 9.4.4) и хранит верхнюю левую точку отдельно в базовом классе
    Shape
    (используя функцию
    add()
    ). Кроме того, в конструкторах содержится проверка ширины и длины — они не должны быть отрицательными.

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

    Polygon
    и
    Circle
    . По этой причине понятие “заполнение цветом” — т.е. закраска пространства внутри прямоугольника — чаще применяется по отношению к прямоугольникам, чем к другим фигурам.

    Заполнение цветом можно реализовать в конструкторе или в виде отдельной функции

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


    Rectangle rect00(Point(150,100),200,100);

    Rectangle rect11(Point(50,50),Point(250,150));

    Rectangle rect12(Point(50,150),Point(250,250)); // ниже rect11

    Rectangle rect21(Point(250,50),200,100);     // правее rect11

    Rectangle rect22(Point(250,150),200,100);    // ниже rect21


    rect00.set_fill_color(Color::yellow);

    rect11.set_fill_color(Color::blue);

    rect12.set_fill_color(Color::red);

    rect21.set_fill_color(Color::green);


    В итоге получаем следующее изображение:



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

    rect00
    .

    Фигуры можно передвигать в окне (см. раздел 14.2.3). Рассмотрим пример.


    rect11.move(400,0); // вправо от rect21

    rect11.set_fill_color(Color::white);

    win12.set_label("rectangles 2");


    В итоге получим изображение, приведенное ниже.



    Заметьте, что только часть белого прямоугольника

    rect11
    помещается в окне. То, что выходит за пределы окна, “отрезается”; иначе говоря, на экране эта часть не отображается.

    Обратите внимание на то, как фигуры накладываются одна на другую. Это выглядит так, будто вы кладете на стол один лист бумаги на другой. Первый лист окажется в самом низу. Наш класс

    Window
    (раздел Д.3) реализует простой способ размещения фигуры поверх другой (используя функцию
    Window::put_on_top()
    ). Рассмотрим пример.


    win12.put_on_top(rect00);

    win12.set_label("rectangles 3");


    В итоге получаем следующее изображение:



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


    rect00.set_color(Color::invisible);

    rect11.set_color(Color::invisible);

    rect12.set_color(Color::invisible);

    rect21.set_color(Color::invisible);

    rect22.set_color(Color::invisible);


    Это приводит к следующему результату:



    Обратите внимание на то, что цвет заполнения и цвет линии заданы параметром

    invisible
    , поэтому прямоугольник
    rect22
    на экране больше не виден.

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

    draw_lines()
    класса
    Rectangle
    становится немного запутанной.


    void Rectangle::draw_lines() const

    {

     if (fill_color().visibility()) { // заполнение

       fl_color(fill_color().as_int());

       fl_rectf(point(0).x,point(0).y,w,h);

     }

      if (color().visibility()) { // линии поверх заполнения

       fl_color(color().as_int());

       fl_rect(point(0).x,point(0).y,w,h);

     }

    }


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

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

    13.10. Управление неименованными объектами

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

    Вот что у нас получится.



    Называть все эти 256 квадратов было бы не только утомительно, но и глупо. Очевидно, что “имя” левого верхнего квадрата в матрице определяется его местоположением в точке (0,0), а все остальные квадраты можно точно так же идентифицировать с помощью пар координат (i, j). Итак, нам необходим эквивалент матрицы объектов. Сначала мы подумали о векторе

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


    template class Vector_ref {

    public:

     // ...

     void push_back(T&);  // добавляет именованный объект

     void push_back(T*);  // добавляет неименованный объект

     T& operator[](int i); // индексация: доступ для чтения и записи

     const T& operator[](int i) const;

     int size() const;

    };


    Наше определение очень похоже на определение типа

    vector
    из стандартной библиотеки.


    Vector_ref rect;

    Rectangle x(Point(100,200),Point(200,300));


    // добавляем именованные объекты

    rect.push_back(x);


    // добавляем неименованные объекты

    rect.push_back(new Rectangle(Point(50,60),Point(80,90)));


    // используем объект rect

    for (int i=0; i


    Оператор new описан в главе 17, а реализация класса

    Vector_ref
    — в приложении Д. Пока достаточно знать, что мы можем использовать его для хранения неименованных объектов. За оператором new следует имя типа (в данном случае
    Rectangle
    ) и, необязательно, список инициализации (в данном случае
    (Point(50,60),Point(80,90))
    ).

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

    Rectangle
    и
    Vector_ref
    мы можем экспериментировать с цветами. Например, можем нарисовать простую диаграмму, состоящую из 256 цветов.


    Vector_ref vr;

    for (int i = 0; i<16; ++i)

     for (int j = 0; j<16; ++j) {

       vr.push_back(new Rectangle(Point(i*20,j*20),20,20));

       vr[vr.size()–1].set_fill_color(Color(i*16+j));

       win20.attach(vr[vr.size()–1]);

    }


    Мы создали объект класса

    Vector_ref
    , состоящий из 256 объектов класса
    Rectangle
    , организованный в объекте класса
    Window
    в виде матрицы 16×16. Мы приписали объектам класса
    Rectangle
    цвета 0, 1, 2, 3, 4 и т.д. После создания каждого из объектов этого типа они выводятся на экран.



    13.11. Класс Text

    Очевидно, что нам необходимо выводить на экран текст. Например, мы могли бы пометить “странный” объект класса

    Closed_polyline
    из раздела 13.8.


    Text t(Point(200,200),"A closed polyline that isn't a polygon");

    t.set_color(Color::blue);


    В этом случае мы получим такое изображение.



    В принципе объект класса

    Text
    определяет строку текста, начиная с точки, заданной объектом класса
    Point
    . Этот объект класса
    Point
    находится в левом нижнем углу текста. Мы ограничиваемся одной строкой, поскольку хотим, чтобы наша программа выполнялась на многих компьютерах. Не пытайтесь вставлять в окно символ перехода на новую строку. Для создания объектов класса
    string
    , подлежащих выводу на экран в объектах класса
    Text
    (см. примеры в разделах 12.7.7 и 12.7.8), очень полезны строковые потоки (см. раздел 11.4).


    struct Text:Shape {

     // точка в левом нижнем углу первой буквы

     Text(Point x, const string& s)

       :lab(s), fnt(fl_font()), fnt_sz(fl_size()) 
    { add(x); }


     void draw_lines() const;

     void set_label(const string& s) { lab = s; }

     string label() const { return lab; }


     void set_font(Font f) { fnt = f; }

     Font font() const { return fnt; }


     void set_font_size(int s) { fnt_sz = s; }

     int font_size() const { return fnt_sz; }

    private:

     string lab; // label

     Font fnt;

     int fnt_sz;

    };


    Класс

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


    void Text::draw_lines() const

    {

     fl_draw(lab.c_str(),point(0).x,point(0).y);

    }


    Цвет символов определяется точно так же, как в фигурах, состоящих из линий (например,

    Open_polyline
    и
    Circle
    ), поэтому можем выбирать новый цвет с помощью функции
    set_color()
    , а определять текущий цвет — с помощью функции
    color()
    . Размер и шрифт символов выбираются аналогично. В классе предусмотрено небольшое количество заранее определенных шрифтов.


    class Font { // шрифт символа

    public:

     enum Font_type {

       helvetica=FL_HELVETICA,

        helvetica_bold=FL_HELVETICA_BOLD,

       helvetica_italic=FL_HELVETICA_ITALIC,

        helvetica_bold_italic=FL_HELVETICA_BOLD_ITALIC,

       courier=FL_COURIER,

        courier_bold=FL_COURIER_BOLD,

       courier_italic=FL_COURIER_ITALIC,

        courier_bold_italic=FL_COURIER_BOLD_ITALIC,

       times=FL_TIMES,

        times_bold=FL_TIMES_BOLD,

       times_italic=FL_TIMES_ITALIC,

        times_bold_italic=FL_TIMES_BOLD_ITALIC,

       symbol=FL_SYMBOL,

        screen=FL_SCREEN,

       screen_bold=FL_SCREEN_BOLD,

        zapf_dingbats=FL_ZAPF_DINGBATS

      };


      Font(Font_type ff):f(ff) { }

      Font(int ff) :f(ff) { }


      int as_int() const { return f; }

    private:

      int f;

    };


    Стиль определения класса

    Font
    совпадает со стилями определения классов
    Color
    (см. раздел 13.4) и
    Line_style
    (см. раздел 13.5).

    13.12. Класс Circle

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

    Circle
    и
    Ellipse
    . Объект класса
    Circle
    определяется центром и радиусом.


    struct Circle:Shape {

     Circle(Point p, int rr); // центр и радиус


     void draw_lines() const;


     Point center() const;

     int radius() const { return r; }

     void set_radius(int rr) { r=rr; }

    private:

     int r;

    };


    Использовать класс

    Circle
    можно следующим образом:


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

    Circle c2(Point(150,200),100);

    Circle c3(Point(200,200),150);


    Эти инструкции рисуют три окружности разных радиусов, центры которых лежат на горизонтальной линии.



    Основной особенностью реализации класса

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


    Circle::Circle(Point p, int rr) // центр и радиус

        :r(rr)

    {

     add(Point(p.x–r,p.y–r));    // хранит левый верхний угол

    }


    Point Circle::center() const

    {

     return Point(point(0).x+r, point(0).y+r);

    }


    void Circle::draw_lines() const

    {

     if (color().visibility())

       fl_arc(point(0).x,point(0).y,r+r,r+r,0,360);

    }


    Обратите внимание на использование функции

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

    13.13. Класс Ellipse

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


    struct Ellipse:Shape {

     // центр, минимальное и максимальное расстояние от центра

     Ellipse(Point p, int w, int h);


     void draw_lines() const;


     Point center() const;

     Point focus1() const;

     Point focus2() const;


     void set_major(int ww) { w=ww; }

     int major() const { return w; }


     void set_minor(int hh) { h=hh; }

     int minor() const { return h; }

    private:

     int w;

     int h;

    };


    Класс

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


    Ellipse e1(Point(200,200),50,50);

    Ellipse e2(Point(200,200),100,50);

    Ellipse e3(Point(200,200),100,150);


    Этот фрагмент программы рисует три эллипса с общим центром и разными осями.



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

    Ellipse
    , для которого выполняется условие
    major()==minor()
    , выглядит как окружность. Эллипс можно также задать с помощью двух фокусов и суммы расстояний от точки до фокусов. Имея объект класса
    Ellipse
    , можем вычислить фокус. Рассмотрим пример.


    Point Ellipse::focus1() const

    {

     return Point(center().x+sqrt(double(w*w–h*h)),center().y);

    }


    Почему класс

    Circle
    не является наследником класса
    Ellipse
    ? С геометрической точки зрения каждая окружность является эллипсом, но не каждый эллипс является окружностью. В частности, окружность — это эллипс, у которого оба фокуса совпадают. Представьте себе, что мы определили класс
    Circle
    как разновидность класса
    Ellipse
    . В этом случае нам пришлось включать в представление дополнительные величины (окружность определяется центром и радиусом; для определения эллипса необходимы центр и пара осей). Мы не приветствуем излишние затраты памяти там, где они не нужны, но основная причина, по которой класс
    Circle
    не сделан наследником класса
    Ellipse
    , заключается в том, что мы не можем определить его, не заблокировав каким-то образом функции
    set_major()
    и
    set_minor()
    . Кроме того, фигура не была бы окружностью (что легко распознают математики), если бы мы использовали функцию
    set_major()
    , чтобы обеспечить выполнение условия
    major()!=minor()
    , — по крайней мере, после этого фигура перестанет быть окружностью. Нам не нужен объект, который иногда относится к одному типу (когда
    major()!=minor()
    ), а иногда к другому (когда
    major()==minor()
    ). Нам нужен объект (класса
    Ellipse
    ), который иногда выглядит как окружность. С другой стороны, объект класса
    Circle
    никогда не превратится в эллипс с двумя неравными осями.

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

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

    13.14. Класс Marked_polyline

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

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


    Marked_polyline mpl("1234");

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

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

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

    mpl.add(Point(300,200));


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



    Определение класса

    Marked_polyline
    имеет следующий вид:


    struct Marked_polyline:Open_polyline {

     Marked_polyline(const string& m):mark(m)

     {

     if (m=="") mark = "*";

      }

      void draw_lines() const;

    private:

      string mark;

    };


    Поскольку этот класс является наследником класса

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


    void Marked_polyline::draw_lines() const

    {

     Open_polyline::draw_lines();

     for (int i=0; i

       draw_mark(point(i),mark[i%mark.size()]);

    }


    Вызов функции

    Open_polyline::draw_lines()
    рисует линии, так что остается просто расставить метки. Эти метки представляют собой строки символов, которые используются в определенном порядке: команда
    mark[i%mark.size()]
    выбирает символ, который должен быть использован следующим, циклически перебирая символы, хранящиеся в объекте класса
    Marked_polyline
    . Оператор
    %
    означает деление по модулю (взятие остатка). Для вывода буквы в заданной точке функция
    draw_lines()
    использует вспомогательную функцию меньшего размера
    draw_mark()
    .


    void draw_mark(Point xy, char c)

    {

     static const int dx = 4;

     static const int dy = 4;

     string m(1,c);

     fl_draw(m.c_str(),xy.x–dx,xy.y+dy);

    }


    Константы

    dx
    и
    dy
    используются для центрирования буквы относительно заданной точки. Объект
    m
    класса хранит единственный символ
    c
    .

    13.15. Класс Marks

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

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


    Marks pp("x");

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

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

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

    pp.add(Point(300,200));


    В итоге будет получено следующее изображение:



    Очевидно, что класс

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

    Класс

    Marks
    — это разновидность класса
    Marked_polyline
    с невидимыми линиями.


    struct Marks : Marked_polyline {

     Marks(const string& m) :Marked_polyline(m)

      {

       set_color(Color(Color::invisible));

      }

    };

    13.16. Класс Mark

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

    Point
    задает координаты в объекте класса
    Window
    . Иногда мы их отображаем, а иногда нет. Если возникает необходимость пометить отдельную точку, чтобы ее увидеть, мы можем изобразить ее в виде крестиков, как показано в разделе 13.2, или воспользоваться классом
    Marks
    . Это объяснение слегка многословно, поэтому рассмотрим простой объект класса
    Marks
    , инициализированный точкой и символом.

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


    Mark m1(Point(100,200),'x');

    Mark m2(Point(150,200),'y');

    Mark m3(Point(200,200),'z');

    c1.set_color(Color::blue);

    c2.set_color(Color::red);

    c3.set_color(Color::green);


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



    Класс

    Mark
    — это разновидность класса
    Marks
    , в котором при создании объекта немедленно задается начальная (и, как правило, единственная) точка.


    struct Mark : Marks {

     Mark(Point xy, char c) : Marks(string(1,c))

      {

       add(xy);

     }

    };


    Функция

    string(1,c)
    — это конструктор класса
    string
    , инициализирующий строку, содержащую единственный символ
    c
    .

    Класс

    Mark
    всего лишь позволяет легко создать объект класса
    Marks
    с единственной точкой, помеченной единственным символом. Стоило ли тратить силы, чтобы определять такой класс? Или он является следствием “ложного стремления к усложнениям и недоразумениям”? Однозначного и логичного ответа на этот вопрос нет. Мы много думали над этим и в конце концов решили, что для пользователей этот класс был бы полезен, а определить его было совсем нетрудно.

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

    x
    ,
    o
    ,
    +
    и
    *
    , обладают центральной симметрией.

    13.17. Класс Image

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

    rita_path.gif
    ), иллюстрирующий путь урагана “Рита”, пришедшего из Мексиканского залива.

    Мы можем выбрать часть этого изображения и добавить фотографию урагана, сделанную из космоса (

    rita.jpg
    ).


    Image rita(Point(0,0),"rita.jpg");

    Image path(Point(0,0),"rita_path.gif");

    path.set_mask(Point(50,250),600,400); // выбираем желательную область

    win.attach(path);

    win.attach(rita);


    Операция

    set_mask()
    выбирает часть рисунка, которую следует изобразить на экране. В данном случае мы выбрали изображение размером 600×400 пикселей из файла
    rita_path.gif
    (загруженный как объект
    path
    ) и показали его в области, левый верхний угол которой имеет координаты (50,250). Выбор части рисунка — довольно распространенный прием, поэтому мы предусмотрели для него отдельную операцию.



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

    path
    оказался на самом “дне”, просто потому, что он был связан с окном до объекта
    rita
    . Изображения могут кодироваться во множестве форматов. Здесь мы используем только два из них: JPEG и GIF.


    struct Suffix {

     enum Encoding { none, jpg, gif };

    };


    В нашей библиотеке графического интерфейса изображение в памяти представляется как объект класса

    Image
    .


    struct Image:Shape {

     Image(Point xy, string file_name,

       Suffix::Encoding e = Suffix::none);

     ~Image() { delete p; }

     void draw_lines() const;

     void set_mask(Point xy, int ww, int hh)

       { w=ww; h=hh; cx=xy.x; cy=xy.y; }

    private:

      int w,h; // определяем "маскировочное окно" внутри изображения

          // по отношению к позиции (cx,cy)

      int cx,cy;

      Fl_Image* p;

      Text fn;

    };


    Конструктор класса

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


    struct Bad_image:Fl_Image {

     Bad_image(int h, int w):Fl_Image(h,w,0) { }

     void draw(int x,int y, int, int, int, int) { draw_empty(x,y); 
    }

    };


    Работа с изображениями в графической библиотеке довольно сложна, но основная сложность класса

    Image
    кроется в файле, который обрабатывает его конструктор.


    // более сложный конструктор, потому что ошибки,

    // связанные с графическими файлами, трудно найти

    Image::Image(Point xy, string s, Suffix::Encoding e)

        :w(0), h(0), fn(xy,"")

    {

     add(xy);

     if (!can_open(s)) {     // можно ли открыть файл s?

       fn.set_label("Невозможно открыть \""+s+" ");

       p = new Bad_image(30,20); // ошибка графики

       return;

     }


     if (e == Suffix::none) e = get_encoding(s);


     switch(e) {         // проверка кодировки

     case Suffix::jpg:

       p = new Fl_JPEG_Image(s.c_str());

       break;

     case Suffix::gif:

       p = new Fl_GIF_Image(s.c_str());

       break;

     default:           // неприемлемая кодировка

       fn.set_label("Неприемлемый тип файла \""+s+" ");

       p = new Bad_image(30,20); // ошибка графики

     }

    }


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

    new
    и связывается с указателем. Подробности его реализации (в главе 17 рассматривается оператор new и указатели) связаны с организацией библиотеки FLTK и не имеют для нас большого значения.

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

    can_open()
    , проверяющую, можно ли открыть файл для чтения.


    bool can_open(const string& s)

     // проверка, существует ли файл s и можно ли его открыть

     // для чтения

    {

     ifstream ff(s.c_str());

     return ff;

    }


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

    Если хотите, можете посмотреть на определение функции

    get_encoding()
    : она просто анализирует суффикс и ищет соответствие в таблице заранее заданных суффиксов. Эта таблица реализована с помощью стандартного типа
    map
    (подробнее об этом — в разделе 21.6).


    Задание

    1. Создайте объект класса

    Simple_window
    размером 800×1000 пикселей.

    2. Разместите сетку размером 88 пикселей в левой части окна размером 800 на 800 пикселей (так что каждый квадрат сетки имеет размер 100×100 пикселей).

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

    Rectangle
    ).

    4. Подберите изображение размером 200×200 пикселей (в формате JPEG или GIF) и разместите три его копии поверх сетки (каждое изображение покроет четыре квадрата). Если вы не найдете изображения, размеры которого точно равнялись бы 200 пикселям, то, используя функцию

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

    5. Добавьте изображение размером 100×100 пикселей. Перемещайте его с одного квадрата на другой, щелкая на кнопке Next. Для этого поместите вызов функции

    wait_for_button()
    в цикл, сопроводив его командами, выбирающими новый квадрат для вашего изображения.


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

    1. Почему мы просто не используем какую-нибудь коммерческую или бесплатную графическую библиотеку?

    2. Сколько классов из библиотеки графического интерфейса нам понадобится, чтобы создать простой вывод графической информации?

    3. Какие заголовочные файлы нужны для использования библиотеки графического интерфейса?

    4. Какие классы определяют замкнутые фигуры?

    5. Почему мы не используем класс

    Line
    для рисования любой фигуры?

    6. Что означают аргументы конструктора класса

    Point
    ?

    7. Перечислите компоненты класса

    Line_style
    .

    8. Перечислите компоненты класса

    Color
    .

    9. Что такое система RGB?

    10. В чем заключается разница между двумя объектами класса

    Line
    и объектом
    Lines
    , содержащим две линии?

    11. Какие свойства можно задать для любого объекта класса

    Shape
    ?

    12. Сколько сторон объекта класса

    Closed_polyline
    определяются пятью объектами класса
    Point
    ?

    13. Что мы увидим на экране, если определим объект класса

    Shape
    , но не свяжем его с объектом класса
    Window
    ?

    14. Чем объект класса

    Rectangle
    отличается от объекта класса
    Polygon
    с четырьмя объектами класса
    Point
    (углами)?

    15. Чем объект класса

    Polygon
    отличается от объекта класса
    Closed_polyline
    ?

    16. Что расположено сверху: заполненная цветом область или границы фигуры?

    17. Почему мы не определили класс

    Triangle
    (ведь мы определили класс
    Rectangle
    )?

    18. Как переместить объект класса

    Shape
    в другое место окна?

    19. Как пометить объект класса

    Shape
    строкой текста?

    20. Какие свойства текстовой строки можно задать в классе

    Text
    ?

    21. Что такое шрифт и зачем он нужен?

    22. Для чего нужен класс

    Vector_ref
    и как его использовать?

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

    Circle
    и
    Ellipse
    ?

    24. Что произойдет, если мы попытаемся изобразить объект класса

    Image
    с заданным именем файла, а заданное имя файла не относится к файлу, содержащему изображение?

    25. Как вывести на экран часть изображения?


    Термины


    Упражнения

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

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

    Arc
    , рисующий часть эллипса. Подсказка:
    fl_arc()
    .

    2. Нарисуйте окно с закругленными углами. Определите класс Box, состоящий из четырех линий и четырех дуг.

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

    Arrow
    , рисующий стрелки.

    4. Определите функции

    n()
    ,
    s()
    ,
    e()
    ,
    w()
    ,
    center()
    ,
    ne()
    ,
    se()
    ,
    sw()
    и
    nw()
    . Каждая из них должна получать аргумент типа
    Rectangle
    и возвращать объект типа
    Point
    . Эти функции должны определять точки соединения, расположенные на границах и внутри прямоугольника. Например,
    nw(r)
    — это левый верхний угол объекта класса
    Rectangle
    с именем
    r
    .

    5. Определите функции из упр. 4 для классов

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

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

    Box
    , объект которого представляет собой прямоугольник с текстовой меткой.

    7. Создайте цветную диаграмму RGB (поищите пример в вебе).

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

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

    9. Покройте часть окна узорами в виде объектов класса

    Regular_hexagon
    (используйте не меньше восьми шестиугольников).

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

    Regular_hexagon
    . В качестве аргументов конструктора используйте центр, количество сторон (не меньше двух) и расстояние от центра до угла.

    11. Нарисуйте эллипс размером 300×200 пикселей. Нарисуйте ось x длиной 400 пикселей и ось y размером 300 пикселей, проходящие через центр эллипса. Пометьте фокусы. Отметьте точку на эллипсе, которая не принадлежит ни одной из осей. Соедините эту точку с фокусами двумя линиями.

    12. Нарисуйте окружность. Заставьте метку перемещаться по окружности (пусть она перемещается каждый раз, когда вы щелкаете на кнопке Next).

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

    14. Определите класс для прямоугольного треугольника. Составьте восьмиугольник из восьми прямоугольных треугольников разного цвета.

    15. Покройте окно узорами в виде маленьких прямоугольных треугольников.

    16. Покройте окно узорами в виде маленьких шестиугольников.

    17. Покройте окно узорами в виде маленьких разноцветных шестиугольников.

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

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

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

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


    Послесловие

    В главе 12 мы играли роль пользователей классов. В этой главе мы перешли на один уровень вверх по “пищевой цепочке” программистов: здесь мы стали разработчиками классов и пользователями инструментов программирования.

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

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

    Витрувий (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 Графические функции и данные

    “Лучшее — враг хорошего”.

    Вольтер (Voltaire)


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

    15.1. Введение

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

    15.2. Построение простых графиков

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



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


    double one(double) { return 1; }


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

    1
    . Поскольку для вычисления результата этот аргумент не нужен, называть его необязательно. Для каждого значения
    x
    , переданного в качестве аргумента функции
    one()
    , получаем значение
    y
    , равное
    1
    ; иначе говоря, эта линия определяется равенством
    (x,y)==(x,1)
    при всех
    x
    .

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


    double slope(double x) { return x/2; }


    Эта функция порождает наклонную линию. Для каждого аргумента

    x
    получаем значение
    y
    , равное
    x/2
    . Иначе говоря,
    (x,y)==(x,x/2)
    . Эти две линии пересекаются в точке
    (2,1)
    .

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


    double square(double x) { return x*x; }


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

    (0,0)
    , т.е.
    (x,y)==(x,x*x)
    . Итак, самая нижняя точка параболы касается наклонной линии в точке
    (0,0)
    .

    Ниже приведен фрагмент кода, который рисует три указанные выше линии.


    const int xmax = 600;    // размер окна

    const int ymax = 400;


    const int x_orig = xmax/2; // точка (0,0) — это центр окна

    const int y_orig = ymax/2;

    const Point orig(x_orig,y_orig);


    const int r_min = –10;   // диапазон [–10:11)

    const int r_max = 11;


    const int n_points = 400;  // количество точек в диапазоне


    const int x_scale = 30;   // масштабные множители

    const int y_scale = 30;


    Simple_window win(Point(100,100),xmax,ymax,"Function graphing");

    Function s(one,r_min,r_max,orig,n_points,x_scale,y_scale);

    Function s2(slope,r_min,r_max,orig,n_points,x_scale,y_scale);

    Function s3(square,r_min,r_max,orig,n_points,x_scale,y_scale);


    win.attach(s);

    win.attach(s2);

    win.attach(s3);

    win.wait_for_button();


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

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

    Function
    :
    s
    ,
    s2
    и
    s3
    .


    Function s(one,r_min,r_max,orig,n_points,x_scale,y_scale);

    Function s2(slope,r_min,r_max,orig,n_points,x_scale,y_scale);

    Function s3(square,r_min,r_max,orig,n_points,x_scale,y_scale);


    Каждый объект класса

    Function
    определяет, как их первый аргумент (функция с одним аргументом типа
    double
    , возвращающая значение типа
    double
    ) будет нарисован в окне. Второй и третий аргументы задают диапазон изменения переменной
    x
    (аргумента изображаемой функции). Четвертый аргумент (в данном случае
    orig
    ) сообщает объекту класса
    Function
    , в каком месте окна расположено начало координат
    (0,0)
    .

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

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

    Text
    (см. раздел 13.11).


    Text ts(Point(100,y_orig–40),"one");

    Text ts2(Point(100,y_orig+y_orig/2–20),"x/2");

    Text ts3(Point(x_orig–100,20),"x*x");

    win.set_label("Function graphing: label functions");

    win.wait_for_button();


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



    Тем не менее этот рисунок еще нельзя считать законченным. Мы уже отметили, что наклонная линия

    x/2
    касается параболы
    x*x
    в точке
    (0,0)
    , а график функции one пересекает линию
    x/2
    в точке
    (2,1)
    , но это известно лишь нам; для того чтобы это стало очевидно читателям, на рисунке следует нанести оси координат.

    Код для построения осей состоит из объявлений двух объектов класса

    Axis
    (раздел 15.4).


    const int xlength = xmax–40; // оси должны быть чуть меньше окна

    const int ylength = ymax–40;

    Axis x(Axis::x,Point(20,y_orig), xlength,

       xlength/x_scale, "one notch == 1");

    Axis y(Axis::y,Point(x_orig, ylength+20),

       ylength, ylength/y_scale, " one notch == 1");



    Использование значения

    xlength/x_scale
    в качестве параметра, задающего количество делений, позволяет использовать целочисленные отметки 1, 2, 3 и т.д. Выбор точки
    (0,0)
    в качестве начала координат является общепринятым. Если хотите, чтобы начало координат было не в центре, а, как обычно, в левом нижнем углу окна (раздел 15.6), вы легко сможете сделать это. Кроме того, для того чтобы различать оси, можно использовать цвет.


    x.set_color(Color::red);

    y.set_color(Color::red);


    Итак, получаем результат, показанный ниже.



    Такой рисунок вполне приемлем, но по эстетическим причинам стоило бы сдвинуть линии немного вниз. Кроме того, было бы неплохо отодвинуть метки оси x немного влево. Однако мы не будем этого делать, поскольку эстетический вид графика можно обсуждать до бесконечности. Одно из профессиональных качеств программиста заключается в том, чтобы знать, когда остановиться и потратить сэкономленное время на что-нибудь более полезное (например, на изучение новых методов или на сон). Помните: “лучшее — враг хорошего”.

    15.3. Класс Function

    Определение класса графического интерфейса

    Function
    приведено ниже.


    struct Function:Shape {

     // параметры функции не хранятся

     Function(Fct f,double r1,double r2,Point orig,

          int count = 100,double xscale = 25,double yscale = 25);

    };


    Класс

    Function
    является производным от класса
    Shape
    . Конструктор класса
    Function
    генерирует множество отрезков линий и хранит их в членах класса
    Shape
    . Эти отрезки линий аппроксимируют значения функции
    f
    . Значения функции
    f
    вычисляются count раз в точках, равномерно распределенных по интервалу
    [r1:r2]
    .


    Function::Function(Fct f,double r1,double r2,Point xy,

              int count,double xscale,double yscale)

     // строит график функции f(x) для x из диапазона [r1:r2),

     // используя count отрезков линий;

     // начало координат (0,0) располагается в точке xy

     // координаты x масштабируются множителем xscale

     // координаты y масштабируются множителем yscale

    {

     if (r2–r1<=0) error("Неправильный диапазон");

     if (count <=0) error("Отрицательное значение count");

     double dist = (r2–r1)/count;

     double r = r1;

     for (int i = 0; i

       add(Point(xy.x+int(r*xscale),xy.y–int(f(r)*yscale)));

       r += dist;

     }

    }


    Параметры

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

    Обратите внимание на то, что объект класса

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

    15.3.1. Аргументы по умолчанию

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

    xscale
    и
    yscale
    конструктора класса Function. Такой способ инициализации называют заданием аргументов по умолчанию (default arguments). Их значения используются тогда, когда при вызове значения аргументов вообще не указываются.


    Function s(one,r_min,r_max,orig,n_points,x_scale,y_scale);

    Function s2(slope,r_min,r_max,orig,n_points,x_scale); // нет

                                // yscale

    Function s3(square,r_min,r_max,orig,n_points); // нет xscale,

                              // нет yscale

    Function s4(sqrt,r_min,r_max,orig); // нет count, нет xscale,

                       // нет yscale


    Этот фрагмент кода эквивалентен следующему:


    Function s(one,r_min,r_max,orig,n_points,x_scale,y_scale);

    Function s2(slope,r_min,r_max,orig,n_points,x_scale, 25);

    Function s3(square,r_min,r_max,orig,n_points,25,25);

    Function s4(sqrt,r_min,r_max,orig,100,25,25);


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


    struct Function:Shape { // альтернатива аргументам, заданным

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

     Function(Fct f,double r1,double r2,Point orig,

     int count, double xscale,double yscale);

      // масштаб переменной y по умолчанию:

      Function(Fct f,double r1,double r2,Point orig,

      int count, double xscale);

      // масштаб переменной x и y:

     Function(Fct f,double r1,double r2,Point orig,int count);

      // значение count по умолчанию и масштаб x и y по умолчанию:

     Function(Fct f,double r1,double r2,Point orig);

    };


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


    struct Function:Shape {

     Function(Fct f,double r1,double r2,Point orig,

      int count = 100,double xscale,double yscale);  // ошибка

    };


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


    struct Function:Shape {

     Function(Fct f,double r1,double r2,Point orig,

     int count = 100,double xscale=25,double yscale=25);

    };


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

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

    15.3.2. Новые примеры

    Мы добавили еще несколько функций — косинус (

    cos
    ) из стандартной библиотеки и — просто для того, чтобы продемонстрировать, как создать сложную функцию, — косинус с наклоном
    x/2
    .


    double sloping_cos(double x) { return cos(x)+slope(x); }


    Результат приведен ниже.



    Соответствующий фрагмент кода выглядит так:


    Function s4(cos,r_min,r_max,orig,400,20,20);

    s4.set_color(Color::blue);

    Function s5(sloping_cos, r_min,r_max,orig,400,20,20);

    x.label.move(–160,0);

    x.notches.set_color(Color::dark_red);


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

    В заключение построим графики логарифма, экспоненты, синуса и косинуса.


    Function f1(log,0.000001,r_max,orig,200,30,30); // ln()

    Function f2(sin,r_min,r_max,orig,200,30,30);   // sin()

    f2.set_color(Color::blue);

    Function f3(cos,r_min,r_max,orig,200,30,30);   // cos()

    Function f4(exp,r_min,r_max,orig,200,30,30);   // exp() 


    Поскольку значение

    log(0)
    не определено (с математической точки зрения оно равно бесконечности), мы начали диапазон изменения функции
    log
    с небольшого положительного числа. Результат приведен ниже.



    Вместо приписывания меток этим графикам мы изменили их цвет.

    Стандартные математические функции, такие как

    cos()
    ,
    sin()
    и
    sqrt()
    , объявлены в стандартном библиотечном заголовке
    . Список стандартных математических функций приведен в разделах 24.8 и B.9.2.

    15.4. Оси

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

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


    struct Axis:Shape {

     enum Orientation { x, y, z };

     Axis(Orientation d, Point xy, int length,

       int number_of_notches=0, string label = "");

     void draw_lines() const;

     void move(int dx, int dy);

     void set_color(Color c);

     Text label;

     Lines notches;

    };


    Объекты

    label
    и
    notches
    остаются открытыми, поэтому пользователи могут ими манипулировать, например приписывать делениям цвет, отличающийся от цвета линии, или перемещать объект
    label
    с помощью функции
    move()
    в более удобное место. Объект класса
    Axis
    — это пример объекта, состоящего из нескольких полунезависимых объектов.

    Конструктор класса

    Axis
    размещает линии и добавляет на них деления, если значение
    number_ of_notches
    больше нуля.


    Axis::Axis(Orientation d, Point xy, int length, int n, string lab)

       :label(Point(0,0),lab)

    {

     if (length<0) error("bad axis length");

      switch (d){

       case Axis::x:

       {

        Shape::add(xy); // линия оси

        Shape::add(Point(xy.x+length,xy.y));

        if (0

          int dist = length/n;

         int x = xy.x+dist;

          for (int i = 0; i

           notches.add(Point(x,xy.y),Point(x,xy.y–5));

          x += dist;

         }

       }

       label.move(length/3,xy.y+20); // размещает метку под линией

       break;

     }

     case Axis::y:

      { Shape::add(xy); // ось y перемещаем вверх

      Shape::add(Point(xy.x,xy.y–length));

       if (0

        int dist = length/n;

        int y = xy.y–dist;

        for (int i = 0; i

         notches.add(Point(xy.x,y),Point(xy.x+5,y));

         y –= dist;

        }

       }

       label.move(xy.x–10,xy.y–length–10); // размещает метку

                         // наверху

       break;

     }

     case Axis::z:

       error("ось z не реализована");

     }

    }


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

    Shape
    , унаследованной классом
    Axis
    (используя функцию
    Shape::add()
    ), хотя деления хранятся в виде отдельного объекта (
    notches
    ). Это позволяет нам манипулировать линией и делениями оси независимо друг от друга; например, мы можем раскрасить их в разные цвета. Аналогично метка была помещена в фиксированное положение, но, поскольку она является независимым объектом, мы всегда можем переместить ее в другое место. Для удобства используем перечисление
    Orientation
    .

    Поскольку класс

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


    void Axis::draw_lines() const

    {

     Shape::draw_lines();

     notches.draw(); // цвет делений может отличаться от цвета линии

     label.draw();  // цвет метки может отличаться от цвета линии

    }


    Для рисования объектов

    notches
    и
    label
    мы используем функцию
    draw()
    а не
    draw_lines()
    , чтобы иметь возможность использовать информацию о цвете, которая в них хранится. Объект класса
    Lines
    хранится в разделе
    Axis::Shape
    и использует информацию о цвете, хранящуюся там же.

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


    void Axis::set_color(Color c) 

    {

     Shape::set_color(c);

     notches.set_color(c);

     label.set_color(c);

    }


    Аналогично, функция

    Axis::move()
    перемещает все три части объекта класса
    Axis
    одновременно.


    void Axis::move(int dx, int dy)

    {

     Shape::move(dx,dy);

     notches.move(dx,dy);

     label.move(dx,dy);

    }

    15.5. Аппроксимация

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

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


    ex = 1 + x + x2/2! + x3/3! + x4/4! + ...


    Чем больше членов ряда мы вычислим, тем точнее будет значение

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


    exp0(x) = 0  // нет членов

    exp1(x) = 1  // один член

    exp2(x) = 1+x // два члена ; pow(x,1)/fac(1)==x

    exp3(x) = 1+x+pow(x,2)/fac(2)

    exp4(x) = 1+x+pow(x,2)/fac(2)+pow(x,3)/fac(3)

    exp5(x) = 1+x+pow(x,2)/fac(2)+pow(x,3)/fac(3)+pow(x,4)/fac(4)

    ...


    Каждая функция немного точнее приближает

    ex
    , чем предыдущая. Здесь
    pow(x,n)
    — стандартная библиотечная функция, возвращающая
    xn
    . В стандартной библиотеке нет функции, вычисляющей факториал, поэтому мы должны определить ее самостоятельно.


    int fac(int n) // factorial(n); n!

    {

     int r = 1;

     while (n>1) {

       r*=n;

       ––n;

     }

     return r;

    }


    Альтернативная реализация функции

    fac()
    описана в упр. 1. Имея функцию
    fac()
    , можем вычислить n-й член ряда.


    double term(double x, int n) { return pow(x,n)/fac(n); } // n-й

                                 // член ряда


    Имея функцию

    term()
    , несложно вычислить экспоненты с точностью до
    n
    членов.


    double expe(double x, int n) // сумма n членов для x

    {

     double sum = 0;

     for (int i=0; i

     return sum;

    }


    Как построить график этой функции? С точки зрения программиста трудность заключается в том, что наш класс

    Function
    получает имя функции одного аргумента, а функция
    expe()
    имеет два аргумента. В языке С++ нет элегантного решения этой задачи, поэтому пока воспользуемся неэлегантным решением (тем не менее, см. упр. 3). Мы можем удалить точность
    n
    из списка аргументов и сделать ее переменной.


    int expN_number_of_terms = 10;

    double expN(double x)

    {

     return expe(x,expN_number_of_terms);

    }


    Теперь функция

    expN(x)
    вычисляет экспоненту с точностью, определенной значением переменной
    expN_number_of_terms
    . Воспользуемся этим для построения нескольких графиков. Сначала построим оси и нарисуем истинный график экспоненты, используя стандартную библиотечную функцию
    exp()
    , чтобы увидеть, насколько хорошо она приближается функцией
    expN()
    .


    Function real_exp(exp,r_min,r_max,orig,200,x_scale,y_scale);

    real_exp.set_color(Color::blue);


    Затем выполним цикл приближений, увеличивая количество членов ряда

    n
    .


    for (int n = 0; n<50; ++n) {

     ostringstream ss;

     ss << " приближение exp; n==" << n ;

     win.set_label(ss.str());

     expN_number_of_terms = n;

     // следующее приближение:

     Function e(expN,r_min,r_max,orig,200,x_scale,y_scale);

     win.attach(e);

     win.wait_for_button();

     win.detach(e);

    }


    Обратите внимание на последний вызов

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

    На первом этапе мы получаем окно, в котором нарисованы оси и “настоящая” экспонента (синий цвет).



    Как видим, значение

    exp(0)
    равно
    1
    , поэтому наш синий график “настоящей” экспоненты пересекает ось y в точке
    (0,1)
    . Если присмотреться повнимательнее, то видно, что на самом деле мы нарисовали первое приближение
    (exp0(x)==0)
    черным цветом поверх оси x. Кнопка Next позволяет получить аппроксимацию, содержащую один член степенного ряда. Обратите внимание на то, что мы показываем количество сленгов ряда, использованного для приближения экспоненты, как часть метки окна.



    Это функция

    exp1(x)==1
    , представляющая собой аппроксимацию экспоненты с помощью только одного члена степенного ряда. Она точно совпадает с экспонентой в точке
    (0,1)
    , но мы можем построить более точную аппроксимацию.



    Используя два члена разложения

    (1+x)
    , получаем диагональ, пересекающую ось y в точке
    (0,1)
    . С помощью трех членов разложения
    (1+x+pow(x,2)/fac(2))
    можем обнаружить признаки сходимости.



    Десять членов приближения дают очень хорошее приближение, особенно для значений

    x
    , превышающих
    –3
    .



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



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

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

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

    15.6. Графические данные

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



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

    • чтение файла;

    • масштабирование данных для подгонки к окну;

    • отображение данных;

    • разметка графика.


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

    Имея набор данных, мы должны подумать о том, как их получше изобразить на экране. Для простоты ограничимся только данными, которые легко изобразить на плоскости, ведь именно такие данные образуют огромный массив приложений, с которыми работают большинство людей. Обратите внимание на то, что гистограммы, секторные диаграммы и другие популярные виды диаграмм на самом деле просто причудливо отображают двумерные данные. Трехмерные данные часто возникают при обработке серии двумерных изображений, при наложении нескольких двумерных графиков в одном окне (как в примере “Возраст населения Японии”) или при разметке отдельных точек. Если бы мы хотели реализовать такие приложения, то должны были бы написать новые графические классы или адаптировать другую графическую библиотеку.

    Итак, наши данные представляют собой пары точек, такие как (

    year,number of children
    ). Если у нас есть больше данных, например (
    year,number of children,number of adults,number of elderly
    ), то мы должны просто решить, какую пару или пары чисел хотим изобразить. В нашем примере мы рисуем пары (
    year,number of children
    ), (
    year,number of adults
    ) и (
    year,number of elderly
    ).

    Существует много способов интерпретации пар (

    x,y
    ). Решая, как изобразить эти данные, важно понять, можно ли их представить в виде функции. Например, для пары (
    year,steel production
    ) разумно предположить, что производство стали (
    steel_production
    ) является функцией, зависящей от года (
    year
    ), и изобразить данные в виде непрерывной линии. Для изображения таких данных хорошо подходит класс
    Open_polyline
    (см. раздел 13.6). Если переменная
    y
    не является функцией, зависящей от переменной
    x
    , например в паре (
    gross domestic product per person,population of country
    ), то для их изображения в виде разрозненных точек можно использовать класс
    Marks
    (см. раздел 13.15).

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

    15.6.1. Чтение файла

    Файл с возрастным распределением состоит из следующих записей:


    (1960 : 30 64 6)

    (1970 : 24 69 7)

    (1980 : 23 68 9)


    Первое число после двоеточия — это процент детей (возраст 0–15) среди населения, второе — процент взрослых (возраст 15–64), а третье — процент пожилых людей (возраст 65+). Наша задача — прочитать эти данные из файла. Обратите внимание на то, что форматирование этих данных носит довольно нерегулярный характер. Как обычно, мы должны уделить внимание таким деталям.

    Для того чтобы упростить задачу, сначала определим тип

    Distribution
    , в котором будем хранить данные и оператор ввода этих данных.


    struct Distribution {

     int year, young, middle, old;

    };


    istream& operator>>(istream& is, Distribution& d)

     // предполагаемый формат: (год: дети взрослые старики)

    {

     char ch1 = 0;

     char ch2 = 0;

     char ch3 = 0;

     Distribution dd;

     if (is >> ch1 >> dd.year

         >> ch2 >> dd.young >> dd.middle >> dd.old

         >> ch3) {

       if (ch1!= '(' || ch2!=':' || ch3!=')') {

        is.clear(ios_base::failbit);

        return is;

       }

     }

     else

       return is;

     d = dd;

     return is;

    }


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

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


    string file_name = "japanese-age-data.txt";

    ifstream ifs(file_name.c_str());

    if (!ifs) error("Невозможно открыть файл ",file_name);

    // ...

    Distribution d;

    while (ifs>>d) {

     if (d.year

       error("год не попадает в диапазон");

      if (d.young+d.middle+d.old != 100)

       error("Проценты не согласованы");

     // ...

    }


    Иначе говоря, мы пытаемся открыть файл

    japanese-age-data.txt
    и выйти из программы, если его нет. Идея не указывать явно имя файла в программе часто оказывается удачной, но в данном случае мы пишем простой пример и не хотим прилагать лишние усилия. С другой стороны, мы присваиваем имя файла
    japanese-age-data.txt
    именованной переменной типа
    string
    , поэтому при необходимости его легко изменить.

    Цикл чтения проверяет диапазон чисел и согласованность данных. Это основные правила проверки таких данных. Поскольку оператор

    >>
    сам проверяет формат каждого элемента данных, в цикле чтения больше нет никаких проверок.

    15.6.2. Общая схема

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

    Open_polyline
    — по одному на каждую возрастную группу. Каждый график должен быть помечен. Для этого мы решили в левой части окна записать “название” каждой линии. Этот выбор кажется удачнее, чем обычная альтернатива
    clearer
    , — поместить метку где-то на самой линии. Кроме того, для того чтобы отличать графики друг от друга, мы используем разные цвета и связываем их с метками.

    Мы хотим пометить ось x, указав годы. Вертикальная линия, проходящая через отметку 2008, означает год, после которого данные являются результатом экстраполяции.

    В качестве названия изображения мы решили просто использовать метку окна.

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


    const int xmax = 600;  // размер окна

    const int ymax = 400;

    const int xoffset = 100;// расстояние от левого края окна до оси y

    const int yoffset = 60; // расстояние от нижнего края окна до оси х

    const int xspace = 40;  // пространство между осями

    const int yspace = 40;

    const int xlength = xmax–xoffset–xspace; // длина осей

    const int ylength = ymax–yoffset–yspace;


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



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

    15.6.3. Масштабирование данных

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


    const int base_year = 1960;

    const int end_year = 2040;

    const double xscale = double(xlength)/(end_year–base_year);

    const double yscale = double(ylength)/100;


    Мы объявили наши масштабирующие множители (

    xscale
    и
    yscale
    ) как числа с плавающей точкой — иначе в наших вычислениях возникли бы серьезные ошибки, связанные с округлением. Для того чтобы избежать целочисленного деления, перед делением преобразовываем наши длины в тип
    double
    (см. раздел 4.3.3).

    Теперь можно поместить точки на ось x, вычитая их базовое значение (

    1960
    ), масштабируя с помощью множителя
    xscale
    и добавляя смещение
    xoffset
    . Значение y обрабатывается аналогично. Эти операции тривиальны, но кропотливы и скучны. Для того чтобы упростить код и минимизировать вероятность ошибок (а также, чтобы не приходить в отчаяние), мы определили небольшой класс, в который включили эти вычисления.


    class Scale { // класс для преобразования координат

      int cbase; // координатная база

      int vbase; // база значений

      double scale;

    public:

     Scale(int b,int vb,double s):cbase(b),vbase(vb),scale(s)
     { }

      int operator()(int v) const

      { return cbase + (v–vbase)*scale; } // см. раздел 21.4

    };


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


    Scale xs(xoffset,base_year,xscale);

    Scale ys(ymax–yoffset,0,–yscale);


    Обратите внимание на то, что мы сделали масштабирующий множитель

    ys
    отрицательным, чтобы отразить тот факт, что координаты y возрастают в направлении вниз, хотя мы привыкли, что они возрастают в направлении вверх. Теперь можем использовать функцию
    xs
    для преобразования лет в координату
    x
    . Аналогично можно использовать функцию
    ys
    для преобразования процентов в координату
    y
    .

    15.6.4. Построение графика

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


    Window win(Point(100,100),xmax,ymax,"Aging Japan");

    Axis x(Axis::x, Point(xoffset,ymax–yoffset),xlength,

        (end_year–base_year)/10,

        "year 1960 1970 1980 1990"

        "2000 2010 2020 2030 2040");

    x.label.move(–100,0);


    Axis y(Axis::y, Point(xoffset,ymax–yoffset),ylength,

        10,"% of population");


    Line current_year(Point(xs(2008),ys(0)),Point(xs(2008),ys(100)));

    current_year.set_style(Line_style::dash);


    Оси пересекаются в точке

    Point(xoffset,ymax–yoffset)
    , соответствующей паре (
    1960,0
    ). Обратите внимание на то, как деления отражают данные. На оси y отложено десять делений, каждое из которых соответствует десяти процентам населения. На оси x каждое деление соответствует десяти годам. Точное количество делений вычисляется по значениям переменных
    base_year
    и
    end_year
    , поэтому, если мы изменим диапазон, оси автоматически будут вычислены заново. Это одно из преимуществ отсутствия “магических констант” в коде. Метка на оси x нарушает это правило, потому что размещать метки, пока числа на окажутся на правильных позициях, бесполезно. Возможно, лучше было бы задать набор индивидуальных меток для каждого деления.

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


    "year 1960 1970 1980 1990"

    "2000 2010 2020 2030 2040"


    Компилятор конкатенирует такие строки, поэтому это эквивалентно следующей строке:


    "year 1960 1970 1980 1990 2000 2010 2020 2030 2040"


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

    Объект

    current_year
    соответствует вертикальной линии, разделяющей реальные данные и прогнозируемые. Обратите внимание на то, как используются функции
    xs
    и
    ys
    для правильного размещения и масштабирования этой линии.

    Построив оси, мы можем обработать данные. Определим три объекта класса

    Open_polyline
    и заполним их в цикле чтения.


    Open_polyline children;

    Open_polyline adults;

    Open_polyline aged;

    Distribution d;

    while (ifs>>d) {

     if (d.year

     error("Год не попадает в диапазон");

     if (d.young+d.middle+d.old != 100)

     error("Проценты не согласованы");

     int x = xs(d.year);

     children.add(Point(x,ys(d.young)));

     adults.add(Point(x,ys(d.middle)));

     aged.add(Point(x,ys(d.old)));

    }


    Использование функций

    xs
    и
    ys
    делает проблему масштабирования и размещения данных тривиальной. “Небольшие классы”, такие как
    Scale
    , могут оказаться очень важными для упрощения кода и устранения лишних повторов — тем самым они повышают читабельность и увеличивают шансы на создание правильной программы.

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


    Text children_label(Point(20,children.point(0).y),"age 0-15");

    children.set_color(Color::red);

    children_label.set_color(Color::red);


    Text adults_label(Point(20,adults.point(0).y),"age 15-64");

    adults.set_color(Color::blue);

    adults_label.set_color(Color::blue);


    Text aged_label(Point(20,aged.point(0).y),"age 65+");

    aged.set_color(Color::dark_green);

    aged_label.set_color(Color::dark_green);


    В заключение нам нужно связать разные объекты класса

    Shape
    с объектом класса
    Window
    и передать управление системе графического пользовательского интерфейса (см. раздел 15.2.3).


    win.attach(children);

    win.attach(adults);

    win.attach(aged);

    win.attach(children_label);

    win.attach(adults_label);

    win.attach(aged_label);

    win.attach(x);

    win.attach(y);

    win.attach(current_year);

    gui_main();


    Весь код можно поместить в функцию

    main()
    , хотя мы предпочитаем использовать вспомогательные классы
    Scale
    и
    Distribution
    , а также оператор ввода, определенный в классе
    Distribution
    .

    Если вы забыли, что мы делаем, посмотрите на рисунок.



    Задание

    Задание, связанное с построением графиков.

    1. Создайте пустое окно 600×600 с меткой “Графики функций”.

    2. Создайте проект, свойства которого заданы в руководстве по инсталляции библиотеки

    FLTK
    .

    3. Поместите файлы

    Graph.cpp
    и
    Window.cpp
    в ваш проект.

    4. Добавьте оси x и y длиной по 400 пикселей каждая, с метками “1 == 20 пикселей” и делениями длиной по 20 пикселей. Оси должны пересекаться в точке (300,300).

    5. Сделайте обе оси красными.


    В дальнейшем используйте отдельный объект класса

    Shape
    для построения каждой из перечисленных ниже функций.

    1. Постройте график функции

    double one(double x) { return 1; }
    в диапазоне [–10,11] с началом координат (0,0) в точке (300,300), используя 400 точек и не делая масштабирования (в окне).

    2. Измените рисунок, применив масштабирование по оси x с коэффициентом 20 и по оси y с коэффициентом 20.

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

    4. Добавьте в окно график функции

    double slope(double x) { return x/2; }
    .

    5. Пометьте наклонную линию с помощью объекта класса

    Text
    со значением "
    x/2
    " в точке, расположенной прямо над левым нижним углом окна.

    6. Добавьте в окно график функции

    double square(double x) { return x*x; }
    .

    7. Добавьте в окно график косинуса (не пишите новую функцию).

    8. Сделайте график косинуса синим.

    9. Напишите функцию

    sloping_cos()
    , суммирующую косинус, и функцию
    slope()
    (как определено выше) и постройте ее график в окне.


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

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

    struct Person
    , содержащий член name типа
    string
    и член
    age
    типа
    int
    .

    2. Определите переменную класса

    Person
    , инициализируйте ее значением “Goofy” и 63 и выведите на экран (
    cout
    ).

    3. Определите оператор ввода (

    >>
    ) и вывода (
    <<
    ) для класса
    Person
    ; считайте объект класса
    Person
    с клавиатуры (
    cin
    ) и выведите его на экран (
    cout
    ).

    4. Напишите конструктор класса

    Person
    , инициализирующий члены
    name
    и
    age
    .

    5. Сделайте представление класса

    Person
    закрытым и включите в него константные функции-члены
    name()
    и
    age()
    , предназначенные для чтения имени и возраста.

    6. Модифицируйте операторы

    >>
    и
    <<
    для заново определенного класса Person.

    7. Модифицируйте конструктор, чтобы определить, что переменная

    age
    лежит в диапазоне [0:150], а переменная
    name
    не содержит символы
    ; : " ' [ ] * & ^ % $ # @ !
    . В случае ошибки используйте функцию
    error()
    . Протестируйте программу.

    8. Считайте последовательность объектов класса

    Person
    с устройства ввода (
    cin
    ) в вектор типа
    vector
    ; выведите его на экран (
    cout
    ). Проверьте правильность ввода.

    9. Измените представление класса

    Person
    так, чтобы вместо члена name использовались члены
    first_name
    и
    second_name
    . Отсутствие хотя бы одного из этих членов должно считаться ошибкой. Исправьте операторы
    >>
    и
    <<
    . Протестируйте программу.


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

    1. Что такое функция одного аргумента?

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

    3. Какая функция определяет наклон? Напишите ее математическую формулу.

    4. Что такое парабола?

    5. Как создать ось x? Как создать ось y?

    6. Что такое аргумент, заданный по умолчанию, и зачем он нужен?

    7. Как составить сложную функцию?

    8. Как при построении графиков используются цвет и метки?

    9. Что представляет собой приближение функции с помощью ряда?

    10. Зачем разрабатывать эскиз графика перед разработкой кода для его построения?

    11. Как масштабировать график?

    12. Как масштабировать входные данные без многократных попыток и ошибок?

    13. Зачем форматировать входные данные? Не лучше ли рассматривать файл, просто заполненный числами?

    14. Как вы разрабатываете общий эскиз графика? Как этот эскиз отражается в вашей программе?


    Термины


    Упражнения

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


    int fac(int n) { return n>1 ? n*fac(n–1) : 1; } // n!


    Эта функция вычисляет значение

    fac(4)
    . Поскольку
    4>1
    , ответ равен
    4*fac(3)
    , т.е.
    4*3*fac(2)
    , т.е
    4*3*2*fac(1)
    , т.е.
    4*3*2*1
    . Посмотрите, как это работает. Функция, вызывающая сама себя, называется рекурсивной (recursive). Альтернативная реализация, описанная в разделе 15.5, называется итеративной (iterative), потому что в ней используется итерация по значениями (в цикле
    while
    ). Убедитесь, что рекурсивная функция
    fac()
    работает и выдает те же результаты, что и итеративная функция
    fac()
    при вычислении факториала чисел 0, 1, 2, 3, 4 и так далее до 20. Какую реализацию функции
    fac()
    вы предпочитаете и почему?

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

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

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

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

    4. Постройте график функций

    sin()
    ,
    cos()
    ,
    sin(x)+cos(x)
    и
    sin(x)*sin(x)+cos(x)*cos(x)
    на одном рисунке. Нарисуйте оси и метки.

    5. “Анимируйте” (как в разделе 15.5) ряд

    1–1/3+1/5–1/7+1/9–1/11+
    ... Он называется рядом Лейбница (Leibniz) и сходится к числу 
    π/4
    .

    6. Разработайте и реализуйте класс для построения гистограммы. Его исходные данные должны храниться в векторе типа

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

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

    8. Ниже перечислено множество пар, составленных из роста и количества людей указанного роста (с точностью до пяти сантиметров): (170,7), (175,9), (180,23), (185,17), (190,6), (195,1). Как изобразить эти данные? Если вы не нашли лучшего решения, постройте гистограмму. Помните об осях и метках. Запишите данные в файл и считайте их оттуда.

    9. Найдите другой набор данных о росте людей (дюйм равен 2,54 см) и нарисуйте их с помощью программы, созданной при выполнении предыдущего упражнения. Например, найдите в веб распределение роста людей в США или попросите своих друзей измерить свой рост. В идеале вы не должны изменять свою программу, чтобы приспособить ее к новому набору данных. Для этого следует применить масштабирование данных. Считывание меток также позволит минимизировать количество изменений, если вы захотите повторно использовать программу.

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

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


    Послесловие

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

    Глава 16 Графические пользовательские интерфейсы

    “Вычисления — это уже не только компьютеры.

    Это образ жизни”.

    Николас Негропонте (Nicholas Negroponte)


    Графический пользовательский интерфейс (graphical user interface — GUI) позволяет пользователю взаимодействовать с программой, щелкая на кнопках, выбирая пункты меню, вводя данные разными способами и отображая текстовые и графические элементы на экране. Именно это мы используем во время работы со своими компьютерами и веб-сайтами. В данной главе излагаются основы написания программ, управляющих приложениями с графическим пользовательским интерфейсом. В частности, мы покажем, как написать программу, взаимодействующую с элементами экрана с помощью функций обратного вызова. Возможности нашего графического пользовательского интерфейса “надстроены” над средствами системы. Низкоуровневые средства и интерфейсы описаны в приложении Д, в котором используются инструменты и методы, рассмотренные в главах 17–18. Здесь мы сосредоточимся лишь на их использовании.

    16.1. Альтернативы пользовательского интерфейса

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

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

    iostream
    из стандартной библиотеки С++ (см. главы 10-11). Если же результаты необходимо вывести в графическом виде, можно использовать графическую библиотеку (см. главы 12–15), не изменяя своему стилю программирования.

    • Использовать библиотеку графического пользовательского интерфейса. Именно это мы делаем, когда хотим, чтобы взаимодействие пользователя с программой осуществлялось посредством манипулирования объектами на экране (указание, щелчки, перетаскивание и опускание, зависание и т.д.). Часто (но не всегда) этот стиль связан с интенсивным отображением графической информации на экране. Любой пользователь современных компьютеров может привести такие примеры. Любой пользователь, желающий “почувствовать” стиль приложения операционных систем Windows/Mac, должен использовать графический пользовательский интерфейс.

    • Использовать интерфейс веб-браузера. В этом случае потребуются язык разметки (markup language), такой как HTML, а также язык сценариев (scripting language). Эта тема выходит за рамки рассмотрения нашей книги, но для приложений с удаленным доступом именно такой выбор часто оказывается самым удачным. В этом случае взаимодействие пользователя с программой также носит текстовый характер (на основе потоков символов). Браузер — это средство графического пользовательского интерфейса, которое переводит текст в графические элементы, транслирует щелчки мышью и другие действия пользователя в текстовые данные и отправляет их обратно программе.


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

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

    16.2. Кнопка Next

    Зачем мы предусмотрели кнопку Next, которая использовалась для управления графическими примерами в главах 12–15? В этих примерах фигуры рисовались после нажатия клавиши. Очевидно, что это простая форма программирования графического пользовательского интерфейса. Фактически она настолько проста, что некоторые люди могут сказать, что это ненастоящий графический пользовательский интерфейс. Однако посмотрим, как это было сделано, поскольку это приведет нас прямо к тому виду программирования, которое все признали как программирование графического пользовательского интерфейса.

    Наш код в главах 12–15 был устроен примерно так:


    // создаем объекты и/или манипулируем ими,

    // изображаем их в объекте win класса Window

    win.wait_for_button();


    // создаем объекты и/или манипулируем ими,

    // изображаем их в объекте win класса Window

    win.wait_for_button();


    // создаем объекты и/или манипулируем ими,

    // изображаем их в объекте win класса Window

    win.wait_for_button();


    Каждый раз, достигая вызова функции

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


    // определяем переменные и/или вычисляем значения, вырабатываем

    // результаты

    cin >> var; // ожидаем ввода


    // определяем переменные и/или вычисляем значения, вырабатываем

    // результаты

    cin >> var; // ожидаем ввода


    // определяем переменные и/или вычисляем значения, вырабатываем

    // результаты

    cin >> var; // ожидаем ввода


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

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

    • Указать, за чем должна следить система графического пользовательского интерфейса (например, “Кто-то щелкнул на кнопке Next”).

    • Указать, что делать, когда произошло ожидаемое событие.

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


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

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

    16.3. Простое окно

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

    • Определить кнопку.

    • Отобразить ее на экране.

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

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

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


    Давайте сделаем это. Кнопка — это часть объекта класса

    Window
    , поэтому (в файле
    Simple_window.h
    ) мы определим класс
    Simple_window
    , содержащий член
    next_button
    .


    struct Simple_window:Graph_lib::Window {

     Simple_window(Point xy,int w,int h,const string& title );

     void wait_for_button(); // простой цикл событий

    private:

     Button next_button; // кнопка Next

     bool button_pushed; // деталь реализации

     static void cb_next(Address, Address); // обратный вызов

                         // для кнопки 
    next_button

     void next(); // действие, которое следует выполнить,

            // когда при щелчке на кнопке next_button

    };


    Очевидно, что класс

    Simple_window
    является производным от класса
    Window
    из библиотеки
    Graph_lib
    . Все наши окна должны быть объектами класса, явно и неявно выведенными из класса
    Graph_lib::Window
    , поскольку именно этот класс (с помощью библиотеки FLTK) связывает наше понятие окна с его реализацией в системе. Детали реализации класса Window описаны в разделе Д.3.

    Наша кнопка инициализируется в конструкторе класса

    Simple_window
    .


    Simple_window::Simple_window(Point xy, int w, int h,
    const string& title)

            :Window(xy,w,h,title),

     next_button(Point(x_max()–70,0),70,20,"Next",cb_next),
    button_pushed(false)

    {

     attach(next_button);

    }


    Нет ничего удивительного в том, что класс

    Simple_window
    передает положение своего объекта (
    xy
    ), размер (
    w,h
    ) и заголовок (
    title
    ) классу
    Window
    из библиотеки
    Graph_lib
    для дальнейшей обработки. Далее конструктор инициализирует член
    next_button
    координатами (
    Point(x_max()–70,0
    ); это где-то в области верхнего правого угла), размером (
    70,20
    ), меткой (
    "Next"
    ) и функцией обратного вызова (
    cb_next
    ). Первые четыре параметра совпадают с параметрами, которые мы использовали при описании класса
    Window
    : мы задаем положение прямоугольника на экране и указываем его метку.

    В заключение вызываем функцию

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

    Член

    button_pushed
    — это довольно запутанная деталь реализации; мы используем его для того, чтобы отслеживать щелчки на кнопке после последнего выполнения функции
    next()
    . Фактически здесь все является деталью реализации и, следовательно, должно быть объявлено в разделе
    private
    . Игнорируя детали реализации, опишем класс в целом.


    struct Simple_window:Graph_lib::Window {

     Simple_window(Point xy,int w,int h,const string& title );

     void wait_for_button(); // простой цикл событий

     // ...

    };


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

    16.3.1. Функции обратного вызова

    Функция

    cb_next()
    — новая и интересная деталь. Именно эта функция должна быть вызвана системой графического пользовательского интерфейса, когда будет зарегистрирован щелчок на кнопке. Поскольку мы передаем такие функции системе графического пользовательского интерфейса, для того чтобы система вызвала их для нас, их часто называют функциями обратного вызова (callback function). Этот факт отображается в префиксе функции
    cb_next()
    (
    cb_
    — “callback”).



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

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

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

    cb_next()
    . Мы передаем адрес функции
    cb_next()
    и адрес нашего объекта класса
    Simple_window
    вниз через уровни программного обеспечения; затем какой-то код “где-то внизу” вызывает функцию
    cb_next()
    , когда выполняется щелчок на кнопке Next.

    Система графического пользовательского интерфейса (и операционная система) может использоваться программами, написанными на разных языках, но они не могут навязывать всем пользователям стиль языка С++. В частности, ей ничего не известно о наших классах

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


    static void cb_next(Address, Address); // обратный вызов для

                         // next_button


    Здесь ключевое слово

    static
    гарантирует, что функция
    cb_next()
    может быть вызвана как обычная функция, т.е. не как функция-член, вызываемая через конкретный объект. Если бы функцию-член могла вызывать сама операционная система, было бы намного лучше. Однако интерфейс обратного вызова нужен для программ, написанных на многих языках, поэтому мы используем статическую функцию-член. Аргументы
    Address
    указывают на то, что функция
    cb_next()
    получает аргументы, имеющие адреса “где-то в памяти”. Ссылки, существующие в языке C++, во многих языках неизвестны, поэтому мы не можем их использовать. Компилятор не знает, какие типы имеют эти аргументы, расположенные “где-то”. Здесь мы снижаемся на уровень аппаратного обеспечения и не можем использовать обычные средства языка. Система вызовет функцию обратного вызова, первый аргумент которой должен представлять собой адрес некоторого элемента графического пользовательского интерфейса (объекта класса
    Widget
    ), для которого был сделан обратный вызов. Мы не хотим использовать этот первый аргумент, поэтому его имя нам не нужно. Второй аргумент — это адрес окна, содержащего данный объект класса
    Widget
    ; для функции
    cb_next()
    аргументом является объект класса
    Simple_window
    .

    Эту информацию можно использовать следующим образом:


    void Simple_window::cb_next(Address,Address pw)

    // вызов Simple_window::next() для окна, расположенного по адресу pw

    {

     reference_to(pw).next();

    }

    Вызов функции

    reference_to(pw)
    сообщает компьютеру, что адрес, хранящийся в переменной
    pw
    , должен интерпретироваться как адрес объекта класса
    Simple_window
    ; иначе говоря, мы можем использовать значение
    reference_to(pw)
    как ссылку на объект класса
    Simple_window
    . В главах 17-18 мы еще вернемся к вопросам адресации памяти. Определение функции
    reference_to
    (кстати, совершенно тривиальное) мы покажем в разделе Д.1. А пока просто рады наконец получить ссылку на наш объект класса
    Simple_window
    и непосредственный доступ к нашим данным и функциям, которые собирались использовать. Теперь поскорее выходим из этого системно-зависимого кода, вызывая нашу функцию-член
    next()
    .

    Мы могли бы привести весь код, который следовало бы выполнить в функции

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

    • Функция

    cb_next()
    превращает системные соглашения об обратных вызовах в вызов обычной функции-члена next().

    • Функция

    next()
    делает то, что мы хотели (ничего не зная о запутанном механизме обратного вызова).


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

    cb_next()
    скрывает низкоуровневую системно-зависимую часть программы, а функция
    next()
    выполняет требуемое действие. В ситуациях, когда необходим обратный вызов (из системы) в одном из окон, мы всегда определяем пару таких функций; например, см. разделы 16.5–16.7. Перед тем как идти дальше, повторим сказанное.

    • Мы определяем наш объект класса Simple_window.

    • Конструктор класса

    Simple_window
    регистрирует свою кнопку
    next_button
    в системе графического пользовательского интерфейса.

    • Когда пользователь щелкает на изображении объекта

    next_button
    на экране, графический пользовательский интерфейс вызывает функцию
    cb_next()
    .

    • Функция

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

    • После щелчка на кнопке функция

    next()
    выполняет требуемое действие.


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

    • Как правило, на компьютере одновременно выполняется много программ.

    • Программа создается намного позже операционной системы.

    • Программа создается намного позже библиотеки графического пользовательского интерфейса.

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

    • Описанный метод охватывает все виды взаимодействий (а не только щелчок на кнопке).

    • Окно может иметь много кнопок, а программа может иметь много окон.


    Однако, поняв, как вызывается функция

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

    16.3.2. Цикл ожидания

    Итак, что должна делать функция

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


    // создаем и/или манипулируем некоторыми объектами, изображаем

    // их в окне

    win.wait_for_button(); // работа программы возобновляется с этой

     // точки

    // создаем и/или манипулируем некоторыми объектами


    На самом деле это просто. Сначала определим функцию

    wait_for_button()
    .


    void Simple_window::wait_for_button()

     // модифицированный цикл событий:

     // обрабатываем все события (по умолчанию),

     // выходим из цикла, когда переменная button_pushed становится

     // true

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

      // управления

    {

     while (!button_pushed) Fl::wait();

     button_pushed = false;

     Fl::redraw();

    }


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

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

    Итак, когда кто-то щелкает на кнопке Next, функция

    wait()
    вызывает функцию
    cb_next()
    и возвращает управление (нашему циклу ожидания). Для того чтобы сделать это в функции
    wait_for_button()
    , функция
    next()
    должна просто присвоить булевой переменной
    button_pushed
    значение
    true
    . Это просто.


    void Simple_window::next()

    {

     button_pushed = true;

    }


    Разумеется, мы также должны где-то определить переменную

    button_pushed
    .


    bool button_pushed; // Инициализируется в конструкторе

               // значением false


    После определенного периода ожидания функция

    wait_for_button()
    должна восстановить прежнее значение переменной
    button_pushed
    и вызвать функцию
    redraw()
    , чтобы все внесенные изменения были видны на экране. Именно это мы и сделали.

    16.4. Класс Button и другие разновидности класса Widget

    Определим класс, описывающий кнопку.


    struct Button:Widget {

     Button(Point xy, int w, int h, const string& label, Callback cb);

     void attach(Window&);

    };


    Класс

    Button
    является производным от класса
    Widget
    с координатами
    xy
    , размерами
    w
    и
    h
    , текстовой меткой
    label
    и обратным вызовом
    cb
    . В принципе все, что появляется на экране в результате какого-то действия (например, обратный вызов), является объектом класса
    Widget
    .

    16.4.1. Класс Widget

    Виджет (widget) — это технический термин. У него есть более информативный, но менее эффектный синоним — элемент управления окном (control). Такой элемент используется для определения форм взаимодействия с программой через графический пользовательский интерфейс. Определение класса

    Widget
    приведено ниже.


    class Widget {

     // Класс Widget — это дескриптор класса Fl_widget,

     // он не является классом Fl_widget;

     // мы стараемся, чтобы наши интерфейсные классы отличались

     // от FLTK

    public:

     Widget(Point xy, int w, int h, const string& s, Callback cb);

     virtual void move(int dx,int dy);

     virtual void hide();

     virtual void show();

     virtual void attach(Window&) = 0;


     Point loc;

     int width;

     int height;

     string label;

     Callback do_it;

    protected:

     Window* own;   // каждый объект класса Widget принадлежит 
    Window

     Fl_Widget* pw; // связь с классом Widget из библиотеки FLTK

    }; 


    Класс

    Widget
    имеет две интересные функции, которые можно применить в классе
    Button
    (а также в любом другом классе, производном от класса
    Widget
    , например
    Menu
    ; см. раздел 16.7).

    • Функция

    hide()
    делает объект класса
    Widget
    невидимым.

    • Функция

    show()
    делает объект класса
    Widget
    снова видимым.


    Изначально объект класса

    Widget
    является видимым.

    Как и в классе

    Shape
    , мы можем с помощью функции
    move()
    перемещать объект класса
    Widget
    в окне и должны связать этот объект с окном, вызвав функцию
    attach()
    перед тем, как использовать. Обратите внимание на то, что мы объявили функцию
    attach()
    чисто виртуальной (см. раздел 16.3.5): каждый класс, производный от класса
    Widget
    , должен самостоятельно определить, что означает его связывание с объектом класса
    Window
    . Фактически системные элементы управления окном создаются в функции
    attach()
    . Функция
    attach()
    вызывается из объекта класса
    Window
    как часть реализации его собственной функции
    attach()
    . В принципе связывание окна и элемента управления окном — это очень тонкое дело, в котором каждая из сторон выполняет свое задание. В результате окно знает о существовании своих элементов управления, а каждый элемент управления знает о своем окне.



    Обратите внимание на то, что объект класса

    Window
    не знает о том, какая разновидность класса
    Widget
    с ним взаимодействует. Как описано в разделах 16.4 и 16.5, объектно-ориентированное программирование позволяет объектам класса
    Window
    взаимодействовать с любыми разновидностями класса
    Widget
    . Аналогично, классу
    Widget
    не известно, с какой разновидностью класса
    Window
    он имеет дело.

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

    own
    и
    pw
    предназначены исключительно для реализации производных классов, поэтому мы объявили из в разделе
    protected
    .

    Определения класса

    Widget
    и его конкретных разновидностей (
    Button
    ,
    Menu
    и т.д.) содержатся в файле
    GUI.h
    .

    16.4.2. Класс Button

    Класс

    Button
    — это простейший класс
    Widget
    , с которым нам придется работать. Все, что он делает, — всего лишь обратный вызов после щелчка на кнопке.


    class Button:public Widget {

    public:

     Button(Point xy,int ww,int hh,const string& s,Callback cb)

         :Widget(xy,ww,hh,s,cb) { }

     void attach(Window& win);

    };


    Только и всего. Весь (относительно сложный) код библиотеки FLTK содержится в функции

    attach()
    . Мы отложили ее объяснение до приложения Д (пожалуйста, не читайте его, не усвоив главы 17 и 18). А пока заметим, что определение простого подкласса
    Widget
    не представляет особого труда.

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

    16.4.3. Классы In_box и Out_box

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

    Widget
    .


    struct In_box:Widget {

     In_box(Point xy,int w,int h,const string& s)

         :Widget(xy,w,h,s,0) { }

     int get_int();

     string get_string();


     void attach(Window& win);

    };


    struct Out_box:Widget {

     Out_box(Point xy, int w, int h, const string& s)

         :Widget(xy,w,h,s,0) { }

     void put(int);

     void put(const string&);


     void attach(Window& win);

    };


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

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


    string s = some_inbox.get_string();

    if (s =="") {

     // текст не введен

    }


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

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

    Мы могли бы предусмотреть функции

    get_floating_point()
    ,
    get_complex()
    и так далее, но не сделали этого, так как вы можете взять строку, поместить ее в поток
    stringstream
    и форматировать ввод, как захотите (см. раздел 11.4).

    16.4.4. Класс Menu

    Определяем очень простое меню.


    struct Menu:Widget {

     enum Kind { horizontal, vertical };

     Menu(Point xy, int w, int h, Kind kk, const string& label);

     Vector_ref

     Kind k;

     int offset;

     int attach(Button& b); // связывает кнопку с меню

     int attach(Button* p); // добавляет новую кнопку в меню


     void show()       // показывает все кнопки

     {

       for (int i = 0; i

       selection[i].show();

     }

     void hide();      // hide all buttons

     void move(int dx, int dy); // перемещает все кнопки


     void attach(Window& win);  // связывает все кнопки с объектом win

    };


    По существу, объект класса

    Menu
    — это вектор кнопок. Как обычно, объект
    Point xy
    задает координаты левого верхнего угла. Ширина и высота используются для изменения размера кнопки при ее добавлении в меню. Примеры описаны в разделах 16.5 и 16.7. Каждая кнопка меню (пункт меню) — это независимый объект класса
    Widget
    , переданный объекту класса
    Menu
    как аргумент функции
    attach()
    . В свою очередь, класс
    Menu
    содержит функцию
    attach()
    , связывающую все свои кнопки с окном. Объект класса
    Menu
    отслеживает все свои кнопки с помощью класса
    Vector_ref
    (разделы 13.10 и E.4).

    Если хотите создать всплывающее меню (“pop-up” menu), то сможете справиться с этой задачей самостоятельно (подробно об этом — в разделе 16.7).

    16.5. Пример

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



    Эта программа позволяет пользователю изобразить последовательность линий (незамкнутая ломаная; см. раздел 13.6), заданную как последовательность пар координат. Идея заключается в том, что пользователь постоянно вводит координаты (x, y) в поля ввода next x и next y; после ввода каждой пары пользователь щелкает на кнопке Next point.

    Изначально поле ввода

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

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



    Определим класс для рисования таких окон. Он довольно прост.


    struct Lines_window:Window {

     Lines_window(Point xy,int w,int h,const string& title );

     Open_polyline lines;

    private:

     Button next_button; // добавляет пару (next_x,next_y)

     // в объект lines

     Button quit_button;

     In_box next_x;

     In_box next_y;

     Out_box xy_out;


     static void cb_next(Address, Address); // обратный вызов

                          // next_button

      void next();

      static void cb_quit(Address, Address); // обратный вызов

                          // quit_button

     void quit();

    };


    Линия изображается как объект класса

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

    Конструктор класса

    Lines_window
    инициализирует все его члены.


    Lines_window::Lines_window(Point xy,int w,int h,const string& title)

           :Window(xy,w,h,title),

     next_button(Point(x_max()–150,0),70,20,"Next point",cb_next),

     quit_button(Point(x_max()–70,0),70,20,"Quit",cb_quit),

     next_x(Point(x_max()–310,0),50,20,"next x: "),

     next_y(Point(x_max()–210,0),50,20,"next y: "),

     xy_out(Point(100,0),100,20,"current (x,y): ")

      {

       attach(next_button);

        attach(quit_button);

       attach(next_x);

       attach(next_y);

       attach(xy_out);

       attach(lines);

      }


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

    Обработка кнопки Quit тривиальна.


    void Lines_window::cb_quit(Address, Address pw) // "как обычно"

    {

     reference_to(pw).quit();

    }


    void Lines_window::quit()

    {

     hide(); // любопытная идиома библиотеки FLTK для удаления окна

    }


    Все как обычно: функция обратного вызова (в данном случае

    cb_quit()
    ) передается функции (в данном случае
    quit()
    ), выполняющей реальную работу (удаляющей объект класса
    Window
    ). Для этого используется любопытная идиома библиотеки FLTK, которая просто скрывает окно.

    Вся реальная работа выполняется кнопкой Next point. Ее функция обратного вызова устроена как обычно.


    void Lines_window::cb_next(Address, Address pw) // " как обычно "

    {

     reference_to(pw).next();

    }


    Функция

    next()
    определяет действие, которое действительно выполняется после щелчка на кнопке Next point: она считывает пару координат, обновляет объект
    Open_polyline
    и позицию считывания, а также перерисовывает окно.


    void Lines_window::next()

    {

     int x = next_x.get_int();

     int y = next_y.get_int();


     lines.add(Point(x,y));


     // обновляем текущую позицию считывания:

     ostringstream ss;

     ss << '(' << x << ',' << y << ')';

     xy_out.put(ss.str());


     redraw();

    }


    Все это совершенно очевидно. Функция

    get_int()
    позволяет получить целочисленные координаты из объектов класса
    In_box
    ; поток
    ostringstream
    форматирует строки для вывода в объект класса
    Out_box
    ; функция-член
    str()
    позволяет вставить строку в поток
    ostringstream
    . Финальная функция,
    redraw()
    , необходима для представления результатов пользователю; старое изображение остается на экране, пока не будет вызвана функция
    redraw()
    из класса
    Window
    .

    А что нового в этой программе? Посмотрим на ее функцию

    main()
    .


    #include "GUI.h"

    int main()

    try {

     Lines_window win(Point(100,100),600,400,"lines");

     return gui_main();

    }

    catch(exception& e) {

     cerr << "Исключение: " << e.what() << '\n';

     return 1;

    }

    catch (...) {

     cerr << "Какое-то исключение\n";

     return 2;

    }


    Так ведь здесь, по существу, ничего нет! Тело функции

    main()
    содержит лишь определение нашего окна
    win
    и вызов функции
    gui_main()
    . Ни других функций, ни операторов
    if
    или
    switch
    , ни цикла — ничего из того, чтобы изучали в главах 6–7, — только определение переменной и вызов функции
    gui_main()
    , которая сама вызывает функцию
    run()
    из библиотеки FLTK. Изучая программу далее, увидим, что функция
    run()
    — это просто бесконечный цикл.


    while(wait());


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

    16.6. Инверсия управления

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

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

    Обычная программа организована следующим образом:



    Программа графического пользовательского интерфейса организована иначе.



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

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

    next()
    класса
    Lines_window
    активизировалась щелчком на кнопке Next point, считывала данные из объектов класса
    In_box
    (
    next_x
    и
    next_y
    ), а затем обновляла переменную-член
    lines
    и объект класса
    Out_box (xy_out)
    . Очевидно, что функция, активизированная обратным вызовом, может делать все, что угодно: открывать файлы, связываться с сетью веб и т.д. Однако пока мы рассмотрим простой случай, когда данные хранятся в окне.

    16.7. Добавление меню

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

    lines
    . Добавим меню
    color_menu
    и обратные вызовы.


    struct Lines_window:Window {

     Lines_window(Point xy,int w,int h,const string& title);


     Open_polyline lines;

     Menu color_menu;


     static void cb_red(Address,Address);  // обратный вызов

                         // для красной кнопки

     static void cb_blue(Address,Address);  // обратный вызов

                         // для синей кнопки

     static void cb_black(Address,Address); // обратный вызов

                          // для черной кнопки


     // действия:

     void red_pressed() { change(Color::red); }

     void blue_pressed() { change(Color::blue); }

     void black_pressed() { change(Color::black); }


     void change(Color c) { lines.set_color(c); }

     // ...как и прежде...

    };


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

    Определив член

    color_menu
    , мы должны его инициализировать.


    Lines_window::Lines_window(Point xy,int w,int h,

        const string&title):Window(xy,w,h,title),

      // ...как и прежде...

      color_menu(Point(x_max()–70,40),70,20,Menu::vertical,"color")

      {

       // ...как и прежде...

        color_menu.attach(new Button(Point(0,0),0,0,"red",cb_red));

       color_menu.attach(new Button(Point(0,0),0,0,"blue",cb_blue));

       color_menu.attach(new Button(Point(0,0),0,0,"black",cb_black));

       attach(color_menu);

    }


    Кнопки динамически связываются с меню (с помощью функции

    attach()
    ) и при необходимости могут быть удалены и/или изменены. Функция
    Menu::attach()
    настраивает размер и место кнопки, а также связывает его с окном. Это все. Теперь мы увидим на экране следующее.

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

    Посмотрим сначала на окно, в которое добавлено несколько линий.




    Мы видим новую кнопку Color menu и несколько черных линий. Щелкнем на кнопке Color menu, и на экране откроется меню.



    Обратите внимание на то, что кнопка Color menu исчезла. Она не нужна, пока открыто меню. Щелкнем на кнопке blue и получим следующий результат.



    Теперь линии стали синими, а кнопка Color menu вновь появилась на экране.

    Для того чтобы достичь такого эффекта, мы добавили кнопку Color menu и модифицировали функцию “pressed”, настроив видимость меню и кнопки. Вот как выглядит класс

    Lines_window
    после всех этих модификаций.


    struct Lines_window:Window {

     Lines_window(Point xy, int w, int h, const string& title );

    private:

     // данные:

     Open_polyline lines;


     // элементы управления окном:

     Button next_button; // добавляет (next_x,next_y) к линиям

     Button quit_button; // завершает работу программы

     In_box next_x;

     In_box next_y;

     Out_box xy_out;

     Menu color_menu;

     Button menu_button;

     void change(Color c) { lines.set_color(c); }

     void hide_menu() { color_menu.hide(); menu_button.show(); }


     // действия, инициирующие обратные вызовы:

     void red_pressed() { change(Color::red); hide_menu(); }

     void blue_pressed() { change(Color::blue); hide_menu(); }

     void black_pressed() { change(Color::black); hide_menu(); }

     void menu_pressed() { menu_button.hide(); color_menu.show(); }

     void next();

     void quit();


     // функции обратного вызова:

      static void cb_red(Address, Address);

      static void cb_blue(Address, Address);

      static void cb_black(Address, Address);

      static void cb_menu(Address, Address);

      static void cb_next(Address, Address);

      static void cb_quit(Address, Address);

    };


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


    Lines_window::Lines_window(Point xy,int w,int h,

           const string&title)

      :Window(xy,w,h,title),

     next_button(Point(x_max()–150,0),70,20,

     "Next point", cb_next),

     quit_button(Point(x_max()–70,0),70,20,"Quit",cb_quit),

     next_x(Point(x_max()–310,0),50,20,"next x:"),

     next_y(Point(x_max()–210,0),50,20,"next y:"),

     xy_out(Point(100,0),100,20,"current (x,y):")

     color_menu(Point(x_max()–70,30),70,20,Menu::vertical,"color"),

     menu_button(Point(x_max()–80,30),80,20,

     "color menu",cb_menu),

      {

       attach(next_button);

       attach(quit_button);

        attach(next_x);

       attach(next_y);

        attach(xy_out);

       xy_out.put("нет точек");

        color_menu.attach(new Button(Point(0,0),0,0,"red",cb_red));

       color_menu.attach(new Button(Point(0,0),0,0,"blue",cb_blue));

        color_menu.attach(new Button(Point(0,0),0,0,"black",cb_black));

       attach(color_menu);

        color_menu.hide();

       attach(menu_button);

        attach(lines);

    }


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

    16.8. Отладка программы графического пользовательского интерфейса

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

    main()
    .


    int main()

    {

     Lines_window (Point(100,100),600,400,"lines");

     return gui_main();

    }


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

    • Тщательно исследовать части программы (классы, функции, библиотеки).

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

    • Проверить все установки редактора связей.

    • Сравнить ее с уже работающей программой.

    • Объяснить код другу.


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

    Итак, в чем же проблема? Вот правильная версия (см. раздел 16.5).


    int main()

    {

     Lines_window win(Point(100,100),600,400,"lines");

     return gui_main();

    }


    Мы забыли указать имя

    win
    объекта класса
    Lines_window
    . Поскольку на самом деле мы не используем это имя, это кажется разумным, но компилятор решит, что, поскольку вы не используете окно, его можно сразу удалить. Ой! Это окно существовало всего несколько миллисекунд. Ничего удивительно, что мы его не заметили.

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

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

    К типичным проблемам, выявляемым при отладке, относится и отсутствие изображений объектов

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


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

    void load_disaster_menu(Menu& m)

    {

     Point orig(0,0);

     Button b1(orig,0,0,"flood",cb_flood);

     Button b2(orig,0,0,"fire",cb_fire);

     // ...

     m.attach(b1);

     m.attach(b2);

     // ...

     }


    int main()

    {

     // ...

     Menu disasters(Point(100,100),60,20,Menu::horizontal,
    "disasters");

     load_disaster_menu(disasters);

     win.attach(disasters);

     // ...

    }


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

    load_disaster_menu
    , и их связывание с меню не изменяет состояние самого меню. Объяснение этого факта приведено в разделе 18.5.4, а размещение локальных переменных в памяти было проиллюстрировано в разделе 8.5.8. Дело в том, что после возврата управления из функции
    load_disaster_menu()
    эти локальные объекты были уничтожены, и меню disasters ссылается на несуществующие (уничтоженные) объекты. Результат неожиданный и неприятный. Устранить эту ошибку можно, используя неименованные объекты, созданные оператором new, а не именованные локальные объекты.


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

    void load_disaster_menu(Menu& m)

    {

     Point orig(0,0);

     m.attach(new Button(orig,0,0,"flood",cb_flood));

     m.attach(new Button(orig,0,0,"fire",cb_fire));

     // ...

    }


    Правильное решение даже проще, чем ошибочный код (впрочем, очень широко распространенный).


    Задание

    1. Создайте совершенно новый проект, связав его с библиотекой FLTK1. (Установки редактора связей описаны в приложении Г.)

    2. Используя средства, описанные в файле

    Graph_lib
    , выведите какой-нибудь текст в программе из раздела 16.5 и выполните ее.

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

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


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

    1. Зачем нужен графический пользовательский интерфейс?

    2. Когда нужен текстовый интерфейс?

    3. Что такое уровень программного обеспечения?

    4. Зачем нужны уровни программного обеспечения?

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

    6. Что такое обратный вызов?

    7. Что такое виджет?

    8. Как еще называют виджет?

    9. Что означает аббревиатура FLTK?

    10. Как читается аббревиатура FLTK?

    11. О каких еще инструментах графического пользовательского интерфейса вы знаете?

    12. Какие системы используют термин виджет, а какие — элемент управления окном?

    13. Приведите примеры виджетов.

    14. Когда используются окна редактирования для ввода?

    15. Какие типы данных могут храниться в окнах редактирования для ввода?

    16. Когда используется кнопка?

    17. Когда используется меню?

    18. Что такое инверсия управления?

    19. Опишите основную стратегию отладки программ с графическим пользовательским интерфейсом.

    20. Почему отладка программ с графическим пользовательским интерфейсом труднее, чем отладка обычной программы с потоками ввода-вывода?


    Термины


    Упражнения

    1. Создайте класс

    My_window
    , похожий на класс
    Simple_window
    , за исключением того, что он имеет две кнопки: Next и Quit.

    2. Создайте окно (на основе класса

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

    3. Разместите объект класса

    Image
    поверх объекта класса
    Button
    ; после щелчка на кнопке переместите оба объекта. Для выбора нового местоположения кнопки с изображением используйте следующий генератор случайных чисел:


    int rint(int low, int high)

    { return low+rand()%(high–low); }


    Эта функция возвращает случайное целое число в диапазоне

    [low, high]
    .

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

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

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

    clock()
    ,
    sleep()
    .

    7. Использование приемов из предыдущего упражнения позволяет создать иллюзию полета самолета по экрану. Создайте кнопки Start и Stop.

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

    9. Модифицируйте калькулятор из главы 7 так, чтобы выражение вводилось в окне редактирование, а результат возвращался в окне вывода.

    10. Разработайте программу, в которой можно выбрать одну из нескольких функций (например,

    sin()
    и
    log()
    ), введите параметры этих функций и постройте ее график.


    Послесловие

    Графический пользовательский интерфейс — неисчерпаемая тема. Большая часть этой темы касается стиля и совместимости с существующими системами. Более того, много сложностей возникает при работе с чрезвычайно разнообразными элементами управления окном (например, библиотека графического пользовательского интерфейса предлагает многие десятки альтернативных стилей кнопок), — раздолье для “ботаников”. Однако лишь немногие вопросы из этой области относятся к фундаментальным методам программирования, поэтому мы не будем углубляться в этом направлении. Другие темы, такие как масштабирование, вращение, анимация, трехмерные объекты и так далее, требуют изложения сложных фактов, связанных с графикой и/или математикой, которые мы затрагивать здесь не хотим.

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

    Загрузка...