1. CRUD i podstawa danych dla listy zadań
Kiedy po raz pierwszy tworzysz Task t{1, "Buy milk", Status::Todo};, wszystko wygląda pięknie… aż do momentu, gdy pojawia się drugie zadanie. Potem trzecie. Potem chcesz „pokaż wszystko”, „znajdź po id”, „oznacz jako zrobione”, „usuń błędne”. I nagle okazuje się, że rozrzucone pojedyncze zmienne to jak próba przechowywania biblioteki w stosie kartek na podłodze: technicznie możliwe, ale potem sam będziesz się pod tym grzebać.
W C++ rolę „półki dla jednakowych obiektów” często pełni std::vector<T>. To dynamiczna tablica: potrafi przechowywać wiele elementów jednego typu (Task), rozszerzać się w razie potrzeby i dawać dostęp do elementów po indeksie. W tym wykładzie weźmiemy nasz Task i zrobimy z niego mały „tracker zadań” w konsoli — bez skomplikowanych parserów, bez plików, bez magii. Po prostu uczciwe funkcje i uczciwy std::vector.
Idea CRUD „na palcach”
Słowo CRUD brzmi jakby zostało wymyślone, żeby przestraszyć początkujących na rozmowach kwalifikacyjnych. W rzeczywistości to po prostu cztery podstawowe działania na danych: create, read, update, delete. Robimy CRUD codziennie w życiu: dodaliśmy kontakt do telefonu (Create), otworzyliśmy listę kontaktów (Read), zmieniliśmy numer (Update), usunęliśmy stary kontakt (Delete). Programy robią to samo — tylko zamiast telefonu mamy std::vector.
Tak to będzie wyglądać w naszym mini‑programie. Świadomie trzymamy wszystko proste i czytelne.
| CRUD | Działanie użytkownika | Co robimy w kodzie | Typowy rezultat |
|---|---|---|---|
| Create | dodać zadanie | |
zadanie pojawia się na liście |
| Read | pokaż / znajdź | |
widzimy zadania / dostajemy indeks |
| Update | zmienić pola | |
zadanie zaktualizowane |
| Delete | usunąć zadanie | |
zadanie znika, pozostałe się przesuwają |
Zwróć uwagę: CRUD — to nie „funkcja bazy danych”, to sposób myślenia o dowolnych kolekcjach. Nawet jeśli nie masz serwera, a prostą listę w pamięci — CRUD nadal dobrze opisuje to, co się dzieje.
Task + std::vector<Task> jako „szkielet” aplikacji
Zanim napiszemy operacje, trzeba uzgodnić kształt danych. Już wprowadziliśmy struct i enum class, więc teraz po prostu złożymy podstawę: typ Task i kontener std::vector<Task> tasks;. To jak przygotować stół przed gotowaniem: jeśli nie ma noża, sałatka będzie… kreatywna, ale krótko.
#include <string>
#include <vector>
enum class Status { Todo, InProgress, Done };
struct Task {
int id;
std::string title;
Status status = Status::Todo;
};
Z perspektywy C++ to zwykłe deklaracje. Ważniejsze jest to: teraz typ Task — jednostka znaczenia, a std::vector<Task> — „pudełko”, w którym takich jednostek może być dowolnie dużo.
3. Create: dodajemy zadania do vector
Naiwny start wygląda tak: gdzieś w main() piszemy tasks.push_back(Task{...}); i cieszymy się. Ale gdy dodawanie zaczyna wymagać logiki (np. „id musi być unikalne”, „title nie może być puste”, „zwrócić sukces/błąd”), main() zamienia się w bałagan. Dlatego warto od razu zrobić funkcję AddTask(...). Nawet jeśli na początku to jedna linia — to „punkt rozszerzenia”, gdzie potem dodamy walidacje.
Zaczniemy od prostego: funkcja przyjmuje vector przez referencję, bo chcemy modyfikować kolekcję, i zadanie przez const&, aby nie kopiować stringa niepotrzebnie.
#include <vector>
bool AddTask(std::vector<Task>& tasks, const Task& t) {
tasks.push_back(t);
return true;
}
Teraz bool wygląda „zbyt poważnie” dla takiej prostej funkcji, ale to zabezpieczenie na przyszłość: niedługo AddTask może zwracać false, jeśli zadanie jest niepoprawne lub id już zajęte.
Aby utworzyć zadanie i dodać je, robisz tak:
#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});
}
Ważne jest, by poczuć myśl: dodajemy cały model, a nie „trzy osobne wartości”. To znacząco zmniejsza ryzyko pomylenia title i id, zwłaszcza gdy pól przybędzie.
4. Read: wypis listy i wyszukiwanie po id
Odczyt zwykle zaczyna się od dwóch codziennych rzeczy: pokazać wszystkie elementy i znaleźć jeden element. Pokaż wszystkie — to po prostu przejść po vector pętlą. Znaleźć jeden — to też iteracja, ale zatrzymujemy się, gdy trafimy na potrzebne id. Tak, to wyszukiwanie liniowe i na dużych danych nie jest najszybsze. Ale na naszym poziomie ważniejsze jest, że jest przezroczyste, zrozumiałe i łatwe do debugowania.
Wypis listy „tymczasowo i prosto”
Jeszcze nie robimy „jednolitego ładnego formatu”, więc wypisujemy maksymalnie bezpośrednio: id, title i status jedną literą. Dla statusu zrobimy małą funkcję, żeby std::cout nie cierpiał, bo enum class sam nie wypisuje się do strumienia.
#include <iostream>
char StatusChar(Status s) {
switch (s) {
case Status::Todo: return 'T';
case Status::InProgress: return 'P';
case Status::Done: return 'D';
}
return '?';
}
I teraz wypis jednej task:
#include <iostream>
void PrintTask(const Task& t) {
std::cout << t.id << " [" << StatusChar(t.status) << "] " << t.title << '\n';
// przykład: 2 [P] Pay rent
}
A wypis całej listy — zwykły range‑for:
#include <vector>
void PrintAll(const std::vector<Task>& tasks) {
for (const Task& t : tasks) {
PrintTask(t);
}
}
Wyszukiwanie: zwracamy indeks lub -1
Teraz kluczowy fragment: wyszukiwanie po id. Mamy subtelność: vector::size() zwraca size_t, a my chcemy „-1 jako nie znalezione”. size_t jest bez znaku, i -1 tam zamienia się w bardzo dużą liczbę.
Dlatego wygodny „edukacyjny” kontrakt jest taki: funkcja wyszukująca zwraca int, a wewnątrz pętli używa size_t, i przy znalezieniu robi 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;
}
To jeden z najbardziej użytecznych wzorców na początku nauki C++: wewnątrz bezpiecznie i poprawnie typowo, na zewnątrz wygodny kontrakt.
5. Update: zmieniamy status i nazwę zadania
Modyfikacja zwykle wygląda podobnie: znajdujemy element, upewniamy się, że go znaleźliśmy, i dopiero potem zmieniamy potrzebne pola. Początkujący często robią odwrotnie: „najpierw zmienię, potem sprawdzę” — i program pada tam, gdzie nie powinien. Dlatego właściwa kolejność to nie „styl”, lecz podstawowa higiena.
Zrobimy dwa scenariusze aktualizacji: oznaczyć zadanie jako wykonane i zmienić nazwę zadania. Oba będą zwracać bool, żeby wywołujący mógł uczciwie powiedzieć użytkownikowi „nie znaleziono”.
Oznaczyć zadanie jako wykonane
#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;
}
Tu jest mała, ale ważna myśl: nie odwołujemy się do tasks[idx] bezpośrednio, bo idx jest int, a indeksowanie oczekuje typu bez znaku. Wyraźnie rzutujemy idx na size_t po sprawdzeniu, że idx nie jest -1. To przypadek, gdy static_cast nie jest „czczą pedanterią”, lecz sygnałem dla kompilatora i czytelnika, że wszystko sprawdzono.
Można przedstawić logikę MarkDone tak:
flowchart TD
A["Wywołano MarkDone(tasks, id)"] --> B["idx = FindIndexById(...)"]
B --> C{idx == -1?}
C -- tak --> D[return false]
C -- nie --> E["tasks[idx].status = Done"]
E --> F[return true]
Zmiana nazwy zadania
Zmiana nazwy jest podobna, ale pojawia się walidacja wejścia: nowy tytuł nie powinien być pusty.
#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;
}
Zwróć uwagę na „psychologię” takiego kodu: czyta się go z góry na dół jak zestaw strażników na wejściu. Jeśli coś jest nie tak — wychodzimy wczesnym return. I tylko jeśli wszystko w porządku — wykonujemy zmianę.
6. Delete: usuwanie przez erase i przesuwanie elementów
Usuwanie w std::vector to jedna z częstszych przyczyn „dziwnych bugów” u początkujących. Powód prosty: vector przechowuje elementy gęsto. Jeśli usuniesz jeden element, wszystkie elementy po prawej „przesuwają się w lewo”, żeby nie zostawić dziury. To oznacza, że indeksy po usunięciu zmieniają się, i jeśli gdzieś zapamiętałeś indeks lub referencję do elementu, może stać się nieprawidłowa.
Zrobimy usuwanie „po id” w dwóch krokach: znajdziemy indeks, potem usuniemy po indeksie przez 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;
}
Ważne zrozumieć mechanikę erase: przyjmuje on iterator, a nie indeks. Ale póki nie zagłębiamy się w iteratory, wystarczy pamiętać praktyczną formułę: „iterator do i‑tego elementu” to begin() + i. Dla vector to działa, bo jego iteratory są „dodawalne”, jak indeksy.
Jeszcze jedna praktyczna uwaga: jeśli wypisujesz listę z numerami wierszy (0, 1, 2…), a potem usuwasz element, numery się zmienią. Dlatego warto rozróżniać dwa pojęcia: id — część danych, a idx — pozycja w vector. Pozycja jest zmienna, id zwykle powinno być stabilne.
7. Mini‑CLI: łączenie CRUD w jeden cykl
Teraz już umiemy robić wszystkie cztery operacje CRUD jako osobne funkcje. Zostało powiązać je w małą aplikację konsolową, żeby można było „poczuć ręką”: dodać kilka zadań, wypisać listę, oznaczyć jako wykonane, usunąć. Zrobimy maksymalnie prosty interfejs: menu cyframi i wprowadzanie id, a tytuł czytamy przez std::getline, żeby spacje nie psuły wejścia.
Na początek — druk menu:
#include <iostream>
void PrintMenu() {
std::cout << "1) Add\n2) List\n3) Done\n4) Rename\n5) Remove\n0) Exit\n";
}
Teraz — jedna ważna pomocnicza funkcja: bezpieczne wczytanie całego wiersza. Znamy już fakt, że po std::cin >> number w buforze zostaje '\n', więc przed getline często trzeba „zjeść pozostałość wiersza”. W idealnym świecie robi się to ładniej, ale teraz zrobimy prosty i zrozumiały wariant.
#include <iostream>
#include <string>
std::string ReadLine() {
std::string s;
std::getline(std::cin, s);
return s;
}
Szkielet main(): jest dłuższy niż 10 linii, więc pokażemy go fragmentami. Zaczynamy od inicjalizacji i pętli:
#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'); // zjadamy '\n' po liczbie
// dalej obsługa poleceń...
}
}
Teraz dodawanie zadania — bezpośrednio w pętli. Czytamy tytuł linią, tworzymy Task i dodajemy:
if (cmd == 1) {
std::cout << "Title: ";
std::string title = ReadLine();
Task t{nextId, title, Status::Todo};
AddTask(tasks, t);
++nextId;
}
Przegląd listy — po prostu wypis:
if (cmd == 2) {
PrintAll(tasks);
}
Oznacz jako wykonane:
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";
}
}
Usuwanie:
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";
}
}
Tak, to póki co „naiwny konsolowy UI”, ale robi najważniejsze: sprawia, że funkcje CRUD działają razem. A kiedy funkcje są oddzielone, niemal fizycznie czujesz, jak łatwiej czytać kod: main() staje się dyspozytorem, a nie „garnkiem z wszystkim”.
8. Typowe błędy przy pracy z std::vector<Model> i CRUD
Błąd nr 1: mylić id z indeksem (idx).
To jeden z najbardziej podstępnych błędów, bo najpierw wszystko „jakby działa”. Dodałeś zadania, id zgadza się z pozycją (1‑sze zadanie mniej więcej na pozycji 0, 2‑gie — na pozycji 1), i wydaje się: „po co szukać, użyję tasks[id]”. Potem usuwasz element przez erase, elementy się przesuwają, i tasks[id] nagle wskazuje nie na to zadanie. Warto wcześnie przyzwyczaić się do idei: id żyje wewnątrz modelu, a indeks to tymczasowa współrzędna w kontenerze.
Błąd nr 2: nie sprawdzać, czy idx == -1, i od razu odwoływać się do tasks[idx].
Taki kod zwykle pada „niespodziewanie”, czyli dokładnie wtedy, gdy użytkownik wpisał nieistniejące id. Szczególnie frustrujące, że program nie zawodzi w momencie wpisu, lecz przy dostępie do pamięci — i początkującemu wydaje się, że „zepsuł się vector”. W rzeczywistości vector nie ma z tym nic wspólnego: po prostu poprosiłeś go o otwarcie szuflady pod ujemnym numerem.
Błąd nr 3: zwracać z wyszukiwania size_t i próbować użyć -1 jako „nie znalezione”.
To klasyczna pułapka signed/unsigned. -1 zapisany w size_t staje się ogromną dodatnią liczbą. Dalej robisz tasks[idx] — i lecicie w odczyt pamięci „gdzieś w kosmos”. Jeśli używacie kontraktu „-1 jako nie znalezione”, zwracajcie int. Jeśli chcecie zwracać size_t, trzeba zmienić kontrakt. Na przykład zwracać tasks.size() jako „nie znalezione”. Ale w ramach tego dnia trzymamy prosty wariant z int.
Błąd nr 4: usuwać element w pętli i dalej iterować po indeksach jakby nic się nie stało.
Nawet jeśli nie używasz iteratorów, logika prosta: po erase wszystkie elementy po prawej przesunęły się. Jeśli usunąłeś tasks[i] i potem zrobiłeś ++i, pominąłeś następny element (został przesunięty na miejsce i). W tej lekcji usuwamy „po jednym elemencie po id”, więc problem nie wychodzi, ale warto pamiętać: erase zmienia strukturę vector, więc pętla wymaga ostrożności.
Błąd nr 5: rozmazać logikę CRUD po main() i stracić kontrolę.
Dopóki program jest mały, wydaje się, że funkcje są zbędne. Ale gdy pojawi się drugi scenariusz aktualizacji lub trzecia walidacja, main() zamienia się w płótno z zagnieżdżonymi if, i każda zmiana staje się ryzykowna. Wyciągnięcie operacji CRUD do funkcji to nie „architektura dla architektury”, lecz zwykła oszczędność nerwów: jedna operacja — jedno miejsce opisu.
GO TO FULL VERSION