Перейти до змісту

Л9. Моделювання даних, ресурси та стандартна бібліотека C++

Коротко про лекцію

Поєднаємо модель ресурсів із точним представленням даних. Далі розберемо switch, вибір контейнера, стандартні алгоритми, auto та прості лямбда-вирази.

У Л3 ми навчилися відрізняти копію, посилання та вказівник. Також побачили, що адреса без живого об’єкта стає небезпечною. Тепер поставимо наступне питання: хто відповідає за створений ресурс і хто повинен завершити його використання?

Практичний сенс. Надійна програма має явно показувати власника ресурсу та структуру даних. Це зменшує витоки пам’яті, висячі адреси й випадкові поєднання несумісних значень.

1. Ресурс — це те, чим треба керувати протягом часу життя

У програмі ресурсом називатимемо щось, що отримується для використання та повинно бути коректно звільнене або закрите після завершення роботи.

Приклади ресурсів:

  1. динамічно виділена пам’ять;
  2. відкритий файл;
  3. мережеве з’єднання;
  4. системний дескриптор.

У цій лекції головним прикладом буде пам’ять. Файли з’являться у Л10.

2. Володіння означає відповідальність за ресурс

Володіння описує відповідальність за коректне завершення роботи з ресурсом.

Термін: володіння

Власник ресурсу відповідає за його звільнення у правильний момент. Модель володіння має чітко показувати, хто несе цю відповідальність.

Якщо частина програми відповідає за завершення використання ресурсу, кажемо, що вона володіє ресурсом.

Для одного ресурсу треба чітко визначити відповідальність.

хто створив ресурс?
хто може ним користуватися?
хто відповідає за його завершення?

Коли відповідальність розмита, виникають дві типові помилки:

  1. ресурс ніхто не звільняє;
  2. ресурс звільняють, а інший код продовжує ним користуватися.
flowchart TD
    A["отримати ресурс"] --> B["власник"]
    B --> C["використання"]
    C --> D["завершення часу життя власника"]
    D --> E["звільнення ресурсу"]

3. Ручне керування пам’яттю показує проблему володіння

C++ має оператор new, який створює об’єкт у динамічній пам’яті та повертає його адресу.

int* pointer{new int{42}};

Після такого створення програміст повинен пізніше звільнити пам’ять через delete:

delete pointer;
pointer = nullptr;

delete завершує час життя динамічно створеного об’єкта. Після цього стару адресу не можна розіменовувати.

#include <iostream>

int main() {
    int* pointer{new int{42}};
    std::cout << *pointer << "\n";

    delete pointer;
    pointer = nullptr;
}

Цей фрагмент показує механіку. У звичайних задачах курсу ми не будемо використовувати new і delete як основний спосіб роботи з даними.

3.1. Чому ручна відповідальність крихка

Між new і delete код може мати кілька гілок, ранній return або помилку. Кожний шлях повинен правильно завершити ресурс.

Якщо delete не виконався, пам’ять лишається зайнятою. Така ситуація називається витоком ресурсу.

Якщо delete виконався двічі або адреса використовується після нього, виникає помилка часу життя.

Тому краще прив’язати ресурс до часу життя звичайного об’єкта.

4. RAII прив’язує ресурс до часу життя об’єкта

Тепер введемо термін RAII. Це міжнародне скорочення назви принципу «отримання ресурсу є ініціалізацією».

Термін: RAII

RAII прив’язує ресурс до часу життя об’єкта-власника. Через це звільнення ресурсу відбувається разом із завершенням життя власника.

Для нашого рівня важлива практична модель:

об’єкт отримує ресурс під час створення та автоматично завершує роботу з ним наприкінці свого часу життя.

Тоді програміст керує часом життя об’єкта, а тип об’єкта керує ресурсом.

Ми вже користувалися цим підходом, хоча ще не називали його. std::vector сам керує пам’яттю для своїх елементів. std::string сам керує пам’яттю для символів.

#include <iostream>
#include <vector>

void process() {
    std::vector<int> values{10, 20, 30};
    std::cout << "У функції: " << values.size() << " елементи\n";
}

int main() {
    process();
    std::cout << "Функція завершилась\n";
}

Коли process() завершується, локальний values завершує час життя. Його внутрішня пам’ять звільняється автоматично.

sequenceDiagram
    participant F as process()
    participant V as контейнер values
    F->>V: створення контейнера
    Note over V: контейнер володіє своєю пам’яттю
    V-->>F: робота з елементами
    Note over F,V: завершення функції
    V-->>V: автоматично звільнити ресурс

5. std::unique_ptr представляє одного власника динамічного об’єкта

