Как связать данные с действиями над ними и защитить их от неверных изменений
this, константными методамиg++ -std=c++17 -Wall -Wextra файл.cpp -o файл.exe.У структуры из раздела 11 любой код может записать в поле что угодно: s.average = -7;. Правило «балл от 2 до 5» живёт только в голове программиста, и одна забытая проверка его нарушает. Класс соединяет данные с функциями, которые с ними работают, — методами, — и закрывает данные так, что изменить их можно только через методы:
// Класс «счёт»: поля закрыты, менять их можно только методами, которые не дают балансу стать отрицательным
#include <iostream>
constexpr int NAME_SIZE = 32;
class Account {
public:
Account(const char ownerName[], long long initial);
bool deposit(long long amount);
bool withdraw(long long amount);
long long getBalance() const;
void print() const;
private:
char owner[NAME_SIZE];
long long balance; // инвариант: balance >= 0
};
int main()
{
Account a("Ivanov", 1000);
a.print();
a.deposit(500);
if (!a.withdraw(2000)) {
std::cout << "withdraw 2000: refused\n";
}
a.withdraw(300);
a.print();
Account b("Petrova", -50); // конструктор не создаст счёт с минусом
b.print();
std::cout << "total " << a.getBalance() + b.getBalance() << '\n';
return 0;
}
// Имя обрезается до NAME_SIZE - 1 символов; отрицательный начальный баланс заменяется нулём
Account::Account(const char ownerName[], long long initial)
{
int i = 0;
for (; ownerName[i] != '\0' && i < NAME_SIZE - 1; ++i) {
owner[i] = ownerName[i];
}
owner[i] = '\0';
balance = initial < 0 ? 0 : initial;
}
// Пополнение на amount > 0; иначе false и баланс не меняется
bool Account::deposit(long long amount)
{
if (amount <= 0) {
return false;
}
balance += amount;
return true;
}
// Снятие amount > 0, не больше баланса; иначе false и баланс не меняется
bool Account::withdraw(long long amount)
{
if (amount <= 0 || amount > balance) {
return false;
}
balance -= amount;
return true;
}
// Текущий баланс — прочитать можно, изменить напрямую нельзя
long long Account::getBalance() const
{
return balance;
}
// Владелец и баланс одной строкой
void Account::print() const
{
std::cout << owner << ": " << balance << '\n';
}
Ivanov: 1000
withdraw 2000: refused
Ivanov: 1200
Petrova: 0
total 1200
main, с именем класса и :: перед именем метода: bool Account::deposit(…). Это то же правило курса, что для функций: сначала объявления, потом main, потом определения.public — открытая часть: её видит любой код. private — закрытая: поля и методы доступны только методам этого класса. Попытка a.balance = 1000000; в main не собирается: 'long long int Account::balance' is private within this context.a.deposit(500). Внутри метода поля объекта доступны просто по имени: balance в deposit — это a.balance при вызове a.deposit и b.balance при вызове b.deposit.class и struct в C++ отличаются одним: у struct всё открыто по умолчанию, у class — закрыто. Принято: struct — для простых записей из полей (раздел 11), class — когда есть правила, которые нужно охранять.Конструктор — метод с именем класса и без типа результата. Он вызывается сам, когда объект создаётся, и приводит его в правильное состояние: у Account не бывает момента, когда баланс — мусор. Объявление Account a("Ivanov", 1000); — это вызов конструктора с этими аргументами. Деструктор — ~ИмяКласса(), без параметров; вызывается сам, когда объект исчезает:
// Конструктор и деструктор: когда объект создаётся и когда исчезает
#include <iostream>
class Tracer {
public:
Tracer(const char name[]);
~Tracer();
private:
const char* label;
};
void work();
int main()
{
Tracer a("a");
work();
{
Tracer c("c");
std::cout << "block\n";
}
std::cout << "end of main\n";
return 0;
}
// Объект b живёт, пока выполняется work
void work()
{
Tracer b("b");
std::cout << "work\n";
}
// Запоминает метку и сообщает о создании
Tracer::Tracer(const char name[])
{
label = name;
std::cout << "create " << label << '\n';
}
// Сообщает об уничтожении
Tracer::~Tracer()
{
std::cout << "destroy " << label << '\n';
}
create a
create b
work
destroy b
create c
block
destroy c
end of main
destroy a
std::ofstream закрывает файл в своём деструкторе — поэтому в разделе 11 close() можно было не писать. В блоке Windows API так закрывают дескрипторы: CloseHandle в деструкторе, и забыть его уже нельзя.Account(const char name[]) с нулевым балансом и Account(const char name[], long long initial). Конструктор без параметров называется конструктором по умолчанию; если в классе объявлен хоть один конструктор, компилятор свой по умолчанию не создаёт, и Point p; не собирается: no matching function for call to 'Point::Point()'.Инвариант — условие, которое истинно для объекта всегда, между любыми вызовами методов: у Account — «баланс не меньше нуля». Инкапсуляция — способ его обеспечить: поля закрыты, и каждый метод, который их меняет, проверяет, что после него инвариант не нарушится.
| Кто | Что делает для инварианта | В Account |
|---|---|---|
| конструктор | устанавливает его: объект с самого начала правильный | отрицательный начальный баланс → 0 |
| изменяющие методы | сохраняют его: неверный запрос отклоняется, объект не меняется | withdraw(2000) при 1500 → false |
| методы доступа (get) | отдают значения, но не ссылки на поля | getBalance() возвращает копию |
private | не даёт обойти методы | a.balance = -5 не собирается |
Метод доступа не обязан быть парой «get + set» к каждому полю. Метод setBalance(long long value) без проверок разрушил бы всю защиту. У Account вместо него — операции предметной области: пополнить, снять. Хороший класс даёт ровно те действия, которые имеют смысл, и ничего больше. Как функция проверяет ввод на границе программы (раздел 6), так класс проверяет запросы на своей границе — в открытых методах.
Обычное поле у каждого объекта своё. Статическое поле (static) — одно на весь класс, общее для всех объектов; оно существует, даже когда объектов нет. Статический метод вызывается через имя класса, без объекта, и работает только со статическими полями:
// Статическое поле — одно на весь класс: счётчик созданных и существующих объектов
#include <iostream>
class Ticket {
public:
Ticket();
~Ticket();
int getNumber() const;
static int getAlive();
private:
int number;
static inline int created = 0; // сколько создано за всё время — номер очередного билета
static inline int alive = 0; // сколько существует сейчас
};
int main()
{
Ticket t1;
Ticket t2;
std::cout << t1.getNumber() << ' ' << t2.getNumber() << ", alive " << Ticket::getAlive() << '\n';
{
Ticket t3;
std::cout << t3.getNumber() << ", alive " << Ticket::getAlive() << '\n';
}
Ticket t4;
std::cout << t4.getNumber() << ", alive " << Ticket::getAlive() << '\n';
return 0;
}
// Новый билет получает следующий номер
Ticket::Ticket()
{
++created;
number = created;
++alive;
}
// Билет исчез — живых меньше, а номера не переиспользуются
Ticket::~Ticket()
{
--alive;
}
// Номер этого билета
int Ticket::getNumber() const
{
return number;
}
// Статический метод: вызывается через имя класса, объект не нужен
int Ticket::getAlive()
{
return alive;
}
1 2, alive 2
3, alive 3
4, alive 3
static inline int created = 0; — объявление и начальное значение прямо в классе (C++17). В старом коде вы встретите другую запись: в классе static int created;, а вне его, один раз на программу, — int Ticket::created = 0;. Статические поля — единственное место, где у класса есть общее состояние; правило «никаких глобальных переменных» они не нарушают, потому что закрыты private и меняются только методами класса. Статические константы — static constexpr int MAX = 100; — удобный способ держать размеры внутри класса.
this и константные методыКак метод узнаёт, чьи поля менять? При вызове p.move(3, 1) в метод неявно передаётся адрес объекта p — он доступен как указатель this. Запись x внутри метода означает this->x.
// this — адрес объекта, для которого вызван метод; const-метод обещает не менять объект
#include <iostream>
#include <cmath>
class Point {
public:
Point(double x, double y);
Point& move(double dx, double dy);
double distanceTo(const Point& other) const;
void print() const;
private:
double x;
double y;
};
int main()
{
Point p(0, 0);
const Point origin(0, 0);
p.move(3, 1).move(0, 3); // move возвращает сам объект — вызовы по цепочке
p.print();
std::cout << "distance " << p.distanceTo(origin) << '\n';
origin.print(); // у const-объекта можно вызывать только const-методы
return 0;
}
// Параметры скрыли поля с теми же именами — поля доступны через this
Point::Point(double x, double y)
{
this->x = x;
this->y = y;
}
// Сдвиг; возвращает ссылку на сам объект
Point& Point::move(double dx, double dy)
{
x += dx;
y += dy;
return *this;
}
// Расстояние до другой точки
double Point::distanceTo(const Point& other) const
{
const double dx = x - other.x; // поля другого объекта того же класса доступны
const double dy = y - other.y;
return std::sqrt(dx * dx + dy * dy);
}
// (x, y)
void Point::print() const
{
std::cout << '(' << x << ", " << y << ")\n";
}
(3, 4)
distance 5
(0, 0)
this->x = x нужен, когда параметр назван так же, как поле: имя параметра скрывает поле, а this->x — поле явно.return *this; — метод возвращает ссылку на сам объект, и вызовы можно продолжать цепочкой. Так устроен std::cout << a << b: каждый << возвращает сам поток.const после списка параметров, и в прототипе, и в определении. Он обещает не менять поля; попытка изменить — ошибка assignment of member 'Point::x' in read-only object. У const-объекта и у параметра const Point& можно вызывать только константные методы: иначе passing 'const Point' as 'this' argument discards qualifiers. Правило курса: каждый метод, который не меняет объект, — const.Поле класса может быть объектом другого класса: отрезок состоит из двух точек. Но у Point нет конструктора по умолчанию — значит, точки нужно создать с координатами до того, как начнёт выполняться тело конструктора Segment. Для этого — список инициализации: после двоеточия, перед телом.
// Объект — поле другого класса; список инициализации создаёт поля до входа в тело конструктора
#include <iostream>
#include <cmath>
class Point {
public:
Point(double x, double y);
double getX() const;
double getY() const;
private:
double x;
double y;
};
class Segment {
public:
Segment(int id, double x1, double y1, double x2, double y2);
int getId() const;
double length() const;
private:
const int id; // константное поле задаётся только в списке инициализации
Point start; // у Point нет конструктора без параметров
Point end;
};
int main()
{
const Segment s(7, 0, 0, 3, 4);
std::cout << "segment " << s.getId() << ": length " << s.length() << '\n';
return 0;
}
// В списке инициализации x(x): слева поле, в скобках параметр
Point::Point(double x, double y)
: x(x), y(y)
{
}
// Координата x
double Point::getX() const
{
return x;
}
// Координата y
double Point::getY() const
{
return y;
}
// Поля создаются в порядке объявления в классе: id, start, end
Segment::Segment(int id, double x1, double y1, double x2, double y2)
: id(id), start(x1, y1), end(x2, y2)
{
}
// Номер отрезка
int Segment::getId() const
{
return id;
}
// Длина отрезка
double Segment::length() const
{
const double dx = end.getX() - start.getX(); // закрытые поля Point — только через методы
const double dy = end.getY() - start.getY();
return std::sqrt(dx * dx + dy * dy);
}
segment 7: length 5
const-полей и полей-ссылок годится только список.x(x) однозначна: снаружи скобок может стоять только поле, внутри имя ищется сначала среди параметров. Поэтому в списке инициализации this не нужен.-Wreorder): 'Range::first' will be initialized after. Опасно, когда одно поле вычисляется через другое, ещё не созданное.Segment не видят закрытых полей Point — только его открытые методы. Инкапсуляция работает и между классами.Прямоугольник и круг — фигуры: у них есть общее (название) и своё (стороны, радиус, формула площади). Наследование описывает это прямо: class Rectangle : public Shape — прямоугольник является фигурой. Производный класс получает все поля и методы базового и добавляет свои.
// Наследование: производный класс получает поля и методы базового и добавляет свои
#include <iostream>
constexpr double PI = 3.14159265358979;
constexpr int NAME_SIZE = 16;
class Shape {
public:
Shape(const char shapeName[]);
const char* getName() const;
double area() const; // у «фигуры вообще» площади нет — 0
protected:
char name[NAME_SIZE]; // доступно наследникам, закрыто для остальных
};
class Rectangle : public Shape {
public:
Rectangle(double width, double height);
double area() const; // своя версия метода базового класса
private:
double width;
double height;
};
class Circle : public Shape {
public:
Circle(double radius);
double area() const;
private:
double radius;
};
int main()
{
const Rectangle r(3, 4);
const Circle c(1);
std::cout << r.getName() << ' ' << r.area() << '\n';
std::cout << c.getName() << ' ' << c.area() << '\n';
const Shape* p = &r; // указатель на базовый класс — на производный объект
std::cout << p->getName() << ' ' << p->area() << '\n';
return 0;
}
// Имя обрезается до NAME_SIZE - 1 символов
Shape::Shape(const char shapeName[])
{
int i = 0;
for (; shapeName[i] != '\0' && i < NAME_SIZE - 1; ++i) {
name[i] = shapeName[i];
}
name[i] = '\0';
}
// Название фигуры
const char* Shape::getName() const
{
return name;
}
// Площадь неизвестной фигуры
double Shape::area() const
{
return 0;
}
// Сначала создаётся базовая часть — конструктором Shape
Rectangle::Rectangle(double width, double height)
: Shape("Rectangle"), width(width), height(height)
{
}
// Площадь прямоугольника
double Rectangle::area() const
{
return width * height;
}
// Базовая часть — «Circle»
Circle::Circle(double radius)
: Shape("Circle"), radius(radius)
{
}
// Площадь круга
double Circle::area() const
{
return PI * radius * radius;
}
Rectangle 12
Circle 3.14159
Rectangle 0
| Доступ в базовом классе | Методы базового | Методы производного | Остальной код |
|---|---|---|---|
public | да | да | да |
protected | да | да | нет |
private | да | нет — только через методы базового | нет |
protected ослабляет инкапсуляцию: любой наследник может сломать инвариант базового класса. Надёжнее оставлять поля private, а наследникам давать protected-методы. Здесь name — protected, чтобы показать разницу.
: Shape("Rectangle"), width(width), …. Если его там нет, вызывается конструктор базового по умолчанию, а у Shape его нет — ошибка сборки.double area() const. Для объекта Rectangle вызывается его версия. Из переопределённого метода версию базового зовут через имя класса: Shape::area().Последняя строка вывода пункта 07 показывает проблему. Указатель типа const Shape* указывает на прямоугольник (это разрешено: прямоугольник является фигурой), но p->area() дал 0 — вызвался метод Shape. Компилятор выбирает метод по типу указателя, который известен при сборке, а не по настоящему классу объекта, который известен только при выполнении. Функция, получающая const Shape*, не могла бы посчитать площадь ни одной фигуры.
Слово virtual перед методом базового класса меняет правило: метод выбирается по настоящему классу объекта во время выполнения. override в производном классе просит компилятор проверить, что метод действительно переопределяет виртуальный:
// Виртуальная функция: через указатель на базовый класс вызывается версия настоящего класса объекта
#include <iostream>
constexpr double PI = 3.14159265358979;
class Shape {
public:
virtual ~Shape();
virtual const char* getName() const;
virtual double area() const;
};
class Rectangle : public Shape {
public:
Rectangle(double width, double height);
const char* getName() const override;
double area() const override;
private:
double width;
double height;
};
class Circle : public Shape {
public:
Circle(double radius);
const char* getName() const override;
double area() const override;
private:
double radius;
};
double totalArea(const Shape* const shapes[], int n);
int main()
{
const Rectangle r(3, 4);
const Circle c(1);
const Rectangle square(2, 2);
const Shape* const shapes[] = {&r, &c, &square}; // разные классы в одном массиве
for (int i = 0; i < 3; ++i) {
std::cout << shapes[i]->getName() << ' ' << shapes[i]->area() << '\n';
}
std::cout << "total " << totalArea(shapes, 3) << '\n';
return 0;
}
// Сумма площадей — функция знает только базовый класс
double totalArea(const Shape* const shapes[], int n)
{
double total = 0;
for (int i = 0; i < n; ++i) {
total += shapes[i]->area();
}
return total;
}
// Остальные определения — как в пункте 07: имена и формулы площадей; полностью — в примерах раздела
Rectangle 12
Circle 3.14159
Rectangle 4
total 19.1416
totalArea написана один раз и работает с любыми фигурами — в том числе с классами, которые появятся позже. Это полиморфизм: один вызов shapes[i]->area(), разное поведение. const Shape* const shapes[] — массив константных указателей на константные фигуры: функция не меняет ни массив, ни фигуры.Shape, от него останется только базовая часть.override ловит ошибку в заголовке: без него метод с опечаткой или без const молча стал бы новым методом, а не переопределением. С ним — marked 'override', but does not override.virtual double area() const = 0; — без реализации. Класс с таким методом — абстрактный: объектов «фигура вообще» создать нельзя, только наследников, которые реализовали метод.| Ошибка | Признак | Сообщение g++ | Как избежать |
|---|---|---|---|
| обращение к закрытому полю снаружи | не собирается | is private within this context | открытый метод предметной области |
| объект без аргументов, а конструктора по умолчанию нет | не собирается | no matching function for call to 'Point::Point()' | аргументы при создании или конструктор без параметров |
| const-метод меняет поле | не собирается | increment of member 'Counter::value' in read-only object | убрать const или не менять поле |
| не-const метод у const-объекта | не собирается | passing 'const Point' as 'this' argument discards qualifiers | const у всех методов, которые не меняют объект |
| порядок в списке инициализации не совпадает с порядком полей | поле вычисляется из ещё не созданного — мусор | will be initialized after [-Wreorder] | список — в порядке объявления полей |
параметр с именем поля без this | поле не меняется: присваивание параметру самому себе | иногда нет | this->x = x или список инициализации |
нет virtual в базовом классе | через указатель на базовый вызывается его версия | нет | virtual в базовом, override в производном |
override у невиртуального метода или с другим заголовком | не собирается | marked 'override', but does not override | сверить заголовок, добавить virtual в базовом |
| производный объект скопирован в переменную базового типа | вызывается версия базового | нет | указатель или ссылка на базовый |
| set-метод без проверки на каждое поле | инвариант нарушается снаружи | нет | методы-операции с проверкой |
private, операции public; методы определяются как Класс::методstatic — одно поле на класс; статический метод вызывается через имя классаthis — адрес объекта; const-метод не меняет объект и единственный доступен у const-объектаconst-поля и базовая часть создаются в списке инициализации, в порядке объявленияprotected — для наследниковvirtual + указатель на базовый класс = вызов версии настоящего класса; override проверяет заголовокconst; раздел 10 — массивы указателей и строки; раздел 11 — структуры, стрелка, const Student&. Нужен для: раздела 13 — описание данных и операций утилиты в техническом задании; разделов 14–19 — объекты-обёртки над дескрипторами Windows с CloseHandle в деструкторе, данные потока в структуре или классе; раздела 18 — виртуальный деструктор и память, выделенная во время работы.