Книга: С++ — это просто. — 3-е изд.: Пер. с англ.
Назад: Деструкторы
Дальше: Взаимодействие с файловой системой

}

Результат работы программы:

В теле speed класса Vehicle

В теле maintenance класса Vehicle

В теле value класса Vehicle

В теле speed класса FourWheeler

В теле maintenance класса FourWheeler

В теле speed класса TwoWheeler

В теле maintenance класса TwoWheeler

В теле speed класса AirBorne

В теле maintenance класса Vehicle

В теле value класса Vehicle

В теле value класса Vehicle

В теле speed класса Vehicle

В теле speed класса FourWheeler

В теле maintenance класса Vehicle

Как мы уже знаем, координирующая таблица VTABLE создается для каждого

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

означает, что в нашей программе будет построена VTABLE для всех четырех классов: Vehicle, TwoWheeler, FourWheeler и AirBorne. Каждая из этих таблиц будет

содержать адреса виртуальных функций. Ко всем объектам, реализованным из этих

классов, компилятор автоматически добавит VPTR, адресующий VTABLE соответствующего класса. Это продемонстрировано на рис. 7.2.

Заметьте, что класс AirBorne не содержит определения функции maintenance( ).

Поэтому его VTABLE содержит адрес функции maintenance( ) базового класса.

Полиморфизм

175

Рис. 7.2. VPTR-указатели и координирующие таблицы для иерархии классов

А теперь давайте разберем шаг за шагом работу программы. Для начала мы занесли

адрес объекта базового класса в указатель на базовый класс, используя команды: Vehicle *ptr1 ;

Vehicle v ;

ptr1 = &v ;

Затем мы вызвали функции-члены базового класса:

ptr1 -> speed( ) ;

ptr1 -> maintenance( ) ;

ptr1 -> value( ) ;

Хотя по форме записи эти вызовы кажутся аналогичными, внутренне они устроены

по-разному. Причина в том, что первые две функции были объявлены виртуальными в базовом классе, а третья нет. Раз функция value( ) не определена как virtual, то

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

Поскольку функция speed( ) была определена с ключевым словом virtual в базовом

классе, выбор конкретной ее реализации (для Vehicle, TwoWheeler, FourWheeler либо AirBorne) зависит от адреса, занесенного в указатель ptr1. В нашем случае им

оказывается адрес объекта Vehicle. Так, при обращении к методу speed( )VPTR сначала адресует объект v класса Vehicle. При помощи этого указателя осуществляется

доступ к VTABLE класса Vehicle. Из этой координирующей таблицы извлекается

адрес функции speed( ), принадлежащей классу Vehicle. И наконец, при помощи

этого адреса вызывается сама функция Vehicle::speed( ). Точь-в-точь по такой же

схеме осуществляется вызов функции maintenance( ). Поскольку получение vptr и

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

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

176

Глава 7

Следующий случай еще любопытнее. Взгляните на следующий фрагмент кода: Vehicle *ptr2 ;

FourWheeler maruti ;

ptr2 = &maruti ;

ptr2 -> speed( ) ;

ptr2 -> maintenance( ) ;

Здесь мы присвоили адрес объекта производного класса указателю на объект базового класса. Теперь, когда мы вызываем функцию speed( ), для доступа к координирующей таблице класса FourWheeler будет задействован VPTR-указатель

объекта maruti. Из этой таблицы будет извлекаться адрес функции FourWheeler:: speed( ).

Через этот адрес будет вызываться функция FourWheeler::speed( ).

Рассуждая в том же ключе, вы сможете разобраться с остальными вызовами, про-изведенными при помощи указателей ptr2, ptr3 и ptr4.

Теперь рассмотрим вызовы:

ptr2 -> value( ) ;

ptr3 -> value( ) ;

Поскольку функция value( ) была объявлена без спецификатора virtual в базовом

классе, координирующие таблицы не участвуют в генерировании вызова

Vehicle::value( ). В нашей программе мы не переопределили value( ) в производном

классе. Однако даже если бы мы это сделали, все равно вызывалась бы реализация

базового класса.

Небезынтересны следующие три обращения:

Vehicle w ;

w.speed( ) ;

FourWheeler f ;

f.speed( ) ;

AirBorne a ;

a.maintenance( ) ;

В них VPTR-указатели и координирующие таблицы вообще не участвуют. При вызове метода f.speed( ) не возникает неоднозначности. Поскольку у компилятора

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

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

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

его тип.

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

В силу той исключительно важной роли, которую играет указатель на таблицу

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

всегда указывал на правильную координирующую таблицу. Вызов виртуальной

Полиморфизм

177

функции не должен выполняться до корректной инициализации VPTR-ука-

зателя. И потому лучше всего осуществлять эту инициализацию в конструкторе.

Вот почему компилятор всегда добавляет в наш класс конструктор с нулевым

аргументом, когда мы не задаем его сами — чтобы при необходимости инициализировать VPTR-указатель.

Как компилятору удается получить значение VPTR из объекта? Все объекты

хранят свой указатель в одной и той же области (часто в самом начале), поэтому

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

Как только функция объявлена виртуальной в базовом классе, она считается

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

virtual в производном классе.

Для чего применять виртуальные функции?

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

так хорош и они безошибочно вызывают «правильный» метод, то к чему другие

варианты? Зачем нам вообще в это вникать?

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

ассемблера, чем простая команда CALL. При этом увеличивается как объем кода, так и время его выполнения. Некоторые объектно-ориентированные языки, такие

как Java, задействуют динамическое связывание для всех вызовов функций. Там

ему нет альтернативы, и, следовательно, программисту не нужно об этом задумы-ваться. Однако C++ является наследником C, где производительность имеет

решающее значение. C++ был призван повысить продуктивность программистов

на C. Если бы все обращения к методам в C++ реализовывались при помощи позднего связывания, то от этого серьезно пострадали бы производительность и эффек-тивность программного продукта.

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

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

Срезание объекта

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

Однако виртуальные функции всегда следует вызывать при помощи указателя или

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

178

Глава 7

ется срезанием объекта (object slicing) или нарезкой объекта. Следующая небольшая программа поможет нам разобраться:

#include <iostream>

using namespace std ;

class base

{

private :

int i ;

public :

base ( int ii )

{

i = ii ;

}

virtual void fun1( )

{

cout << endl << i << endl ;

}

} ;

class derived : public base

{

private :

int j ;

public :

derived ( int ii, int jj ) : base ( ii )

{

j = jj ;

}

void fun1( )

{

base::fun1( ) ;

cout << endl << j << endl ;

}

} ;

int main( )

