Раздел 12 — Классы, компонентные данные, наследование

Как связать данные с действиями над ними и защитить их от неверных изменений

Прогресс курса Раздел 12 из 20

Что вы освоите в этом разделе

9 академических часов теории, практической работы нет. В конце — два теста на портале: по разделу (пока он не сдан на 70 баллов, раздел 13 закрыт) и по всему блоку Б, разделы 7–12 (текущий контроль, не замок). Все программы страницы — полные; собирайте их командой g++ -std=c++17 -Wall -Wextra файл.cpp -o файл.exe.
01

От структуры к классу

У структуры из раздела 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
02

Конструкторы и деструктор

Конструктор — метод с именем класса и без типа результата. Он вызывается сам, когда объект создаётся, и приводит его в правильное состояние: у 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
03

Инкапсуляция и инвариант

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

КтоЧто делает для инвариантаВ Account
конструкторустанавливает его: объект с самого начала правильныйотрицательный начальный баланс → 0
изменяющие методысохраняют его: неверный запрос отклоняется, объект не меняетсяwithdraw(2000) при 1500 → false
методы доступа (get)отдают значения, но не ссылки на поляgetBalance() возвращает копию
privateне даёт обойти методыa.balance = -5 не собирается

Метод доступа не обязан быть парой «get + set» к каждому полю. Метод setBalance(long long value) без проверок разрушил бы всю защиту. У Account вместо него — операции предметной области: пополнить, снять. Хороший класс даёт ровно те действия, которые имеют смысл, и ничего больше. Как функция проверяет ввод на границе программы (раздел 6), так класс проверяет запросы на своей границе — в открытых методах.

04

Статические поля и методы

Обычное поле у каждого объекта своё. Статическое поле (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; — удобный способ держать размеры внутри класса.

05

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)
06

Объекты-члены и список инициализации

Поле класса может быть объектом другого класса: отрезок состоит из двух точек. Но у 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
07

Наследование

Прямоугольник и круг — фигуры: у них есть общее (название) и своё (стороны, радиус, формула площади). Наследование описывает это прямо: 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, чтобы показать разницу.

08

Конструкторы при наследовании и переопределение

Последняя строка вывода пункта 07 показывает проблему. Указатель типа const Shape* указывает на прямоугольник (это разрешено: прямоугольник является фигурой), но p->area() дал 0 — вызвался метод Shape. Компилятор выбирает метод по типу указателя, который известен при сборке, а не по настоящему классу объекта, который известен только при выполнении. Функция, получающая const Shape*, не могла бы посчитать площадь ни одной фигуры.

09

Виртуальные функции

Слово 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
10

Ловушки раздела

ОшибкаПризнакСообщение 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 qualifiersconst у всех методов, которые не меняют объект
порядок в списке инициализации не совпадает с порядком полейполе вычисляется из ещё не созданного — мусор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 проверяет заголовок
Связи раздела. Опирается на раздел 7 — функции, перегрузка, время жизни локальных переменных; раздел 9 — указатели и const; раздел 10 — массивы указателей и строки; раздел 11 — структуры, стрелка, const Student&. Нужен для: раздела 13 — описание данных и операций утилиты в техническом задании; разделов 14–19 — объекты-обёртки над дескрипторами Windows с CloseHandle в деструкторе, данные потока в структуре или классе; раздела 18 — виртуальный деструктор и память, выделенная во время работы.
Тесты по разделу и по блоку Б — на учебном портале. Тесты, попытки и оценки — в курсе на portal.nevabit.ru. Учётную запись выдаёт преподаватель.
Раздел 11: Файлы и структуры Тесты на портале