Іноді динамічний об’єкт справді потрібний. Для одного власника стандартна бібліотека має тип std::unique_ptr<T>.

Практичне правило

У прикладному коді курсу віддавайте перевагу RAII-типам. Ручні new і delete потрібні тут для пояснення проблеми володіння.

Слово unique підкреслює модель: у звичайному стані ресурс має одного власника.

Для створення такого власника використаємо std::make_unique<T>(...).

std::unique_ptr<int> owner{std::make_unique<int>(42)};

Цей запис означає:

  1. створити динамічний int зі значенням 42;
  2. передати відповідальність об’єкту owner;
  3. автоматично звільнити int, коли owner завершить час життя.

Для std::unique_ptr підключаємо:

#include <memory>
#include <iostream>
#include <memory>

int main() {
    std::unique_ptr<int> owner{std::make_unique<int>(42)};
    std::cout << *owner << "\n";
}

Оператор *owner звертається до об’єкта, яким володіє owner. Ця дія схожа на розіменування звичайного вказівника з Л3.

5.1. Що поки не розгортаємо

Передавання володіння між unique_ptr потребує окремої моделі переміщення об’єктів. Її зараз не вводимо.

Для поточного курсу достатньо правила: якщо потрібен один динамічний власник, std::unique_ptr робить відповідальність видимою та автоматичною.

6. Модель даних повинна об’єднувати значення однієї сутності

До цього ми могли зберігати ім’я та бал окремими змінними:

std::string name{"Олена"};
int score{87};

Для одного студента це працює. Для десятків студентів треба гарантувати, що правильне ім’я лишається пов’язаним із правильним балом.

C++ має конструкцію struct, яка створює власний тип із кількох полів.

Термін: struct

struct описує форму одного запису даних. Кожний об’єкт такого типу має однаковий набір названих полів.

Поле — це іменована частина запису даних.

struct Student {
    std::string name;
    int score;
};

Тепер Student є новим типом, а name і score є його полями.

Поле конкретного об’єкта вибирають оператором крапки .:

student.name
student.score

Як читати доступ до поля

Запис student.name читаємо як «поле name об’єкта student». Ліва частина називає об’єкт, а права — його поле.

#include <iostream>
#include <string>

struct Student {
    std::string name;
    int score;
};

int main() {
    const Student student{"Олена", 87};
    std::cout << student.name << ": " << student.score << "\n";
}
flowchart LR
    S["Student"] --> N["name: рядок"]
    S --> P["score: ціле"]

7. Послідовність записів описує набір сутностей

Коли тип Student уже визначений, std::vector може зберігати саме такі об’єкти:

std::vector<Student> students;

Тепер один елемент std::vector містить усі поля одного студента.

#include <iostream>
#include <string>
#include <vector>

// Один Student тримає пов’язані дані разом, щоб ім’я не відокремилося від своєї оцінки.
struct Student {
    std::string name;
    int score;
};

int main() {
    const std::vector<Student> students{
        {"Олена", 87},
        {"Максим", 54},
        {"Ірина", 73}
    };

    // Рахуємо записи, що задовольняють правило складання, не змінюючи сам список студентів.
    int passed{0};
    for (Student student : students) {
        if (student.score >= 60) {
            ++passed;
        }
    }

    std::cout << "Склали: " << passed << "\n";
}

Такий запис краще підтримує інваріант даних: ім’я та бал рухаються разом як одна сутність.

7.1. struct описує форму даних

struct не визначає алгоритм обробки. Він описує, які поля утворюють одну сутність.

Після цього знайомі алгоритми працюють із новим типом:

  1. цикл проходить Student за Student;
  2. умова перевіряє student.score;
  3. функція може отримати const Student&;
  4. std::vector може зберігати багато Student.

У C++ struct і class належать до одного сімейства користувацьких типів. Для простого запису даних struct зручний тим, що його поля відкриті за замовчуванням. class частіше використовують, коли треба приховати внутрішній стан за операціями типу. Повну модель класів та об’єктно-орієнтованого проєктування цей курс не розгортає, але такий запис студент уже має вміти розпізнати.

8. Обмежений набір станів краще представити окремим типом

Нехай замовлення може бути лише в одному з трьох станів.

active
paused
closed

Зберігати їх числами 0, 1, 2 ризиковано. Число 7 теж має тип int, хоча такого стану немає.

Для фіксованого набору іменованих значень C++ має enum class.

Термін: enum class

enum class створює окремий тип із наперед визначеним набором іменованих значень. Він доречний, коли допустимі стани відомі заздалегідь.

enum class Status {
    active,
    paused,
    closed
};

Status стає окремим типом. Конкретне значення записується через ім’я типу:

Status::paused
#include <iostream>