{

base b ( 10 ) ;

derived d ( 15, 20 ) ;

base *ptr1 = &b ;

ptr1->fun1( ) ;

base *ptr2 = &d ;

ptr2->fun1( ) ;

base &ref1 = b ;

ref1.fun1( ) ;

base &ref2 = d ;

ref2.fun1( ) ;

Полиморфизм

179

b = d ; // срезанный объект

b.fun1( ) ;

return 0 ;

}

Уверен, вы самостоятельно разобрались с вызовами fun1( ), осуществленными при

помощи указателей и ссылок. Проблема возникает в последнем присваивании. При

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

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

при обращении к b.fun1( ) вызывается только функция-член базового класса.

Срезание объекта фактически удаляет часть объекта, а не просто меняет значение

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

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

в базовый класс: при попытке создать объект базового класса это вызовет ошибку

на этапе компиляции.

Виртуальные деструкторы

Так же как работа конструктора состоит в создании объекта по частям, работа деструктора состоит в его разрушении по частям. При создании и разрушении объектов

должны вызываться все конструкторы и деструкторы в иерархии классов. Разница

лишь в порядке их вызова. Создание объекта начинается с вызова конструктора

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

меняется на обратный. То есть сначала вызывается деструктор самого последнего

по происхождению класса и далее вверх, вплоть до базового класса. Это не только

логично, но и безопасно.

Конструктор производного класса может получить доступ к любому открытому и

защищенному члену базового класса. Следовательно, к моменту доступа к ним они

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

Точно так же при разрушении объекта текущему деструктору всегда известно, что

члены базового класса живы и активны. Не должно случиться так, чтобы члены

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

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

произведено из него.

180

Глава 7

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

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

функции базового класса в обычных методах (со спецификатором virtual или

без) — это эксплицитно вызвать данную конкретную функцию.

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

ключевого слова new. Адрес этого объекта следует занести в указатель на объект

базового класса. Теперь, если мы удалим этот указатель посредством оператора

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

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

Посмотрим на практике:

#include <iostream>

using namespace std ;

class Base

{

public :

Base( )

{

cout << "В конструкторе класса Base" << endl ;

}

virtual ~Base( )

{

cout << "В деструкторе класса Base" << endl ;

}

} ;

class Derived : public Base

{

public :

Derived( )

{

cout << "В конструкторе класса Derived" << endl ;

}

~Derived( )

{

cout << "В деструкторе класса Derived" << endl ;

}

} ;

int main( )

{

Base *b ;

b = new Derived ;delete b ;

return 0 ;

}

Полиморфизм

181

Программа выводит на экран:

В конструкторе класса Base

В конструкторе класса Derived

В деструкторе класса Derived

В деструкторе класса Base

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

известен его тип (тогда как на момент его создания еще нет). Как только объект

реализован, инициализируется его VPTR-указатель, и тогда могут выполняться

вызовы виртуальных функций.

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

виртуальный деструктор. Однако мы должны предоставить чистому виртуальному

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

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

часть процедуры разрушения объекта.

Возьмите за правило — каждый раз, когда вы объявляете виртуальную функцию

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

Вызов виртуальных функций

из конструкторов/деструкторов

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

версия функции-члена; средства виртуализации игнорируются. В следующем листинге мы найдем тому подтверждение.

Виртуальные базовые классы

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

Предположим, имеется один родительский класс с именем Base и два производных

от него класса, Derived1 и Derived2. Мы создаем класс Derived3, наследующий

Derived1 и Derived2. Допустим теперь, что функция-член класса Derived3 хочет

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

Derived1 и Derived2 происходят от Base, каждый наследует его копию. Эта копия

называется подобъектом (subobject). Каждый подобъект содержит копию данных

класса Base. Это проиллюстрировано на рис. 7.3.

182

Глава 7

Рис. 7.3. Множественные подобъекты при множественном наследовании

Теперь, когда Derived3 ссылается на данные класса Base, к какой из двух копий

этих данных он будет обращаться? Это неоднозначная ситуация для компилятора, поэтому он сообщает об ошибке. Чтобы избежать такой неоднозначности, мы должны объявить Derived1 и Derived2 виртуальными базовыми классами, как

показано в следующей программе.

#include <iostream>

using namespace std ;

class Base

{

protected :

int data ;

public :

Base( )

{

data = 500 ;

}

} ;

class Derived1 : virtual public Base

{

} ;

class Derived2 : virtual public Base

{

} ;

class Derived3 : public Derived1, public Derived2

{

public :

int getData( )

Полиморфизм

183

{

return data ;

}

} ;

int main( )

{

Derived3 ch ;

int a ;

a = ch.getData( ) ;

cout << a << endl ;return 0 ;

}

Применение ключевого слова virtual в двух базовых классах приводит к тому, что

они совместно используют один подобъект класса Base. А поскольку имеется лишь

один подобъект, то и ссылка на него в классе Derived3 не порождает логического

противоречия. В этом примере классы Derived1 и Derived2 выступают в роли виртуальных базовых классов.

Допустим, в классе Base есть функция fun( ), и мы обращаемся к ней, используя

объект Derived3. В таком случае компилятор сообщает об ошибке, т. к. Derived3

наследует две копии fun( ), одну от Derived1, а другую от Derived2. Следовательно, когда мы пытаемся вызвать fun( ), компилятору не будет известно, намерены ли

мы вызвать копию Derived1 или копию Derived2. Опять же, ошибки можно избежать, объявив Derived1 и Derived2 виртуальными базовыми классами.

Иерархия наследования, используемая в этой программе, часто называется ромбо-видной структурой (см. рис. 7.3).

1. Укажите, верны ли следующие утверждения:

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

указателю базового класса.

На каждую координирующую таблицу (VTABLE) и на каждый объект прихо-

дится один VPTR-указатель.

VPTR-указатель всегда адресует координирующую таблицу класса.

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

единый вызов функции.

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

Невозможно создать объект из класса, содержащего чистую виртуальную функцию.

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

базовым классом.

184

Глава 7

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

функций.

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

Виртуальные функции можно смело вызывать посредством объектов.

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

они через указатели или ссылки.

При создании объекта не имеет значения, что вызывается в первую очередь —

конструктор базового или производного класса.

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

класса, а затем деструктор базового класса.

Виртуальный деструктор обеспечивает корректный порядок вызова деструкторов в иерархии классов.

Чистый виртуальный деструктор может иметь тело функции.

2. Решите следующие задачи и ответьте на вопросы:

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

Разработайте программу, содержащую класс Derived, производный от класса

Base. Класс Base должен содержать виртуальную функцию fun( ), и она должна

быть переопределена в классе Derived. Попытайтесь вызвать fun( ) из конструктора класса Derived и проанализируйте полученный результат.

Полиморфизм имеет два значения:

• одно явление существует в нескольких формах;

• одно действие ведет к совершению различных операций.

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

Связывание означает решение, какую функцию вызывать.

Если связывание осуществляется во время компиляции, оно называется ранним

