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

Глава 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