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

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

C++ SELF
19 уровень , 3 лекция
Открыта

1. CRUD и основа данных для списка задач

Когда вы впервые делаете Task t{1, "Buy milk", 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> — «коробка», в которой таких единиц может быть сколько угодно.

3. 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, "Buy milk", Status::Todo});
    AddTask(tasks, Task{2, "Pay rent", Status::InProgress});
}

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

4. 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] Pay rent
}

А печать всего списка — обычный 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++: внутри безопасно и корректно по типам, снаружи удобно по контракту.

5. 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] напрямую, потому что idxint, а индексирование ожидает беззнаковый тип. Мы явно приводим idx к size_t после проверки idx == -1. Это тот случай, когда static_cast — не «занудство», а способ показать компилятору и читателю, что вы всё проверили и контролируете.

Можно представить логику MarkDone вот так:

flowchart TD
    A["Вызвали MarkDone(tasks, id)"] --> B["idx = FindIndexById(...)"]
    B --> C{idx == -1?}
    C -- да --> D[return false]
    C -- нет --> E["tasks[idx].status = Done"]
    E --> F[return 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. И только если всё хорошо — делаем изменение.

6. 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 обычно должен быть стабильным.

7. Мини‑CLI: связываем CRUD в один цикл

Сейчас мы уже умеем делать все четыре CRUD‑операции как отдельные функции. Осталось связать их в маленькое консольное приложение, чтобы можно было «пощупать руками»: добавить пару задач, вывести список, отметить выполненной, удалить. Мы сделаем максимально простой интерфейс: меню цифрами и ввод id, а название читаем через std::getline, чтобы пробелы не ломали ввод.

Для начала — печать меню:

#include <iostream>

void PrintMenu() {
    std::cout << "1) Add\n2) List\n3) Done\n4) Rename\n5) Remove\n0) Exit\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 << "Title: ";
    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 << "Task not found\n";
    }
}

Удаление:

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

    if (!RemoveById(tasks, id)) {
        std::cout << "Task not found\n";
    }
}

Да, это пока «наивный консольный UI», но он делает главное: заставляет ваши CRUD‑функции работать вместе. А когда функции отделены, вы почти физически ощущаете, насколько проще читать код: main() превращается в диспетчера, а не в «кастрюлю со всем».

8. Типичные ошибки при работе с std::vector<Model> и CRUD

Ошибка №1: путать id и индекс (idx).
Это одна из самых коварных ошибок, потому что сначала всё «как будто работает». Вы добавили задачи, id совпадает с позицией (1-я задача лежит примерно на позиции 0, 2-я — на позиции 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‑операций в функции — это не «архитектура ради архитектуры», а банальная экономия нервов: одна операция — одно место, где она описана.

1
Задача
C++ SELF, 19 уровень, 3 лекция
Недоступна
Три заметки
Три заметки
1
Задача
C++ SELF, 19 уровень, 3 лекция
Недоступна
Поиск пользователя
Поиск пользователя
1
Задача
C++ SELF, 19 уровень, 3 лекция
Недоступна
Закрытие заявки
Закрытие заявки
1
Задача
C++ SELF, 19 уровень, 3 лекция
Недоступна
Каталог книг
Каталог книг
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