связыванием.

Если связывание осуществляется во время выполнения программы, оно называется поздним связыванием.

C++ выполняет раннее связывание, когда это возможно, а также позднее связывание, когда раннее связывание невозможно.

Раннее связывание также известно как статическое связывание или связывание

во время компиляции.

Полиморфизм

185

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

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

Если вызов осуществляется для объекта, то он всегда подлежит раннему связыванию.

Указатель с приведением вверх — это указатель базового класса, содержащий

адрес объекта производного класса.

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

Чтобы запретить создание объекта из класса, он должен содержать хотя бы одну

чистую виртуальную функцию.

Класс, из которого нельзя создать объект, называется абстрактным классом.

Если класс содержит виртуальную функцию, для нее создается координирующая таблица (VTABLE). Все объекты этого класса будут содержать VPTR-указатель.

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

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

класса рассматривается как виртуальная, даже если она эксплицитно не объявлена как виртуальная.

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

класс не содержит функции с таким же прототипом, то VTABLE производного

класса содержит адрес виртуальной функции базового класса.

Виртуальный деструктор обеспечивает надлежащий порядок вызова деструкторов в иерархии классов.

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

Система ввода-вывода в C++

Нет особого смысла писать программу, которая занята лишь

тем, что общается сама с собой. Практически все программы

вынуждены в той или иной форме выполнять операции

ввода-вывода данных. В этой главе мы исследуем

этот важный аспект программирования на C++

188

Глава 8

Требования к системе ввода-вывода

Решение с использованием потоков в C++

Предопределенные потоковые объекты

Библиотека iostream

Класс istream

Класс ostream

• Вывод символов в кодировке Unicode

Класс iostream

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

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

• Пользовательские манипуляторы с аргументами

Работа с потоками ввода-вывода в файл

Символьный ввод-вывод

• Открытие файла

• Чтение данных

• Обнаружение конца файла (EOF)

• Закрытие файла

Программа копирования файлов

Ввод-вывод строк

Ввод-вывод записей

Прямой доступ

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

Строковые потоки

• Работа с istrstream

Ввод-вывод объектов

Сериализация

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

Взаимодействие с файловой системой

Упражнения

Важное

Система ввода-вывода в C++

189

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

И когда появился C++ на основе ООП, программисты ожидали полноцен-

Я ную систему ввода-вывода, которая была бы простой, ясной, надежной

и адаптируемой. Это мы уже видели при использовании cout и cin:

Для применения cout и cin нам не требуется запоминать спецификаторы формата, такие как %c, %d, %f и т. п., которые обязательны в функциях scanf( ) и printf( ) в языке C. Это просто и удобно.

cout и cin адаптируемы, поскольку могут стать входными или выходными

объектами для пользовательских типов данных.

Если операция ввода-вывода не была определена для какого-либо типа данных, компилятор выдаст ошибку при попытке использовать cout и cin с этим типом.

Однако cout и cin составляют лишь малую толику тех средств, что предлагает программистам система ввода-вывода в C++. В этой главе предлагается изучить эти

средства с целью эффективной реализации потребностей программы в инструмен-тах ввода/вывода.

Требования к системе ввода-вывода

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

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

диск или иное внешнее устройство.

Возможность ввода-вывода различных объектов — программа должна иметь

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

Многообразие средств связи — ввод-вывод должен осуществляться в различных

режимах, последовательный и прямой доступ и т. п.

Связь с файловой системой — под этим мы понимаем возможность взаимодей-ствовать с объектами файловой системы, такими как файлы и каталоги, а также

иметь возможность получать доступ и управлять путями, установкой дат, пра-вами доступа и т. д.

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

ввода-вывода.

Посмотрим, насколько C++ удовлетворяет этим требованиям.

Библиотека C для выполнения ввода-вывода основана на функциях, а библиотека

C++ — на классах. Оба языка не имеют ключевых слов для выполнения ввода-вывода. Библиотека C для выполнения ввода-вывода известна как библиотека stdio, а библиотека C++ — как библиотека iostream.

190

Глава 8

Решение с использованием потоков в C++

Для соответствия требованиям к полноценной системе ввода-вывода разработчик

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

из потока или записывать данные в поток. Потоки связываются с физическими устройствами при помощи C++-системы ввода-вывода. Большинство составляющих

элементов такого сообщения скрыты от нас системой ввода-вывода, дабы мы смог-ли сосредоточиться исключительно на том, что и откуда мы хотим считать и что и

куда хотим записать. Рис. 8.1 поможет вам визуально представить эту концепцию.

Рис. 8.1. Потоки ввода-вывода

Потоки реализованы при помощи классов в библиотеке iostream. Эта абстракция

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

Рис. 8.2. Потоковый вывод на различные устройства

Система ввода-вывода в C++

191

Чтение и запись — две основные операции, которые можно выполнять с потоком.

Чтение включает передачу данных из потока в структуру данных, такую как массив

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

Предопределенные потоковые объекты

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

стандартный выходной поток, а также стандартные потоки вывода информации об

ошибках передаются из программы на экран.

В C++ поток представлен объектом определенного класса. Так, для вышеупомянутых потоков библиотека iostream предоставляет предопределенные объекты cin, cout, cerr и clog. Из них cin — это объект класса istream, а cout, cerr и clog — объекты класса ostream. Все они применяются для выполнения текстового ввода-вывода для символов ASCII (размером 1 байт). Для символов Unicode (занимающих

2 байта) используются их аналоги wcin, wcout, wcerr и wclog. Они являются объектами классов wistream и wostream соответственно.

Вы, должно быть, заметили, что объекты cout, cerr и clog представляют собой поток данных из программы на экран. В чем же между ними разница?

Дело в том, что cout предназначен для вывода на экран, cerr — для отправки ошибок на стандартное устройство вывода информации об ошибках, а clog — для передачи записей в журнала событий (логов). Под двумя последними устройствами

также может пониматься экран.

Заметьте, что вывод при помощи clog буферизуется, а при помощи cerr — нет.

Это означает, что данные, отправленные в поток cerr, выводятся немедленно — что

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

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

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

Библиотека iostream

Библиотека iostream предоставляет множество шаблонных классов для обработки

общих операций ввода-вывода. Шаблонные классы — это универсальные классы, из которых можно создавать классы для определенных типов данных. Иерархия

классов в библиотеке iostream частично проиллюстрирована на рис. 8.3.

Как видно из рис. 8.3, класс спецификации ввода-вывода (basic_ios) расположен

в корне иерархии классов iostream. Этот класс содержит специфичные для всех

потоков особенности. Сюда входят флаги форматирования данных потока, флаги

192

Глава 8

статуса проверки ошибок и режимы файловых операций. Вскоре мы подробно их

рассмотрим.

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

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

basic_streambuf.