// Тип Status обмежує стан трьома названими варіантами замість довільного числа.
enum class Status {
    active,
    paused,
    closed
};

int main() {
    const Status status{Status::paused};

    if (status == Status::active) {
        std::cout << "Активний\n";
    } else if (status == Status::paused) {
        std::cout << "Призупинений\n";
    } else {
        std::cout << "Закритий\n";
    }
}
flowchart LR
    S["Status"] --> A["active"]
    S --> B["paused"]
    S --> C["closed"]

enum class робить допустимі стани явними та не дозволяє випадкове число без перетворення використовувати як Status.

8.1. Відомий switch добре поєднується з enum class

У Л2 ми використали switch для числових кодів. Тепер той самий механізм працює з типом Status. Іменовані значення роблять кожну гілку змістовнішою.

Кожна гілка починається словом case та значенням Status. Уже знайомий break завершує вибрану гілку.

Як читати switch з enum class

Вираз у switch має тип Status, тому мітки case використовують Status::active, Status::paused і Status::closed. Компілятор перевіряє відповідність типів.

switch (status) {
    case Status::active:
        // Цей блок виконується лише для активного стану.
        break;
    case Status::paused:
        // Цей блок виконується лише для призупиненого стану.
        break;
    case Status::closed:
        // Цей блок виконується лише для закритого стану.
        break;
}

У Л4 break завершував цикл. Тут той самий оператор завершує switch, у якому він безпосередньо знаходиться.

#include <iostream>

enum class Status {
    active,
    paused,
    closed
};

int main() {
    const Status status{Status::paused};

    // Кожна гілка відповідає одному дозволеному значенню Status.
    switch (status) {
        case Status::active:
            std::cout << "Активний\n";
            break;
        case Status::paused:
            std::cout << "Призупинений\n";
            break;
        case Status::closed:
            std::cout << "Закритий\n";
            break;
    }
}

Для діапазонів, складених логічних умов і перевірок меж залишайте if. Для переліку окремих станів switch часто робить структуру вибору помітнішою.

9. struct і enum class разом створюють точнішу модель

Наприклад, запис замовлення може мати поля:

struct Order {
    int id;
    double total;
    Status status;
};

Тепер типи полів показують різні ролі даних.

int описує ідентифікатор, double — суму, Status — один із дозволених станів.

Модель даних стає частиною перевірки програми. Чим точніше типи описують предметну область, тим менше некоректних комбінацій легко створити випадково.

10. Стандартна бібліотека дає готові структури й алгоритми

Ми вже використали std::array, std::vector, std::string, std::unique_ptr і std::sort. Стандартна бібліотека містить багато інших контейнерів та алгоритмів. Їх варто обирати за властивістю задачі.

10.1. Контейнер обираємо за потрібною операцією

Потреба Типовий контейнер Важлива властивість
фіксована послідовність std::array<T, N> кількість позицій стала
послідовність зі змінним розміром std::vector<T> зручний індекс і push_back()
унікальні впорядковані значення std::set<T> дублікати не зберігаються
відповідність «ключ → значення» std::map<K, V> ключі впорядковані
швидкий пошук за ключем без вимоги порядку std::unordered_map<K, V> порядок ключів не гарантується

Хешування перетворює ключ на допоміжне числове значення для пошуку місця зберігання. std::unordered_map використовує хешування для організації доступу за ключем. Детальна будова хеш-таблиці виходить за межі цієї дисципліни, але властивість контейнера треба враховувати під час вибору структури.

Межа вибору контейнера

Назва контейнера не визначає алгоритм автоматично. Спочатку сформулюйте потрібні операції: порядок, унікальність, індексний доступ, додавання або пошук за ключем.

std::set зручно використовувати, коли важлива унікальність:

#include <iostream>
#include <set>

int main() {
    const std::set<int> allowed{200, 201, 204};

    std::cout << allowed.size() << "\n";
    std::cout << allowed.contains(201) << "\n";
    std::cout << allowed.contains(500) << "\n";
}

std::map пов’язує ключ зі значенням. Тут код помилки є ключем, а текст — значенням:

#include <iostream>
#include <map>
#include <string>

int main() {
    const std::map<int, std::string> messages{
        {200, "Успішно"},
        {404, "Не знайдено"}
    };

    // Шукаємо значення за ключем 404; порядок вставлення тут не визначає доступ.
    std::cout << messages.at(404) << "\n";
}

10.2. Стандартний алгоритм відділяє намір від механіки циклу

Ми навмисно реалізовували пошук і сортування вручну, щоб зрозуміти механіку та складність. У прикладному коді готовий алгоритм часто виражає намір коротше.

