Классы похожи на структуры: это пользовательские типы данных, в которых хранятся поля. Синтаксически даже ключевые слова struct
и class
взаимозаменяемы (только объявления внутри struct
по умолчанию публичны, а внутри class
— приватны, об этом ниже). Однако мы будем различать семантику структуры и класса:
-
Мы будем использовать
struct
в типах, где не требуется сложная логика по инициализации и обработке значений. Структуры — это просто набор полей, не связанных какими-либо ограничениями. Типичный пример —struct Point
из параграфа «Составные типы данных». -
Мы будем использовать
class
там, где требуются какие-либо действия при инициализации и обработке данных. Класс предполагает некоторый инвариант: он не позволяет изменить данные произвольным образом. Хороший пример —class Time
, который мы рассмотрим ниже. Часы, минуты и секунды не могут быть произвольными числами, и класс должен гарантировать, что они всегда корректны.
Класс, как и структура, задаёт тип данных, но дополнительно определяет его поведение. Переменные этого типа по традиции называются объектами.
Объявление класса
Рассмотрим сначала простую структуру, которая хранит число время в сутках в виде часов, минут и секунд:
struct Time {
int hours = 0;
int minutes = 0;
int seconds = 0;
};
Такая структура очень проста, но она никак не проверяет корректность времени. Предполагается, что hours
находится в пределах от 0 до 23, а minutes
и seconds
— от 0 до 59. Но никто не помешает нам присвоить им другие значения:
int main() {
Time t;
t.hours = 42;
t.minutes = -5;
t.seconds = 61;
}
Чтобы контролировать значения этих полей и гарантировать их корректность, объявим класс Time
. Три наших поля в классе будут объявлены в приватной, или закрытой области. Это значит, что доступ к ним будут иметь только особо указанные дружественные функции и функции из класса (member functions). Кстати, в других языках программирования функции из класса именуют методами, но в стандарте C++ термина «метод» нет.
В публичной области объявим конструктор для начальной инициализации переменной класса Time
и три функции для чтения полей:
class Time {
private:
int hours;
int minutes;
int seconds;
public:
Time(int h, int m, int s); // объявляем конструктор
// Объявляем три функции для чтения полей:
int GetHours() const;
int GetMinutes() const;
int GetSeconds() const;
};
Здесь мы пока только объявили эти функции, но пока не написали их тела. Мы это сделаем чуть позже. А пока обратите внимание, что конструктор — это особая функция, которая вызывается при создании объекта. Имя конструктора совпадает с именем класса, а возвращаемое значение не указывается. Три функции GetHours
, GetMinutes
и GetSeconds
объявлены константными — с пометой const
в конце. Это значит, что эти функции не могут менять состояние объекта (в нашем случае — не изменяют значения полей hours
, minutes
и seconds
).
Фактически мы скрыли детали реализации нашего класса и предоставили публичный интерфейс — набор функций, через которые можно что-то сделать с объектом.
Определение функций из класса
Давайте теперь определим эти функции, — то есть, напишем их тела. Это можно сделать прямо внутри объявления класса (и дальше мы будем для краткости писать их именно так). Но, вообще говоря, определить функцию из класса можно и отдельно. Пишем после объявления класса:
Time::Time(int h, int m, int s) {
if (s < 0 || s > 59) {
// обрабатываем ошибочные секунды
}
if (m < 0 || m > 59) {
// обрабатываем ошибочные минуты
}
if (h < 0 || h > 23) {
// обрабатываем ошибочные часы
}
hours = h;
minutes = m;
seconds = s;
}
int Time::GetHours() const {
return hours;
}
int Time::GetMinutes() const {
return minutes;
}
int Time::GetSeconds() const {
return seconds;
}
Обратите внимание, что при внешнем определении функции из класса мы предваряем её имя префиксом с именем класса и двумя двоеточиями. Это напоминает пространства имён (вспомните, что мы везде пишем std::
перед именами из стандартной библиотеки).
Каждая такая функция из класса неявно применяется к текущему объекту. Например, вызов t.GetHours()
в этом коде будет применён к объекту t
:
#include <iostream>
int main() {
Time t(13, 30, 0); // 13:30:00
std::cout << t.GetHours() << "\n"; // 13
}
Тела функций из класса написаны так, как будто поля этого неявно переданного объекта попали в текущую область видимости. Сам же этот объект доступен в теле функции через указатель this
. Можно было бы написать
int Time::GetHours() const {
return this->hours; // то же самое, что (*this).hours
}
Но так обычно не пишут. То, что this
— это указатель, а не более удобная ссылка, — историческое недоразумение.
Обратите внимание, что теперь из кода любой другой функции, которая не является функцией из класса, нельзя обратиться к полям и изменить их:
int main() {
Time t(13, 30, 0);
t.hours = 42; // ошибка компиляции: приватное поле недоступно!
}
Следует отличать функции из класса от обычных функций. Например, вот это — самая обычная функция, которая просто принимает аргумент типа Time
:
bool IsAfternoonTime(const Time& time) {
return time.GetHours() >= 12;
}
#include <iostream>
int main() {
Time t(13, 30, 0);
if (IsAfternoonTime(t)) { // вызываем обычную функцию
std::cout << t.GetHours() - 12 << "PM\n"; // вызываем функцию из класса
}
}
Обратите внимание, что у внешней функции IsAfternoonTime
никакой пометы const
указывать не нужно: сведения о константности уже заложены в описание типа параметра time
.
Конструктор и обработка ошибок
Вернёмся к нашему конструктору. Мы пока не написали в нём код обработки ошибочных аргументов. Было бы слишком плохо просто выйти из конструктора в случае ошибки:
Time::Time(int h, int m, int s) {
if (s < 0 || s > 59) {
return;
}
// ...
}
В таком случае как ни в чём не бывало был бы создан объект, причём его поля никак не были бы проинициализированы. Поскольку у полей примитивный тип int
, то в них, как и в непроинициализированных локальных переменных, содержался бы «мусор»:
#include <iostream>
int main() {
Time t(42, -5, 61);
// Неопределённое поведение: может быть напечатано всё, что угодно:
std::cout << t.GetHours() << "\n";
}
Программисту надо решить, как должен вести себя конструктор в этой ситуации. Есть два варианта:
-
Сгенерировать исключение — специальное сообщение об ошибке.
Работа конструктора в этом случае прерывается. Объект не считается созданным. Такое исключение должно быть перехвачено специальным обработчикомtry
/catch
. Подробнее об исключениях мы будем говорить в отдельном параграфе, а пока просто научимся их генерировать:#include <stdexcept> Time::Time(int h, int m, int s) { if (s < 0 || s > 59 || m < 0 || m > 59 || h < 0 || h > 23) { throw std::out_of_range("Wrong time!"); } // ... }
-
Всё же создать объект, выполнив инициализацию его полей какими-то значениями.
Давайте сейчас поступим именно таким способом. Приведём часы, минуты и секунды к привычной шкале, перекидывая лишнее в другие разряды.Time::Time(int h, int m, int s) { m += s / 60; s %= 60; // Если s было отрицательным, то остаток тоже будет отрицательным if (s < 0) { // Уменьшим в этом случае минуты и сделаем секунды положительными m -= 1; s += 60; } h += m / 60; m %= 60; if (m < 0) { h -= 1; m += 60; } h %= 24; if (h < 0) { h += 24; } hours = h; minutes = m; seconds = s; }
Теперь все создаваемые объекты класса
Time
будут поддерживать инвариант «время задано корректно в пределах от 00:00:00 до 23:59:59»:int main() { Time t1(10, 18, -5); // 10:17:55 Time t2(25, 10, 42); // 01:10:42 Time t3(23, 59, 61); // 00:00:01 }
Перегрузка конструкторов
Заметим, что нельзя создать объект, не указав параметры конструктора или указав их неправильно:
int main() {
Time t; // ошибка компиляции: у класса Time нет конструктора без аргументов!
Time t2(3600); // ошибка компиляции: у класса Time нет конструктора от одного аргумента!
}
Однако в классе может быть несколько конструкторов. Добавим перегруженные версии для конструктора без аргументов (он будет инициализировать время нулями) и для конструктора, получающего число секунд с начала дня. Удобно будет указать нулевые значения наших полей в качестве значений по умолчанию.
class Time {
private:
int hours = 0;
int minutes = 0;
int seconds = 0;
public:
Time() = default;
Time(int h, int m, int s); // этот конструктор уже был написан раньше
Time(int s): Time(0, 0, s) {
}
};
Здесь мы объявили конструктор Time()
с пометой default
. Это значит, что компилятор сгенерирует его по умолчанию (в данном случае с пустым телом). Такая пометка встретится нам дальше при изучении других специальных функций класса. Конечно, мы могли бы просто написать тут пустое тело, но default
здесь выразительнее.
Конструктор Time(int s)
объявлен делегирующим: он ссылается на другой конструктор.
Подробнее про конструкторы мы поговорим в параграфе о жизненном цикле объектов.
Константные и неконстантные функции из класса
Сейчас мы можем проинициализировать объекты класса Time
только в момент создания и далее никак не можем их изменить. Они пока ведут себя как константы. Давайте теперь добавим в класс функции для изменения состояния объекта. Напишем функцию AddSeconds
, которая добавляет ко времени заданное количество секунд. Для краткости определим её тело прямо внутри объявления класса, а тела конструктора и Get
-функций не будем повторять:
class Time {
private:
int hours, minutes, seconds;
public:
Time(int h, int m, int s);
int GetHours() const;
int GetMinutes() const;
int GetSeconds() const;
void AddSeconds(int s) {
seconds += s;
// дальше следует выполнить такие же преобразования, как в конструкторе
}
};
Функцию AddSeconds
мы не объявили константной, так как она изменяет поля объекта. Однако вызвать такую функцию у константного объекта, конечно, не получится:
#include <iostream>
int main() {
Time t(10, 8, 0); // 10:08:00
t.AddSeconds(40); // 10:08:40
// Константная ссылка: через псевдоним cref объект нельзя изменять
const Time& cref = t;
// Константную функцию из класса вызвать можно
std::cout << cref.GetHours() << "\n"; // OK
// Неконстантную функцию из класса нельзя вызвать у константной сущности
cref.AddSeconds(20); // ошибка компиляции
}
Удобно оформить нормализацию в виде отдельной приватной функции Normalize
.
Её потребуется вызывать только в конструкторе и в функции AddSeconds
, а внешним пользователям класса она не нужна. Фактически, эта функция будет поддерживать инвариант класса.
class Time {
private:
int hours, minutes, seconds;
void Normalize() {
minutes += seconds / 60;
seconds %= 60;
if (seconds < 0) {
minutes -= 1;
seconds += 60;
}
hours += minutes / 60;
minutes %= 60;
if (minutes < 0) {
hours -= 1;
minutes += 60;
}
hours %= 24;
if (hours < 0) {
hours += 24;
}
}
public:
Time(int h, int m, int s) {
hours = h;
minutes = m;
seconds = s;
Normalize();
}
void AddSeconds(int s) {
seconds += s;
Normalize();
}
int GetHours() const;
int GetMinutes() const;
int GetSeconds() const;
};
Кстати, более правильный синтаксис для нашего конструктора такой:
class Time {
// ...
public:
Time(int h, int m, int s):
hours(h), // явно указываем, как инициализировать поля
minutes(m),
seconds(s)
{
Normalize();
}
Мы подробнее поговорим об этом в параграфе «Жизненный цикл объекта».
Перегрузка операторов
Вместо вызова t.AddSeconds(40)
было бы заманчиво написать просто t += 40
. Для этого нужно перегрузить соответствующие арифметические операторы для нашего класса. А для этого достаточно переименовать нашу функцию AddSeconds
в operator +=
.
Согласно канонам, такой оператор обычно возвращает ссылку на текущий объект *this
. В выражении t += 40
это возвращаемое значение игнорируется, однако оно позволяет писать странные цепочки вида (t += 40) += 20
. Не будем отступать от традиции:
class Time {
// пропустим объявления полей и функций
public:
Time& operator += (int s) {
seconds += s;
Normalize();
return *this;
}
};
Аналогично, объявим operator +
для сложения объекта Time
и целого числа секунд. В отличие от оператора +=
он будет создавать новый объект Time
, а не модифицировать текущий.
class Time {
// ...
public:
Time operator + (int s) const {
return Time(hours, minutes, seconds + s);
}
};
Проверяем:
int main() {
Time t(13, 30, 0);
t += 40; // теперь в t записано время 13:30:40
Time t2 = t + 20; // объект t не изменился, а в t2 записано 13:31:00
}
Вообще-то этот оператор +
мы могли бы объявить и как внешнюю функцию. Если интерфейс класса это позволяет сделать, то такой способ более предпочтителен:
Time operator + (const Time& t, int s) { // обратите внимание: тут не может быть модификатора const, это внешняя функция
return Time(t.GetHours(), t.GetMinutes(), t.GetSeconds() + s);
}
Более того, можно воспользоваться готовым оператором +=
:
// Принимаем t по значению, чтобы эту копию можно было изменить
Time operator + (Time t, int s) {
t += s;
return t;
}
Было бы большой ошибкой возвращать из operator +
значение по ссылке, как в operator +=
. Действительно, в операторе +
возвращается по значению новый объект, который был сконструирован локально. Ссылка на него сразу бы стала висячей.
Перегрузим теперь оператор вычитания. Он позволит вычитать одно время из другого и получать число секунд между этими временными метками. Но сначала добавим в класс полезную функцию TotalSeconds
, которая вернёт число секунд с начала суток:
class Time {
// ...
public:
int TotalSeconds() const {
return hours * 60 * 60 + minutes * 60 + seconds;
}
};
Теперь можно определить operator -
просто как внешнюю функцию:
int operator - (const Time& t1, const Time& t2) {
return t1.TotalSeconds() - t2.TotalSeconds();
}
Перегружать можно большинство операторов. Часто используется перегрузка оператора ()
, чтобы объект мог имитировать вызов функции. Особенно важно правильно перегружать оператор присваивания =
там, где это нужно (мы встретимся с этим в параграфe «Жизненный цикл объекта»).
Примерами перегрузок операторов у классов стандартной библиотеки являются оператор обращения по индексу в массиве []
у контейнеров std::vector
и std::map
, или оператор <<
для вывода в поток. Встречаются и экзотические случаи вроде перегрузки оператора /
для формирования пути в файловой системе или конструирования дат. Однако в общем случае, если речь не идёт о математическом классе, контейнере или потоковом вводе-выводе, увлекаться перегрузкой операторов не стоит.
Перегрузка операторов ввода-вывода
Операторы <<
и >>
на самом деле применяются к целым числам и выполняют побитовые сдвиги. Как мы знаем, в стандартной библиотеке они перегружены для форматированного потокового ввода и вывода. Покажем, как сделать это для своего класса.
#include <iostream>
std::ostream& operator << (std::ostream& out, const Time& t) {
out << t.GetHours() << ":" << t.GetMinutes() << ":" << t.GetSeconds();
return out;
}
std::istream& operator >> (std::istream& in, Time& t) {
int h, m, s;
char dummy;
// Считываем число и любой непробельный символ за ним
in >> h >> dummy;
in >> m >> dummy;
in >> s >> dummy;
// У нас нет другого способа изменить время через публичный интерфейс
// кроме присваивания нового значения
t = Time(h, m, s);
return in;
}
Мы перегрузили два оператора <<
и >>
. Первым аргументом они получают по ссылке входной или выходной поток. Второй аргумент — переменная нашего класса. Для оператора <<
она передаётся по константной ссылке, а для >>
— просто по ссылке, так как будет изменяться. Оба оператора возвращают ссылку на поток, чтобы можно было создавать цепочки вида std::cout << x << y << z
.
Обратите внимание, что оба перегруженных оператора работают с абстрактными потоками in
и out
, а не с конкретными std::cin
или std::cout
. Это позволяет их применять к потокам, связанным с файлами или строками.
Теперь можно читать и печатать время:
int main() {
Time t;
// Считываем время в формате hh:mm:ss,
// где на самом деле вместо двоеточия может быть любой разделитель
std::cin >> t;
t += 40;
std::cout << t << "\n"; // печатаем время в формате hh:mm:ss
}
Меняем реализацию, сохраняем интерфейс
Интерфейсом мы называем объявления из открытой части класса и объявления внешних функций, которые связаны с классом. Это то, что доступно пользователю нашего класса. Напомним, как выглядит интерфейс нашего класса Time
:
class Time {
private:
// детали реализации
public:
Time(int h, int m, int s);
int GetHours() const;
int GetMinutes() const;
int GetSeconds() const;
int TotalSeconds() const;
Time& operator += (int s);
};
Time operator + (const Time& t, int s);
int operator - (const Time& t1, const Time& t2);
Предположим, что мы включили этот класс в библиотеку, а разные пользователи её используют в своих программах. В какой-то момент мы можем захотеть улучшить библиотеку и сделать новую версию класса. При этом программы пользователей не должны от этого сломаться: наши изменения должны оказаться обратно совместимыми. Разделение на интерфейс и на детали реализации как раз позволяет менять эти детали, сохраняя интерфейс неизменным.
Переделаем класс Time
, чтобы он хранил не три переменных типа int
(часы, минуты и секунды), а одну переменную с числом секунд, прошедших с начала суток. Реализация большинства функций от этого упростится, хотя функции вида GetHours
станут чуть сложнее.
class Time {
private:
int totalSeconds;
void Normalize() { // смотрите, как упростилась эта функция!
const int secondsInDay = 24 * 60 * 60;
totalSeconds %= secondsInDay;
if (totalSeconds < 0) {
totalSeconds += secondsInDay;
}
}
public:
Time(int h, int m, int s) {
totalSeconds = h * 60 * 60 + m * 60 + s;
Normalize();
}
int GetHours() const {
return totalSeconds / (60 * 60);
}
int GetMinutes() const {
return (totalSeconds / 60) % 60;
}
int GetSeconds() const {
return totalSeconds % 60;
}
int TotalSeconds() const {
return totalSeconds;
}
Time& operator += (int s) {
totalSeconds += s;
Normalize();
return *this;
}
};
Заметьте, что реализация внешних функций operator +
и operator -
никак не изменится, потому что они написаны в терминах публичного интерфейса класса.