Рис. 8.3. Иерархия классов ввода-вывода

Класс basic_istream является шаблонным. Из этого универсального класса создаются специализированные классы istream и wistream для выполнения операций

потокового ввода с 8-битовыми и 16-битовыми символами соответственно. По аналогии шаблонный класс basic_ostream и специализированные классы ostream и wostream поддерживают операции потокового вывода.

И точно так же basic_iostream, iostream и wiostream поддерживают операции потокового ввода и вывода.

Класс istream

Класс istream выведен из класса ios. Он осуществляет операции, специфичные для

ввода. Одной из наиболее востребованных компонентных функций этого класса

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

char str[ 100 ] ;

cin >> str ;

Направление оператора >> дает представление об исходном и целевом устройствах.

Здесь символы извлекаются из объекта входного потока cin и помещаются в строку

str. Однако в таком случае мы не можем контролировать количество знаков, которые будут извлечены в строку. Всегда существует опасность переполнения строки.

Кроме того, если мы указываем строку из нескольких слов, cin извлекает из нее

Система ввода-вывода в C++

193

в строку str лишь первое слово. В подобных случаях мы можем воспользоваться

функцией-членом get( ) или getline( ) класса istream. Функция get( ) представлена

в нескольких формах, приведенных в табл. 8.1.

Таблица 8.1. Функции ввода в классе istream

Функция Назначение

get ( ch )

Извлекает один символ в ch

get ( str, MAX )

Извлекает до MAX числа знаков в str

get ( str, DELIM )

Извлекает символы в массив str до указанного

символа-разделителя (обычно ′\n′).

Оставляет разделитель в потоке

get ( str, MAX, DELIM )

Извлекает символы в str до MAX числа знаков либо до символа-

разделителя DELIM

Оставляет разделитель в потоке

getline ( str, MAX, DELIM )

Извлекает символы в массив str до MAX числа знаков либо

до символа-разделителя DELIM. Извлекает символ-разделитель

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

Они приведены в табл. 8.2.

Таблица 8.2. Различные функции класса istream

Функция Назначение

putback ( ch )

Вставляет последний считанный символ обратно во входной

поток

peek ( ch )

Считывает один символ, оставляет его в потоке

num = gcount( )

Возвращает число символов, считанных (непосредственно

предшествующим) вызовом get( ), getline( ) или read( )

ignore ( MAX, DELIM )

Извлекает и отбрасывает до MAX числа знаков до указанного

разделителя (включительно) (обычно ′\n′)

Следующая короткая программа демонстрирует работу этих функций:

#include <iostream>

using namespace std ;

int main( )

{

char ch ;

cout << endl << "Введите символ: " ;

cin.get ( ch ) ;

cout << ch ;

cin.putback ( ch ) ;

194

Глава 8

cin.get ( ch ) ;

cout << endl << ch ;

int count = cin.gcount( ) ;

cout << endl << "Символы, извлеченные при последнем вызове

get( ) = " << count ;

// вставить в поток символ Z

cin.putback ( 'Z' ) ;

ch = cin.peek( ) ;

cout << endl << ch ;

// Z всё еще в потоке

cin.get ( ch ) ;

cout << endl << ch << endl ;

return 0 ;

}

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

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

Класс ostream

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

вывода <<. Две другие широко востребованные функции-члены этого класса —

put( ) и flush( ). Первый помещает символ в поток, а второй сбрасывает содержимое буфера и вставляет символ новой строки.

Помимо стандартных типов данных, при помощи оператора << на консоль также

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

#include <iostream>

using namespace std ;

int main( )

{

int ch = 90 ;

cout << char ( 65 ) << endl ;

cout << char ( ch ) << endl ;

char str[ ] = "Молчи. Пусть говорит производительность!" ; char *p = "Будь красноречив. Вырази себя!" ;

cout << str << endl ;

cout << p << endl ;

cout << static_cast < void * > ( str ) << endl ;

cout << static_cast < void * > ( p ) << endl ;

return 0 ;

}

Система ввода-вывода в C++

195

Программа выводит на экран следующее:

A Z

Молчи. Пусть говорит производительность! Будь красноречив. Вырази себя!

0032FBEC

000C7830

Обратите внимание, что для отображения символов, эквивалентных 65 и 90, мы

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

str или адреса, хранящегося в указателе p, необходимо преобразовать char *

в void *, используя синтаксис статического приведения. Этот вид приведения типов

обсуждается в главе 9.

Вывод символов в кодировке Unicode

Давайте рассмотрим еще одну интересную программу. Допустим, нам необходимо

вывести символы кириллицы или деванагари. Для этого нам нужно сначала получить значения кодировки Unicode для символов в этих системах письма. Эти значения доступны по адресу https://jrgraphix.net/r/Unicode. Значения Unicode для

кириллических символов Б, Д, Ж, Й, Л — шестнадцатеричные 0411, 0414, 0416, 0419 и 041B. Символам अ, आ, इ, ई и उ соответствуют шестнадцатеричные значения, 0906, 0907, 0908 и 0909. Приведенная ниже программа показывает, как использовать эти значения для вывода на экран символов на кириллице и хинди:

#include <iostream>

#include <io.h>

#include <fcntl.h> using namespace std ;

int main( )

{

_setmode ( _fileno ( stdout ), _O_U16TEXT ) ;

wcout << "Вывод символов на кириллице:" << endl ; wcout << L"\x0411\x0414\x0416\x0419\x041B" << endl ; wcout << "Вывод символов на деванагари:" << endl ; wcout << L"\x0905\x0906\x0907\x0908\x0909" << endl ; return 0 ;

}

При выполнении программы на экран выводится:

Вывод символов на кириллице:

БДЖЙЛ

Вывод символов на деванагари:

अआइईउ

В этой программе следует обратить внимание на ряд моментов:

Чтобы указать, что мы отправляем на экран 16-битные символы в кодировке

Unicode, следует вызвать функцию _setmode( ). Прототип этой функции объявлен в файле ′fcntl.h′.

196

Глава 8

Макрос _O_U16TEXT определен в файле ′io.h′.

Чтобы указать, что строка закодирована в Unicode, требуется добавить к ней

букву «L»; \x перед каждым значением Unicode указывает на то, что это шестна-дцатеричное значение.

Поскольку мы выводим на экран 16-битные значения, необходимо использовать

wcout вместо cout.

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

Когда те же значения Unicode использовались для вывода символов в приложении

Windows, я получил следующий результат:

Класс iostream

Класс iostream выведен из istream и ostream путем множественного наследования.

Он выступает лишь в роли базового класса, из которого могут быть произведены

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

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

C++ располагает различными манипуляторами потоков, выполняющими задачи

форматирования. Манипуляторы потоков предоставляют такие возможности, как

установка ширины поля, настройка разрядности, сброс потоков, вставка новой

