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

“Помните, все требует времени”.

Пит Хейн (Piet Hein)


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

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

9.1. Типы, определенные пользователем

 В языке С++ есть встроенные типы, такие как

char
,
int
и
double
(подробнее они описаны в разделе A.8). Тип называется встроенным, если компилятор знает, как представить объекты такого типа и какие операторы к нему можно применять (такие как
+
и
) без уточнений в виде объявлений, которые создает программист в исходном коде.

 Типы, не относящиеся к встроенным, называют типами, определенными пользователем (user-defined types — UDT). Они могут быть частью стандартной библиотеки, доступной в любой реализации языка С++ (например, классы

string
,
vector
и
ostream
, описанные в главе 10), или типами, самостоятельно созданными программистом, как классы
Token
и
Token_stream
(см. разделы 6.5 и 6.6). Как только мы освоим необходимые технические детали, мы создадим графические типы, такие как
Shape
,
Line
и
Text
(речь о них пойдет в главе 13). Стандартные библиотечные типы являются такой же частью языка, как и встроенные типы, но мы все же рассматриваем их как определенные пользователем, поскольку они созданы из таких же элементарных конструкций и с помощью тех же приемов, как и типы, разработанные нами; разработчики стандартных библиотек не имеют особых привилегий и средств, которых нет у нас. Как и встроенные типы, большинство типов, определенных пользователем, описывают операции. Например, класс
vector
содержит операции
[]
и
size()
(см. разделы 4.6.1 и В.4.8), класс
ostream
операцию
<<
, класс
Token_stream
операцию
get()
(см. раздел 6.8), а класс
Shape
операции
add(Point)
и
set_color()
(см. раздел 14.2).

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

int;
когда хотим манипулировать текстом, класс
string
— хороший выбор; когда хотим манипулировать входной информацией для калькулятора, нам нужны классы
Token
и
Token_stream
. Необходимость этих классов имеет два аспекта.

Представление. Тип “знает”, как представить данные, необходимые в объекте.

Операции. Тип знает, какие операции можно применить к объектам.


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

Итак, мы хотим выразить “идею” или “понятие” в коде в виде структуры данных и набора функций. Возникает вопрос: “Как именно?” Ответ на этот вопрос изложен в данной главе, содержащей технические детали этого процесса в языке С++.

 В языке С++ есть два вида типов, определенных пользователем: классы и перечисления. Классы носят намного более общий характер и играют более важную роль в программировании, поэтому мы сосредоточим свое внимание в первую очередь на них. Класс непосредственно выражает некое понятие в программе. Класс (class) — это тип, определенный пользователем. Он определяет, как представляются объекты этого класса, как они создаются, используются и уничтожаются (раздел 17.5). Если вы размышляете о чем-то как об отдельной сущности, то, вполне возможно, должны определить класс, представляющий эту “вещь” в вашей программе. Примерами являются вектор, матрица, поток ввода, строка, быстрое преобразование Фурье, клапанный регулятор, рука робота, драйвер устройства, рисунок на экране, диалоговое окно, график, окно, термометр и часы.

В языке С++ (как и в большинстве современных языков) класс является основной строительной конструкцией в крупных программах, которая также весьма полезна для разработки небольших программ, как мы могли убедиться на примере калькулятора (см. главы 6 и 7).

9.2. Классы и члены класса

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


class X {

public:

  int m; // данные - члены

  int mf(int v) { int old = m; m=v; return old; } // функция - член

};


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


X var; // var — переменная типа X

var.m = 7; // присваиваем значение члену m объекта var

int x = var.mf(9); // вызываем функцию - член mf() объекта var


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

int
, вызывать функцию-член и т.д. 

9.3. Интерфейс и реализация

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

public:
, а реализация — меткой
private:
. Итак, объявление класса можно представить следующим образом:


class X { // класс имеет имя X

public:

  // открытые члены:

  // – пользовательский интерфейс (доступный всем)

  // функции

  // типы

  // данные (лучше всего поместить в раздел private)

private:

  // закрытые члены:

  // – детали реализации (используется только членами

  // данного класса)

  // функции

  // типы

  // данные

};


Члены класса по умолчанию являются закрытыми. Иначе говоря, фрагмент


class X {

 int mf(int);

 // ...

};


означает


class X {

private:

  int mf(int);

  // ...

};


поэтому


X x;            // переменная x типа X

int y = x.mf(); // ошибка: переменная mf является закрытой

                // (т.е. недоступной)


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


class X {

  int m;

  int mf(int);

public:

  int f(int i) { m=i; return mf(i); }

};


X x;

int y = x.f(2);


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


struct X {

  int m;

  // ...

};


Он эквивалентен следующему коду:


class X {

public:

  int m;

  // ...

};


