JavaRush /Курси /C++ SELF /Колекція моделей: std::vector<Model>

Колекція моделей: std::vector<Model>

C++ SELF
Рівень 19 , Лекція 3
Відкрита

1. CRUD і структура даних для списку задач

Коли ви вперше створюєте Task t{1, "Купити молоко", Status::Todo}; — усе виглядає чудово… аж доки не зʼявляється друга задача. Потім третя. Далі ви хочете «показати всі», «знайти за id», «позначити як виконану», «видалити помилкову». І раптом зʼясовується: розсип окремих змінних — це як зберігати бібліотеку у вигляді стосу аркушів на підлозі. Технічно можна, але потім саме ви шукатимете потрібне під цим завалом.

У C++ роль «полиці для однотипних обʼєктів» часто виконує std::vector<T>. Це динамічний масив: він уміє зберігати багато елементів одного типу (Task), розширюватися за потреби й надавати доступ до елементів за індексом. У цій лекції ми візьмемо наш Task і перетворимо його на маленький консольний «менеджер задач» — без складних парсерів, файлів і магії. Лише чесні функції й чесний std::vector.

Ідея CRUD «на пальцях»

Слово CRUD звучить так, ніби його вигадали, щоб лякати новачків на співбесідах. Насправді це просто чотири базові дії з даними: створити, прочитати, змінити, видалити. Ми щодня робимо CRUD у звичайному житті: додаємо контакт у телефон (Create), відкриваємо список контактів (Read), змінюємо номер (Update), видаляємо старий контакт (Delete). Програми роблять те саме — тільки замість телефона в нас std::vector.

Ось як це виглядатиме в нашій мініпрограмі. Ми свідомо тримаємо все простим і читабельним.

CRUD Людська дія Що робимо в коді Типовий результат
Create додати задачу
push_back(Task{...})
задача зʼявляється в списку
Read показати / знайти
цикл по vector
бачимо задачі / отримуємо індекс
Update змінити поля
tasks[idx].status = ...
задачу оновлено
Delete видалити задачу
erase(begin()+idx)
задача зникає, решта зсувається

Зверніть увагу: CRUD — це не «особливість бази даних», а спосіб мислити про будь-які колекції. Навіть якщо у вас не сервер, а простий список у памʼяті, CRUD однаково добре описує те, що відбувається.

Task + std::vector<Task> як «каркас» застосунку

Перш ніж писати операції, потрібно домовитися про форму даних. Ми вже ввели struct і enum class, тому тепер просто акуратно зберемо основу: тип Task і контейнер std::vector<Task> tasks;. Це як підготувати стіл перед приготуванням: якщо немає ножа, салат буде… креативним, але недовго.

#include <string>
#include <vector>

enum class Status { Todo, InProgress, Done };

struct Task {
    int id;
    std::string title;
    Status status = Status::Todo;
};

З погляду C++ це звичайні оголошення. Та важливіше інше: тепер тип Task — одиниця сенсу, а std::vector<Task> — «коробка», у якій таких одиниць може бути скільки завгодно.

2. Create: додаємо задачі у vector

Наївний старт виглядає так: десь у main() ми пишемо tasks.push_back(Task{...}); і радіємо. Але щойно додавання починає вимагати бодай трохи логіки — наприклад, «id має бути унікальним», «title не має бути порожнім», «повернути успіх або помилку» — main() перетворюється на безлад. Тому майже одразу варто зробити функцію AddTask(...). Навіть якщо всередині поки що один рядок, це вже «точка розширення», куди згодом можна додати перевірки.

Почнемо з простого: функція отримує vector за посиланням, бо ми хочемо змінювати колекцію, а задачу — за const&, щоб зайвий раз не копіювати рядок.

#include <vector>

bool AddTask(std::vector<Task>& tasks, const Task& t) {
    tasks.push_back(t);
    return true;
}

Зараз bool виглядає надто серйозно для такої простої функції, але це запас на майбутнє: скоро AddTask зможе повертати false, якщо задача некоректна або id уже зайнятий.