строки в выходной поток, пропуск пробельных символов во входном потоке и т. п.

Манипуляторы бывают двух видов — с аргументами и без них. Манипуляторы без

аргументов собраны в ′iostream′, а параметризированные манипуляторы

в ′iomanip′.

В табл. 8.3 приведен список всех манипуляторов с указанием их назначения.

Система ввода-вывода в C++

197

Таблица 8.3. Различные манипуляторы потока

Манипулятор Назначение

skipws / noskipws

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

dec / oct / hex

Преобразовать в десятичный, восьмеричный, шестнадцатерич-

ный формат

left / right

Выровнять по левому/правому краю, заполнить символами спра-

ва/слева

internal

Использовать заполнение символами между знаковым или базо-

вым показателем и значением

endl

Вставить новую строку и сбросить выходной поток

showpos / noshowspos

Показать/скрыть знак плюса для положительных значений

uppercase / nouppercase

Показывать/скрыть прописные буквы A–F для шестнадцатерич-

ных значений и E для научных значений

showpoint / noshowpoint

Показывать/скрыть десятичную точку и нулевые младшие разря-

ды для значений с плавающей точкой

scientific / fixed

Использовать научную запись для вывода значений с плавающей

точкой или представление с фиксированной точкой

ends

Вставить псевдосимвол для завершения строки

flush Сбросить

выходной

поток

lock / unlock

Установить/снять блокировку дескриптора файла

setw ( int n )

Изменить ширину поля для вывода на n

setfill ( char n )

Изменить символ-заполнитель на n (по умолчанию пробел)

setprecision ( int n )

Установить разрядность до n знаков после точки

setbase ( base n )

Изменить основание системы счисления на n, где n равно 8, 10

или 16

setiosflags ( fmtflags n )

Установить флаги формата, обозначенные n. Настройка

действует до следующего изменения

resetiosflags ( fmtflags n)

Очистить только флаги формата, обозначенные n. Настройка

действует до следующего изменения

Следующая программа демонстрирует применение этих манипуляторов:

#include <iostream>

#include <iomanip>

using namespace std ;

int main( )

{

int i = 752 ;

float a = 425 ;

float b = 123.500328f ;

char str[ ] = "Мечта. Воплоти же ее!" ;

198

Глава 8

ios_base::fmtflags oldFlags ;

oldFlags = cout.flags( ) ;

cout << "Текущие флаги = " << cout.flags( ) << endl ; cout << hex << i << endl ;

cout << showbase << hex << i << endl ;

cout << uppercase << showbase << hex << i << endl ; cout << dec << i << endl ;

cout << internal << showpos << setw ( 10 ) << i << endl ; cout << i << endl ;

cout << setfill ( '0' ) ;

cout << "Символ-заполнитель:" << cout.fill( ) << endl ; cout << setw ( 40 ) << str << endl ;

cout << left << setw ( 40 ) << str << endl ; cout.precision ( 6 ) ;

cout << "Разрядность: " << cout.precision( ) << endl ; cout << showpoint << showpos << a << endl ; cout << fixed << b << endl ;

cout << scientific << b << endl ;

cout << "Текущие флаги = " << cout.flags( ) << endl ; cout.flags ( oldFlags ) ;

cout << "Текущие флаги = " << cout.flags( ) << endl ; return 0 ;

}

Вот результат работы программы:

Текущие флаги = 513 2f0

0x2f0 0X2F0

752

+752

+ 752

Символ-заполнитель:0

0000000000000Мечта. Воплоти же ее!

Мечта. Воплоти же ее!0000000000000

Разрядность:+6

+425.000

+123.500328

+1.235003E+002

Текущие флаги = +4733

Текущие флаги = 513

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

манипуляторов потока из табл. 8.3.

Система ввода-вывода в C++

199

Обратите внимание на действие таких манипуляторов, как hex, showbase, left, showpos и др. После применения они остаются в силе и для последующих приме-нений cout. Рекомендуется сохранить настройки флага по умолчанию, прежде чем

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

значение одну за другой.

Функция-член flags( ) класса ios_base возвращает текущие настройки формата как

тип данных fmtflags. Мы занесли эти настройки в переменную oldFlags. Совершив

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

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

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

Давайте посмотрим, как создавать оба типа манипуляторов — с аргументами и без

них. И начнем со вторых.

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

Манипулятор endl — это функция, объявление которой в iostream выглядит следующим образом:

ostream& endl ( ostream& ) ;

Теперь рассмотрим строку

cout << endl ;

Поскольку << является перегруженным оператором, внутри он преобразуется в

cout.operator << ( endl ) ;

Поскольку endl( ) является функцией, ее адрес передается функции перегруженного оператора. Оператор << определен в ′iostream′ следующим образом: ostream & ostream::operator << ( ostream & ( *_f ) (ostream & ) )

{

return ( *_f )( *this ) ;

}

Это означает, что когда мы передаем адрес endl( ) этой функции, она заносит его

в указатель на функцию, которая получает ссылку на ostream и возвращает ссылку

на ostream. Если внимательно присмотреться, то можно заметить, что это соответствует прототипу функции endl( ). Поскольку функция оператора << вызывается

для объекта cout, this-указатель содержит его адрес. Следовательно, *this создает

объект. Затем он передается в функцию endl( ) при помощи следующей команды: ( *_f )( *this ) ;

При вызове функция endl( ) лишь выводит ′\n′ в выходной поток.

200

Глава 8

Все достаточно просто! Теперь мы можем приступить к разработке собственного

манипулятора под названием tab. Вот он:

#include <iostream>

using namespace std ;

ostream & tab ( ostream &o )

{

return o << '\t' ;

}

int main( )

{

cout << "Не" ;

cout << tab << tab ;

cout << "паникуй" << endl ;

return 0 ;

}

Эта программа работает по тому же принципу, что и предыдущая. Единственное

отличие состоит в том, что вместо вызова функции endl( ) здесь вызывается функция tab( ). Проследите за ее выполнением в отладчике. Это поможет лучше понять

работу программы.

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

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

unsigned long в качестве аргумента и выводит его эквивалент в римской нотации.

Для этого нам необходимо определить класс с именем roman. Он должен состоять

из конструктора и функции перегрузки оператора <<. Эта функция реализуется как

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

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

программы, в которой определяется и реализуется класс roman:

#include <iostream>

using namespace std ;

class roman

{

private :

unsigned long num ;

public :

roman ( unsigned long n ) ;

friend ostream & operator << ( ostream& o, roman& r) ;

} ;

roman :: roman ( unsigned long n )

{

num = n ;

}

Система ввода-вывода в C++

201

ostream & operator << ( ostream& o, roman& r)

