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 | добавить задачу | |
задача появляется в списке |
| Read | показать / найти | |
видим задачи / получаем индекс |
| Update | изменить поля | |
задача обновлена |
| Delete | удалить задачу | |
задача исчезает, остальные сдвигаются |
Обратите внимание: 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] напрямую, потому что 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[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‑операций в функции — это не «архитектура ради архитектуры», а банальная экономия нервов: одна операция — одно место, где она описана.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