Щоб створити задачу й додати її, ви робите так:

#include <string>
#include <vector>

void DemoCreate(std::vector<Task>& tasks) {
    AddTask(tasks, Task{1, "Купити молоко", Status::Todo});
    AddTask(tasks, Task{2, "Сплатити оренду", Status::InProgress});
}

Тут важливо вловити головну думку: ми додаємо цілу модель, а не «три окремі значення». Це помітно знижує ризик переплутати місцями title та id, особливо коли полів стане більше.

3. Read: друк списку й пошук за id

Читання зазвичай починається з двох побутових речей: показати всі елементи й знайти один. Показати всі — це просто пройтися по vector циклом. Знайти один — теж пройтися циклом, але зупинитися, щойно трапиться потрібний id. Так, це лінійний пошук. І так, на великих даних він не найшвидший. Але на нашому рівні важливіше інше: він прозорий, зрозумілий і легко налагоджується.

Друк списку — тимчасово й просто

Ми ще не робимо єдиний красивий формат, тому друкуємо максимально прямо: id, title і статус однією літерою. Для статусу зробимо маленьку функцію, щоб std::cout не страждав, оскільки enum class сам по собі не виводиться в потік.

#include <iostream>

char StatusChar(Status s) {
    switch (s) {
        case Status::Todo:       return 'T';
        case Status::InProgress: return 'P';
        case Status::Done:       return 'D';
    }
    return '?';
}

Ось функція для друку однієї задачі:

#include <iostream>

void PrintTask(const Task& t) {
    std::cout << t.id << " [" << StatusChar(t.status) << "] " << t.title << '\n';
    // приклад: 2 [P] Сплатити оренду
}

А весь список друкуємо звичайним range‑for:

#include <vector>

void PrintAll(const std::vector<Task>& tasks) {
    for (const Task& t : tasks) {
        PrintTask(t);
    }
}

Пошук: повертаємо індекс або -1

Тепер головне — пошук за id. Тут є один нюанс: vector::size() повертає size_t, а ми хочемо «-1 як не знайдено». size_t — беззнаковий тип, тому -1 у ньому перетворюється на дуже велике число.

Тому зручний навчальний контракт такий: функція пошуку повертає int, а всередині циклу працює з size_t і, коли знаходить збіг, робить static_cast<int>(i).

#include <cstddef>
#include <vector>

int FindIndexById(const std::vector<Task>& tasks, int id) {
    for (std::size_t i = 0; i < tasks.size(); ++i) {
        if (tasks[i].id == id) {
            return static_cast<int>(i);
        }
    }
    return -1;
}

Це один із найкорисніших шаблонів на початку вивчення C++: усередині все безпечно й коректно з погляду типів, а назовні — зручний контракт.

4. Update: змінюємо статус і назву задачі

Оновлення майже завжди виглядає однаково: ми шукаємо елемент, переконуємося, що знайшли його, і лише потім змінюємо потрібні поля. Новачки часто роблять навпаки: «спочатку зміню, а потім перевірю» — і програма радо падає там, де взагалі не мала б. Тому правильний порядок дій — це не просто стиль, а базова гігієна.

Зробимо два сценарії оновлення: позначити задачу виконаною й перейменувати її. Обидві функції повертатимуть bool, щоб код, який їх викликає, міг чесно сказати користувачеві: «не знайшов».

Позначити задачу виконаною

#include <cstddef>
#include <vector>

bool MarkDone(std::vector<Task>& tasks, int id) {
    int idx = FindIndexById(tasks, id);
    if (idx == -1) return false;

    tasks[static_cast<std::size_t>(idx)].status = Status::Done;
    return true;
}

Тут є маленька, але важлива думка: ми не звертаємося до tasks[idx] напряму, бо idx — це int, а індексація очікує беззнаковий тип. Ми явно приводимо idx до size_t після перевірки idx == -1. Це той випадок, коли static_cast — не «занудство», а спосіб показати компілятору й читачеві, що ви все перевірили й контролюєте ситуацію.