{

struct key

{

char ch ;

int val ;

} ;

key z[ ] = {

{ 'm', 1000 }, { 'd', 500 }, { 'c', 100 }, { 'l', 50 },

{ 'x', 10 }, { 'v', 5 }, { 'i', 1 }

} ;

int sz, k ;

sz = sizeof ( z ) / sizeof ( z[ 0 ] ) ;

for ( int i = 0 ;

i < sz ;i++ )

{

k = r.num / z[ i ].val ;

for ( int j = 1 ;j <= k ;j++ )

o << z[ i ].ch ;

r.num = r.num % z[ i ].val ;

}

return o ;

}

int main( )

{

long yr = 2019 ;

cout << roman ( yr ) ;

cout << endl << roman ( 1752 ) << endl ;

return 0 ;

}

Программа выводит на экран:

mmxviiii mdcclii

Обратите внимание на следующую инструкцию:

cout << roman ( yr ) ;

В ней roman ( yr ) создает временный объект класса roman. Разумеется, при его

создании вызывается конструктор, и переданное ему значение присваивается закрытой переменной num. Затем этот временный объект и объект cout передаются

перегруженной функции operator << ( ). Заметьте, что при использовании дружественной функции вызов операторной функции не принимает вид

cout.operator << ( roman ( yr ) ) ;

202

Глава 8

В операторной функции мы преобразовали значение num в его римский эквивалент

и вывели его посредством ссылки на объект cout. В конце мы вернули ссылку. Возврат ссылки необходим, если мы хотим использовать наш манипулятор в каскад-ном выражении с cout типа:

cout << endl << roman ( 1752 ) << endl ;

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

него класс. Значит ли это, что для каждого манипулятора с одним аргументом из

библиотеки iostream существует отдельный класс? Да. Однако вместо того, чтобы

писать отдельный класс для каждого манипулятора, авторы библиотеки iostream собрали универсальный класс, спрятанный за сложным набором директив #define в файле ′iomanip′. При желании вы можете изучить этот файл и попытаться разобраться в устройстве этого универсального класса.

Работа с потоками ввода-вывода в файл

До сих пор для выполнения консольных операций ввода-вывода мы применяли

объекты istream и ostream (cin и cout). В аналогичном ключе производится и фай-ловый ввод-вывод. Для этого нам необходимо использовать объекты классов

ifstream (для ввода), ofstream (для вывода) и fstream (для ввода и вывода). Как вы, должно быть, уже догадались, эти классы являются специализированными версиями шаблонных классов basic_ifstream (для ввода в файл), basic_ofstream (для вывода в файл) и basic_fstream (для ввода и вывода в файл). Иерархия этих классов

отображена на рис. 8.4.

Рис. 8.4. Иерархия классов файлового ввода-вывода

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

Когда дело доходит до чтения из файла / записи в файл, в основном применяются

следующие три сценария:

Система ввода-вывода в C++

203

Чтение/запись файла по символам.

Чтение/запись файла по строкам.

Чтение/запись файла по записям.

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

Символьный ввод-вывод

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

#include <fstream>

#include <iostream>

using namespace std ;

int main( )

{

char ch ;

ifstream infile ( "Sample.cpp" ) ;

while ( ! infile.eof( ) )

{

infile.get ( ch ) ;

cout << ch ;

}

infile.close( ) ;

return 0 ;

}

Хотя программа и невелика, в ней появляется целый ряд новых моментов. Рассмотрим их по очереди.

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

Для начала мы задали объект с именем infile класса ifstream при помощи команды

ifstream infile ( "Sample.cpp" ) ;

При этом вызывается конструктор с одним аргументом класса ifstream. Этот конструктор выделяет ресурсы и открывает файл ′Sample.cpp′. Однако мы не указали, должен ли файл открываться для чтения или записи. В этом нет необходимости, поскольку конструктор задействует настройки по умолчанию. Прототип конструктора выглядит следующим образом:

ifstream ( const char*, int = ios::in ) ;

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

ios::in, устанавливая тем самым режим открытия файла. Следовательно, файл открыт для чтения.

204

Глава 8

Нередко при создании объекта ifstream имя файла нам еще неизвестно. В таком

случае мы можем сначала создать объект, а затем вызвать функцию ifstream:: open( ) для открытия файла. Эта процедура показана ниже:

ifstream infile ;

infile.open ( "Sample.cpp" ) ;

И снова мы не указали режим открытия файла. Причина в том, что ios::in (ORed) включен по умолчанию в режиме чтения (второй параметр).

Чтение данных

После открытия файла мы воспользовались функцией ifstream::get( ) для посим-вольного чтения файла. Каждый символ, считанный из открытого файла, выводится

на экран при помощи обычного объекта cout.

Классы ifstream, ofstream и fstream объявлены в заголовочном файле ′fstream′.

Поэтому мы эксплицитно подключили его.

Обнаружение конца файла (EOF)

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

Для этого мы воспользовались функцией ifstream::eof( ). Она возвращает значение

«истинно» (true), если достигнут конец файла, во всех остальных случаях она возвращает значение «ложно» (false).

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

while ( infile )

Закрытие файла

Мы закрываем файл после завершения чтения, вызвав функцию ifstream::close( ).

Закрытие файла отключает его от потока.

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

infile выйдет из области видимости. В результате будет вызван деструктор, закры-вающий файл.

Программа копирования файлов

Попробуем теперь составить программу, копирующую содержимое одного файла

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

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

будет продолжаться до тех пор, пока не будет достигнут конец исходного файла.

#include <fstream>

#include <iostream>

#include <string>

Система ввода-вывода в C++

205

using namespace std ;

int main( )

{

char ch ;

string source, target ;

cout << "Введите имя исходного файла: " ;

cin >> source ;

cout << "Введите имя целевого файла: " ;

cin >> target ;

ifstream infile ( source ) ;

ofstream outfile ( target ) ;

while ( infile )

{

infile.get ( ch ) ;

outfile.put ( ch ) ;

}

return 0 ;

}

Здесь символы считываются функцией istream::get( ) и записываются в целевой

файл при помощи ostream::put( ).

Ввод-вывод строк

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

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

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

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

файлов, считывая/записывая файл по строкам.

#include <iostream>

#include <fstream>

#include <string>

using namespace std ;

int main( )

{

206

Глава 8

string source, target ;

char str[ 80 ] ;

cout << "Введите имя исходного файла: " ;

cin >> source ;

cout << "Введите имя целевого файла: " ;

cin >> target ;

ifstream infile ( source ) ;

ofstream outfile ( target ) ;

while ( infile )

{

infile.getline ( str, 79, '\n' ) ;

outfile << str << endl ;

}

infile.close( ) ;

return 0 ;

}

Здесь мы производим чтение текста из файла построчно при помощи функции

getline( ). Эта функция является членом класса istream (из которого выводится

ifstream). Чтение строки завершается, когда считано 79 символов в строке, или

