# Глава 5.5. Классы и структуры C++ появился как надстройка над Си под названием «Си с классами». А от Си он унаследовал структуры. В этой главе мы разберемся, что такое классы и структуры, и чем они отличаются. ## Классы [Класс](https://en.cppreference.com/w/cpp/language/class) (class) — это составной тип данных. Он позволяет: - Объединять данные и методы их обработки. - Хранить состояние. - Описывать абстракции и предоставлять интерфейс для работы с ними. - Ограничивать доступ к полям и методам, то есть [инкапсулировать](https://ru.wikipedia.org/wiki/%D0%98%D0%BD%D0%BA%D0%B0%D0%BF%D1%81%D1%83%D0%BB%D1%8F%D1%86%D0%B8%D1%8F_(%D0%BF%D1%80%D0%BE%D0%B3%D1%80%D0%B0%D0%BC%D0%BC%D0%B8%D1%80%D0%BE%D0%B2%D0%B0%D0%BD%D0%B8%D0%B5)) внутреннюю реализацию. - Автоматически управлять ресурсами (об этом чуть позже). Верхнеуровнево класс выглядит так: ```cpp class имя_класса { // содержимое: поля и методы }; ``` Данные класса хранятся в его полях. {#block-class-message} ```cpp class Message { public: std::string id; std::string text; }; ``` [Спецификатор доступа](https://en.cppreference.com/w/cpp/language/access) `public` означает, что перечисленные после него поля и методы составляют его публичный интерфейс и открыты пользователям класса. Методы класса выглядят как функции, определённые внутри класса. Они имеют доступ ко всем его полям и методам. Методы могут иметь перегрузки. {#block-class-unixtimestamp} ```cpp {.example_for_playground .example_for_playground_009} class UnixTimestamp { public: void show_days() { const std::time_t seconds_in_day = 24 * 60 * 60; std::println("{} days since 01.01.1970", seconds / seconds_in_day); } std::time_t seconds = 0; }; ``` Для хранения временной метки формата [Unix time](https://en.wikipedia.org/wiki/Unix_time) мы воспользовались [типом std::time_t](https://en.cppreference.com/w/cpp/chrono/c/time_t). Его реализация зависит от компилятора, но чаще всего это знаковое целое число. Оно хранит количество секунд, прошедших с 00:00 1 января 1970 года (UTC). Обратите внимание, что поле `seconds` мы инициализировали нулём, то есть явно задали его значение по умолчанию: ```cpp std::time_t seconds = 0; ``` Явно инициализировать поля простых типов значениями можно _и нужно._ Для обращения к полям и методам _объекта класса_ используется оператор доступа к элементу `.`: ```cpp {.example_for_playground .example_for_playground_010} UnixTimestamp ts; ts.seconds = 1153044000; ts.show_days(); ``` Поля и методы можно сделать внутренними — доступными только из других методов, но не снаружи. Для этого предназначен спецификатор доступа `private`. Все, что перечислено после него, составляет внутреннюю реализацию класса. Рассмотрим класс `Task`, описывающий задачу в типичном таск-трекере. У него есть методы для логирования времени работы над задачей и обновления её статуса. Поля, хранящие время и статус, сделаны приватными. В некоторых код-стайлах приватные поля именуются с префиксом `m_` (member). {#block-class-task} ```cpp {.example_for_playground .example_for_playground_011} enum class State { Todo = 1, InProgress = 2, Done = 3 }; class Task { public: bool log_work_hours(std::size_t hours) { if (m_state == State::Todo) m_state = State::InProgress; if (m_state == State::InProgress) m_workHours += hours; return m_state == State::InProgress; } bool update_state(State new_state) { if (new_state > m_state) m_state = new_state; return m_state == new_state; } void show_work_hours() { std::println("{}", m_workHours); } private: State m_state = State::Todo; std::size_t m_workHours = 0; }; ``` А теперь поработаем с объектом класса `Task`: ```cpp {.example_for_playground .example_for_playground_012} Task t; t.update_state(State::InProgress); t.log_work_hours(5); t.show_work_hours(); ``` Напишите класс `Device` с публичными методами: {.task_text} `void start()` — помечает устройство как запущенное. Сохраняет время включения в секундах (тип `std::time_t`). {.task_text} `void stop()`— помечает устройство как выключенное. По умолчанию (если метод `start()` ни разу не вызывался) устройство считается выключенным. {.task_text} `void set_latest_healthcheck()` — сохраняет время последнего хэлсчека — аудита «здоровья». Если на этот момент устройство выключено, кидает исключение `std::logic_error`. {.task_text} `bool is_active()` — возвращает `true`, если устройство включено и с момента последнего хэлсчека прошло не больше 1 минуты. {.task_text} `std::time_t uptime()` — возвращает время работы. Если устройство выключено, возвращает 0. {.task_text} Для получения текущего времени в проекте уже есть функция `std::time_t get_cur_time()`. Используйте её. {.task_text} Чтобы хранить состояние устройства, заведите 3 приватных поля: `is_on` — включено устройство или нет; `time_started` — время запуска; `time_checked` — время последнего хэлсчека. При объявлении полей обязательно проинициализируйте их. Значение по умолчанию `is_on` равно `false` (устройство должно быть выключено до явного вызова `start()`); а время запуска и время хэлсчека по умолчанию должны быть равны 0. {.task_text} ```cpp {.task_source #cpp_chapter_0055_task_0060} ``` Метод `start()` должен устанавливать поле `is_on` в `true`, а `time_started` в `get_cur_time()`. Метод `stop()` должен устанавливать `is_on` в `false`. Метод `set_latest_healthcheck()` должен проверять `is_on` и бросать `std::logic_error`. А если устройство включено, устанавливать `time_checked` в `get_cur_time()`. Метод `is_acitve()` должен возвращать результат выражения `is_on && get_cur_time() < time_checked + 60`. А метод `uptime()` — результат выражения `is_on ? get_cur_time() - time_started : 0`. {.task_hint} ```cpp {.task_answer} class Device { public: void start() { is_on = true; time_started = get_cur_time(); } void stop() { is_on = false; } void set_latest_healthcheck() { if (!is_on) throw std::logic_error("device is off"); time_checked = get_cur_time(); } bool is_active() { return is_on && get_cur_time() < time_checked + 60; } std::time_t uptime() { return is_on ? get_cur_time() - time_started : 0; } private: std::time_t time_checked = 0; std::time_t time_started = 0; bool is_on = false; }; ``` ### Конструкторы и деструкторы У классов есть особые методы — конструкторы и деструкторы. Конструкторы нужны для корректного создания объектов. А деструкторы — для их разрушения. Из конструкторов и деструкторов, как и из обычных методов, можно обращаться к полям класса, вызывать методы и свободные функции. Базовые факты о конструкторах: - Конструктор нужен для инициализации состояния объекта. - Имя конструктора совпадает с именем класса. - Возвращаемый тип конструктора не указывается. Он ничего не возвращает. Но может бросить исключение. - Класс может иметь несколько конструкторов: допустима их перегрузка. - Конструктор, не принимающий параметров, называется конструктором по умолчанию. - У класса может не быть явно добавленных конструкторов. Тогда при создании объекта вызывается конструктор по умолчанию. Он в свою очередь вызывает конструкторы по умолчанию для полей класса. - Конструктор вызывается неявно при создании объекта, но может быть вызван и напрямую. Базовые факты о деструкторах: - Деструктор нужен для освобождения используемых объектом ресурсов. - Имя деструктора совпадает с именем класса, перед которым ставится символ `~`. Например, `~Device()`. - Как и у конструктора, у деструктора не указывается возвращаемый тип. - Если деструктор бросит исключение, в общем случае программа аварийно завершится. Позже вы узнаете о причинах такого поведения и о способах его обхода. - Деструктор не принимает аргументов, поэтому его перегрузка невозможна. - Если деструктор не указан явно, для класса создаётся деструктор по умолчанию. Он вызывает деструкторы полей класса. - Деструктор срабатывает в момент удаления объекта. Практически никогда не требуется вызывать его напрямую. - Деструкторы вызываются в порядке, обратном конструированию. ```cpp {.example_for_playground .example_for_playground_013} class File { public: // Конструктор по умолчанию File() { } // Конструктор с параметром File(std::string path) { } // Деструктор ~File(){ } }; int main() { // Создание объекта с помощью конструктора по умолчанию File f1; // Создание объекта с помощью конструктора с параметром File f2("/tmp/dump.csv"); // При выходе из main() для f2 и f1 будут вызваны // деструкторы, причём именно в таком порядке } ``` ### Идиома RAII {#block-raii} Конструкторы и деструкторы позволяют в C++ реализовывать идиому [RAII.](https://ru.wikipedia.org/wiki/%D0%9F%D0%BE%D0%BB%D1%83%D1%87%D0%B5%D0%BD%D0%B8%D0%B5_%D1%80%D0%B5%D1%81%D1%83%D1%80%D1%81%D0%B0_%D0%B5%D1%81%D1%82%D1%8C_%D0%B8%D0%BD%D0%B8%D1%86%D0%B8%D0%B0%D0%BB%D0%B8%D0%B7%D0%B0%D1%86%D0%B8%D1%8F) Resource Acquisition Is Initialization (получение ресурса есть инициализация) — это подход для автоматического управления ресурсами, требующими парных действий. Например, выделение и освобождение памяти, подключение и отключение от БД, открытие и закрытие файла. Получение ресурса реализуется в конструкторе, а освобождение — в деструкторе. Когда объект разрушается, ресурс освобождается автоматически. Разработчику не нужно заботиться об этом в каждой из веток кода. В проекте есть класс `DBConn` для работы с БД. Этот класс — обёртка над старым кодом на Си. Он реализует методы для подключения, отключения и выполнения запроса к БД. {.task_text} Перепишите `DBConn` в соответствии с идиомой RAII. Заведите конструктор, внутри которого происходит подключение к БД. В случае неудачи конструктор должен бросать исключение `std::runtime_error`. Отключение от БД должно быть в деструкторе. {.task_text} Затем перепишите в соответствии с этими изменениями класса функцию `handle_metrics()`. Перехватывать в ней исключения не нужно: они обрабатываются в коде, вызывающем `handle_metrics()`. {.task_text} ```cpp {.task_source #cpp_chapter_0055_task_0070} class DBConn { public: bool open(std::string conn_str) { if (is_open()) return false; // Метод строки c_str() возвращает // указатель на строку в сишном стиле. // Про указатели вы узнаете позже. m_handle = open_db(conn_str.c_str()); return is_open(); } // После переписывания класса на RAII этот // метод больше не будет нужен. bool is_open() { return m_handle != INVALID_DB_HANDLE; } // Этот метод тоже будет не нужен. // Соединение будет закрывать деструктор. void close() { if (is_open()) { close_db(m_handle); m_handle = INVALID_DB_HANDLE; } } bool exec(std::string query) { if (!is_open()) return false; return exec_db_query(m_handle, query.c_str()); } private: db_handle m_handle = INVALID_DB_HANDLE; }; // Перехватывать исключения в этом методе не нужно. // Пусть они пробрасываются дальше. void handle_metrics() { DBConn db_conn; if (!db_conn.open("postgresql://user:secret@localhost")) return; if (!db_conn.exec("select * from metrics")) { // Упс! Здесь забыт вызов db_conn.close(). // Открытое соединение продолжит висеть. // С RAII подобные ошибки исключены. return; } db_conn.close(); } ``` Заведите конструктор `DBConn(std::string conn_str)`, открывающий подключение к БД. Закрывайте подключение в деструкторе `~DBConn()`. {.task_hint} ```cpp {.task_answer} class DBConn { public: DBConn(std::string conn_str) { m_handle = open_db(conn_str.c_str()); if (m_handle == INVALID_DB_HANDLE) throw std::runtime_error("Couldn't connect"); } ~DBConn() { close_db(m_handle); } bool exec(std::string query) { return exec_db_query(m_handle, query.c_str()); } private: db_handle m_handle = INVALID_DB_HANDLE; }; void handle_metrics() { DBConn db_conn("postgresql://user:secret@localhost"); db_conn.exec("select * from metrics"); } ``` Идиома RAII — один из столпов современного C++. Мы не раз к ней вернёмся. ## Структуры Помимо классов в C++ есть [структуры](https://en.cppreference.com/w/c/language/struct) (struct). Они очень похожи. Но поля и методы класса по умолчанию приватные (private). А у структуры — публичные (public): ```cpp {.example_for_playground .example_for_playground_014} struct UserMessage { std::size_t id = 0; std::size_t time_created = 0; std::string text; }; UserMessage msg; msg.text = "C++: Simula in wolf’s clothing"; ``` Когда лучше использовать структуры, а когда — классы? Структуры подходят для группировки данных, каждое поле которых изменяется отдельно от других без нарушения целостности объекта. Все поля таких структур остаются открытыми. И зачастую такие структуры не имеют методов. Например, координата точки на плоскости состоит из двух значений. Изменение любого из них не приводит координату в неконсистентное состояние. ```cpp {.example_for_playground .example_for_playground_015} struct Point { double x = 0.0; double y = 0.0; }; Point p; p.x = 10.1; p.y = -2.0; ``` Если же изменение полей способно сломать состояние объекта, то вместо структур применяются классы. Поля делаются закрытыми, а их чтение и изменение реализуется через методы. В этой главе мы обсудили классы и привели три простых примера: [Message,](/courses/cpp/chapters/cpp_chapter_0055/#block-class-message) [UnixTimestamp,](/courses/cpp/chapters/cpp_chapter_0055/#block-class-unixtimestamp) [Task.](/courses/cpp/chapters/cpp_chapter_0055/#block-class-task) Перечислите те из них, которые логичнее было бы сделать структурами. {.task_text} ```consoleoutput {.task_source #cpp_chapter_0055_task_0080} ``` Только в классе `Task` изменение одного из полей на произвольное значение приведёт объект в неконсистентное состояние. {.task_hint} ```cpp {.task_answer} Message UnixTimestamp ``` ---------- ## Резюме - Классы позволяют описывать абстракции, отделять интерфейс для работы с ними от внутренней реализации, объединять данные и методы их обработки. - Конструкторы и деструкторы классов позволяют реализовать идиому RAII. - Структуры подходят для группировки полей, изменение которых по отдельности не приведёт объект в неконсистентное состояние.
Отправка...
Наша группа в telegram. Здесь можно задавать вопросы и общаться.
Задонатить. Если вам нравится курс, вы можете поддержать развитие площадки!