Логіку MarkDone можна уявити так:

flowchart TD
    A["Виклик MarkDone(tasks, id)"] --> B["idx = FindIndexById(...)"]
    B --> C{idx == -1?}
    C -- так --> D["повертаємо false"]
    C -- ні --> E["tasks[idx].status = Done"]
    E --> F["повертаємо true"]

Перейменувати задачу

Перейменування схоже, але тут зʼявляється перевірка вхідних даних: нова назва не може бути порожньою.

#include <cstddef>
#include <string>
#include <vector>

bool RenameTask(std::vector<Task>& tasks, int id, const std::string& newTitle) {
    if (newTitle.empty()) return false;

    int idx = FindIndexById(tasks, id);
    if (idx == -1) return false;

    tasks[static_cast<std::size_t>(idx)].title = newTitle;
    return true;
}

Зверніть увагу на логіку такого коду: він читається згори донизу як набір охоронців на вході. Якщо щось не так, виходимо раннім return. І лише якщо все гаразд, виконуємо зміну.

5. Delete: видалення через erase і зсув елементів

Видалення в std::vector — одна з найчастіших причин «дивних збоїв» у новачків. Причина проста: vector зберігає елементи впритул. Якщо ви видалили один елемент, усі елементи праворуч зсуваються вліво, щоб не залишалося дірки. Це означає, що індекси після видалення змінюються, і якщо ви десь запамʼятали індекс або посилання на елемент, воно може виявитися неправильним.

Ми зробимо видалення «за id» у два кроки: спочатку знайдемо індекс, а потім видалимо елемент за цим індексом через erase.

#include <cstddef>
#include <vector>

bool RemoveById(std::vector<Task>& tasks, int id) {
    int idx = FindIndexById(tasks, id);
    if (idx == -1) return false;

    tasks.erase(tasks.begin() + static_cast<std::size_t>(idx));
    return true;
}

Тут важливо зрозуміти механіку erase: він приймає ітератор, а не індекс. Але поки ми не заглиблюємося в ітератори, нам достатньо памʼятати просту формулу: «ітератор на i-й елемент» — це begin() + i. Для vector це працює, бо його ітератори підтримують додавання майже так само, як індекси.

Ще один практичний момент: якщо ви друкуєте список задач із порядковими номерами (0, 1, 2…), а потім видаляєте елемент, ці номери зміняться. Тому корисно чітко розрізняти два поняття: id — це частина даних, а idx — позиція в vector. Позиція змінюється, а id зазвичай має залишатися стабільним.

6. Міні-CLI: поєднуємо CRUD в один цикл

Тепер ми вже вміємо виконувати всі чотири CRUD-операції окремими функціями. Залишилося поєднати їх у маленький консольний застосунок, щоб можна було спробувати все на практиці: додати кілька задач, вивести список, позначити задачу виконаною, перейменувати її, видалити. Зробимо максимально простий інтерфейс: меню з цифрами та введення id, а назву читатимемо через std::getline, щоб пробіли не ламали зчитування.

Для початку — друк меню:

#include <iostream>

void PrintMenu() {
    std::cout << "1) Додати\n2) Список\n3) Виконати\n4) Перейменувати\n5) Видалити\n0) Вийти\n";
}

Тепер — одна важлива утиліта: безпечно зчитати рядок повністю. Ми вже знаємо, що після std::cin >> number у буфері лишається '\n', тому перед getline часто потрібно «зʼїсти» залишок рядка. В ідеалі це оформлюють акуратніше, але зараз візьмемо простий і зрозумілий варіант.

#include <iostream>
#include <string>

std::string ReadLine() {
    std::string s;
    std::getline(std::cin, s);
    return s;
}

І каркас main(): він довший за 10 рядків, тому покажемо його частинами. Почнемо з ініціалізації та циклу:

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

int main() {
    std::vector<Task> tasks;
    int nextId = 1;

    while (true) {
        PrintMenu();
        int cmd = -1;
        std::cin >> cmd;
        if (cmd == 0) break;

        std::cin.ignore(10000, '\n'); // зʼїдаємо '\n' після числа
        // далі обробка команд...
    }
}