когда встречается символ конца строки ′\n′. Перед возвратом функция getline( ) помещает в файл символ завершения строки (′\0′). Затем содержимое str[ ] записывается в целевой файл посредством команды

outfile << str ;

Этот процесс будет повторяться до тех пор, пока не считаются все строки исходного файла. Заметьте, что функция getline( ) не работает со строковым типом string, поэтому для представления текстовой строки нам пришлось воспользоваться обычным массивом символов типа char.

Ввод-вывод записей

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

Каждая наша запись о сотруднике содержит такие сведения, как имя (name), возраст (age), базовая зарплата (basic salary) и зарплата до вычета налогов (gross salary). Если применить перегруженный оператор << для сохранения таких записей

в файл, нам в конечном итоге потребуется больше места для записи на диске по

сравнению с областью, занимаемой той же записью в памяти.

Произойдет так потому, что каждое число (целого или вещественного типа) будет

записано в виде строки символов, а не двоичных битов. К примеру, 4250.55 будет

записано как 7-байтовая строка, содержащая «4», «2», «5», «0», «.», «5», «5», а не

как 4-байтовое число с плавающей точкой.

Система ввода-вывода в C++

207

Если значительное количество записей о сотрудниках будет храниться в файле, это

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

#include <fstream>

#include <iostream>

#include <string>

using namespace std ;

int main( )

{

struct Employee

{

string name ;

int age ;

float basic, gross ;

} ;

Employee e ;c

har ch = 'Y' ;

// Создаем файл для записи

ofstream outfile ;

outfile.open ( "EMPLOYEE.DAT", ios::out | ios::binary ) ; while ( ch == 'Y' || ch == 'y' )

{

cout << endl << "Введите имя, возраст, базовую зарплату и оклад брутто:"

<< endl ;

cin >> e.name >> e.age >> e.basic >> e.gross ; outfile.write ( reinterpret_cast< const char * > ( &e ),

sizeof ( e ) ) ;

cout << "Добавить запись? (Y/N) " ;

cin >> ch ;

}

outfile.close( ) ;

// Открываем файл для чтения

ifstream infile ;

infile.open ( "EMPLOYEE.DAT", ios::in | ios::binary ) ;

cout << endl ;

while ( infile.read ( reinterpret_cast< char * > ( &e ), sizeof ( e ) ) )

{

cout << e.name << '\t' << e.age << '\t'

<< e.basic << '\t' << e.gross << endl ;

}

208

Глава 8

return 0 ;

}

Приведем несколько примеров взаимодействия с программой:

Введите имя, возраст, базовую зарплату и оклад брутто:

Dinesh 23 3456.55 5644.45

Добавить запись? (Y/N) Y

Введите имя, возраст, базовую зарплату и оклад брутто:

Shirish 34 4455.55 5566.55

Добавить запись? (Y/N) Y

Введите имя, возраст, базовую зарплату и оклад брутто:

Leena 21 3455.55 3655.55

Добавить запись? (Y/N) N

Dinesh 23

3456.55 5644.45

Shirish 34

4455.55 5566.55

Leena 21

3455.55 3655.55

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

ofstream::write( ). После того как все записи сохранены, файл закрывается. Затем

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

записью. Каждая прочитанная запись отображается на экране.

Здесь мы использовали две новые функции — write( ), член класса ofstream, и read( ), член класса ifstream. Эти функции работают с данными как с набором

байтов. Им не важно, как форматируются данные, они просто перемещают буфери-зированные данные из файла на диске и в него. Рассмотрим вызов метода write( ): outfile.write ( reinterpret_cast< const char * > ( &e ), sizeof ( e ) ) ; Здесь мы указываем методу write( ) записать все, начиная с адреса, заданного &e, и заканчивая следующим количеством sizeof (e) байт. Обратите внимание, что адрес, переданный методу write( ), необходимо преобразовать в const char *, т. к.

write( ) ничего не известно об employee *. Для такого преобразования из указателя

одного типа в несвязанный тип указателя язык C++ предоставляет оператор

reinterpret_cast.

Параметры, передаваемые в read( ), аналогичны тем, что передаются в write( ) —

адрес буфера данных и его длина в байтах. Указатель этого типа должен быть преобразован в char *.

Прямой доступ

В предыдущем разделе мы составили программу для сохранения записей в файл

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

Система ввода-вывода в C++

209

файла, а после этого считать все записи или конкретную запись. Получается, у нас

должна быть возможность перемещаться внутри файла для выполнения желаемой

операции. Посмотрим, как библиотека iostream облегчает выполнение этой задачи.

С каждым потоком связаны два длинных целых числа — указатель считывания

(get pointer) и указатель записи (put pointer). Значение в указателе считывания

адресует номер байта в файле, из которого будет считан следующий символ. По

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

следующая запись. Термин «указатель» (pointer) в этом контексте не следует пу-тать с обычными указателями C++. Таким образом, можно управлять процессом

чтения/записи, меняя значения этих указателей при помощи функций-членов.

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

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

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

Функции istream::seekg( ) и ostream::seekp( ) позволяют установить значение указателя считывания и указателя записи соответственно. Для получения текущих по-зиций можно использовать функции istream::tellg( ) и ostream::tellp( ).

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

позиции в файле. Так, команда

file.seekg ( 1000L ) ;

поместит указатель считывания на 1000-й байт в потоке file.

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

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

отрицательные значения. Второй аргумент может быть одним из трех — beg, cur или end. К примеру:

file.seekg ( -25L, ios::cur ) ;

переместит указатель считывания на 25 байтов влево от его текущей (current) позиции. Соответственно beg и end относятся к началу (beginning) и концу (end) файла.

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

файл, занести в него все записи, кроме той, что подлежит удалению, удалить

исходный файл и переименовать новый файл в исходный. И хотя данный способ

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

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

Для повышения эффективности мы можем принять двухэтапную политику —

«Удаление» и «Упаковка». «Удаление» лишь пометит запись для удаления. Запись

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

210

Глава 8

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

предоставить возможность ее восстановить. При этом «звездочку» можно заменить

пробелом.

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

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

режимов открытия файла. Каждый режим задается определенным битом в классе ios.

Мы можем совмещать эти биты при помощи логического оператора OR. В табл. 8.4

представлены различные варианты.

Таблица 8.4. Режимы открытия файла

Бит режима

Результат

in

Открытие файла для чтения (по умолчанию для ifstream)

out

Открытие файла для записи (по умолчанию для ofstream)

ate

Начало чтения или записи с конца файла (AT End)

app

Начало записи с конца файла

trunc

Усечение файла до нулевой длины, если файл существует

norcreate

Ошибка при открытии, если файл еще не создан

noreplace

Ошибка при открытии для записи в существующий файл, если не установлены ate или app

binary