std::find шукає значення у діапазоні [begin, end). Результатом є ітератор. Якщо значення не знайдено, алгоритм повертає end().

Ключове слово auto просить компілятор вивести тип зі значення праворуч. Воно корисне, коли точний бібліотечний тип громіздкий, а його роль зрозуміла з виразу.

Термін: auto

auto означає виведення типу з ініціалізатора під час компіляції. Тип залишається статичним; змінна не стає «безтиповою».

Тепер результат пошуку можна зберегти так:

const auto position{std::find(values.begin(), values.end(), 91)};
const bool found{position != values.end()};
#include <algorithm>
#include <iostream>
#include <vector>

int main() {
    const std::vector<int> values{72, 55, 91, 60};
    // std::find повертає end(), якщо значення відсутнє в заданому діапазоні.
    const auto position{std::find(values.begin(), values.end(), 91)};

    if (position != values.end()) {
        std::cout << "Знайдено\n";
    } else {
        std::cout << "Немає\n";
    }
}

10.3. Лямбда-вираз задає коротку локальну умову для алгоритму

Деякі алгоритми потребують додаткової поведінки. Наприклад, std::count_if рахує елементи, для яких умова дає true. Таку коротку умову можна передати як лямбда-вираз.

[](int score) {
    return score >= 60;
}

Порожні квадратні дужки [] означають, що лямбда-вираз не використовує зовнішні локальні змінні. У круглих дужках записаний параметр, а тіло повертає логічний результат.

Термін: лямбда-вираз

Лямбда-вираз — коротка функція без окремого імені, записана в місці використання. Вона доречна для невеликої локальної поведінки, яку передають іншому алгоритму.

#include <algorithm>
#include <iostream>
#include <vector>

int main() {
    const std::vector<int> scores{72, 55, 91, 60};

    // Лямбда задає правило підрахунку, а count_if керує самим обходом діапазону.
    const auto passed{std::count_if(
        scores.begin(),
        scores.end(),
        [](int score) { return score >= 60; }
    )};

    std::cout << "Склали: " << passed << "\n";
}

Лямбда-вираз не повинен приховувати великий алгоритм. Якщо поведінка має власний контракт, тести або кілька кроків, звичайна іменована функція читається краще.

10.4. std::transform застосовує однакове перетворення до діапазону

Алгоритм std::transform корисний, коли кожний елемент треба перетворити за одним правилом. Наступний приклад збільшує кожний бал на один пункт. Вхідний і вихідний діапазони тут збігаються.

#include <algorithm>
#include <iostream>
#include <vector>

int main() {
    std::vector<int> scores{72, 55, 91};

    // Результат записуємо назад у той самий вектор, тому кожна оцінка замінюється оновленою.
    std::transform(
        scores.begin(),
        scores.end(),
        scores.begin(),
        [](int score) { return score + 1; }
    );

    for (int score : scores) {
        std::cout << score << " ";
    }
    std::cout << "\n";
}

Тепер у нас є два рівні читання коду. Ручний цикл показує механіку зміни стану, а стандартний алгоритм виражає відому операцію над діапазоном. Обидва рівні потрібні для впевненого читання C++.

11. Власник, користувач і спостерігач мають різні ролі

Корисно розділяти три питання:

  1. Хто володіє? Цей об’єкт відповідає за ресурс.
  2. Хто змінює? Цей код має право змінювати стан.
  3. Хто лише читає? Цьому коду достатньо const& або іншого безпечного доступу.
flowchart TD
    O["власник ресурсу"] --> R["ресурс"]
    M["код, що змінює"] --> R
    V["код, що лише читає"] --> R
    O --> E["автоматичне завершення ресурсу"]

Ці ролі не завжди збігаються. Наприклад, std::vector володіє своїми елементами, а функція з const std::vector<int>& лише читає їх.

12. Робочі правила для початкового C++

  1. Для фіксованої послідовності використовуйте std::array, а для змінного розміру — std::vector.
  2. Для одного динамічного власника використовуйте std::unique_ptr, коли динамічний об’єкт справді потрібний.
  3. Ручні new і delete розглядайте як механіку мови, а не як звичайний стиль колекцій.
  4. Групуйте поля однієї сутності в struct, а фіксовані стани описуйте через enum class.
  5. Обирайте контейнер за потрібними операціями та властивостями даних.
  6. Після розуміння механіки віддавайте перевагу доречному стандартному алгоритму.
  7. Перевіряйте, чи час життя користувача ресурсу не довший за час життя власника.

13. Інтерактивна самоперевірка лекції

Самоперевірка вимагає визначати власника ресурсу, читати struct, enum class і switch, а також вибирати контейнер та доречний стандартний алгоритм.