Тепер додавання задачі — просто всередині циклу. Ми читаємо назву цілим рядком, створюємо Task і додаємо його:

if (cmd == 1) {
    std::cout << "Назва: ";
    std::string title = ReadLine();

    Task t{nextId, title, Status::Todo};
    AddTask(tasks, t);
    ++nextId;
}

Перегляд списку — це просто друк:

if (cmd == 2) {
    PrintAll(tasks);
}

Позначення як виконано:

if (cmd == 3) {
    std::cout << "ID: ";
    int id = 0;
    std::cin >> id;
    std::cin.ignore(10000, '\n');

    if (!MarkDone(tasks, id)) {
        std::cout << "Задачу не знайдено\n";
    }
}

Видалення:

if (cmd == 5) {
    std::cout << "ID: ";
    int id = 0;
    std::cin >> id;
    std::cin.ignore(10000, '\n');

    if (!RemoveById(tasks, id)) {
        std::cout << "Задачу не знайдено\n";
    }
}

Так, це поки що наївний консольний інтерфейс, але він робить головне: дає вашим CRUD-функціям змогу працювати разом. А коли функції відокремлені, ви майже фізично відчуваєте, наскільки простіше читати код: main() перетворюється на координатор, а не на «все в одному».

7. Типові помилки під час роботи з std::vector<Model> і CRUD

Помилка № 1: плутати id та індекс (idx).
Це одна з найпідступніших помилок, бо спочатку все «ніби працює». Ви додали задачі, id збігається з позицією (перша задача лежить приблизно на позиції 0, друга — на позиції 1), і здається: «та навіщо мені взагалі пошук, я просто зроблю tasks[id]». Потім ви видаляєте елемент через erase, елементи зсуваються, і tasks[id] раптом починає вказувати не на ту задачу. Важливо якомога раніше звикнути до ідеї: id живе всередині моделі, а індекс — це тимчасова координата в контейнері.

Помилка № 2: не перевіряти idx == -1 і відразу звертатися до tasks[idx].
Такий код зазвичай падає «у свята», тобто рівно тоді, коли користувач увів неіснуючий id. Особливо прикро те, що програма падає не в момент введення, а під час доступу до памʼяті — і новачкові здається, ніби «зламався vector». Насправді vector ні до чого: ви просто попросили його відкрити шухляду з відʼємним номером.

Помилка № 3: повертати з пошуку size_t і намагатися використати -1 як «не знайдено».
Це класична пастка signed/unsigned. -1, записаний у size_t, перетворюється на величезне додатне число. Далі ви робите tasks[idx] — і злітаєте в читання памʼяті кудись у невідомість. Якщо ви використовуєте контракт «-1 як не знайдено», повертайте int. Якщо ж хочете повертати size_t, тоді доведеться змінити контракт. Наприклад, повертати tasks.size() як «не знайдено». Але в межах цього уроку ми тримаємося простого варіанта з int.

Помилка № 4: видаляти елемент у циклі й далі рухатися за індексами, ніби нічого не сталося.
Навіть якщо ви не використовуєте ітератори, логіка проста: після erase усі елементи праворуч зсунулися. Якщо ви видалили tasks[i] і потім зробили ++i, ви пропустили наступний елемент: він змістився на місце i. У цій лекції ми видаляємо «по одному елементу за id», тому проблема не проявляється, але дуже важливо памʼятати про неї заздалегідь: erase змінює структуру vector, а отже, цикл потребує обережності.

Помилка № 5: розкидати CRUD-логіку по main() і втратити керованість.
Поки програма маленька, здається, що функції — це зайве. Але щойно зʼявляється другий сценарій оновлення або третя перевірка, main() перетворюється на великий блок із вкладеними if, і будь-яка правка стає ризикованою. Винесення CRUD-операцій у функції — це не «архітектура заради архітектури», а проста економія нервів: одна операція — одне місце, де її описано.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