Открытие файла в двоичном (не текстовом) режиме

Если желательно сохранить содержимое файла, следует использовать ios::app.

В таком случае все, что мы запишем, будет добавлено в конец файла.

Если мы хотим осуществлять как ввод, так и вывод файловых данных в двоичном

режиме, можно воспользоваться следующей командой открытия:

fstream file ;

file.open ( filename, ios::in | ios::out | ios::binary ) ;

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

одновременно.

Блиц-вопрос! Можете ли вы предложить комбинацию однобитовых флагов для

открытия файла для чтения и записи? Если файла не существует, он должен быть

создан, а если он существует, то не должен перезаписываться. Вот ответ: fstream file ;

file.open ( filename, ios::in | ios::out | ios::noreplace ) ;

Система ввода-вывода в C++

211

Строковые потоки

До сих пор мы работали с двумя типами потоков ввода-вывода (iostream) — стандартным и файловым. Третий тип iostream работает со строками в памяти и называется strstream. Прелесть потоков ввода-вывода заключается в том, что и с этим

потоком мы можем применять те же самые методы чтения и форматирования.

Имена классов strstreams схожи с именами в файловых потоках. Так, для извлечения символов из strstream создается istrstream, а для их вставки — ostrstream.

Читателям, знакомым с программированием для Windows, наверняка известен ряд

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

строку в MessageBox( ), нужно сначала преобразовать их в строки, соединить эти

строки, а затем передать итоговую строку в MessageBox( ). В C++ мы используем

для этого strstreams, что иллюстрирует следующая короткая программа. При передаче данных в strstream производится их форматирование.

#include <iostream>

#include <iomanip>

#include <strstream>

using namespace std ;

int main( )

{

char ch = 'Z' ;

int i = 350 ;

float a = 3.141528f ;

char str[ ] = "strstreams в работе" ;

ostrstream s ;

s << "ch = " << ch << endl

<< "i = " << hex << i << endl

<< "a = " << fixed << a << endl

<< "str = " << str

<< ends ;

cout << s.str( ) << endl ;

return 0 ;

}

Результат работы программы демонстрирует аккуратное согласование всех

элементов:

ch = Z

i = 15e

a = 3.141528

str = strstreams в работе

212

Глава 8

Сначала мы создали объект s класса ostrstream. Сделав это, можно вывести в него

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

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

вставить его самим, используя манипулятор ends. Построив строку, мы можем получить к ней доступ при помощи ostrstream::str( ).

Работа с istrstream

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

простая программа, показывающая, как это реализовать.

#include <iostream>

#include <strstream>

#include <string>

using namespace std ;

int main( )

{

int age ;

float salary ;

string fname, mname, sname ;

char str[ ] = "Sameer Shekhar Deshpande 35 12004.50" ;

istrstream s ( str ) ;

s >> fname >> mname >> sname >> age >> salary ; cout << fname << endl << mname << endl

<< sname << endl << age << endl << salary ; return 0 ;

}

При создании объекта s вызывается конструктор istrstream. Он принимает указатель на массив символов с нулевым флагом завершения. Вместо массива мы также

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

После создания объекта istrstream мы можем извлекать из него байты до обнаружения знака '\0' в конце строки.

Выражение

s >> fname >> mname >> sname >> age >> salary ; извлекает различные элементы данных из istrstream.

Таким образом, strstreams представляют собой гибкое и универсальное средство

преобразования строк символов в типизированные значения.

Система ввода-вывода в C++

213

Ввод-вывод объектов

Для начала мы сосредоточились на вводе-выводе стандартных типов, таких как

char, int, float, string и их варианты. Помимо этого, можно производить ввод-вывод объектов пользовательского типа, перегрузив операторы >> и << для соответствующих потоков. К примеру, если у нас есть объект c1 пользовательского

класса Сomplex, мы можем считать его и отобразить посредством команд

Complex c1 ;

cin >> c1 ;

cout << c1 ;

Следующая программа демонстрирует, как реализовать такую перегрузку:

#include <iostream>

using namespace std ;

class Complex

{

private :

double real, imag ;

public :

Complex( )

{

}

Complex ( double r, double i )

{

real = r ;

imag = i ;

}

friend ostream& operator << ( ostream& s, Complex& c ) ; friend istream& operator >> ( istream& s, Complex& c ) ;

} ;

ostream& operator << ( ostream& s, Complex& c )

{

s << "( " << c.real << ", " << c.imag << " )" ; return s ;

}

istream& operator >> ( istream& s, Complex& c )

{

s >> c.real >> c.imag ;

return s ;

}

int main( )

{

Complex c1 ( 1.5, 2.5 ), c2 ( 3.5, 4.5 ), c3 ;

cout << "c1 = " << c1 << endl << "c2 = " << c2 << endl ;

214

Глава 8

cout << "Введите комплексное число: " ;

cin >> c3 ;

cout << "c3 = " << c3 << endl ;

return 0 ;

}

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

cout << "c1 = " << c1 << endl << "c2 = " << c2 << endl ; cout << "Введите комплексное число: " ;

cin >> c3 ;

cout << "c3 = " << c3 << endl ;

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

Здесь мы определили две дружественные функции: operator << ( ) и operator >> ( ).

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

Операторные функции не являются членами класса Complex. Поэтому команда

cin >> c3

не преобразуется в форму

cin.operator >> ( c3 ) ;

Объекты по обе стороны от >> передаются операторной функции, оба как ссылки, что предотвращает создание их копий. Функция возвращает объект istream по

ссылке, чтобы сделать возможным каскадирование, как в

cin >> c4 >> c5 ;

Точно такой же аргумент применяется к функции operator << ( ).

Сериализация

Мы также можем перегрузить операторы >> и << класса Complex, речь о которых

шла в предыдущем разделе, для чтения/записи объектов из файла. Однако в таком

случае в файл будут заноситься лишь значения членов данных объекта. Не будет

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

класса Complex.

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

нет, то она не сможет его реконструировать.

Кроме того, не исключена вероятность, что мы сохраним объекты разных типов

в одном файле. И тогда мы не сможем различить их при чтении.

Система ввода-вывода в C++

215

Решением в обоих случаях является сериализация и десериализация объектов.

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

файла и с их помощью объект реконструируется в памяти.

C++ не предоставляет встроенного инструмента сериализации/десериализации. Для

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

объектов в текстовом, двоичном и XML-форматах.

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

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

состояния конца файла. Однако ни одна профессиональная программа не выдержит

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

файла, а также при чтении/записи в файл. Следующая программа демонстрирует, каким образом реализуется проверка на ошибки:

#include <fstream>

#include <stdlib>

#include <iostream>

using namespace std ;

int main( )

{

void report ( ofstream& ) ;

ofstream file ;

Назад: Деструкторы
Дальше: Взаимодействие с файловой системой