Объектно-ориентированное программирование
В этой главе мы приступаем, наконец, к самому существенному из того, ради чего писалась эта книга и ради чего, будем надеяться, вы ее читаете. Прежде чем заняться конкретным изучением классов C++, нам хотелось бы немного поговорить об объектно-ориентированном программировании вообще. Конечно, для читателей, которые уже знакомы с его концепциями, следующий раздел будет неинтересен, за что мы заранее извиняемся.
Класс
Класс — это множество объектов, имеющих одинаковую структуру. Класс в программировании является аналогом понятия, или категории. В то время как объект представляет собой конкретную сущность, класс является абстракцией сущности объекта. Конкретный объект является представителем, или (не совсем грамотно) экземпляром класса.
Другими словами, структура характеристик объекта и все потенциальные отношения между объектами заложены в классе. Однако классы могут находиться еще и в специфических отношениях между собой.
Наследование
Если существенными видами отношений между объектами являются связь и агрегация, то фундаментальное отношение между классами — это наследование. Один класс может наследовать другому. В C++ в таком случае говорят, что один класс является базовым, а другой (который наследует первому) — производным. Еще их называют соответственно классом-предком и классом-потомком. Наследование может быть прямым, когда один класс является непосредственным предком (потомком) другого, или косвенным, когда имеют место промежуточные наследования.
Производный класс наследует всю структуру характеристик и поведение базового, однако может дополнять или модифицировать их. Если класс В является производным по отношению к А, то с логической точки зрения “В есть А”. Например, понятие, или класс, “автомобиль” (В) является производным по отношению к понятию “средство передвижения” (А). По Л. Н. Толстому, В есть А.
Как и в логике, здесь существует взаимосвязь между “содержанием” и “объемом” понятия. Производный класс имеет большее содержание, но меньший объем, чем базовый.
Наследование может быть простым, когда производный класс имеет всего одного непосредственного предка, и сложным, если в наследовании участвуют несколько базовых классов.
Полиморфизм
Полиморфизм, наряду с наследованием, является фундаментальной концепцией объектной модели программирования. Без него объектно-ориентированное программирование потеряло бы значительную долю своего смысла. Суть полиморфизма в том, что с объектами различных классов, имеющих один и тот же базовый класс, можно при определенных условиях обращаться, как с объектами базового класса; однако объект, являющийся, по видимости, объектом базового класса, будет вести себя по-разному в зависимости от того, что он такое на самом деле, т. е. представитель какого из производных классов.
В C++ полиморфное поведение объектов обеспечивается механизмом виртуальных функций-элементов. В одной из предыдущих глав мы на самом деле уже приводили пример “полиморфизма”, реализованного на языке С. Допустим, программа должна в числе всего прочего выводить на экран различные геометрические фигуры. Она определяет класс “фигура”, в котором предусмотрен виртуальный метод “нарисовать” (в C++ это был бы абстрактный класс). От данного класса можно произвести несколько классов — “точка”, “линия”, “круг” и т. д., — каждый из которых будет по-своему определять этот метод.
Указатель на класс “фигура” может ссылаться на объект любого из производных классов (поскольку все они являются фигурами), и для указываемого им объекта можно вызвать метод “нарисовать”, не имея представления о том, что это на самом деле за фигура.
Абстракция и инкапсуляция
Об этих принципах объектного подхода мы уже упоминали. На самом деле это принципы программирования, присущие не только объектно-ориентированной модели. Но хотелось бы несколько уточнить их в отношении к организации классов.
Чтобы абстрагировать объект, он должен быть сравнительно “слабо связан” с окружающим миром. Он должен обладать сравнительно небольшим набором (существенных) свойств, характеризующих его отношения с другими объектами. С другой стороны, выделение класса как некоторого понятия, охватывающего целый ряд различных объектов, также является моментом абстракции.
Поэтому абстрагирование, как таковое, имеет два аспекта: выделение общих и в тоже время существенных свойств, описывающих поведение ряда схожих предметов.
С абстракцией неразрывно связан принцип инкапсуляции. Инкапсуляция — это сокрытие второстепенных деталей объекта. Для этого нужно выделить сначала существенные его свойства. Но чтобы выделить существенные свойства, нужно сначала отвлечься от второстепенных. Так что в действительности речь может идти только о едином акте, в котором можно лишь отвлеченно выделить два отдельных момента.
С технической точки зрения абстракция и инкапсуляция выражаются в том, что классы состоят из интерфейса и реализации. Интерфейс представляет абстрагированную сущность объектов. Реализация скрыта в своих деталях от пользователя класса.
Сделав такие предварительные замечания о понятиях объектно-ориентированного подхода, перейдем к конкретному рассмотрению классов, как они реализованы в языке C++.
Об объектном подходе к программированию
Существует разные подходы к программированию. Любому из них присущ свой собственный способ абстрагирования сущностей, с которыми он работает. Так, процедурно-ориентированный подход оперирует абстракцией алгоритма. С, кстати, — типичный процедурный язык, хотя на нем возможно писать программы, напоминающие по стилю объектно-ориентированные. Логико-ориентированный имеет в виду цели, выражаемые на языке логических предикатов (таков язык Prolog). Наконец, объектно-ориентированное программирование абстрагирует классы и объекты. В чем же состоит его суть?
Известный теоретик Грейди Буч так определяет этот подход:
“Объектно-ориентированное программирование — это методология программирования, основанная на организации программы, в виде совокупности объектов, каждый из которых является представителем определенного класса, а классы образуют иерархию наследования.”
В “строго” объектно-ориентированных языках объектами (или их составными частями) являются решительно все элементы программы, в том числе она сама. В языке Java, например. Язык C++ таковым, кстати, не является хотя бы потому, что он сохраняет все возможности процедурного языка С. В C++ можно создать, например, совершенно отдельно стоящую глобальную переменную, да и сама функция main)) — совершенно “внеклассовая”. Такая универсальность C++ может быть как преимуществом, так и недостатком, если ею злоупотреблять. У программиста, впервые применяющего объектный подход, всегда имеется тенденция мыслить старыми, процедурными категориями.
Итак, центральным элементом абстракции объектно-ориентированной методологии является, очевидно, объект.
Объект
Объекты нашего программного мира моделируют объекты реального или воображаемого мира (например, мира некоторой игры, хотя это мир тоже мыслимый, а стало быть, реальный в смысле логики). Как модель, программный объект представляет собой некоторую абстракцию объекта реального, что предполагает выделение существенных свойств последнего и игнорирование тех, что безразличны с насущной, “сиюминутной”, точки зрения.
Как нечто, как единичность, объект (реальный или программный) отличен от всего остального. Он обладает индивидуальностью, или самотождественностью, если применить опять же термин логики. Понятие индивидуальности программного объекта определить сложно, пожалуй, даже сложнее, чем для реальных объектов, и о нем редко вообще задумываются. Однако интуитивно ясно, что объект, как бы его ни переименовывать и как бы ни менялось его состояние, остается той же самой единичной сущностью с момента создания и до своего уничтожения.
Наконец, заметим, что о программном объекте можно говорить точно так же, как о реальном. Мы можем не только говорить о нем, но и описывать на специальном формальном языке — языке программирования.
В общем, как видно, объект в программе мало чем отличается от предмета реального мира. Он является моделью последнего. Поэтому, в известном смысле, пока не важно, о каких объектах пойдет речь — реальных или программных.
Состояние объекта
Объект, прежде всего, характеризуется своим состоянием. Возьмем, к примеру, базу данных. Она — объект, поскольку есть нечто цельное. К числу моментов, определяющих ее состояние, относится текущее число записей. Каждая запись тоже, очевидно, является объектом. Отдельные поля существующей записи могут модифицироваться, и их совокупность определяет текущее состояние записи, а вместе с ней и состояние всей базы данных.
По-видимому, состояние программного объекта полностью определяется некотором набором (структурой) характеристик и их текущими значениями. Эти характеристики называют полями или (в C++) элементами данных объекта. Но не все моменты состояния объекта должны быть непосредственно видимы извне. Классический пример, который неизменно приводят американские авторы, — автомобиль. С точки зрения водителя у него есть приборный щиток, отражающий скорость, обороты, температуру двигателя и т. д., и органы управления. Но в автомобиле масса частей, спрятанных под капотом, состояние которых не исчерпывается показаниями приборов.
На самом деле состояние, как таковое, может быть вообще скрыто от внешнего взгляда. Оно, как говорят, инкапсулировано в объекте. Однако в зависимости от своего состояния объект по-разному взаимодействует со своим окружением, что приводит нас к следующему понятию.
Поведение объекта
Поведение — это то, как объект взаимодействует с окружением (другими объектами). Объект может подвергаться воздействию окружения или сам воздействовать на него. Объект может рассматриваться как аналог предмета, а поведение — как реакция на манипуляции с ним или действия, инициированные самим объектом. В некоторых объектных системах (например, OLE 2) говорят о глаголах, т. е. действиях, которые могут связываться с объектом. Для каждого объекта существует определенный набор возможных действий над ним.
Действия в отношении к объектам иногда называют передачей сообщений между ними. В языках, подобных Object Pascal, операции над объектами называют обычно методами. В C++ благодаря его “процедурному наследству” чаще говорят о функциях-элементах объекта. Эти функции являются структурными элементами определения класса, к которому принадлежит объект.
Отношения между объектами
Естественно, программа, т. е. цельная система, реализуется только во взаимодействии всех ее объектов. Здесь можно выделить в основном два типа взаимодействий, или отношений: связь и агрегацию.
Связь является довольно очевидной разновидностью взаимодействий — один объект может воздействовать на другой, являющийся в известном смысле автономной сущностью. Тут существует отношение подчинения — “А использует В”. Один объект является активным, другой — пассивным. Понятно, что в системе один и тот же объект может выступать как в активной, так и в пассивной роли по отношению к различным объектам.
Другой тип отношений — агрегация, когда один объект является составной частью, т. е. элементом другого — “А содержит В”. Агрегация может означать физическое вхождение одного объекта в другой; в C++ это соответствует описанию первого объекта в качестве элемента данных другого объекта. Но это не обязательно. Например, в Windows есть понятие дочернего окна. Здесь имеет место отношение агрегации, хотя на физическом уровне родительское и дочернее окна автономны.
Определение класса
Приведенный ниже код определяет два класса, которые могли бы применяться в графической программе. Это классы точек и линий.
////////////////////////////////////////////////////////////
// Classesl.h: Пример двух геометрических классов. //
const int MaxX = 200; // Максимальные значения координат.
const int MaxY = 200;
//
struct,Point { // Класс точек.
int GetX(void) ;
int GetY(void) ;
void SetPoint(int, int);
private:
int x;
int y;
};
class Line
{
// Класс линий.
Point p0;
Point p1;
public:
Line(int x0, int y0, int xl, int yl);
// Конструктор.
~Line(void); // Деструктор.
void Show(void);
};
Ну вот, такие вот классы. Теперь разберем различные моменты этих определений.
Иногда может потребоваться предварительное объявление класса, если нужно, например, объявить указатель на объект класса прежде, чем будет определен сам класс. Предварительное объявление в этом смысле подобно прототипу функции и выглядит так:
class SomeClass;
Заголовок определения
Определение класса начинается с ключевых слов class, struct или union. Правда, union применяется крайне редко. И структуры, и классы, и объединения относятся к “классовым” типам C++. Разницу между этими типами мы рассмотрим чуть позже.
Спецификации доступа
Ключевые слова private и public называются спецификаторами доступа. Спецификатор private означает, что элементы данных и элементы-функции, размещенные под ним, доступны только функциям-элементам данного класса. Это так называемый закрытый доступ.
Спецификатор public означает, что размещенные под ним элементы доступны как данному классу, так и функциям других классов и вообще любым функциям программы через представитель класса.
Есть еще спецификатор защищенного доступа protected, означающий, что элементы в помеченном им разделе доступны не только в данном классе, но и для функций-элементов классов, производных от него.
Структуры, классы и объединения
Типы, определяемые с ключевыми словами struct, class и union, являются классами. Отличия их сводятся к следующему:
Структуры и классы отличаются только доступом по умолчанию. Элементы, не помеченные никаким из спецификаторов, в структурах имеют доступ public (открытый); в классах — private (закрытый).
В объединениях по умолчанию принимается открытый доступ.
Элементы (разделы) объединения, как и в С, перекрываются, т. е. начинаются с одного и того же места в памяти.
Я никогда не видел, чтобы в C++ применяли объединения. Хотя это, возможно, и имело бы смысл в некоторых ситуациях, когда требовалось бы объединить несколько разнородных классов в один тип.
Элементы данных и элементы-функции
Элементы данных класса совершенно аналогичны элементам структур в С, за исключением того, что для них специфицирован определенный тип доступа. Объявления элементов-функций аналогичны прототипам обычных функций.
Конструктор и деструктор
В классе могут быть объявлены две специальные функции-элемента. Это конструктор и деструктор. Класс Line в примере объявляет обе эти функции.
Конструктор отвечает за создание представителей данного класса. Его объявление записывается без типа возвращаемого значения и ничего не возвращает, а имя должно совпадать с именем класса. Конструктор может иметь любые параметры, необходимые для конструирования, т. е. создания, нового представителя класса. Если конструктор не определен, компилятор генерирует конструктор по умолчанию, который просто выделяет память, необходимую для размещения представителя класса.
Деструктор отвечает за уничтожение представителей класса. Это происходит либо в случае, когда автоматический объект данного класса выходит из области действия, либо при удалении динамических объектов, созданных операцией new.
Деструктор объявляется без типа возвращаемого значения, ничего не возвращает и не имеет параметров. Если деструктор не определен, генерируется деструктор по умолчанию, который просто возвращает системе занимаемую объектом память.
Заключение
В этой главе мы постарались познакомить вас, весьма конспективно, с основными понятиями и терминологией объектно-ориентированного программирования. После этого мы перешли к элементарному введению в классы C++. В следующей главе мы продолжим изучение классов, уже на более серьезном уровне.
Введение в классы С++
Сначала мы приведем простейший пример определений классов, которые можно было бы разместить в заголовочном файле. Так обычно и делается, если классы должны быть доступны нескольким модулям программы. Следует напомнить, что класс — это расширение понятия типа данных, а точнее, понятия структуры. В C++ принято говорить просто о типах; представитель класса уже нельзя считать просто данными, поскольку ему присуще некоторое поведение.
После этого мы, опираясь на показанный пример, объясним элементарные моменты строения класса в C++.
Чисто виртуальные функции и абстрактные классы
Виртуальная функция-элемент некоторого класса может быть объявлена чистой. Это выглядит так:
virtual тип имя функции(список параметров} = 0;
Другими словами, тело функции объявляется как ='0 (т. н. чистый спецификатор). Действительная реализация ее не нужна (хотя и возможна). Предполагается, что чисто виртуальная функция будет переопределяться в классах, производных от него. Класс, объявляющий одну или несколько чисто виртуальных функций, является абстрактным базовым классом. Нельзя создать представитель такого класса. Он может служить только в качестве базового для порождения классов, которые полностью реализуют его чисто виртуальные функции.
Если класс не определяет see чисто виртуальные функции абстрактного базового класса, то он также является абстрактным.
Абстрактные классы
не могут, как уже сказано, иметь представителей;
не могут использоваться в качестве типа параметров или возвращаемых значений;
не могут участвовать в явных приведениях типа.
Тем не менее, можно объявлять указатели или ссылки на абстрактный класс.
Смысл абстрактных базовых классов в том, что они способствуют лучшей концептуальной организации классовой иерархии и позволяют тем самым в полной мере использовать преимущества виртуальных механизмов C++.
Предположим опять-таки, что вы хотите работать с геометрическими фигурами — линиями, кругами и т. д. Конечно, имеет смысл определить общий базовый класс “фигура”. Тогда можно будет представлять наборы разнородных фигур в виде единообразных списков или массивов указателей. Но нет смысла создавать представителей собственно класса “фигура”. Не бывает в мире фигур просто. Фигура — это абстракция.
С другой стороны, каждый конкретный класс фигур должен иметь метод “нарисовать”. Чтобы этот метод был виртуальным, его должен объявлять базовый класс (как виртуальный, естественно). Но что может делать такой метод, если неизвестно, что рисовать? Конечно, можно написать функцию, которая ничего не делает или возвращает состояние ошибки. Но гораздо лучше объявить такую функцию-элемент базового класса как чистую. Тем самым будет образована виртуальная база для переопределения в производных классах, а также запрещено явное или неявное создание представителей базового класса.
Сказанное иллюстрирует следующий набросок иерархии классов:
class Shape { // Абстрактный базовый класс.
// . . .
public:
virtual -Shape () {} //На всякий случай...
virtual void Draw() =-0; //Чисто виртуальная функция. // . . . };
//
// Производные классы:
//
class Line: public Shape
{ // . . . public:
void Draw() {
// Определение тела
class Rectangle: public Shape
{
// . . .
public:
void Draw()
{
// . . .
}
//...
// И т.д.
Деструктор
Деструктор является противоположностью конструктора. Он вызывается при уничтожении объектов и должен производить необходимую очистку объекта перед освобождением занимаемой им памяти
Именем деструктора должно быть имя класса, которому предшествует тильда (~). Свойства деструкторов таковы:
Деструктор не имеет параметров.
Он не может возвращать значений.
Деструктор не наследуется.
Деструктор не может объявляться как static, const или volatile.
Деструктор может быть объявлен виртуальным.
Доступ к базовым классам
Ключ доступа определяет “внешний” доступ к элементам базового класса через объекты производного. Что касается доступа самого производного класса к элементам базового класса, то ключ доступа на него не влияет. Для производного класса доступны разделы protected и public базового класса; раздел private строго недоступен вне области действия базового класса.
Для доступа к элементам базового класса через производный можно сформулировать такое правило: права доступа, определяемые для них базовым классом, остаются неизменными, если они такие же или строже, чем специфицировано ключом доступа. В противном случае права доступа определяются ключом в определении производного класса.
Например, при наследовании с ключом public права доступа к элементам базового класса остаются неизменными; при закрытом наследовании (ключ private) все элементы базового класса будут недоступны за пределами производного класса.
При закрытом наследовании можно сделать некоторые открытые функции базового класса открытыми в производном, если переобъявить их имена в производном классе:
class First { public:
void FFunc(void) ;
//... }
class Second: private First { public:
First::FFunc; // First::FFunc() открыта в классе Second.
//.. .
}
Нужно сказать, что в прикладном программировании применяется почти исключительно открытое наследование.
Доступ к элементам данных
Поскольку функции-элементы класса находятся в его области действия, они могут обращаться к элементам данных непосредственно по имени, как можно видеть в последнем примере. Обычные функции или функции-элементы других классов могут обращаться к элементам существующего представителя класса с помощью операций “.” или “->”:
class Time { public:
int hour;
int min;
} ;
int main()
{
Time start; // Локальный объект Time.
Time *pTime = Sstart; //Указатель на локальный объект.
start.hour = 17; // Операция доступа к элементу.
pTime->min = 30; // Косвенный доступ к элементу.
return 0;
}
“Друзья”
Спецификаторы доступа позволяют указать, к каким элементам класса могут обращаться функции, в него не входящие. Однако могут быть случаи, когда целесообразно разрешить некоторому классу или функции обращаться к закрытым или защищенным элементам данного класса. Это можно сделать с помощью ключевого слова friend.
“Друзьями” класса могут быть объявлены другие классы или отдельные функции, как обычные, так и являющиеся элементами некоторых классов. Друзья могут объявляться в любом из разделов определения класса (закрытом, защищенном или открытом), — в каком именно, не имеет значения. В любом случае дружественный класс или функция будет иметь полный доступ к элементам класса.
Вот пример объявления Друзей класса:
class SomeClass (
friend class AnotherClass;
friend void regularFunc (int);
friend void OtherClass::MemFunc(double);
//...
};
Следует иметь в виду такие правила:
Дружественность не обратима: если А объявляет В другом, это не значит, что А будет другом В. Дружба “даруется”, но не “присваивается”.
Дружественность не наследуется: если А объявляет В другом, то классы, производные от В, не будут автоматически друзьями А.
Дружественность не транзитивна: если А объявляет В другом, то классы, производные от А, не будут автоматически признавать В своим другом.
Функции преобразования
Объекты класса могут быть преобразованы в другие типы (или созданы из других типов) с помощью операций приведения типа или конструкторов преобразования.
Конструкторы преобразования
Если конструктор класса А имеет единственный параметр типа В, то объект В может быть неявно преобразован в класс А с помощью такого конструктора. Другими словами, компилятор может сам вызывать такой конструктор, чтобы “из В сделать А”. Возьмите пример из предыдущего раздела. Локальный объект можно было бы инициализировать по-другому:
class Hold {
char *str;
public:
Hold(const char*);
//...
};
main () {
Hold mainObj = "This is a local object in main.";
//. . .
return 0;
Таким образом, в этом примере объявленный в классе конструктор Hold(const char*) является по сути конструктором преобразования.
Ключевое слово explicit
Можно запретить вызовы конструктора с одним параметром в качестве конструктора преобразования, объявив его с ключевым словом explicit. Тем самым будут запрещены неявные вызовы конструктора, подобные показанному в предыдущем параграфе:
class Hold {
char *str;
public:
explicit Hold(const char*);
//. . .
};
main () {
//
// Неявное преобразование недопустимо:
//
// Hold mainObj = "This is a local object in main.";
//...
return 0;
}
Такой конструктор можно вызывать только явным образом, т. е.
Hold mainObj("This is a local object in main.");
или
Hold mainObj = Hold("This is a local object in main.");
Последняя форма предполагает вызов конструктора копии, который должен обязательно определяться, если класс содержит указатели на динамические объекты подобно классу из листинга 8.1. Там этого не сделано.
Операции приведения
В классе можно определять элементы-функции, которые будут обеспечивать явное преобразование типа данного класса в другие типы. Эти функции называют операциями приведения или процедурами преобразования. Синтаксис их следующий:
operator имя_нового_типа();
Процедуры преобразования характеризуются следующими правилами:
У процедуры преобразования нет параметров.
для процедуры преобразовании не специфицируется явно тип возвращаемого значения. Подразумевается тип, имя которого следует за ключевым словом operator.
Процедура преобразования может быть объявлена виртуальной.
Она может наследоваться.
Вот пример процедуры преобразования:
#include <stdio.h>
class Time { int hr, min;
public:
Time(int h, int m): hr(h), min(m) {}
// Конструктор.
operator int();
// Процедура преобразования.
};
Time::operator int() {
// Преобразует время в число секунд от начала суток:
return (3600*hr + 60*min);
}
main ()
{
int h = 7;
int m = 40;
Time t (h, m);
//
// Последний параметр вызывает Time::operator int():
//
printf("Time: %02d:%02d = %5d seconds.\n", h, m, (int)t);
return 0;
Элементы данных
Суммируем и дополним то, что говорилось об элементах данных в предыдущей главе. Элементы данных аналогичны элементам структур языка С. Стоит добавить следующее:
Они не могут быть объявлены как auto, extern или register.
Они могут быть перечислениями, битовыми полями, а также объектами ранее объявленных классов.
Элемент данных класса не может быть представителем самого класса.
Элемент данных класса может быть указателем или ссылкой на сам класс.
Элементы-функции
Функция-элемент класса объявляется внутри определения класса. Там же может быть расположено и оределение тела функции. В этом случае функцию-элемент называют встроенной и компилятор будет генерировать ее встроенное расширение на месте вызова. Если определение функции располагается вне тела класса, то к ее имени добавляется префикс, состоящий из имени класса и операции разрешения области действия. В этом случае функцию-элемент также можно определить как встроенную с помощью ключевого слова inline. Вот несколько модифицированный класс Point из предыдущей главы вместе с его реализацией:
#include <assert.h>
const int MaxX = 200; // Максимальные значения координат.
const int MaxY = 200;
//
struct Point { // Класс.точек.
private:
int fx;
int fy;
public:
int GetX(void) ( return fx; }
int GetY(void) { return fy; }
void SetPoint(int, int);
};
void Point::SetPoint(int x, int y)
{
assert(x >=0 && x < MaxX);
assert(y >= 0 && у < MaxY);
fx = x;
fy = y;
}
Здесь обе функции Get () определены как встроенные, а функция SetPoint () определяется вне тела класса и не является встроенной.
Элементы класса
Как уже говорилось раньше, элементы класса распадаются на две категории. Это данные, инкапсулирующие состояние объектов, и код, отвечающий за их поведение и реализуемый в форме функций-элементов класса.
Класс как область действия
В языке С выделялось несколько различных типов области действия: глобальная, файл, функция, блок. В C++'вводится новый вид области действия — класс. Имена элементов класса расположены в области действия класса, и к ним можно обращаться из функций-элементов данного класса. Кроме того, получить доступ к элементам класса можно в следующих случаях:
Для существующего представителя класса с помощью операции доступа к элементу (точки).
Через указатель на существующий представитель класса с помощью операции косвенного доступа к элементу (стрелки).
С помощью префикса, состоящего из имени файла и операции разрешения области действия (::).
Классы С++
В прошлой главе мы показали, как определяются простейшие классы C++. То, что содержится в приведенном коде — это интерфейс класса. В самом определении класса объявляются обычно лишь прототипы функций-элементов. Чтобы класс стал работоспособным, необходима еще его реализация. Реализация класса, располагаемая часто в отдельном файле, содержит код его функций-элементов, а также некоторые элементы данных, называемые статическими.
Мы переходим теперь к подробному изучению классов, включая, естественно, и аспекты их реализации.
Ключевое слово mutable
Константная функция-элемент “обещает” не изменять значений элементов данных класса, что позволяет применять ее на константных объектах. Тем не менее, в некоторых ситуациях имеет смысл разрешить некоторым элементам меняться даже у константных объектов. Например, некоторый существенный набор данных изменять ни в коем случае нельзя, в то время как отдельный элемент, скажем, некоторое сообщение, может и должно меняться. В этом случае можно объявить элемент данных с ключевым словом mutable:
class AnyClass {
int value;
mutable const char *msg;
public.:
AnyClass (): value (0), msg(NULL) {}
int GetValueO const;
// ... };
j nt AnvClass::Get Value() const
{
msg - "New message!";
// Допускается, поскольку msg - mutable.
//
// value изменять нельзя:
//
// value = 111;
//
return value;
}
Модификатор mutable не может применяться совместно с const или static (в приведенном примере все верно, поскольку const относится не к msg, а к содержимому строки, на которую он ссылается).
Константные объекты и функции-элементы
Можно создать представитель класса с модификатором const. Тем самым гарантируется, что после инициализации содержимое объекта нельзя будет изменить. Компилятор C++Builder выдает предупреждение в случае, если для объекта вызывается функция-элемент, не объявленная как const. Другие компиляторы могут выдать сообщение об ошибке и отказаться транслировать подобный код.
Со своей стороны, константная функция-элемент
объявляется с ключевым словом const после списка параметров;
не может изменять содержимое элементов данных класса;
не может вызывать не-константные функции-элементы. Вот пример константной функции и константного объекта:
class Time {
int hr, min;
public:
Time(int h, int m): hr(h), min(m) {}
void SetTime(int h, int m) { hr = h; min = m;
}
void GetTime(int&, int&) const;
}
void Time::GetTime(int &h, int &m) const {
h = hr;
m = min;
//
// Следующий оператор здесь был бы некорректен:
//
// min = 0;
int main ()
{
Time t(17, 45); // Обычный объект.
const Time ct(18, 0); // Константный объект.
int h, m;
ct.GetTime(h, m); // Вызов const-функции для const-объекта. t.SetTime(h, m) ;
//
// Следующий вызов некорректен:
// // ct.SetTime(0, 0) ;
return 0;
}
Конструктор
Конструктор имеет то же имя, что и класс. Он вызывается компилятором всегда, когда создается новый представитель класса. Если в классе не определен никакой конструктор, компилятор генерирует конструктор по умолчанию (не имеющий параметров).,Относительно конструкторов имеют место такие правила:
Для него не объявляется тип возвращаемого значения.
Он не может возвращать значений оператором return.
Конструктор не наследуется.
Конструктор не может быть объявлен как const, volatile, virtual или static.
Поскольку конструктор не возвращает значений, то для сигнализации об ошибке при инициализации объекта, если требуется, нужно применять механизм управления исключениями, о котором мы будем говорить в 10-й главе.
Можно вызвать конструктор для инициализации уже существующего объекта, если перегрузить глобальную операцию new таким образом, чтобы она принимала дополнительный аргумент — указатель типа void*. Это называют размещающей формой операции; мы о ней уже упоминали в прошлой главе. Такая методика иногда применяется для глобальных представителей класса, если их нужно инициализировать после выполнения каких-то предварительных действий. Вот пример:
#include <new.h>
// Операция new, допускающая форму размещения:
inline void *operator new(size_t, void* p)
{
return p;
}
class Dummy
{
public:
Dummy() // Конструктор.
};
Dummy gblDummy;// Глобальный объект.
int main ()
{
InitSystem(); // Какие-то проверки
// и инициализации.
new(&gblDummy) Dummy; // Вызывает конструктор
// для gblDummy.
return 0;
}
Элементы данных класса часто инициализируют в теле конструктора, присваивая им соответствующие значения. Однако существует альтернативный механизм инициализации. Он использует список инициализации элементов.
Список инициализации следует за заголовком (сигнатурой) определения конструктора после двоеточия и состоит из инициализаторов элементов данных и базовых классов, разделенных запятыми. Каждому элементу списка передается один или несколько параметров, требуемых для инициализации.
Вот простейший пример класса с двумя перегруженными конструкторами, в одном из которых применяется обычный способ инициализации в теле функции, а во втором — список инициализации элементов:
class Time { int hr, min;
public:
Time(int h)
{
hr = h; min = 0;
}
Time(int h, int m): hr(h), min(m)
{
}
};
Тело второго конструктора, как видите, пусто.
Список инициализации является единственным средством присвоения значений элементам данных класса, объявленным как const или как ссылки (а также закрытым элементам базовых классов).
Конструктор копии
Конструктор копии является конструктором специального вида, который принимает в качестве параметра ссылку или константную ссылку на объект данного класса. Он автоматически вызывается компилятором, когда вновь создаваемый объект инициализируется значениями существующего объекта:
class Time {
int hr, min;
public:
Time(int h, int m): hr(h), min(m) {}
Time(const Time &src) // Конструктор копии.
{ hr = src.hr; min = src.min; } //
};
int main()
(
Time start (17,45); // Вызывается первый конструктор.
Time current = start; // Вызывается конструктор копии.
return 0;
}
Если вы не предусмотрели в классе конструктор копии, компилятор генерирует конструктор копии по умолчанию, который производит простое копирование данных объекта в новый представитель класса. Если класс содержит какие-то указатели или ссылки, то такое копирование скорее всего будет бессмысленным или опасным.
Иногда, когда копирование объектов класса в принципе не может привести ни к чему хорошему, имеет смысл объявить конструктор копии (это может быть просто “пустышка”) в закрытом разделе определения класса. Тогда пользователь класса не сможет создавать копии существующих объектов.
Конструкторы, деструкторы и наследование
Конструкторы не наследуются. Это утверждение требует некоторых пояснений. Оно означает, что если в базовом классе имеются конструктор с некоторыми параметрами, он не будет вызываться автоматически, если вы . попробуете создать объект производного класса с такими параметрами. Для этого нужно написать конструктор производного класса, в котором конструктор базового класса будет вызываться через посредство списка инициализации. О нем мы уже говорили выше в связи с инициализацией элементов данных класса. Базовые классы в смысле инициализации ничем от них не отличаются.
Если в списке инициализации конструктора отсутствует вызов какого бы то ни было конструктора базового класса, компилятор все равно вызовет для последнего конструктор по умолчанию, т. е. конструктор без параметров. В примере предыдущего параграфа объявлен конструктор базового класса, который может вызываться без параметров, поскольку для него определены аргументы по умолчанию. Если вы не поленитесь пройти по этому примеру в отладчике, то увидите последовательность вызовов при создании объекта производного класса в функции main () .
Однако базовый конструктор можно вызвать явно через список инициализации. Класс из предыдущего параграфа нужно модифицировать примерно так:
class Alarm: public Time { // Класс сообщений таймера.
char *msg;
public:
Alarm(char*);
Alarmfchar*, int, int); // Новый конструктор.
~Alarm() { delete[] msg; }
void SetMsg(char*) ;
void Show(); // Переопределяет Time:: Show ().
//. . .
Alarm::Alarm(char *str, int h, int m): Time(h, m) {
msg = new char[strlen(str) + 1];
strcpy(msg, str);
}
С другой стороны, деструкторы базовых классов никогда явно не вызываются. Деструкторы, можно сказать, даже не имеют имен. Компилятор автоматически генерирует вызовы всех необходимых деструкторов.
Наследование
Класс в C++ может наследовать элементы-данные и элементы-функции от одного или нескольких базовых классов. Сам класс называется в этом случае производным по отношению к базовым классам или классом-потомком. В свою очередь, производный класс может являться базовым по отношению к другим классам.
Принцип наследования, или порождения новых классов, позволяет абстрагировать (инкапсулировать) некоторые общие свойства и поведение в одном базовом классе, которые будут наследоваться всеми его потомками.
Наследование позволяет также модифицировать поведение базового класса. Производный класс может переопределять некоторые функции-элементы базового класса, оставляя основные свойства класса в неприкосновенности .
Синтаксис производного класса следующий:
class имя класса: ключ доступа имя_базового класса [, ...] {
тело_объявления_класса } ;
Ключ_доступа — это одно из ключевых слов private, protected или public.
Некоторые замечания
При перегрузке операций полезно помнить следующее:
C++ не умеет образовывать из простых операций более сложные. Например, в классе со сложением строк мы определили присваивание и сложение; но это не значит, что тем самым будет автоматически определено присвоение суммы (+=). Такую операцию нужно реализовывать отдельно.
Невозможно изменить синтаксис перегруженных операций. Одноместные операции должны быть одноместными, а двухместные — двухместными.
Нельзя изобретать новые обозначения операций. Возможные операции ограничиваются тем списком, что приведен в начале этого раздела.
Желательно сохранять смысл перегружаемой операции. Например, конкатенация — естественная семантика сложения для строк.
Операции класса new и delete
Класс может определять свои собственные операции new и delete (new[] и delete [] для массива объектов):
Функция имя_класса: : operator new () вызывается при создании динамических объектов.
Функция имя_класса: : operator new [ ] () вызывается при создании динамических массивов объектов.
Функция имя_класса:: operator delete() вызывается при удалении динамических объектов.
Функция имя_класса:: operator delete [] () вызывается при удалении динамических массивов.
Ниже приведен довольно длинный пример, демонстрирующий определение операций класса new и delete, а также глобальных new [ ] и delete []. Вывод программы позволяет проследить порядок вызовов конструкторов/деструкторов и операций new/delete при создании автоматических и динамических объектов.
Листинг 8.1. Определение операций класса new и delete
//////////////////////////////////////////////
// NewDel.cpp: Операции класса new и delete.
//
#pragma hdrstop
#include <condefs.h>
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#define trace(msg)
printf(#msg "\n")
#define MaxStr 32
void *operator new[](size_t size)
// Замена глобальной
// new[].
{
trace(Global new [ ] .);
return malloc(size);
}
void operator delete[](void *p)
// Замена глобальной
// delete[].
{
trace(Global delete [].);
free(p) ;
}
class Hold {
char *str;
// Закрытый указатель на строку.
public:
Hold(const char*) ;
~Hold() ;
void *operator new(size t);
// Операция new класса.
void operator delete(void*);
// Операция delete класса.
void Show(void)
{ printf("%s\n", str);
}
};
Hold::Hold(const char *s)
// Конструктор.
{
trace (Constructor.) ;
str = new char[MaxStr];
// Вызов глобальной new[].
strncpy(str, s, MaxStr);
// Копирование строки в объект.
str[MaxStr-1] = 0;
// На всякий случай...
}
Hold::~Hold ()
// Деструктор.
{
trace(Destructor.);
delete[] str;
// Очистка объекта.
}
void *Hold::operator new(size_t size)
{
trace(Class new.);
return malloc (size);
)
void Hold::operator delete(void *p)
{
trace(Class delete.);
free(p) ;
)
void Func()
// Функция с локальным объектом.
{
Hold funcObj ("This is a local object in Func.");
funcObj.Show() ;
}
int main () {
Hold *ptr;
Hold mainObj ("This is a local object in main.");
mainObj.Show ();
trace (*);
ptr = new Hold("This is a dynamic object.");
ptr->Show();
delete ptr;
trace (*);
FuncO ;
trace (*);
return 0;
}
Результат работы программы показан на рис. 8.1.
Рис. 8.1 Программа NewDel
Пример заодно поясняет, зачем нужен деструктор. Здесь он удаляет динамически созданную строку, адрес которой хранится в объекте. Если не определить деструктор, то генерированный компилятором деструктор по умолчанию удалит сам объект, но не строку, которая окажется “потерянной”. Подобные упущения являются распространенной причиной утечек памяти.
Операция присваивания
Операция присваивания — это функция-элемент класса с именем operator=, которая принимает в качестве своего единственного параметра ссылку или константную ссылку на объект данного класса. Она вызывается компилятором, когда существующему объекту присваивается другой объект. Если операция присваивания не предусмотрена, компилятор генерирует ее по умолчанию. В этом случае при присваивании будет выполняться поэлементное (как говорят, поразрядное) копирование данных объекта.
Как конструктор копии, так и операция присваивания выполняют, по видимости, одинаковые действия. Однако конструктор копии вызывается при инициализации вновь создаваемого объекта, в то время как операция присваивания служит для изменения содержимого существующих объектов.
Вот пример класса с операцией присваивания:
class Time { int hr, min;
public:
Time(int h, int m): hr(h), min (m) {}
Time &operator=(const Times); // Операция присваивания.
};
Time &Time::operator=(const Time &src)
{
if(&src == this) // Проверка на самоприсваивание.
error("Self assignment!");
hr = src.hr;
min = src.min;
}
return *this; // Возвращает ссылку на свой объект.
int main() {
Time start (17,45);
Time current (18, 0);
start = current; // Вызывает operator=.
return 0;
}
Здесь, кстати, показан прием проверки на самоприсваивание, позволяющей предотвратить присваивание объекта самому себе.
Обычно операцию присваивания определяют так, чтобы она возвращала ссылку на свой объект. В этом случае сохраняется семантика арифметических присваивании, допускающая последовательные присваивания в выражении (т. е. с = b = а;).
Параметры конструктора копии и операции присваивания могут иметь тип либо имя_класса&, либо const имя_класса&. Последнее предпочтительнее, так как простая ссылка на класс не позволяет копировать константные объекты.
Если класс содержит указатели или ссылки, может быть целесообразным, как и в случае конструктора копии, запретить присваивание объектов, объявив операцию присваивания в закрытом разделе класса.
Операция вызова объекта
Перегрузка операции вызова operator () () позволяет “вызывать” объект класса, как функцию. Возвращаемое значение будет чем-то вроде значения объекта по умолчанию. Но вообще эта операция может производить любые действия над объектом. Вот пример операции вызова:
class AClass {
int x;
public:
AClass(int n) { x = n; }
int operator ()(void) { return x; }
//. . .
};
int main() {
AClass object = 100;
//...
int у = objectO; // Объект вызывается, как функция.
return 0;
}
Перегрузка функций-элементов
Функции-элементы класса могут быть перегружены подобно обычным функциям; несколько функций-элементов могут иметь одно и то же имя, если их можно однозначно идентифицировать по списку аргументов. Вы уже встречались в этой главе с перегруженным конструктором. Это весьма распространенная ситуация. Вот еще подобный пример:
class Time {
long sec; public:
Time(): sec(O) {}
Time(long s): sec(s) {}
Time(int h, int m) {
sec = 3600*h + 60*m;
}
//... };
int main ()
{
Time tl; // Вызывает Time::Time().
Time t2(86399); // Вызывает Time::Time(long).
Time t3(ll, 33); // Вызывает Time::Time(int, int).
//. . .
return 0;
}
Перегрузка операций
+ | — | * | / | % | /\ | & | | | ~ | ! | = | |||||||||||
< | > | += | -= | *= | /= | %= | ^= | &= | |= | << | |||||||||||
>> | >>= | <<= | = = | ! = | <= | >= | && | || | ++ | — | |||||||||||
' | ->* | -> | () | [] | new | delete | new[] | delete | [ ] |
Язык C++ позволяет переопределять для классов существующие обозначения операций. Это называется перегрузкой операций. Благодаря ей класс можно сделать таким, что он будет вести себя подобно встроенному типу. В классе можно перегрузить любые из следующих операций:
Нельзя перегружать операции:
. | .* | :: | ?: |
Функции-операции, реализующие перегрузку операций, имеют вид
operator операция ([операнды]) ;
Если функция является элементом класса, то первый операнд соответствующей операции будет самим объектом, для которого вызвана функция-операция. В случае одноместной операции список параметров будет пуст. Для двухместных операций функция будет иметь один параметр, соответствующий второму операнду. Если функция-операция не является элементом класса, она будет иметь один параметр в случае одноместной операции и два — в случае двухместной.
Для перегрузки операций существуют такие правила:
Приоритет и правила ассоциации для перегруженных операций остаются теми же самыми, что и для операций над встроенными типами.
Нельзя изменить поведение операции по отношению к встроенному типу.
Функция-операция должна быть либо элементом класса, либо иметь один или несколько параметров типа класса.
Функция-операция не может иметь аргументов по умолчанию.
Функции-операции, кроме operator=(), наследуются.
Ниже мы приводим два примера
Ниже мы приводим два примера классов с перегруженными операциями. Первый из них определяет рудиментарный класс строк, допускающих конкатенацию с помощью знака сложения. Второй пример показывает перегрузку индексации.
Листинг 8.2. Перегрузка операции сложения
//////////////////////////////////////////////////////
// StrAdd.cpp: Демонстрация перегрузки сложения для строк.
//
#pragma hdrstop
#include <condefs.h>
#include <string.h>
#include <stdio.h>
class String {
char *str; // Указатель на динамическую строку.
int len; // Длина строки.
String(int); // Вспомогательный конструктор. public:
String(const Strings); // Конструктор копии.
String(const char*); // Конструктор преобразования.
~String () ;
String Soperator=(const Strings);
String operators- (const Strings);
friend String operator+(const Strings, const Strings);
void Show () ;
};
String::String(int size)
{
len = size;
str = new char[len + 1]; // +1 байт для завершающего 0.
}
String::String(const String ssrc)
{
len = src.len;
str = new char[len + 1];
strcpy(str, src.str);
}
String::String(const char *s)
{
len = strlen(s) ;
str = new char[len + 1];
strcpy(str, s);
String::~String()
{
delete [] str;
///////////////////////////////////////////////////
// Операция присваивания.
//
String SString::operator=(const String &op)
{
delete [] str; // Удаление старых данных.
len = op.len;
str = new char[len + 1]; // Выделение новой строки.
strcpy(str, op.str);
return *this;
}
///////////////////////////////////////////////////
// Функция-элемент operator+0.
//
String String::operators- (const String &op)
{
String temp(len + op.len); // Временный объект.
strcpy(temp.str, str); // Копирование первой строки.
strcat(temp.str, op.str); // Присоединение второй строки.
return temp;
}
///////////////////////////////////////////////////
// Дружественная функция operator+() Аналогична предыдущей,
// но допускает С-строку в качестве первого операнда.
//
String operator+( const String Sfop, const String &sop)
{
String temp(fop.len + sop.len);
strcpy(temp.str, fop.str);
strcat(temp.str, sop.str);
return temp;
}
void String::Show()
{
printf("%s: %d \n", str, len);
}
irit main()
{
char cStr[] °= "This is а С string! ";
String rirst = "First String string. ;
String second = "Second String string. ";
String resStr = "";
resStr.Show() ;
resStr = first + second; // Вызывает операцию класса.
resStr.:Shp,w ();
resStr = cStr + resStr; // Вызывает дружественную
// операцию reeStr.Show()
resStr = first + second + cStr; // Обе операции - из
// класса. resStr.Show () ;
return 0;
}
На рис. 8.2 показан вывод программы. Пример позволяет пояснить, почему перегруженные функции операций часто делают друзьями, а не элементами класса. Это делается для того, чтобы передать функции первый операнд в параметре, а не как объект *this. В одном из операторов функции main () из примера первый операнд сложения — С-строка. Но это не объект класса, и компилятор никак не может вызвать String: :operator+ (Strings) . Однако есть дружественная функция operator+ (Strings, Strings). Поскольку имеется конструктор преобразования char* в String, компилятор автоматически приводит первый операнд к типу String, создавая на стеке временный объект, и затем выполняет сложение с помощью дружественной функции. Это аналог “возведения типа”, происходящего в арифметических выражениях.
На самом деле функция-элемент operator+ (Strings) здесь является излишней. Можно было бы'обойтись одной дружественной функцией сложения.
В данном классе необходима реализация конструктора копии. Компилятор вызывает его при передаче возвращаемого значения функциями-операциями operator+ (). На стеке конструируется копия локального автоматического объекта temp (см. листинг), который при завершении функции выходит из области действия и удаляется. Конструктор копии по умолчанию не годится, так как класс содержит указатель на динамическую строку.
Рис. 8.2 Программа StrAdd
Листинг 8.3. Перегрузка операции индексации
////////////////////////////////////////////////////////////
// Index.срр: Строка в качестве индекса.
//
#pragma hdrstop
#include <condefs.h>
#include <stdio.h>
#include <string.h>
const int Maxltems = 16;
class AArr {
int nitems;
char * keys[Maxltems];
char *items[Maxltems];
static const char error [];
public:
AArr() { nitems =0; }
~AArr();
void Addltem(const char*, const char*);
const char ^operator[](const char*);
};
consk char AArr::error[] = "*** Not found. ***";
////////////////////////////////////////////////////////////
// Деструктор: удаляет динамические строки,
// созданные Addltem().
//
AArr::~AArr ()
{
for (int j=0; j<nltems; j++) {
delete [] keys[j];
delete[] items[j];
}
////////////////////////////////////////////////////////////
// Создает новую запись с указателями в keys[] и items[].
//
void AArr::Addltem(const char *key, const char *data)
{
if (nitems < Maxltems) {
keys[nitems] == new char[strlen(key)+1];
items[nitems] = new char[strlen(data)+1] ;
strcpy(keys[nitems], key);
strcpy(items[nitems], data);
n Items++; }
////////////////////////////////////////////////////////////
// Перегруженная индексация: ищет запись с указанным ключом. //
const char *AArr::operator[1 (const char *idx)
(
int j ;
for (j=0; j<nltems; j++)
if (!strcmp(keys[j], idx)) break;
if (j < nitems) return items[j];
else
return error;
}
int main() {
AArr a;
// Несколько записей... a.AddItem("first", "String One!");
a.AddItem("second", "String Two!");
a.AddItem("another", "Another String!");
a.AddItem("one more", "One More String!");
a.AddItem("finish", "That's all folks!");
// Проверка:
char *i;
i = "second";
printf("\n%s: %s\ri", i, a[i]);
i = "one more";
printf ("%s: %s\n", i, a[i]);
i = "abracadabra";
printf ("%s: %s\n", i, a[i]);
i = "finish"; printf("%s: %s\n", i, a[i]);
return 0;
}
Этот пример не требует особых пояснений. Здесь перегружается функция-операция с именем Aarr: : operator []. Получившийся класс ведет себя как массив с “индексами”-строками. Вывод программы показан на рис. 8.3.
Рис. 8.3 Программа Index
Простое наследование
При простом, наследовании производный класс порождается всего одним базовым классом. Вот пример:
#include <stdio.h>
#include <string.h>
class Time { // Базовый класс - время.
int hr, min;
public:
Time(int h=12, int m=0): hr(h), min(m) {}
void SetTime(int h, int m) ( hr = h; min = m; }
void Show() ;
};
void Time::Show() {
printf("%02d:%02d", hr, min);
}
class Alarm: public Time { // Класс сообщений таймера.
char *msg;
public:
Alarm(char*);
~Alarm() { delete [] msg; }
void SetMsg(char*);
void Show(); // Переопределяет Time::Show (). };
Alarm::Alarm(char *str) ;
{
msg = new char[strlen (str) + 1];
strcpy(msg, str);
}
void Alarm::SetMsg(char *str) {
delete [] msg;
msg = new char[strlen (str) + 1];
strcpyfmsg, str);
}
void Alarm::Show()
{
Time::Show(); // Вызов базовой Show(). printf(": %s\n", msg);
int main () {
Alarm a = "Test Alarm!!!"; // Время по умолчанию 12:00.
а.Show ();
a.SetTime(7, 40); // Функция базового класса. а.Show () ;
а.SetMsg("It's time!); // Функция производного класса. a.Show();
return 0;
}
Реализация виртуального механизма
Для реализации виртуальных свойств классов нужно обеспечить выбор нужной функции на этапе выполнения программы. Это называют поздним связыванием (объекта с его методами). Компилятор не может заранее разрешить обращение к виртуальной функции-элементу объекта, если последний представлен указателем или ссылкой. На этапе компиляции действительный тип объекта неизвестен. Поэтому компилятор делает примерно следующее:
Для каждого класса, содержащего виртуальные функции, формируется таблица адресов этих функций. Ее называют таблицей виртуальных методов.
Для каждой отдельной иерархии классов адрес конкретной виртуальной функции находится па том же самом месте в таблице виртуальных методов каждого из классов, т. е. имеет одно и то же смещение от начала таблицы.
Каждый представитель класса с виртуальными функциями содержит скрытый указатель на его таблицу виртуальных методов. Компилятор автоматически вставляет в начало конструктора класса код, обеспечивающий инициализацию этого указателя адресом виртуальной таблицы класса.
При вызове виртуальной функции компилятор генерирует код, который прежде всего извлекает из объекта его указатель на виртуальную таблицу. После этого код обращается к пункту таблицы для данной функции, получая, таким образом, нужный адрес. Затем уже несложно генерировать вызов.
Как видите, виртуальный механизм, как и все хорошее в этом мире, связан с некоторыми издержками, как в плане занимаемой памяти (виртуальная таблица), так и в плане времени выполнения (дополнительные косвенные ссылки при вызове). Однако эти издержки, как это ни удивительно, очень малы.
Заключение
Содержание данной главы практически исчерпывает объектно-ориентированные аспекты C++. Однако в языке еще немало различных возможностей, среди которых можно назвать управление исключительными ситуациями и шаблоны. Одним из таких аспектов C++ являются классы потоков ввода-вывода, изучением которых мы займемся в следующей главе. Реализация ввода-вывода может служить примером использования ряда объектно-ориентированных концепций, описанных выше.
Сложное наследование
Язык C++ допускает не только простое, но и сложное наследование, т. е. наследование от двух и более непосредственных базовых классов. Это позволяет создавать классы, комбинирующие в себе свойства нескольких независимых классов-предков.
Это чаще всего имеет смысл, когда у вас есть некоторый набор понятий, которые могут более или менее независимо комбинироваться с различными элементами другого набора понятий. Простейший пример. Имеется понятие “растение”. Бывают “культурные растения”, и бывают “дикорастущие растения”. С другой стороны, растение может иметь или не иметь “товарной ценности”, т. е. быть полезным или бесполезным с коммерческой точки зрения. Если говорить о товарной ценности, то тут у растений бывают “цветы” и “плоды” и т. д. Все это образует довольно развернутую структуру, которая может порождать понятия вроде “дикое растение, цветы которого можно продавать на рынке”. (Возможно, кстати, и такое: “дикое растение, цветы которого имеют товарную ценность, но которые нельзя продавать на рынке”!) А можно, с некоторыми модификациями, говорить то же самое не о растениях, а о животных или веществах, минералах. И есть не только “товарные” сущности, но и сорняки, вредители. И так далее.
Очевидно, здесь существует ряд довольно независимых категорий — “растение”, “товар”, “культурность” и прочее. Подобная структура — отличный кандидат на реализацию в виде иерархии классов со сложным наследованием.
Кстати, в языке Object Pascal, реализованном в сходном продукте Борланда — Delphi, — нет сложного наследования, что в ряде случаев может значительно ограничить его полезность в сравнении с C++.
Для иллюстрации сложного наследования возьмем последний пример с “сообщениями таймера”. Понятия времени и понятие сообщения — независимые, и, возможно, в программе будут другие классы, родственные “времени” и “сообщению”. Поэтому вполне разумным будет определить для них отдельные классы и породить от них третий класс, применив методику сложного наследования:
#include <stdio.h>
#include <string.h>
//////////////////////////////////////////////////////////////////////
// Базовый класс - время.
//
class Time {
protected;
int hr, min;
public:
Time(int h=12, int m=0): hr(h), min (m):{}
void Show() ;
};
void Time::Show() {
printf("%02d:%02d\n", hr, min);
}
//////////////////////////////////////////////////////////
// Базовый класс - сообщение. //
class Message { protected:
char *msg;
public:
Message(char*) ;
~Message () { delete[]msg; }
void Show () ;
}
Message::Message(char*msg)
// Конструктор Message. {
msg = new char[strlen(str)+1];
strcpy(msg, str);
}
void Message::Show()
{
printf(%s\n", msg);
}
///////////////////////////////////////////////////////
// Производный класс сообщений таймера.
//
class Alarm: public Time, public Message { public:
Alarm(char* str, int h, int m): Time(h, m), Message(str) {}
void Show ();
};
Alarm::Show() // Переопределяет базовые Show().
{
printf("%02d:%02d: %s\n", hr, min, msg);
}
int main() {
Alarm a("Test Alarm!!!", 11, 30);
a.Show() ;
return 0;
}
Вы видите, что конструктор производного класса Alarm имеет пустое тело и список инициализации, вызывающий конструкторы базовых классов. Элементы данных базовых классов объявлены как protected, чтобы можно было непосредственно обращаться к ним в функции Show () производного класса.
Неоднозначности при сложном наследовании
В иерархии классов со сложным наследованием вполне может получиться так, что класс косвенно унаследует несколько экземпляров некоторого базового класса. Если В и С оба являются наследниками A, a D наследует В и С, то D получает двойной набор элементов класса А. Это может приводить к неоднозначностям при обращении к ним, что будет вызывать ошибки времени компиляции. Вот иллюстрация:
class A { public:
int AData;
void AFunc ();
II... };
class B: public A
{
// ... };
class C: public A {
// ...
};
class D: public B, public С // Двукратно наследует А.
{
// ... ,
};
int main (void)
{
D d;
d.AData = 0; // Ошибка! d. AFunc ();
// Ошибка!
return 0;
}
В этом примере строки в main () , содержащие обращения к унаследованным от А элементам, будут вызывать ошибку компиляции с выдачей сообщения о том, что элемент класса неоднозначен. Однако эту неоднозначность несложно устранить, применив операцию разрешения области действия, например, так:
d.B::AData= 0;
d.С::AFunc();
Виртуальные базовые классы
В качестве альтернативы операции разрешения области действия при сложном наследовании, подобном описанному в предыдущем параграфе, можно потребовать, чтобы производный класс содержал только одну копию базового. Этого можно достигнуть, описав базовый класс при наследовании от него как виртуальный с помощью ключевого слова virtual. Вот модификация предыдущего примера, которая делает класс А виртуальным базовым классом:
class A { public:
int AData;
void AFunc ();
// . .. };
class B: public virtual A // A - виртуальный базовый класс.
{
}:
class C: public virtual A // A - виртуальный базовый класс.
{
// ...
};
class D: public B, public С // Содержит только одну копию А.
{
// ...
};
int main(void) {
D d;
d.AData = 0; // Теперь неоднозначности не возникает.
d.AFunc();
//
return 0;
}
Виртуальные базовые классы — более хитрая вещь, чем может показаться с первого взгляда. Если, допустим, конструкторы “промежуточных” классов В и С явно вызывают в своих списках инициализации какой-то конструктор с параметрами класса А, то снова возникает неоднозначность — какой набор параметров компилятор должен использовать при конструировании той единственной копии А, которая содержится в С?
Поэтому, если при конструировании производного класса должен инициализироваться виртуальный базовый класс (пусть даже он инициализируется косвенным образом через конструкторы промежуточных классов), в списке инициализации требуется явно указать инициализатор виртуального базового класса, примерно так:
D: :D(...) : В(. . .) , С(. . .) , А(.. .) {
// ... }
Все выглядит так, как если бы виртуальный базовый класс был непосредственным предком производного класса D “в обход” промежуточных классов иерархии. Поэтому, если требуется вызов конструктора виртуального базового класса, последний обязательно должен присутствовать в списке инициализации производного класса, даже если реально не возникает никакой неоднозначности. Такая ситуация существует, например, в библиотеке OWL, которая имеет иерархию со сложным наследованием и виртуальными базовыми классами. Конструктор главного окна приложения там должен определяться так:
TMyWindow::TMyWindow(TWindow *parent, const char *title):
TFrameWindow(parent, title), TWindow(parent, title) {
// . . . }
TWinoow — виртуальный базовый класс, поэтому он должен инициализироваться отдельно, хотя это выглядит совершенно излишним, так как TFrameWindow, промежуточный класс, сам вызывает конструктор TWindow с теми же параметрами.
Специальные функции-элементы класса
Специальными функциями-элементами называют функции, которые могут вызываться компилятором неявно. Это может происходить при создании и уничтожении представителей класса, при их копировании и преобразовании в другие типы. К таким функциям относятся:
Конструктор. Инициализирует представители класса.
Конструктор копии. Инициализирует новый представитель, используя значения существующего объекта.
Операция присваивания. Присваивает содержимое одного представителя класса другому.
Деструктор. Производит очистку уничтожаемого объекта.
Операция new. Выделяет память для динамически создаваемого объекта.
Операция delete. Освобождает память, выделенную под динамический объект.
Функции преобразования. Преобразуют представитель класса в другой тип (и наоборот).
Статические элементы данных
Статический элемент данных является по существу глобальной переменной с областью действия в классе и разделяется всеми представителями класса. Он только один, вне зависимости от того, сколько представителей имеет класс. На самом деле статический элемент данных существует даже в том случае, когда никаких представителей класса не создано.
Помимо объявления в определении класса, статический элемент данных должен еще и определяться:
class SomeClass
{
static int iCount;
// Объявление статического
// элемента.
//.. .
};
int SomeClass::iCount = 0;
// Определение статического
// элемента.
Обращаться к открытым статическим элементам класса можно либо через любой его представитель операциями “.” и “->”, либо с помощью операции разрешения области действия (SomeClass : : iCount). Последний способ предпочтительнее, так как ясно показывает, что элемент не связан с конкретным объектом.
Статические элементы-функции
Функция класса, объявленная с модификатором static, не связывается ни с какими его конкретными представителями. Другими словами, ей не передается указатель this в качестве скрытого параметра. Это означает, что:
Статическая функция-элемент может вызываться, даже если никаких
Статическая функция-элемент может обращаться только к статическим элементам данных класса и вызывать только другие статические функции-элементы класса.
Такая функция не может быть объявлена виртуальной.
Статические функции-элементы класса могут передаваться процедурам API Windows в качестве возвратно-вызываемых, поскольку не предполагают наличия на стеке параметра this. Обычные функции-элементы для этого не годятся.
Статические элементы класса
Можно объявить элемент класса (данные или функцию) как статический.
Указатель this
Любая функция-элемент класса, не являющаяся статической (что это такое, выяснится позднее) имеет доступ к объекту, для которого она вызвана, через посредство ключевого слова this. Типом this является имя_класса*.
class Dummy {
void SomeFunc(void) {...};
public:
Dummy();
};
Dummy::Dummy)
{
SomeFunc();
this->SomeFunc();
(*this).SomeFunc();
}
В этом примере каждый оператор конструктора вызывает одну и ту же функцию SomeFunc (). Поскольку функции-элементы могут обращаться к элементам класса просто по имени, подобное использование указателя this довольно бессмысленно. Это ключевое слово чаще всего применяется для возврата из функции-элемента указателя или ссылки на текущий объект (вы увидите это позже, когда будут рассматриваться функции-операции).
На самом деле this является скрытым параметром, передаваемым функции-элементу класса при вызове. Именно этим функция-элемент (не статическая) отличается от обычной функции. При вызове функции-элемента компилятор генерирует код, который после всех указанных в вызове параметров помещает на стек указатель на объект, для которого функция вызвана. Поэтому, например, нельзя вызвать функцию-элемент через обычный указатель на функцию. Указатель на функцию-элемент объявить можно, но в нем явно должен быть специфицирован класс, например:
long (Dummy::*fPtr)();
fPtr = &Dummy::SomeFunc;
Привести такой указатель к обычному типу указателя на функцию невозможно. При его разыменовании всегда нужно указывать конкретный объект, и для этого в C++ предусмотрены две новых операции “. *” и “->*”. Первая из них применяется с непосредственным объектом, вторая — с указателем на объект. Вызов фун.кции-элемента через указатель выглядит так:
1 = (dummyObj.*fptr)();
1 = (dummyPtr->*fptr)();
Все сказанное приложимо и к элементам данных. На них тоже можно ссылаться с помощью указателей, которые нельзя привести к обычному типу указателя. На самом деле они содержат не адрес некоторой ячейки памяти, а смещение элемента данных относительно начала объекта. Для разыменования указателей на элемент данных используются те же операции:
double Dummy::*dptr;
dptr = &Dummy::someData;
d = dumrnyObj . *dptr;
d = duinmyPtr->*dptr;
Приоритет и правило ассоциации у этих специфических операций те же, что и у обычного разыменования (*) и других одноместных операций (14, справа налево).
Виртуальные функции
Функции-элементы класса могут объявляться в C++ как виртуальные. Ключевое слово virtual заставляет компилятор генерировать для класса некоторую дополнительную информацию 6 функции. Происходит следующее: если виртуальная функция переопределяется в производном классе, и если имеется указатель или ссылка на базовый класс (которые могут с тем же успехом ссылаться на производный класс, поскольку производный объект есть в то же время и объект базового класса), то при обращении к функции через указатель (ссылку) будет вызвана правильная функция-элемент (т. е. соответствующая типу действительного объекта) — базового или одного из производных классов, в зависимости от типа конкретного объекта.
Не хочу показаться занудой, но, как мне кажется, стоит повторить, чем косвенное обращение к объекту (указатель или ссылка) в данном отношении отличается от прямого. Можно недвусмысленно объявить объект базового и объект производного классов. Потом можно присвоить объект производного класса переменной базового типа. Не требуется даже никаких приведений, потому что, как я уже говорил, производный объект является объектом базового класса. “Автомобиль” есть “средство передвижения”. Однако при этом будет потеряна всякая специфика “автомобиля”, отличающая его от всех других средств передвижения, наземных, водных или воздушных. Но применение указателей или ссылок в объектно-ориентированных языках типа C++ приводит к тому, что объект сам может помнить, к какому типу он относится, и указатель на базовый тип может быть в данном случае снова приведен
Следующий пример покажет вам разницу между виртуальным и не виртуальным переопределением функции.
//////////////////////////////////////////////////////////
// Virtual.cpp: Демонстрация виртуальной функции.
//
#pragma hdrstop
#include <condefs.h>
#include <stdio.h>
class One
{
// Базовый класс. public:
virtual void ShowVirtO
// Виртуальная функция.
{
printf("It's One::ShowVirt()!\n");
}
void ShowNonVirt() // Не-виртуальная функция.
{
printf("It's One::ShowNonVirt()!\n") ;
}
};
class Two: public One
{
// Производный класс. public:
virtual void ShowVirt()
(
printf ("It's Two::ShowVirtO !\n") ;
)
void ShowNonVirt ()
(
printf("If s Two::ShowNonVirt ()!\n") ;
) };
int main(void)
{
Two derived;
One *pBase = sderived;
pBase->ShowVirt(); // Вызовет Two::ShowVirt().
pBase->ShowNonVirt(); // Вызовет One::ShowNonVirt().
//
// Следующий вызов подавляет виртуальный механизм:
// pBase->One::ShowVirt();
// Явно вызывает One::ShowVirt().
return 0;
}
Результат работь! программы (рис. 8.4) показывает, что при обращении к виртуальной функции через базовый указатель будет'вызвана “правильная” функция, соответствующая типу действительного (производного) объекта.
Ключевое слово virtual при объявлении функции в производных классах не обязательно. Функция, однажды объявленная виртуальной, остается таковой во всех производных классах иерархии.
Рис. 8.4 Демонстрация виртуальной и не-виртуальной функции
Виртуальная функция не может быть статической.
Желательно, а иногда просто необходимо, объявлять виртуальным деструктор базового класса. Если этого не сделать, то при удалении динамического объекта, на который ссылается указатель базового класса, всегда будет вызываться только базовый деструктор вне зависимости от того, чем является данный объект. Это можно проиллюстрировать такой схемой:
class One { public:
~One () { /* ... */ }
};
class Two: public One
{
Something *s;
public:
Two()
{
s = new Something; // Выделение ресурса.
}
~Two()
{
delete s; // Очистка. } };
int main() {
One *pBase = new Two;
// ...
delete pBase; // Удаление динамического объекта.
return 0;
}
В данном примере при удалении объектаоперацией delete будет вызван только базовый деструктор ~0nе (), хотя объект принадлежит к производному классу. Чтобы вызывался правильный деструктор, следовало бы объявить его виртуальным в базовом классе:
virtual ~One() { /* ... */}
Вызов функций-элементов класса
Совершенно аналогично тому, что имеет место в случае элементов-данных, функции-элементы класса могут вызываться функциями-элементами того же класса просто по имени. Обычные функции и элементы других классов могут вызывать функции-элементы данного класса для существующих его представителей с помощью операций “ . ” или “->” (через указатель). Приведенный ниже пример это иллюстрирует.
#include <stdio.h>
class Time ( int hour;
int min;
public:
void SetTime(int h, int m)
{
hour = h; min = m; } void ShowTime(void)
{
printf("Time: %02d:%02d.\n", hour, min);
}
};
int main()
{
Time start;
Time *pStart = &start;
int hr, min;
start.SetTime(17, 15); // Вызов элемента для объекта
// start.
pStart~>ShowTime(); // вызов элемента через указатель
//на объект.
return 0;
}
Бесформатный ввод-вывод
До сих пор речь у нас шла почти исключительно о вводе-выводе с использованием операций извлечения/передачи данных. Эти операции перегружены для всех встроенных типов и выполняют соответствующие преобразования из внутреннего представления данных в текстовое и из текстового во внутреннее (машинное).
Однако в библиотеке C++ имеется немало функций бесформатного ввода-вывода, которые часто применяют для чтения и записи двоичных (не-текстовых) файлов.
Чтение и запись сырых данных
Чтение сырых данных производится функцией read () класса istream:
istream &read(char *buf, long len);
Здесь buf — адрес буфера, в который будут читаться данные, а len — число символов, которые нужно прочитать.
Запись сырых данных производится функцией write () класса ostream. Она выглядит точно так же, как функция read () :
ostream &write(char *buf, long len);
Здесь buf — адрес буфера, в котором содержатся данные, а len — число символов, которые должны быть записаны в поток.
Обе функции возвращают ссылку на свой объект-поток. Это означает, что возможны их цепные вызовы, т. е. выражения вроде
ostream os (...);
os.write(...).write (...).write(...) ;
Вот небольшой пример записи и чтения сырых данных:
#include <iostream.h>
#include <fstream.h>
int main(void) {
char name[] = "testfile.dat";
int i = 1234567;
double d = 2.718281828;
//
// Открытие выходного потока в двоичном режиме
//и запись тестовых данных.
//
ofstream ofs(name, ios::out | ios::binary);
if (ofs) {
ofs.write((char*)&i, sizeof(i)); // Целое.
ofs.write((char*)&d, sizeof(d)); // Вещественное.
ofs.write(name, sizeof(name)); // Строка. ofs.close ();
}
//
// Открытие входного потока в двоичном режиме.
// if stream ifs(name, ios::in | ios::binary) ;
i = 0; //
d = 0; // Уничтожить данные.
name[0] = '\0'; //
//
// Прочитать данные.
//
if (ifs) {
ifs.read((char*)&i, sizeof (i));
ifs.read((char*)&d, sizeof(d));
ifs.read(name, sizeof(name));
ofs.close () ;
} //
// Проверка - напечатать прочитанные данные. //
cout “ "Data read from file: i = " << i<< ", d = " << d
<< ", name = " << name << endl;
return 0;
}
Чтение символов и строк
Для чтения одиночных символов, а также строк применяется функция get класса istream. Эта функция перегружена следующим образом:
int get () ;
istream &get(char &c) ;
istream &get(char *buf, long len, char t = '\n');
Две первые формы функции предназначены для извлечения из потока одиночного символа. Функция int get() возвращает символ в качестве своего значения. Функция get (char &c) передает символ в параметре и возвращает ссылку на свой поток.
Вот, например, как можно было бы выполнить посимвольное копирование файлов:
ifstream ifs("infile.dat");
ofstream ofs("outfile.dat");
while (ifs & ofs)
ofs.put(ifs.get());
// put (char) передает в поток
// одиночный символ.
Последняя форма функции get () извлекает из потока последовательность символов. Символы читаются в буфер buf, пока не произойдет одно из следующих событий:
будет встречен ограничивающий символ t (по умолчанию ' \n ');
будет встречен конец файла;
в буфер будет записано len символов, включая завершающий строку 0. Имеется еще функция getline(), во всем аналогичная этой форме
get ():
istream Sgetline(char *buf, long len, char t = '\n');
Разница между этими двумя функциями состоит в том, что get line () извлекает из потока ограничивающий символ, в то время как get () этого не делает. Ни та, ни другая функция не записывает ограничивающий символ в буфер.
Пример использования getline () вы уже видели в листинге 9.4. Вот небольшой пример чтения строк с помощью get ():
#inciude <iostream.h>
int main(void) {
char name[9], ext[4];
cout << "Enter a filename with extension: ";
cin.get(name, 9, '.');
cin.ignore (80, '.'); // Удалить все оставшиеся
// до точки символы. cin.get(ext, 4) ;
cin.ignore(80, '\n'); // Удалить все, что осталось
// в строке.
cout<< "Name: "<< name << "; extension: " << ext << endl;
return 0;
}
Эта программа, как нетрудно догадаться, усекает произвольное имя файла до формата 8.3.
Двоичный режим ввода-вывода
Двоичный режим открытия файла (с установленным битом binary) означает, что никакой трансляции данных при передаче из файла в поток и обратно производиться не будет. Речь здесь идет не о форматных преобразованиях представления данных. При текстовом режиме (он принимается по умолчанию) при передаче данных между файлом и потоком производится замена пар символов CR/LF на единственный символ LF (' \n ') и наоборот. Это происходит до преобразований представления, которые выполняются операциями извлечения/передачи. Двоичный ввод-вывод означает всего-навсего, что такой замены происходить не будет; тем не менее двоичный режим необходим при работе с сырыми данными, т. е. данными в машинной форме без преобразования их в текстовый формат.
Чтобы открыть файл в двоичном режиме, нужно, как уже упоминалось, установить в параметре mode конструктора потока или функции open() бит ios::binary.
Файловые потоки
Файловые потоки библиотеки ввода-вывода реализуют объектно-ориентированную методику работы с дисковыми файлами. Имеется три класса таких потоков:
ifstream специализирован для ввода из дисковых файлов.
of stream специализирован для записи дисковых файлов.
fstream управляет как вводом, так и записью на диск.
Эти классы выводятся соответственно из istream, ostream и iostream. Таким образом, они наследуют все их функциональные возможности (перегруженные операции << и>>” для встроенных типов, флаги форматирования и состояния, манипуляторы и т. д.).
Чтобы работать с файловым потоком, нужен, во-первых, объект потока, а во-вторых, открытый файл, связанный с этим объектом.
Форматирование
Библиотека ввода-вывода предусматривает три способа форматирования: посредством вызова форматирующих функций-элементов, с помощью манипуляторов или путем установки или сброса флагов потока.
Форматирующие флаги
Флаги управления форматированием являются битовыми полями, хранящимися в переменной типа fmtflags (псевдоним int). Для их чтения и/или модификации могут применяться следующие функции-элементы класса ics:
int flags (), int flags (int). Без параметра возвращает текущее состояние флагов. При указанном параметре устанавливает новые значения флагов и возвращает их прежнее состояние.
int setf(int), long setf(int, int). Первая форма устанавливает флаги, биты которых установлены в параметре. Вторая форма модифицирует флаги, биты которых установлены во втором параметре. Значения этих флагов задаются первым параметром. Возвращает прежнее состояние всех флагов.
void unsetf(int). Сбрасывает флаги, биты которых установлены в параметре.
Помимо функций, для управления флагами можно пользоваться манипуляторами setiosflags (аналог setf() с одним параметром) и reset-iosflags (аналог unsetf ()).
В таблице 9.3 описаны форматирующие флаги потоков.
Таблица 9.3. Форматирующие флаги класса ios
Флаг | Описание | ||
internal | Если установлен, при выводе чисел знак выводится на левом краю поля вывода, а само число выравнивается по правому краю поля. Промежуток заполняется текущим символом заполнения. | ||
dec | Устанавливает десятичное представление чисел. Принимается по умолчанию. | ||
oct | Устанавливает восьмеричное представление чисел. | ||
hex | Устанавливает шестнадцатеричное представление чисел. | ||
showbase | Если установлен, то при восьмеричном и шестнадцатеричном представлении чисел выводит индикатор основания (0 для восьмеричных и Ох для шестнадцатеричных чисел). | ||
showpoint | Если установлен, для вещественных чисел всегда выводится десятичная точка. | ||
uppercase | Если установлен, шестнадцатеричные цифры от А до F, а также символ экспоненты Е выводятся в верхнем регистре. | ||
boolalpfa | Если установлен, булевы значения выводятся как слова “true/false”. В противном случае они представляются соответственно единицей и нулем. | ||
showpos | Выводит + для положительных чисел. | ||
scientific | Если установлен, вещественные числа выводятся в научной (экспоненциальной) нотации. | ||
fixed | Если установлен, вещественные числа выводятся в десятичном формате (с фиксированной точкой). | ||
unitbuf | Если установлен, поток сбрасывается после каждой операции передачи. |
Несколько замечаний относительно перечисленных в таблице флагов.
Флаги left, right и internal являются взаимоисключающими. В данный момент времени может быть установлен только один из них.
Взаимоисключающими являются также флаги dec, oct и hex.
При модификации базы представления в качестве второго параметра setf() можно использовать константу ios: :basefield.
При модификации выравнивания в поле можно аналогичным образом использовать константу ios: :adjustfield.
При модификации формы представления (нотации) чисел с плавающей точкой можно использовать константу ios : : floatfield. Ниже мы приводим листинг программы, демонстрирующей применение различных флагов форматирования.
Имена перечисленных выше флагов и других констант принадлежат к области действия класса ios. Вне этого класса нужно либо воспользоваться разрешением области действия (ios : : scientific), либо обращаться к ним, как к элементам существующего объекта (cout. scientific). Мы поедпочитаем первый способ.
Листинг 9.3. форматирующие флаги потоков
////////////////////////////////////////////////////
// Flags.срр: Форматирующие флаги потоков.
//
#include <iostream.h>
#include <iomanip.h> #pragma hdrstop
#include <condefs.h>
#pragma argsused
int main(int argc, char* argv[])
{
//
// Демонстрация флага skipws. Если его сбросить, то при
// наличии начальных пробелов при вводе возникает ошибка.
//
long 1;
cout<< "Enter an integer: ";
cin.unsetf(ios::skipws);
cin >> 1;
if (cin) // При ошибке потока
cin == NULL. cout<< "You entered "<< 1<< endl;
else {
cout << "Incorrect input."<< endl;
cin.clear (); // Обнуление битов ошибки.
} cout<<endl;
//
// Демонстрация флагов основания и знака.
// Задается основание 16, вывод индикатора и знака +.
//
1 = 8191;
cout.setf(ios::hex, ios::basefield);
cout.setf(ios::showbase | ios::showpos);
cout << "hex: " <<1 << oct // Изменим основание
<< " oct: "<< 1 << dec // манипулятором.
<< " dec: " << 1 << endl;
cout << endl;
//
// Демонстрация флагов формата вещественных чисел.
//
double dl = 1.0е9, d2 = 34567.0;
cout <<"Default: " << dl << " "<<d2 << end1;
// Вывод десятичной точки. cout.setf(ios::showpoint);
cout << "Decimal: " << dl<< " " << d2 << endl;
// Нотация с фиксированной точкой.
// Заодно сбросим вывод знака +.
cout.setf(ios::fixed, ios::floatfield | ios::showpos);
cout << "Fixed: " << dl << " " << d2 << endl;
cout<< endl;
//
// Вывод булевых значений как "true/false".
//
bool b = true;
cout.setf(ios::boolalpha) ;
cout << "Boolean values: " << b << '' << !b “ endl;
return 0;
}
Рис. 9.2 Демонстрация флагов форматиоования потока
Форматирующие функции-элементы
Эти функции являются элементами класса ios и перегружены таким образом, чтобы можно было либо читать, либо устанавливать значение соответствующего атрибута потока. Если аргумент в вызове отсутствует, функция возвращает текущее значение атрибута. Если аргумент указан, функция устанавливает новое и возвращает предыдущее значение атрибута.
long width(long)
Эта функция предназначена для чтения или установки атрибута ширины поля.
Применяемая ко входному потоку, функция позволяет задать максимальное число вводимых символов.
На выходном потоке функция задает минимальную ширину поля вывода.
Если действительное поле вывода меньше установленной ширины, выводятся дополнительные заполняющие символы. Символ заполнения определяется специальным атрибутом потока.
Если действительное поле вывода больше установленной ширины, ее значение игнорируется.
Значением ширины по умолчанию является 0 (ширина поля определяется выводимыми данными).
Ширина поля сбрасывается в 0 после каждой передачи в поток.
char fill(char)
Функция позволяет прочитать или установить текущий символ заполнения.
По умолчанию символ заполнения — пробел.
long precision(long)
Эта функция позволяет прочитать или установить значение атрибута точности, определяющего либо общее число выводимых цифр, либо число цифр дробной части.
Точность по умолчанию равна шести цифрам.
Если установлен флаг scientific или fixed, точность задает число цифр после десятичной точки.
Если ни один из этих флагов не установлен, точность задает общее число значащих цифр.
Пример
Ниже приводится программа, демонстрирующая форматирование потока с помощью функций-элементов класса ios.
Листинг 9.1. Демонстрация форматирующих функций потока
///////////////////////////////////////////////
// Format.срр: Форматирующие функции-элементы ios.
//
#include <iostream.h>
#pragma hdrstop
#include <condefs.h>
#pragma argsused
int main(int argc, char* argv[])
{
//
// Ширина поля при вводе и выводе.
//
cnar sir [16];
cout<< "Enter something: ";
cin.width(16); // Ввод не более 15 символов. cin>> str;
cout.width(32); // Вывести в поле шириной 32. cout<< str<< "\n\n";
//
// Заполняющий символ и ширина поля. Ширина сбрасывается
// после каждой операции, поэтому она устанавливается
// для каждого числа.
//
int h = 7, m = 9, s = 0; // Выводятся в виде hh:mm:ss.
cout.fill('0'); cout << "Time is ";
cout.width (2); cout << h << ' : ' ; cout.width (2) ;
cout<< m<< ' : ' ;
cout.width (2) ;
cout<< s<< ".\n\n";
cout.fill (' '); // Восстановить пробел.
//
// Точность.
//
double d = 3.14159265358979;
float f = 27182.81828;
cout.precision (5);
cout << f << '\n'; . // Выводит "27183" .
cout << d << '\n'; ' // Выводит "3.1416".
cout .precision (4) ;
cout << f << '\n'; // Выводит "2.718е+04".
cout.setf(ios::fixed); // Установить флаг fixed.
cout<< f<<'\n'; // Выводит "27182.8184".
return 0;
}
Потоки ввода-вывода
До сих пор в программных примерах мы пользовались только функциями стандартной библиотеки С. Однако в C++ имеются собственные средства, основанные на принципах классовой модели. Другими словами, в исполнительной библиотеке C++ имеется набор классов для управления вводом-выводом.
В отличие от функций буферизованного ввода-вывода С (таких, как print f и scanf, не выполняющих никаких проверок на соответствие аргументов форматной строке) классы потоков C++ безопасны в отношении типа. Ввод-вывод использует механизм перегрузки операций, гарантирующий вызов нужной функции-операции для указанного типа данных. Это главное преимущество потоков языка C++.
Классы потоков
К классам потоков относятся следующие:
Класс streambuf управляет буфером потока, обеспечивая базовые операции заполнения, опорожнения, сброса и прочих манипуляций с буфером.
Класс ios является базовым классом потоков ввода-вывода.
Классы istream и ostream — производные от ios и обеспечивают работу потоков соответственно ввода и вывода.
Класс iоstream является производным от двух предыдущих и предусматривает функции как для ввода, так и для вывода.
Классы ifstream, of stream и f stream предназначены для управления файловым вводом-выводом.
Классы istrstream и ostrstream управляют резидентными потоками (форматированием строк в памяти). Это устаревшая методика, оставшаяся в C++Builder в качестве пережитка.
Для работы с потоками вам потребуется включить в программу заголовочный файл iostream.h. Кроме того, может потребоваться подключить файлы fstream.h (файловый ввод-вывод), iomanip.h (параметризованные манипуляторы) и strstream.h (форматирование ь памяти).
Конструирование объекта потока
Каждый из трех классов файловых потоков имеет четыре конструктора.
Конструктор, создающий объект без открытия файла:
ifstream () ;
of stream();
fstream () ;
Конструктор, создающий объект, открывающий указанный файл и закрепляющий этот файл за потоком. Аргументами являются имя файла, режим открытия и режим защиты (в Windows не используется):
if stream(const char *name,
int mode = ios::in, long prot = 0666);
ofstream(const char *name,
int mode = ios::out, long prot = 0666);
fstream (const char *name, int mode, long prot = 0666);
Конструктор, создающий объект и связывающий с ним уже открытый файл. В качестве аргумента передается дескриптор файла:
ifstreamfint file);
ofstream(int file);
fstream (int file) ;
Конструктор, создающий объект и связывающий с ним уже открытый файл; объект ассоциируется указанным буфером:
ifstream(int file, char *buf, int len)
of stream(int file, char *buf, int len)
fstream (int file, char *buf, int len)
Манипуляторы
Манипуляторы потоков являются по существу функциями, которые можно вызывать непосредственно в цепочке операций извлечения или передачи в поток. Различают простые и параметризованные манипуляторы. У простых манипуляторов аргументы отсутствуют. Параметризованные манипуляторы имеют аргумент.
Ниже приводится сводка имеющихся манипуляторов, как простых, так и параметризованных. Они Перечислены в алфавитном порядке.
Таблица 9.2. Простые и параметризованные манипуляторы
Манипулятор | Описание | ||
dec | Задает десятичную базу преобразования. | ||
end1 | Передает в поток символ новой строки и сбрасывает поток. | ||
ends | Передает в поток символ завершающего строку нуля. | ||
flush | Сбрасывает выходной поток. | ||
hex | Задает шестнадцатеричную базу преобразования. | ||
lock(ios Sir) | Блокирует дескриптор файла потока ir. | ||
oct | Задает восьмеричную базу преобразования. | ||
resetiosflags(int f) | Сбрасывает флаги, биты которых установлены в f. | ||
setbase(int b) | Устанавливает базу преобразования (0, 8, 10 или 16). | ||
setiosflags(int f) | Устанавливает флаги, биты которых установлены в f. | ||
setfill(int c) | Задает символ заполнения (аналогичен функции
fiilO). | ||
setprecision(long p) | Задает точность (аналогичен функции precision ()). | ||
setw(iong w) | Задает ширину поля (аналогичен функции width ()). | ||
lunlock(ios &ir) | Разблокирует дескриптор файла для потока ir. | ||
ws | Исключает начальные пробельные символы. |
Вот пример использования некоторых манипуляторов (мы создали один свой собственный):
Листинг 9.2. Форматирование с помощью манипуляторов
/////////////////////////////////////////////////
// Manip.cpp: Демонстрация некоторых манипуляторов.
//
#include <iomanip.h>
#pragma hdrstop
#include <condefs.h>
//////////////////////////////////////////////////
// Манипулятор, определенный пользователем - звонок.
//
ostream shell(ostream &os)
{
return os<< '\a';
#pragma argsused
int main(int argc, char* argv[])
{
cout “ bell; // Тестирование манипулятора bell.
//
// Манипуляторы базы преобразования.
//
long 1 = 123456;
cout<< "Hex: "<< hex<< 1<< end1
<<"Oct: "<< oct<< 1<< end1
<< "Dec: " << dec << 1 << end1;
//
// Параметризованные манипуляторы.
//
int h=12, m=5, s=0; // To же, что в примере
// Format.cpp. cout << "The time is " << setfill('0')
<< setw(2) << h << ':'
<< setw(2) << m << ':'
<< setw(2) << s << setfillC ') << end1;
return 0;
}
Как видите, очень несложно определить свой собственный простой манипулятор. Это всего лишь функция, возвращающая ссылку на переданный ей в параметре поток.
Создать параметризованный манипулятор не так просто. Существуют различные способы сделать это, но наиболее очевидный из них — реализация манипулятора через класс эффектора. Идея состоит вот в чем. Нужно определить для манипулятора собственный класс с конструктором, принимающим нужные параметры, •и перегрузить для этого класса операцию передачи (извлечения) соответствующего потока. После этого конструктор можно вызывать в качестве параметризованного манипулятора. Создается временный объект, который выводится в поток перегруженной операцией и удаляется. Ниже показан манипулятор, который выводит в поток свой аргумент типа unsigned в двоичной форме.
#include <iostream.h>
// Класс эффектора.
class Bin {
int val;
public:
Bin(unsigned arg) { val = arg; }
friend ostream &operator“(ostreams. Bin);
};
// Вывод числа в двоичной форме.
ostream &ooerator<<(ostream &os. Bin b) {
int cb = 1; // Контрольный бит для отсчета циклов.
do {
if (b.val <0) // Если val < 0, то старший бит = 1. os << 1;
else
os<< 0;
} while (b.vai<<= 1, cb<<= 1) ;
return os;
}
int main ()
(
unsigned n = Ox00ff0f34;
cout<< "Some binary: "<< Bin(n)<< end1;
return 0;
}
Рис. 9.1 Манипулятор, выводящий свой аргумент в двоичной форме
Некоторые функции потоков
В классах istream и ostream есть ряд функций, которые позволяют выполнять над потоками разные полезные операции (в основном при бесформатном вводе-выводе). Здесь мы опишем наиболее часто употребляемые из них.
Класс istream
Следующие функции являются элементами класса istream:
istream &ignore(long n = 1, int t = EOF) ; Эта функция позволяет пропустить при вводе ряд символов. Она извлекает из потока максимум n символов, пока среди них не встретится ограничитель t. Ограничитель также извлекается из потока.
long gcount() ; Функция возвращает число символов, извлеченных из потока последней функцией неформатированного ввода. (Если после нее выполнялись какие-либо форматные процедуры, это число может быть модифицировано.)
int peek () ; Функция возвращает следующий символ потока, не извлекая его. Возвращает EOF, если установлен какой-либо из флагов состояния потока.
istream Sputback (char с) ; Возвращает во входной поток последний извлеченный символ.
istream &seekg(long pos) , istream &seekg(long ofs, int dir) ;
Устанавливает положение указателя потока. Для первой формы указывается абсолютная, для второй — относительная позиция указателя. Параметр dir может принимать следующие значения:
ios : :beg смещение (ofs) от начала файла; ios : : cur смещение от текущей позиции указателя;
ios : : end смещение от конца файла.
long tellg() ; Возвращает текущее положение указателя входного потока.
Класс ostream
Последним двум функциям из istream соответствуют аналогичные функции класса ostream:
ostream &seekp(long pos), ostream &seekp(long ofs, int dir) ; Аналогична see kg () . Выполняет абсолютное или относительное позиционирование выходного потока.
long tellp() ; Аналогична tellg() . Возвращает текущую позицию выходного потока.
Операции извлечения и передачи в поток
Основными классами ввода-вывода C++ являются istream и ostream. Первый из них перегружает операцию правого сдвига (>>), которая служит в нем для ввода данных и называется операцией извлечения из потока. Класс ostream перегружает соответственно операцию левого сдвига (<<); она применяется для вывода и называется операцией передачи в поток.
Нужно сказать, что стандартной русской терминологии как таковой в C++ не существует. Каждый изобретает свою собственную; иногда удачно, иногда — нет.
Вот простейшие операторы ввода и. вывода на стандартных потоках:
#include <iostream.h>
int main()
{
char name [ 8.0] ;
cout<< "Enter your name: ";
cin>> name;
cout <<"Hello " << name << "!";
return 0;
}
Как видите, при действиях над потоками возможно последовательное сцепление операций, подобно последовательному присваиванию. Как вы уже знаете, такая форма записи обеспечивается благодаря тому, что функции-операции извлечения и передачи возвращают ссылку на свой объект.
Перегруженные операции для встроенных типов
Классы istream и ostream перегружают операции соответственно извлечения и передачи в поток для всех встроенных типов. Это позволяет единообразно применять эти операции для чтения и вывода символов, целых, вещественных чисел (т. е. с плавающей точкой) и строк. Вот небольшая иллюстрация, где попутно показан еще простейший прием проверки на ошибку при вводе:
#include <iostream.h>
void check(void) {
if (!cin.good())
{
// Либо просто if (!cin) {
cout << "Error detected!";
exit (1);
}
int main(void)
{
double d;
long 1;
cout << "Enter a floating point value: ";
cin >> d;
check () ;
cout << "You entered: " << d << '\n';
cout << "Enter an integer value: ";
cin >> 1;
check () ;
cout << "You entered: " << 1 << '\n';
return 0;
}
Операции извлечения и передачи в поток (соответственно для классов istream и ostream) можно перегрузить таким образом, чтобы можно было применять их для ввода или вывода объектов класса, определенного пользователем. Приведенный ниже пример демонстрирует эту методику. Вообще-то в подобных случаях совершенно необходимо предусмотреть детектирование и обработку ошибок ввода, но здесь мы этого не сделали.
#include <iostream.h>
class Point { int x, у;
public:
Point(int xx = 0, int yy = 0) {
x = xx; у = yy;
}
friend istream &operator>>(istream&, Points);
friend ostream &operator“(ostream&, Points);
};
istream &operator”(istream &is, Point &p)
//
// При вводе точка представляется просто парой чисел,
// разделенных пробелом.
// is >> р.х > р.у;
return is;
}
ostream &operator<<(ostream &os.Point &p) {
//
// Вывод в виде (х, у).
//
os<< ' ( '<< р. х<< ", "<< р. у<<') ' ;
return os;
}
int main() {
Point р;
cout<< "Enter point coordinates: ";
cin>> р;
cout<< "The point values are " << р;
return 0;
}
Предопределенные потоки
Библиотека ввода-вывода C++ предусматривает четыре предопределенных объекта-потока, связанных со стандартными входным и выходным устройствами. Ниже дана сводка этих объектов.
Таблица 9.1. Предопределенные объекты-потоки C++
Имя | Класс | Описание | |||
cin | istream | Ассоциируется со стандартным вводом (клавиатурой). | |||
cout | ostream | Ассоциируется со стандартным выводом (экраном). | |||
cerr | ostream | Ассоциируется со стандартным устройством ошибок (экраном) без буферизации. | |||
clog | ostream | Ассоциируется со стандартным устройством ошибок (экраном)с буферизацией. |
Примеры файловых потоков
Следующий пример (листинг 9.4) демонстрирует различные режимы и способы открытия потока.
Листинг 9.4. Примеры открытия файловых потоков
/////////////////////////////////////////////////////////
// Filemode.срр: Режимы открытия файлов.
//
#include <f stream .,h>
#include <string.h>
#pragma hdrstop
#include <condefs.h>
char *data[] = {"It's the first line of test data.",
"Second ,line.",
"Third line.",
"That's enough!"};
//
// Функция для распечатки содержимого файла. //
int Print(char *fn) {
char buf[80] ;
ifstream ifs(fn) ;
if (!ifs) {
cout <<fn<< " - Error reading file." << endl;
return -1;
} while (ifs) {
ifs.getline(buf, sizeof(buf)) ;
if (ifs)
cout << buf<< end1;
} return 0;
}
#pragma argsused
int main(int argc, char* argv[])
{
char name[]= "Newfile.txt";
fstream fs(name, ios::in);
if (fs) { // Файл уже существует. cout “ name “ " - File already exists." << endl;
} else { // Создать новый файл.
cout<< name<< " - Creating new file."<< endl;
fs.open(name, ios::out);
for (int i=0; i<3; i++) fs << data[i] << endl;
}
fs.close () ;
cout << end1;
//
// Файл либо уже существовал, либо мы его только что
// создали. Распечатаем его.
// Print(name);
cout << endl;
//
// Допишем строку в конец файла.
// fs.open(name, ios::app);
if (rs) {
fs M<< data[3]<< endl;
fs.close ();
} Print(name);
return 0;
}
Рис. 9.3 Результат работы программы Filemode
Для чтения строки из файла мы применили в программе функцию getline () , которая будет подробно описана чуть позже.
Режимы открытия файла
Параметр mode, который имеет вторая форма конструктора, задает режим открытия файла. Для значений параметра класс ios определяет символические константы, перечисленные в таблице 9.5.
Таблица 9.5. Константы класса ios для режимов открытия файла
Константа |
Описание |
||
арр | Открытие для записи в конец файла. | ||
ate | При открытии позиционирует указатель на конец файла. | ||
binary | Файл открывается в двоичном (не текстовом) режиме. | ||
in | Файл открывается для ввода. | ||
out | Файл открывается для вывода. | ||
trunc | Если файл существует, его содержимое теряется. |
Константы можно комбинировать с помощью поразрядного OR. Для конструкторов классов if stream и ofstream параметр mode имеет значения по умолчанию — соответственно ios : : in и ios : : out.
Состояние потока
Состояние объекта класса ios (и производных от него) содержится в его закрытом элементе _state в виде набора битов. Следующая таблица перечисляет имеющиеся биты состояния потока.
Таблица 9.4. Биты состояния потока
Бит |
Описание |
||
goodbit | С потоком все в порядке (на самом деле это не какой-то бит, а 0 — отсутствие битов ошибки). | ||
eofbit | Показывает, что достигнут конец файла. | ||
failbit | Индицирует ошибку формата или преобразования. После очистки данного бита работа с потоком может быть продолжена. | ||
badbit | Индицирует серьезную ошибку потока, связанную обычно с буферными операциями или аппаратурой. Скорее всего, поток далее использовать невозможно. |
Для опроса или изменения состояния потока в классе ios имеется ряд функций и операций.
int rdstate() ; Возвращает текущее состояние.
bool eof() ; Возвращает true, если установлен eofbit.
bool good () ; Возвращает true, если не установлен ни один из битов ошибки.
bool fail () ; Возвращает true, если установлен failbit или bad-bit.
bool bad() ; Возвращает true, если установлен badbit.
void clear (int =0); Сбрасывает биты ошибки (по умолчанию) или устанавливает состояние потока в соответствии с аргументом.
void setstate(int) ; Устанавливает состояние битов ошибки с соответствии с аргументом.
operator void*() ; Возвращает нулевой указатель, если установлен какой-либо из битов ошибки.
bool operator! () ; Возвращает true, если установлен какой-либо из битов ошибки.
Функция operator void*() неявно вызывается, если поток сравнивается с нулем (как cin в примере из листинга),
Ввод-вывод с произвольным доступом
Понятие произвольного доступа к файлу подразумевает два, или даже три, различных момента. Во-первых, оно означает, что можно произвольно обращаться к любой записи или любому байту в файле, в противоположность последовательному доступу, когда данные извлекаются или передаются в поток строго по очереди. Во-вторых, предполагается, что на открытом файле можно произвольно чередовать операции чтения и записи. И, наконец, из сказанного вытекает, что ввод-вывод с произвольным доступом является по преимуществу бесформатным.
Приведенная ниже программа открывает (создает новый или переписывает старый) свой файл как двоичный, и, кроме того, сразу для ввода и вывода. Она применяет функции позиционирования потока и функции бесформатного чтения-записи.
Листинг 9.5. Произвольный доступ к файлу
//////////////////////////////////////////////////
// Random.cpp: Демонстрация файла с произвольным доступом.
//
#include <fstream.h>
#include <iomanip.h>
#pragma hdrstop
#include <condefs.h>
const int NP = 10;
const int IS = sizeof(int);
#pragma argsused
int main(int argc, char* argv[])
{
int pt, i;
//
// Открытие файла для чтения/записи.
//
fstream fs("random.pts",
ios::binary | ios::in | ios::out | ios::trunc);
if (ifs) {
cerr << "Failed to open file." << endl;
return (1);
}
//
// Первоначальная запись файла.
//
cout << "Initial data:" << endl;
for (i=0; i<NP; i++){
pt = i;
fs.write((char*)&pt, IS);
cout << setw(4) << pt;
}
cout << endl << endl;
//
// Чтение файла от конца к началу.
//
cout << "Read from the file in reverse order:"<< endl;
for (i=0; i<NP; i++) {
fs.seekg(-(i + 1) * IS, ios::end);
fs.read((char*)&pt, IS);
cout “ setw(4)<< pt; . }
cout<< end1 << end1;
//
// Переписать четные индексы.
//
for (i=l; i<NP/2; i++) {
fs.seekg(2 * i * IS) ;
fs.read((char*)&pt, IS);
pt = -pt;
fs.seekg(fs.tellg () - IS); // Возврат на шаг.
fs.write((char*)&pt, IS);
}
//
// Распечатать файл.
//
cout << "After rewriting the even records:"<<endl;
fs.seekg(0) ;
for (i=0; i<NP; i++) {
fs.read((char*)&pt, IS);
cout << setw(4) << pt;
}
cout << endl;
fs.close ();
return 0;
}
Когда эта программа открывает уже существующий файл, он усекается до нулевой длины (т. е. все его данные теряются). Если вы хотите работать с имеющимися в файле данными, нужно убрать бит ios: :trunc из режима открытия потока. Кстати, в примере это можно сделать безболезненно — данные файла все равно сразу переписываются заново.
В этом примере мы пользовались для позиционирования потока функцией seekg () . Но поскольку поток у нас типа f stream, и открыт он в режиме чтения-записи, то все равно, какую функцию применять для позиционирования — seekg () или seekp () .
He следует упускать из виду, что при выполнении операций бесформатного чтения или записи (read/write) указатель потока сдвигается вперед на число прочитанных (записанных) байтов.
Вывод программы показан на рис. 9.4.
Рис. 9.4 Программа Random
Заключение
Аппарат потоковых классов библиотеки C++ довольно громоздок, если сравнивать его, например, с функциями языка С вроде printf (). Однако средства потоков C++ единообразны, надежны и расширяемы. Как вы узнали из этой главы, можно достаточно просто перегрузить операции извлечения и передачи, чтобы с точки зрения потоков ввода-вывода определенный вами тип выглядел бы подобно встроенным типам данных.
В следующей главе мы займемся шаблонами — средством C++, которое позволяет создавать “обобщенные” классы, служащие моделью некоторого множества конкретных классов.
Закрытие файла
В классах файловых потоков имеется функция close (), которая сбрасывает содержимое потока и закрывает ассоциированный с ним файл.
Кроме того, деструктор потока автоматически закрывает файл при уничтожении объекта потока.
При ошибке закрытия файла устанавливается флаг failbit.