Структуры (

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

9.4. Разработка класса

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

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

9.4.1. Структуры и функции

 Как можно представить дату? На этот вопрос большинство людей отвечают: “Указать год, месяц и день месяца”. Это не единственный и далеко не лучший ответ, но для наших целей он вполне подходит. Для начала попробуем создать простую структуру.


// простая структура Date (слишком просто?)

struct Date {

  int y; // год

  int m; // месяц года

  int d; // день месяца

};


Date today; // переменная типа Date (именованный объект)


Объект типа

Date
, например
today
, может просто состоять из трех чисел типа
int
.



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

Date
, — это предположение будет использовано во всех вариантах этой структуры на протяжении всей главы. Итак, теперь у нас есть объекты типа
Date;
что с ними можно делать? Все что угодно, в том смысле, что мы можем получить доступ ко всем членам объекта
today
(и другим объектам типа
Date
), а также читать и записывать их по своему усмотрению. Загвоздка заключается в том, что все это не совсем удобно. Все, что мы хотим делать с объектами типа
Date
, можно выразить через чтение и запись их членов. Рассмотрим пример.


// установить текущую дату 24 декабря 2005 года

today.y = 2005;

today.m = 24;

today.d = 12;


Этот способ утомителен и уязвим для ошибок. Вы заметили ошибку? Все, что является утомительным, уязвимо для ошибок! Например, ответьте, имеет ли смысл следующий код?


Date x;

x.y = –3;

x.m = 13;

x.d = 32;


Вероятно нет, и никто не стал бы писать такую чушь — или стал? А что вы скажете о таком коде?


Date y;

y.y = 2000;

y.m = 2;

y.d = 29;


Был ли двухтысячный год високосным? Вы уверены?

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

Date
к общим операциям относится также увеличение значения объекта
Date
. Итак, напишем следующий код:


// вспомогательные функции:

void init_day(Date& dd, int y, int m, int d)

{

  // проверяет, является ли (y,m,d) правильной датой

  // если да, то инициализирует объект dd

}


void add_day(Date& dd, int n)

{

  // увеличивает объект dd на n дней

}


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

Date
.


void f()

{

  Date today;

  init_day(today, 12, 24, 2005); // Ой! (в 12-м году не было

                                 // 2005-го дня)

  add_day(today,1);

}


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

9.4.2. Функции-члены и конструкторы

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

Date
, которая проверяет корректность его объектов. Однако функции проверки приносят мало пользы, если мы не можем их использовать. Например, допустим, что мы определили для типа
Date
оператор вывода
<<
(раздел 9.8):


void f()

{

  Date today;

  // ...

  cout << today << '\n'; // использовать объект today

  // ...

  init_day(today,2008,3,30);

  // ...

  Date tomorrow;

  tomorrow.y = today.y;

  tomorrow.m = today.m;

  tomorrow.d = today.d+1;   // добавляем единицу к объекту today

  cout << tomorrow << '\n'; // используем объект tomorrow

}


Здесь мы “забыли” немедленно инициализировать объект

today
, и до вызова функции
init_day()
этот объект будет иметь неопределенное значение. Кроме того, “кто-то” решил, что вызывать функцию
add_day()
лишняя потеря времени (или просто не знал о ее существовании), и создал объект
tomorrow
вручную. Это плохой и даже очень плохой код. Вероятно, в большинстве случае эта программа будет работать, но даже самые небольшие изменения приведут к серьезным ошибкам. Например, отсутствие инициализации объекта типа
Date
приведет к выводу на экран так называемого “мусора”, а прибавление единицы к члену
d
вообще представляет собой мину с часовым механизмом: когда объект
today
окажется последним днем месяца, его увеличение на единицу приведет к появлению неправильной даты. Хуже всего в этом очень плохом коде то, что он не выглядит плохим.

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


// простая структура Date,

// гарантирующая инициализацию с помощью конструктора

// и обеспечивающая удобство обозначений

struct Date {

  int y, m, d; // год, месяц, день

  Date(int y, int m, int d); // проверяем корректность даты

                             // и выполняем инициализацию

  void add_day(int n);       // увеличиваем объект типа Date на n дней

};


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


Date my_birthday;        // ошибка: объект my_birthday не инициализирован

Date today(12,24,2007);  // Ой! Ошибка на этапе выполнения

Date last(2000, 12, 31); // OK (разговорный стиль)

Date christmas = Date(1976,12,24); // также OK (многословный стиль)


Попытка объявить объект

my_birthday
провалится, поскольку мы не указали требуемое начальное значение. Попытку объявить объект
today
компилятор пропустит, но проверочный код в конструкторе на этапе выполнения программы обнаружит неправильную дату ((
12,24,2007
) — 2007-й день 24-го месяца 12-го года).

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

last
содержит в скобках сразу после имени переменной начальное значение — аргументы, требуемые конструктором класса
Date
. Этот стиль инициализации переменных класса, имеющего конструктор с аргументами, является наиболее распространенным. Кроме того, можно использовать более многословный стиль, который позволяет явно продемонстрировать создание объекта (в данном случае
Date(1976,12,24)
) с последующей инициализацией с помощью синтаксиса инициализации
=
. Если вы действительно пишете в таком стиле, то скоро устанете от него.

Теперь можно попробовать использовать вновь определенные переменные.


last.add_day(1);

add_day(2); // ошибка: какой объект типа Date?


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

add_day()
вызывается из конкретного объекта типа
Date
с помощью точки, означающей обращение к члену класса. Как определить функцию-член класса, показано в разделе 9.4.4. 

9.4.3. Скрываем детали

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

add_day()
? Что произойдет, если кто-то решит непосредственно изменить месяц? Оказывается, мы забыли предусмотреть возможности для выполнения этой операции.


Date birthday(1960,12,31); // 31 декабря 1960 года

++birthday.d;              // Ой! Неправильная дата

Date today(1970,2,3);

today.m = 14;              // Ой! Неправильная дата

                           // today.m == 14


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

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

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

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


// простой типа Date (управление доступом)

class Date {

  int y, m, d; // год, месяц, день

public:

  Date(int y, int m, int d); // проверка и инициализация даты

  void add_day(int n);       // увеличение объекта типа Date на n дней

  int month() { return m; }

  int day() { return d; }

  int year() { return y; }

};


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


Date birthday(1970, 12, 30);      // OK

birthday.m = 14;                  // ошибка: Date::m — закрытый член

cout << birthday.month() << endl; // доступ к переменной m


 Понятие “правильный объект типа

Date
” — важная разновидность идеи о корректном значении. Мы пытаемся разработать наши типы так, чтобы их значения гарантированно были корректными; иначе говоря, скрываем представление, предусматриваем конструктор, создающий только корректные объекты, и разрабатываем все функции-члены так, чтобы они получали и возвращали только корректные значения. Значение объекта часто называют состоянием (state), а корректное значение — корректным состоянием объекта.

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

 Правило, регламентирующее смысл корректного значения, называют инвариантом (invariant). Инвариант для класса

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

9.4.4. Определение функций-членов

До сих пор мы смотрели на класс

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


// простой класс Date (детали реализации будут рассмотрены позднее)

class Date {

public:

  Date(int y, int m, int d); // проверка и инициализация даты

  void add_day(int n);       // увеличивает объект класса Date на n дней

  int month();

  // ...

private:

  int y, m, d;               // лет, месяцев, дней

};


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

Определяя члены за пределами класса, мы должны указать, какому классу они принадлежат. Для этого используется обозначение имя_класса::имя_члена.


Date::Date(int yy, int mm, int dd)// конструктор

     :y(yy), m(mm), d(dd)         // примечание: инициализация члена

{

}


void Date::add_day(int n)

{

  // ...

}


int month()  // Ой: мы забыли про класс Date::

{

   return m; // не функция-член, к переменной m доступа нет

}


Обозначение

:y(yy)
,
m(mm)
,
d(dd)
указывает на то, как инициализируются члены. Оно называется списком инициализации. Мы могли бы написать эквивалентный фрагмент кода.


Date::Date(int yy, int mm, int dd) // конструктор

{

  y = yy;

  m = mm;

  d = dd;

}


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

:y(yy)
,
m(mm)
,
d(dd)
точнее отражает наши намерения. Разница между этими фрагментами точно такая же, как между двумя примерами, приведенными ниже. Рассмотрим первый из них.


int x; // сначала определяем переменную x

// ...

x = 2; // потом присваиваем ей значение


Второй пример выглядит так:


int x = 2; // определяем и немедленно инициализируем двойкой


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


int x(2);               // инициализируем двойкой

Date sunday(2009,8,29); // инициализируем объект Sunday

                        // триадой (2009,8,29)


Функцию-член класса можно также определить в определении класса.


// простой класс Date (детали реализации будут рассмотрены позднее)

class Date {

public:

  Date(int yy, int mm, int dd)

  :y(yy), m(mm), d(dd)

  {

    // ...

  }


void add_day(int n)

{

  // ...

}


int month() { return m; }

  // ...

private:

  int y, m, d; // год, месяц, день

};


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

add_day()
могут содержать десятки строк. Это в несколько раз увеличивает размер объявления класса и затрудняет поиск интерфейса среди деталей реализации. Итак, мы не рекомендуем определять большие функции в объявлении класса. Тем не менее посмотрите на определение функции
month()
. Оно проще и короче, чем определение
Date::month()
, размещенное за пределами объявления класса. Определения коротких и простых функций можно размещать в объявлении класса.

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

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

 Определение функции-члена в классе приводит к следующим последствиям.

• Функция становится подставляемой (inlined), т.е. компилятор попытается сгенерировать код подставляемой функции вместо ее вызова. Это может дать значительное преимущество часто вызываемым функциям, таким как

month()
.

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


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

9.4.5. Ссылка на текущий объект

Рассмотрим простой пример использования класса

Date
.


class Date {

  // ...

  int month() { return m; }

  // ...

private:

  int y, m, d; // год, месяц, день

};


void f(Date d1, Date d2)

{

  cout << d1.month() << ' ' << d2.month() << '\n';

}


Откуда функции

Date::month()
известно, что при первом вызове следует вернуть значение переменной
d1.m
, а при втором —
d2.m
? Посмотрите на функцию
Date::month()
еще раз; ее объявление не имеет аргумента! Как функция
Date::month()
“узнает”, для какого объекта она вызывается? Функции-члены класса, такие как
Date::month()
, имеют неявный аргумент, позволяющий идентифицировать объект, для которого они вызываются. Итак, при первом вызове переменная m правильно ссылается на
d1.m
, а при втором — на
d2.m
. Другие варианты использования неявного аргумента описаны в разделе 17.10.

9.4.6. Сообщения об ошибках

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

Date
. Если мы создали правильные объекты класса
Date
и все функции-члены написаны правильно, то мы никогда не получим объект класса
Date
с неверным значением. Итак, следует предотвратить создание неправильных объектов класса
Date
.


// простой класс Date (предотвращаем неверные даты)

class Date {

public:

  class Invalid { };         // используется как исключение

  Date(int y, int m, int d); // проверка и инициализация даты

  // ...

private:

  int y, m, d;  // год, месяц, день

  bool check(); // если дата правильная, возвращает true

};


Мы поместили проверку корректности даты в отдельную функцию

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


Date::Date(int yy, int mm, int dd)

     :y(yy), m(mm), d(dd) // инициализация данных - членов класса 

{

  if (!check()) throw Invalid(); // проверка корректности

}


bool Date::check() // возвращает true, если дата корректна

{

  if (m<1 || 12

  // ...

}


Имея это определение класса

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


void f(int x, int y)

try {

  Date dxy(2009,x,y);

  cout << dxy << '\n';   // объявление оператора << см. в разделе 9.8

  dxy.add_day(2);

}

catch(Date::Invalid) {

  error("invalid date"); // функция error() определена

  // в разделе 5.6.3

}


Теперь мы знаем, что оператор

<<
и функция
add_day()
всегда будут работать с корректными объектами класса
Date
. До завершения разработки класса
Date
, описанной в разделе 9.7, опишем некоторые свойства языка, которые потребуются нам для того, чтобы сделать это хорошо: перечисления и перегрузку операторов. 

9.5. Перечисления

 Перечисление

enum
(enumeration) — это очень простой тип, определенный пользователем, который задает множество значений (элементов перечисления) как символические константы. Рассмотрим пример.


enum Month {

  jan=1, feb, mar, apr, may, jun, jul, aug, sep, oct, nov, dec

};


“Тело” перечисления — это просто список его элементов. Каждому элементу перечисления можно задать конкретное значение, как это сделано выше с элементом

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


enum Month {

  jan=1, feb=2, mar=3, apr=4, may=5, jun=6,

  jul=7, aug=8, sep=9, oct=10, nov=11, dec=12

};


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

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


enum Day {

  monday, tuesday, wednesday, thursday, friday, saturday, sunday

};


где

monday==0
и
sunday==6
. На практике лучше всего выбирать начальное значение счетчика, равным нулю.

Перечисление

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


Month m = feb;

m = 7;     // ошибка: нельзя присвоить целое число перечислению

int n = m; // OK: целочисленной переменной можно присвоить

           // значение Month

Month mm = Month(7); // преобразование типа int в тип Month

                     //(без проверки)


 Обратите внимание на то, что

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


Month bad = 9999; // ошибка: целое число невозможно преобразовать

                  // объект типа Month


 Если вы настаиваете на использовании обозначения

Month(9999)
, то сами будете виноваты! Во многих ситуациях язык С++ не пытается останавливать программиста от потенциально опасных действий, если программист явно на этом настаивает; в конце концов, программисту, действительно, виднее.

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


Month int_to_month(int x)

{

  if (x

  return Month(x);

}


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


void f(int m)

{

  Month mm = int_to_month(m);

 // ...

}


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

up
,
down
;
yes
,
no
,
maybe
;
on
,
off
;
n
,
ne
,
e
,
se
,
s
,
sw
,
w
,
nw
) или отличительных признаков (
red
,
blue
,
green
,
yellow
,
maroon
,
crimson
,
black
).

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


enum Traffic_sign { red, yellow, green };

int var = red; // примечание: правильно Traffic_sign::red


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

red
,
on
,
ne
и
dec
. Например, что значит
ne:
“северо-восток” (northeast) или “не равно” (nor equal)? Что значит
dec:
“десятичный” (decimal) или “декабрь” (December)? Именно о таким проблемах мы предупреждали в разделе 3.7. Они легко возникнут, если определить перечисление с короткими и общепринятыми именами элементов в глобальном пространстве имен. Фактически мы сразу сталкиваемся с этой проблемой, когда пытаемся использовать перечисление
Month
вместе с потоками
iostream
, поскольку для десятичных чисел существует манипулятор с именем
dec
(см. раздел 11.2.1). Для того чтобы избежать возникновения этих проблем, мы часто предпочитаем определять перечисления в более ограниченных областях видимости, например в классе. Это также позволяет нам явно указать, на что ссылаются значения элементов перечисления, такие как
Month::jan
и
Color::red
. Приемы работы с перечислениями описываются в разделе 9.7.1. Если нам очень нужны глобальные имена, то необходимо минимизировать вероятность коллизий, используя более длинные или необычные имена, а также прописные буквы. Тем не менее мы считаем более разумным использовать имена перечислений в локальных областях видимости.

9.6. Перегрузка операторов

Для класса или перечисления можно определить практически все операторы, существующие в языке С++. Этот процесс называют перегрузкой операторов (operator overloading). Он применяется, когда требуется сохранить привычные обозначения для разрабатываемого нами типа. Рассмотрим пример.


enum Month {

  Jan=1, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct, Nov, Dec

};


Month operator++(Month& m)         // префиксный инкрементный оператор

{

  m = (m==Dec) ? Jan : Month(m+1); // "циклический переход"

  return m;

}


Конструкция

? :
представляет собой арифметический оператор “если”: переменная
m
становится равной
Jan
, если (
m==Dec
), и
Month(m+1)
в противном случае. Это довольно элегантный способ, отражающий цикличность календаря. Тип
Month
теперь можно написать следующим образом:


Month m = Sep;

++m; // m становится равным Oct

++m; // m становится равным Nov

++m; // m становится равным Dec

++m; // m становится равным Jan ("циклический переход")


Можно не соглашаться с тем, что инкрементация перечисления

Month
является широко распространенным способом, заслуживающим реализации в виде отдельного оператора. Однако что вы скажете об операторе вывода? Его можно описать так:


vector month_tbl;

ostream& operator<<(ostream& os, Month m)

{

  return os << month_tbl[m];

}


Это значит, что объект

month_tbl
был инициализирован где-то, так что, например,
month_tbl[Mar]
представляет собой строку "March" или какое-то другое подходящее название месяца (см. раздел 10.11.3).

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

+
,
,
*
,
/
,
%
,
[]
,
()
,
^
,
!
,
&
,
<
,
<=
,
>
и
>=
. Невозможно определить свой собственный оператор; можно себе представить, что программист захочет иметь операторы
**
или
$=
, но язык С++ этого не допускает. Операторы можно определить только для установленного количества операндов; например, можно определить унарный оператор
, но невозможно перегрузить как унарный оператор
<=
(“меньше или равно”). Аналогично можно перегрузить бинарный оператор
+
, но нельзя перегрузить оператор
!
(“нет”) как бинарный. Итак, язык позволяет использовать для определенных программистом типов существующие синтаксические выражения, но не позволяет расширять этот синтаксис.

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


int operator+(int,int); // ошибка: нельзя перегрузить встроенный

                        // оператор +

Vector operator+(const Vector&, const Vector &); // OK

Vector operator+=(const Vector&, int);           // OK


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

+
должен обозначать сложение; бинарный оператор
*
— умножение; оператор
[]
— доступ; оператор
()
— вызов функции и т.д. Это просто совет, а не правило языка, но это хороший совет: общепринятое использование операторов, такое как символ
+
для сложения, значительно облегчает понимание программы. Помимо всего прочего, этот совет является результатом сотен лет опыта использования математических обозначений.

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

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

+
,
,
*
, и
/
, как можно было бы предположить, а
=
,
==
,
!=
,
<
,
[]
и
()
.

9.7. Интерфейсы классов

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

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

• Интерфейс должен быть полным.

• Интерфейс должен быть минимальным.

• Класс должен иметь конструкторы.

• Класс доложен поддерживать копирование (или явно запрещать его) (см. раздел 14.2.4).

• Следует предусмотреть тщательную проверку типов аргументов.

• Необходимо идентифицировать немодифицирующие функции-члены (см. раздел 9.7.4).

• Деструктор должен освобождать все ресурсы (см. раздел 17.5). См. также раздел 5.5, в котором описано, как выявлять ошибки и сообщать о них на этапе выполнения программы.


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

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

9.7.1. Типы аргументов

Определяя конструктор класса

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


Date d1(4,5,2005); // Ой! Год 4, день 2005

Date d2(2005,4,5); // 5 апреля или 4 мая?


Первая проблема (недопустимый день месяца) легко решается путем проверки в конструкторе. Однако вторую проблему (путаницу между месяцем и днем месяца) невозможно выявить с помощью кода, написанного пользователем. Она возникает из-за того, что существуют разные соглашения о записи дат; например, 4/5 в США означает 5 апреля, а в Англии — 4 мая. Поскольку эту проблему невозможно устранить с помощью вычислений, мы должны придумать что-то еще. Очевидно, следует использовать систему типов.


// простой класс Date (использует тип Month)

class Date {

public:

  enum Month {

    jan=1, feb, mar, apr, may, jun, jul, aug, sep, oct, nov, dec

  };

  Date(int y, Month m, int d); // проверка даты и инициализация

  // ...

private:

  int y; // год

  Month m;

  int d; // день

};


Когда мы используем тип

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


Date dx1(1998, 4, 3);          // ошибка: 2-й аргумент не имеет

                               // тип Month

Date dx2(1998, 4, Date::mar);  // ошибка: 2-й аргумент не имеет

                               // тип Month

Date dx2(4, Date::mar, 1998);  // Ой: ошибка на этапе выполнения:

                               // день 1998

Date dx2(Date::mar, 4, 1998);  // ошибка: 2-й аргумент не имеет

                               // тип Month

Date dx3(1998, Date::mar, 30); // OK


Этот код решает много проблем. Обратите внимание на квалификатор

Date
перечисления
mar: Date::mar
. Тем самым мы указываем, что это перечисление
mar
из класса
Date
. Это не эквивалентно обозначению
Date.mar
, поскольку
Date
— это не объект, а тип, а
mar
— не член класса, а символическая константа из перечисления, объявленного в классе. Обозначение
::
используется после имени класса (или пространства имен; см. раздел 8.7), а
.
(точка) — после имени объекта.

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

А нельзя ли подобным образом выявить путаницу между днем месяца и годом? Можно, но решение этой проблемы будет не таким элегантным, как для типа

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

Вероятно, было бы лучше всего (не вникая в предназначение класса Date) написать следующий код:


class Year { // год в диапазоне [min:max)

  static const int min = 1800;

  static const int max = 2200;

public:

  class Invalid { };

  Year(int x) : y(x) { if (x

  int year() { return y; }

private:

  int y;

};


class Date {

public:

  enum Month {

    jan=1, feb, mar, apr, may, jun, jul, aug, sep, oct, nov, dec

  };

  Date(Year y, Month m, int d); // проверка даты и инициализация

  // ...

private:

  Year y;

  Month m;

  int d; // день

};


Теперь получаем фрагмент кода.


Date dx1(Year(1998),4,3);          // ошибка: 2-й аргумент — не Month

Date dx2(Year(1998),4,Date::mar);  // ошибка: 2-й аргумент — не Month

Date dx2(4, Date::mar,Year(1998)); // ошибка: 1-й аргумент — не Year

Date dx2(Date::mar,4,Year(1998));  // ошибка: 2-й аргумент — не Month

Date dx3(Year(1998),Date::mar,30); // OK


Следующая фатальная и неожиданная ошибка выявится только на этапе выполнения программы.


Date dx2(Year(4),Date::mar,1998); // ошибка на этапе выполнения:

                                  // Year::Invalid


Стоило ли выполнять дополнительную работу и вводить обозначения для лет? Естественно, это зависит от того, какие задачи вы собираетесь решать с помощью типа Date, но в данном случае мы сомневаемся в этом и не хотели бы создавать отдельный класс

Year
.

 Когда мы программируем, то всегда устанавливаем критерии качества для данного приложения. Как правило, мы не можем позволить себе роскошь очень долго искать идеальное решение, если уже нашли достаточно хорошее. Втягиваясь в поиски наилучшего решения, мы настолько запутаем программу, что она станет хуже, чем первоначальный вариант. Как сказал Вольтер: “Лучшее — враг хорошего”.

Обратите внимание на слова

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

9.7.2. Копирование

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

Затем необходимо решить, можно ли копировать объекты и как это делать? Для класса

Date
или перечисления
Month
ответ очевиден: копирование необходимо, и его смысл тривиален: просто копируются все члены класса. Фактически это предусмотрено по умолчанию. Если не указано ничего другого, компьютер сделает именно это. Например, если перечисление
Date
используется для инициализации или стоит в правой части оператора присваивания, то все его члены будут скопированы.


Date holiday(1978, Date::jul, 4);    // инициализация

Date d2 = holiday;

Date d3 = Date(1978, Date::jul, 4);

holiday = Date(1978, Date::dec, 24); // присваивание

d3 = holiday;


Обозначение

Date(1978, Date::dec, 24)
означает создание соответствующего неименованного объекта класса Date, которое затем можно соответствующим образом использовать. Рассмотрим пример.


cout << Date(1978, Date::dec, 24);


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

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

9.7.3. Конструкторы по умолчанию

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

Date::Date(int,Month,int)
, чтобы гарантировать, что каждый объект класса
Date
будет правильно проинициализирован. В данном случае это значит, что программист должен предоставить три аргумента соответствующих типов. Рассмотрим пример.


Date d1;                // ошибка: нет инициализации

Date d2(1998);          // ошибка: слишком мало аргументов

Date d3(1,2,3,4);       // ошибка: слишком много аргументов

Date d4(1,"jan",2);     // ошибка: неправильный тип аргумента

Date d5(1,Date::jan,2); // OK: используется конструктор с тремя

                        // аргументами

Date d6 = d5;           // OK: используется копирующий конструктор


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

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


string s1;             // значение по умолчанию: пустая строка ""

vector v1;     // значение по умолчанию: вектор без элементов

vector v2(10); // вектор, по умолчанию содержащий 10 строк


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

vector
и
string
имеют конструкторы по умолчанию, которые неявно выполняют желательную инициализацию.

Для типа

T
обозначение
T()
— значение по умолчанию, определенное конструктором, заданным по умолчанию. Итак, можно написать следующий код: 


string s1 = string();   // значение по умолчанию: пустая строка ""

vector v1 = vector(); // значение по умолчанию:

                                 // пустой вектор; без элементов

vector v2(10,string());  // вектор, по умолчанию содержащий

                                 // 10 строк


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


string s1;             // значение по умолчанию: пустая строка ""

vector v1;     // значение по умолчанию: пустой вектор;

                       // без элементов

vector v2(10); // вектор, по умолчанию содержащий 10 строк


Для встроенных типов, таких как

int
и
double
, конструктор по умолчанию подразумевает значение
0
, так что запись
int()
— это просто усложненное представление нуля, а
double()
— долгий способ записать число
0.0
.

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

()
при инициализации.


string s1("Ike"); // объект, инициализированный строкой "Ike"

string s2();      // функция, не получающая аргументов и возвращающая

                  // строку


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

string
и
vector
.


string s;

for (int i=0; i

                                // количество раз

  s[i] = toupper(s[i]);         // ой: изменяется содержание

                                // случайной ячейки памяти

vector v;

v.push_back("bad");             // ой: запись по случайному адресу


Если значения переменных

s
и
v
действительно не определены, то непонятно, сколько элементов они содержат или (при общепринятом способе реализации; см. раздел 17.5) неясно, где эти элементы должны храниться. В результате будут использованы случайные адреса — и это худшее, что может произойти. В принципе без конструктора мы не можем установить инвариант, поскольку не можем гарантировать, что его объекты будут корректными (см. раздел 9.4.3). Мы настаиваем на том, что такие переменные должны быть проинициализированы. В таком случае фрагмент можно было бы переписать следующим образом:


string s1 = "";

vector v1(0);

vector v2(10,""); // вектор, содержащий 10 пустых строк


Однако этот код не кажется нам таким уж хорошим. Для объекта класса

string
строка
""
является очевидным обозначением пустой строки, а для объекта класса vector легко догадаться, что число
0
означает пустой вектор. Однако для многих типов правильно интерпретировать значение, заданное по умолчанию, совсем не так легко. В таких случаях лучше было бы определить конструктор, создающий объект без использования явной инициализации. Такие конструкторы не имеют аргументов и называются конструкторами по умолчанию.

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


class Date {

public:

  // ...

  Date(); // конструктор по умолчанию

  // ...

private:

  int y;

  Month m;

  int d;

};


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


Date::Date()

     :y(2001), m(Date::jan), d(1)

{

}


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


const Date& default_date()

{

  static Date dd(2001,Date::jan,1);

  return dd;

}


Здесь использовано ключевое слово

static
, чтобы переменная
dd
создавалась только один раз, а не каждый раз при очередном вызове функции
default_date()
. Инициализация этой переменной происходит при первом вызове функции
default_date()
. С помощью функции
default_date()
легко определить конструктор, заданный по умолчанию, для класса
Date
.


Date::Date()

     :y(default_date().year()),

      m(default_date().month()),

      d(default_date().day())

}


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

default_date
, уже сделал это. Имея конструктор для класса
Date
по умолчанию, мы можем создать векторы объектов класса
Date
.


vector birthdays(10);


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


vector birthdays(10,default_date());

9.7.4. Константные функции-члены

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

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


void some_function(Date& d, const Date& start_of_term)

{

  int a = d.day();             // OK

  int b = start_of_term.day(); // должно бы правильно (почему ?)

  d.add_day(3);                // отлично

  start_of_term.add_day(3);    // ошибка

}


Здесь подразумевается, что переменная

d
будет изменяться, а переменная
start_of_term
— нет; другими словами, функция
some_function()
не может изменить переменную
start_of_term
. Откуда компилятору это известно? Дело в том, что мы сообщили ему об этом, объявив переменную
start_of_term
константой (
const
). Однако почему же с помощью функции
day()
можно прочитать переменную
day
из объекта
start_of_term
? В соответствии с предыдущим определением класса
Date
функция
start_of_term.day()
считается ошибкой, поскольку компилятор не знает, что функция
day()
не изменяет свой объект класса
Date
. Об этом в программе нигде не сказано, поэтому компилятор предполагает, что функция
day()
может модифицировать свой объект класса
Date
, и выдаст сообщение об ошибке.

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


class Date {

public:

  // ...

  int day() const;       // константный член: не может изменять

                         // объект

  Month month() const;   // константный член: не может изменять

                         // объект 

 int year() const;       // константный член: не может изменять

                         // объект

  void add_day(int n);   // неконстантный член: может изменять

                         // объект

  void add_month(int n); // неконстантный член: может изменять

                         // объект

  void add_year(int n);  // неконстантный член: может изменять

                         // объект

private:

  int y; // год

  Month m;

  int d; // день месяца

};


Date d(2000, Date::jan, 20);

const Date cd(2001, Date::feb, 21);

cout << d.day() << " — " << cd.day() << endl; // OK

d.add_day(1);  // OK

cd.add_day(1); // ошибка: cd — константа


Ключевое слово

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


int Date::day() const

{

  ++d; // ошибка: попытка изменить объект в константной

       // функции - члене

  return d;

}


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

9.7.5. Члены и вспомогательные функции

 Разрабатывая минимальный (хотя и полный) интерфейс, мы вынуждены оставлять за бортом много полезных операций. Функцию, которая могла бы быть просто, элегантно и эффективно реализована как самостоятельная функция (т.е. не функция-член), следует реализовать за пределами класса. Таким образом, функция не сможет повредить данные, хранящиеся в объекте класса. Предотвращение доступа к данным является важным фактором, поскольку обычные методы поиска ошибок “вращаются вокруг типичных подозрительных мест”; иначе говоря, если с классом что-то не так, мы в первую очередь проверяем функции, имеющие прямой доступ к его представлению: одна из них обязательно является причиной ошибки. Если таких функций десяток, нам будет намного проще работать, чем если их будет пятьдесят.

Пятьдесят функций для класса

Date
! Возможно, вы думаете, что мы шутим. Вовсе нет: несколько лет назад я делал обзор нескольких коммерческих библиотек для работы с календарем и обнаружил в них множество функций вроде
next_Sunday()
,
next_workday()
и т.д. Пятьдесят — это совсем не невероятное число для класса, разработанного для удобства пользователей, а не для удобства его проектирования, реализации и сопровождения.

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

Date
, мы могли решить, что дату лучше представлять в виде целого числа дней, прошедших с 1 января 1900 года, а не в виде тройки (год, месяц, день). В этом случае нам придется изменить только функции-члены.

Рассмотрим несколько примеров вспомогательных функций (helper functions).


Date next_Sunday(const Date& d)

{

  // имеет доступ к объекту d, используя d.day(), d.month()

  // и d.year()

  // создает и возвращает новый объект класса Date

}


Date next_weekday(const Date& d) { /* ... */ }


bool leapyear(int y) { /* ... */ }


bool operator==(const Date& a, const Date& b)

{

  return a.year()==b.year()

&& a.month()==b.month()

&& a.day()==b.day();

}


bool operator!=(const Date& a, const Date& b)

{

  return !(a==b);

}


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

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


namespace Chrono {

class Date { /* ... */ };

  bool is_date(int y, Date::Month m, int d); // true для

                                             // корректных данных

  Date next_Sunday(const Date& d) { /* ... */ }

  Date next_weekday(const Date& d) { /* ... */ }

  bool leapyear(int y) { /* ... */ } // см. пример 10

  bool operator==(const Date& a, const Date& b) { /* ... */ }

  // ...

}


Обратите внимание на функции

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

Отметьте также, что мы ввели вспомогательную функцию

is_date()
, которая заменяет функцию
Date::check()
, поскольку проверка корректности даты во многом не зависит от представления класса
Date
. Например, нам не нужно знать, как представлены объекты класса
Date
для того, чтобы узнать, что дата “30 января 2008 года” является корректной, а “30 февраля 2008 года” — нет. Возможно, существуют аспекты даты, которые зависят от ее представления (например, корректна ли дата “30 января 1066 года”), но (при необходимости) конструктор
Date
может позаботиться и об этом.

9.8. Класс Date

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

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


// файл Chrono.h

#include "Chrono.h"


namespace Chrono {

class Date {

public:

  enum Month {

    jan=1, feb, mar, apr, may, jun, jul, aug, sep, oct, nov, dec

  };


class Invalid { }; // для генерации в виде исключения


Date(int y, Month m, int d); // проверка и инициализация даты

  Date();                    // конструктор по умолчанию

                             // операции копирования по умолчанию

                             // в порядке


  // немодифицирующие операции:

  int day() const { return d; }

  Month month() const { return m; }

  int year() const { return y; }


  // модифицирующие операции:

  void add_day(int n);

  void add_month(int n);

  void add_year(int n);

private:

  int y;

  Month m;

  int d;

};


bool is_date(int y, Date::Month m, int d); // true для корректных дат


bool leapyear(int y); // true, если y — високосный год


bool operator==(const Date& a, const Date& b);

bool operator!=(const Date& a, const Date& b);


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

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

} // Chrono


Определения находятся в файле

Chrono.cpp
.


// Chrono.cpp

namespace Chrono {

// определения функций-членов:

  Date::Date(int yy, Month mm, int dd)

       :y(yy), m(mm), d(dd)

  {

    if (!is_date(yy,mm,dd)) throw Invalid();

  }


  Date& default_date()

  {

    static Date dd(2001,Date::jan,1); // начало XXI века

    return dd;

  }


  Date::Date()

       :y(default_date().year()),

        m(default_date().month()),

        d(default_date().day())

  {

  }


  void Date:: add_day(int n)

  {

    // ...

  }


  void Date::add_month(int n)

  {

    // ...

  }


  void Date::add_year(int n)

  {

    if (m==feb && d==29 && !leapyear(y+n)) { // помните о високосных годах!

      m = mar; // 1 марта вместо

               // 29 февраля

      d = 1;

    }

    y+=n;

  }


  // вспомогательные функции:

  bool is_date(int y, Date::Month m, int d)

  {

    // допустим, что y — корректный объект

    if (d<=0) return false; // d должна быть положительной

    if (m < Date::jan || Date::dec < m) return false;

    int days_in_month = 31; // большинство месяцев состоит из 31 дня

    switch (m) {

    case Date::feb: // продолжительность февраля варьирует

      days_in_month = (leapyear(y)) ? 29:28;

      break;

    case Date::apr: case Date::jun: case Date::sep: case

      Date::nov:

      days_in_month = 30; // остальные месяцы состоят из 30 дней

      break;

    }

    if (days_in_month

    return true;

  }


  bool leapyear(int y)

  {

    // см. упражнение 10

  }


  bool operator==(const Date& a, const Date& b)

  {

    return a.year()==b.year()

&& a.month()==b.month()

&& a.day()==b.day(); 

  }


  bool operator!=(const Date& a, const Date& b)

  {

    return !(a==b);

  }


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

  {

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

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

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

  }


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

  {

    int y, m, d;

    char ch1, ch2, ch3, ch4;

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

    if (!is) return is;

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

      is.clear(ios_base::failbit); // установлен неправильный 
бит

      return is;

    }

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

    return is;

  }


  enum Day {

    sunday, monday, tuesday, wednesday, thursday, friday, saturday

  };


  Day day_of_week(const Date& d)

  {

    // ...

  }


  Date next_Sunday(const Date& d)

  {

    // ...

  }


  Date next_weekday(const Date& d)

  {

    // ...

  }


} // Chrono 


Функции, реализующие операции

>>
и
<<
для класса
Date
, будут подробно рассмотрены в разделах 10.7 и 10.8.


Задание

Это задание сводится к запуску последовательности версий класса

Date
. Для каждой версии определите объект класса
Date
с именем
today
, инициализированный датой 25 июня 1978 года. Затем определите объект класса
Date
с именем tomorrow и присвойте ему значение, скопировав в него объект
today
и увеличив его день на единицу с помощью функции
add_day()
. Выведите на печать объекты
today
и
tomorrow
, используя оператор
<<
, определенный так, как показано в разделе 9.8.

Проверка корректности даты может быть очень простой. В любом случае не допускайте, чтобы месяц выходил за пределы диапазона [1,12], а день месяца — за пределы диапазона [1,31]. Проверьте каждую версию хотя бы на одной некорректной дате, например (2009, 13, –5).

1. Версия из раздела 9.4.1.

2. Версия из раздела 9.4.2.

3. Версия из раздела 9.4.3.

4. Версия из раздела 9.7.1.

5. Версия из раздела 9.7.4.


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

1. Какие две части класса описаны в главе?

2. В чем заключается разница между интерфейсом и реализацией класса?

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

Date
, описаны в этой главе?

4. Почему в классе

Date
используется конструктор, а не функция
init_day()
?

5. Что такое инвариант? Приведите примеры.

6. Когда функции следует размещать в определении класса, а когда — за его пределами? Почему?

7. Когда следует применять перегрузку оператора? Перечислите операторы, которые вы хотели бы перегрузить (укажите причину).

8. Почему открытый интерфейс класса должен быть минимальным?

9. Что изменится, если к объявлению функции-члена добавить ключевое слово

const
?

10. Почему вспомогательные функции лучше всего размещать за пределами класса?


Термины


Упражнения

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

2. Разработайте и реализуйте класс

Name_pairs
, содержащий пару (имя,возраст), где имя — объект класса
string
, а возраст — переменная типа
double
. Представьте эти члены класса в виде объектов классов
vector
(с именем name ) и
vector
(с именем
age
). Предусмотрите операцию ввода
read_names()
, считывающую ряд имен. Предусмотрите операцию
read_ages()
, предлагающую пользователю ввести возраст для каждого имени. Предусмотрите операцию
print()
, которая выводит на печать пары (
name[i]
,
age[i]
) (по одной на строке) в порядке, определенном вектором name. Предусмотрите операцию
sort()
, упорядочивающую вектор
name
в алфавитном порядке и сортирующую вектор
age
соответствующим образом. Реализуйте все “операции” как функции-члены. Проверьте этот класс (конечно, проверять надо как можно раньше и чаще).

3. Замените функцию

Name_pair::print()
(глобальным) оператором
operator<<
и определите операции
==
и
!=
для объектов класса
Name_pair
.

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

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

Book
, который является частью программного обеспечения библиотеки. Класс
Book
должен иметь члены для хранения кода ISBN, названия, фамилии автора и даты регистрации авторских прав. Кроме того, он должен хранить данные о том, выдана книга на руки или нет. Создайте функции, возвращающие эти данные. Создайте функции, проверяющие, выдана ли книга на руки или нет. Предусмотрите простую проверку данных, которые вводятся в объект класса
Book;
например, код ISBN допускается только в форме
n-n-n-x
, где
n
— целое число;
x
— цифра или буква.

6. Добавьте операторы в класс

Book
. Пусть оператор
==
проверяет, совпадают ли коды ISBN у двух книг. Пусть также оператор
!=
сравнивает цифры ISBN, а оператор
<<
выводит на печать название, фамилию автора и код ISBN в отдельных строках.

7. Создайте перечисление для класса

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

8. Создайте класс

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

9. Создайте класс

Library
. Включите в него векторы классов
Book
и
Patron
. Включите также структуру
Transaction
и предусмотрите в ней члены классов
Book
,
Patron
и
Date
. Создайте вектор объектов класса
Transaction
. Создайте функции, добавляющие записи о книгах и клиентах библиотеки, а также о состоянии книг. Если пользователь взял книгу, библиотека должна быть уверена, что пользователь является ее клиентом, а книга принадлежит ее фондам. Если эти условия не выполняются, выдайте сообщение об ошибке. Проверьте, есть ли у пользователя задолженность по уплате членских взносов. Если задолженность есть, выдайте сообщение об ошибке. Если нет, создайте объект класса
Transaction
и замените его в векторе объектов класса
Transaction
. Кроме того, создайте метод, возвращающий вектор, содержащий имена всех клиентов, имеющих задолженность.

10. Реализуйте функцию

leapyear()
из раздела 9.8.

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

Date
, включая такие функции, как
next_workday()
(в предположении, что любой день, кроме субботы и воскресенья, является рабочим) и
week_of_year()
(в предположении, что первая неделя начинается 1 января, а первый день недели — воскресенье).

12. Измените представление класса

Date
и пронумеруйте дни, прошедшие с 1 января 1970 года (так называемый нулевой день), с помощью переменной типа
long
и переработайте функции из раздела 9.8. Предусмотрите идентификацию дат, выходящих за пределы допустимого диапазона (отбрасывайте все даты, предшествующие нулевому дню, т.е. не допускайте отрицательных дней).

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

Rational
. Рациональное число состоит из двух частей: числителя и знаменателя, например 5/6 (пять шестых, или .83333). При необходимости еще раз проверьте определение класса. Предусмотрите операторы присваивания, сложения, вычитания, умножения, деления и проверки равенства. Кроме того, предусмотрите преобразование в тип
double
. Зачем нужен класс
Rational
?

14. Разработайте и реализуйте класс

Money
для вычислений, связанных с долларами и центами, точность которых определяется по правилу округления 4/5 (0,5 цента округляется вверх, все, что меньше 0,5, округляется вниз). Денежные суммы должны представляться в центах с помощью переменной типа
long
, но ввод и вывод должны использовать доллары и центы, например $123.45. Не беспокойтесь о суммах, выходящих за пределы диапазона типа
long
.

15. Уточните класс

Money
, добавив валюту (как аргумент конструктора). Начальное значение в виде десятичного числа допускается, поскольку такое число можно представить в виде переменной типа
long
. Не допускайте некорректных операций. Например, выражение
Money*Money
не имеет смысла, а
USD1.23+DKK5.00
имеет смысл, только если существует таблица преобразования, определяющая обменный курс между американскими долларами (USD) и датскими кронами (DKK).

16. Приведите пример вычислений, в котором класс

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

17. Приведите пример вычислений, в котором класс

Rational
позволяет получить более точные результаты, чем тип
double
.


Послесловие

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

Часть II