1. Pętle indeksowe: kiedy się bez nich nie obejść
Łatwo polubić pętlę indeksową za prostotę: jest i, jest v[i], wszystko jasne. Jednak praktyka przynosi zadania, w których bez indeksu jesteś jak bez rąk: trzeba wydrukować numer elementu, odwołać się do sąsiadów i-1 i i+1, przejść kontener w odwrotnym porządku lub zmodyfikować konkretną pozycję według numeru podanego przez użytkownika. W takich sytuacjach range-for staje się zbyt „bezimienny”: daje element, ale nie mówi, który on jest w kolejności.
Aby nie wybierać „kocham tylko range-for” albo „kocham tylko indeksy”, warto pamiętać prostą myśl: pętla indeksowa to nie konkurent dla range-for, lecz narzędzie do sytuacji, gdzie ważne są pozycje.
Krótka „mapa wyboru” (schemat):
flowchart TD
A[Należy przejść przez kontener] --> B{Czy potrzebny numer elementu i?}
B -- nie --> C[range-for: prościej i bezpieczniej]
B -- tak --> D{Czy potrzebni sąsiedzi i-1 / i+1
lub odwrotny porządek?}
D -- tak --> E[Pętla indeksowa]
D -- nie --> F[Można indeksową lub iteratorową
ale indeksowa czyta się łatwiej]
std::size_t: czym jest ten typ i dlaczego „przeszkadza”
std::size_t to typ całkowity przeznaczony do rozmiarów i indeksów. W bibliotece standardowej rozmiary kontenerów (size()) zwracane są właśnie jako std::size_t, i koncepcja „rozmiar to size_t” występuje w standardzie szeroko, także w kontekście granic i indeksów.
Główny „psychologiczny” problem size_t dla początkującego: jest on bez znaku. To znaczy, nie ma wartości ujemnych. I z tego wynikają dwie pułapki: po pierwsze, porównania int i size_t mogą zachowywać się nieoczekiwanie (a kompilator będzie wyrzucać ostrzeżenia), po drugie, próba wykonania i-1, gdy i == 0, nie da -1, lecz zamieni się w ogromną liczbę.
Spójrzmy na mini-demonstrację (tak, to przypadek, gdy arytmetyka zachowuje się jak modulo):
#include <cstddef>
#include <iostream>
int main() {
std::size_t n = 0;
std::cout << (n - 1) << '\n'; // wydrukuje bardzo dużą liczbę
}
Sens: n - 1 „przeskakuje” przez zero, ponieważ typ nie może być ujemny. To nie „błąd kompilatora”, to normalna arytmetyka typów bez znaku.
2. Przejście proste i sąsiedzi: granice pętli decydują
Podstawowy bezpieczny wzorzec: i < v.size()
Gdy piszesz pętlę indeksową, mózg zwykle myśli: „zaczynamy od 0, idziemy do rozmiaru”. I łatwo tu popełnić błąd o jedną iterację — klasyczny błąd typu off-by-one. Dlatego warto utrwalić poprawny wzorzec jako „zaklęcie”:
for (std::size_t i = 0; i < v.size(); ++i) {
// pracujemy z v[i]
}
Dlaczego dokładnie <, a nie <=? Ponieważ size() to liczba elementów, a indeksy idą od 0 do size()-1. Czyli size() to „pozycja tuż za ostatnim elementem”. Jeśli napisać i <= v.size(), ostatnia iteracja będzie z i == v.size(), i wtedy v[i] wyjdzie poza granice.
Mini-sprawdzenie na prostym przykładzie (wypisujemy elementy z numerami):
#include <cstddef>
#include <iostream>
#include <vector>
int main() {
std::vector<int> v{10, 20, 30};
for (std::size_t i = 0; i < v.size(); ++i) {
std::cout << i << ": " << v[i] << '\n';
}
// 0: 10
// 1: 20
// 2: 30
}
Zauważ: zgodowaliśmy typ indeksu z tym, co zwraca size(). To od razu eliminuje cały rodzaj ostrzeżeń i „dziwnych porównań”.
Sąsiedzi i+1 i i-1: bez sprawdzenia granic nie można
Gdy w zadaniu pojawia się „porównaj bieżący element ze następnym” lub „policz różnicę między sąsiadami”, indeks staje się wygodny — i jednocześnie wymaga dyscypliny: trzeba pilnować, żeby i+1 i i-1 istniały.
Bardzo typowe zadanie: przejść po parach sąsiednich v[i] i v[i+1]. Tutaj właściwa granica pętli się zmienia: trzeba zatrzymać się wcześniej, aby i+1 nie wyszedł poza zakres.
#include <cstddef>
#include <iostream>
#include <vector>
int main() {
std::vector<int> v{5, 8, 2, 10};
for (std::size_t i = 0; i + 1 < v.size(); ++i) {
int diff = v[i + 1] - v[i];
std::cout << diff << ' ';
}
std::cout << '\n';
// 3 -6 8
}
Dlaczego warunek akurat i + 1 < v.size()? Ponieważ dla ostatniego dopuszczalnego i chcemy, żeby i+1 równał się v.size()-1. Jeśli i stanie się v.size()-1, to i+1 stanie się v.size(), i wyjdziemy za granice.
Teraz „niebezpieczny brat bliźniak” tego zadania: użycie i-1. Jeśli i jest std::size_t, to przy i == 0 wyrażenie i-1 nie stanie się -1, lecz zamieni się w ogromną liczbę. Dlatego takie przejścia zwykle zaczyna się od i = 1.
#include <cstddef>
#include <iostream>
#include <vector>
int main() {
std::vector<int> v{5, 8, 2, 10};
for (std::size_t i = 1; i < v.size(); ++i) {
int diff = v[i] - v[i - 1];
std::cout << diff << ' ';
}
std::cout << '\n';
// 3 -6 8
}
Tutaj „zabezpieczenie” jest w starcie: i = 1, więc i-1 zawsze będzie poprawne.
3. Najbardziej podstępne miejsca: size()-1, pętla odwrotna i mieszanie typów
Najbardziej podstępna linia: v.size()-1
Ten temat zasługuje na oddzielny „głęboki oddech”. Linia v.size()-1 wygląda niewinnie i logicznie: „ostatni indeks”. I rzeczywiście jest logiczna… dopóki v nie jest pusty. Gdy v jest pusty, v.size() równa się 0, a dalej mamy arytmetykę bez znaku: 0 - 1 zmienia się w gigantyczną wartość.
Dlatego prosta i dość rygorystyczna zasada: jeśli zamierzasz pisać size()-1, MUSISZ wcześniej sprawdzić empty() (lub że size() > 0).
#include <cstddef>
#include <iostream>
#include <vector>
int main() {
std::vector<int> v; // pusty
if (!v.empty()) {
std::size_t last = v.size() - 1;
std::cout << v[last] << '\n';
} else {
std::cout << "vector is empty\n"; // vector is empty
}
}
Zauważ, że nie próbujemy „sprytnie” obliczyć last przed if: to jak najpierw skoczyć ze spadochronem, a potem przypomnieć sobie, że trzeba go było założyć.
Pętla odwrotna i pułapka i >= 0
Gdy po raz pierwszy próbujesz przejść kontener od końca do początku, ręka często pisze coś w stylu:
// NIE RÓB TEGO
for (std::size_t i = v.size() - 1; i >= 0; --i) { ... }
I to jest jeden z najpopularniejszych sposobów na otrzymanie albo nieskończonej pętli, albo dostępu poza zakres. Powód znów w bez znaku: i >= 0 dla std::size_t zawsze jest prawdziwe, ponieważ i nigdy nie stanie się ujemne.
Normalny bezpieczny wzorzec pętli odwrotnej dla std::size_t wygląda nietypowo, ale jest niezawodny:
for (std::size_t i = v.size(); i-- > 0; ) {
// używamy v[i]
}
Wyjaśnienie prostym językiem: zaczynamy z i = v.size() (czyli „za ostatnim”), potem w warunku i-- > 0 najpierw porównujemy stare i z zerem, a następnie dekrementujemy i. W efekcie pierwsza realna iteracja ma i = v.size()-1, a ostatnia — i = 0.
Demonstracja:
#include <cstddef>
#include <iostream>
#include <vector>
int main() {
std::vector<int> v{10, 20, 30};
for (std::size_t i = v.size(); i-- > 0; ) {
std::cout << v[i] << ' ';
}
std::cout << '\n';
// 30 20 10
}
Miły bonus: jeśli v jest pusty (v.size() == 0), pętla po prostu się nie wykona, i to jest poprawne.
Dlaczego kompilator narzeka na int i std::size_t, i ma rację
Wielu z przyzwyczajenia pisze indeks jako int:
for (int i = 0; i < v.size(); ++i) { ... }
Czasem „działa”, i to jest szczególnie niebezpieczne: zaczynasz ufać kodowi. Problem w tym, że v.size() zwraca std::size_t, i porównanie int i size_t to porównanie typu ze znakiem i bez znaku. Kompilator może ostrzegać, a w niektórych sytuacjach logika naprawdę się łamie.
Wyobraź sobie, że gdzieś w kodzie i stało się ujemne (np. wyliczono i = something - 1). Porównanie i < v.size() może nagle stać się dziwne, bo ujemny int po rzutowaniu na size_t zmienia się w ogromną dodatnią wartość.
Dlatego praktyczna zasada dla początkującego: jeśli porównujesz z v.size(), to indeks deklaruj jako std::size_t. A int zostaw do rzeczy, które semantycznie mogą być ujemne (np. „nie znaleziono: -1” — choć później omówimy to bardziej elegancko).
Czasem jednak potrzebujesz liczby jako int (np. chcesz wypisać indeks jako zwykłe całkowite lub przyjąć indeks od użytkownika jako int). Wtedy lepiej robić konwersję jawnie, żeby było widać ten moment. Na naszym poziomie wystarczy zapamiętać static_cast.
#include <cstddef>
#include <iostream>
#include <vector>
int main() {
std::vector<int> v{7, 8, 9};
int i = 1;
if (i >= 0 && static_cast<std::size_t>(i) < v.size()) {
std::cout << v[static_cast<std::size_t>(i)] << '\n'; // 8
}
}
Tutaj zrobiliśmy dwie rzeczy: sprawdziliśmy, że i nie jest ujemne, i dopiero potem zamieniliśmy je na size_t do porównania i indeksowania.
4. Mini-aplikacja: „Tracker wydatków” i dlaczego potrzebuje indeksów
W poprzednich częściach kursu zaczęliśmy proste konsolowe aplikacja: tracker wydatków na dzień. Przechowujemy sumy wydatków w std::vector<int> expenses;, umiemy dodawać nowe wartości, a dziś chcemy zrobić dwie rzeczy, do których indeks jest niezwykle wygodny: wypisać wydatki z numerami i móc odwołać się do „konkretnego wydatku po jego numerze”.
Wypisywanie wydatków z numerami
Gdy wypisujesz listę, użytkownik zwykle potrzebuje widzieć nie tylko wartości, ale i „numer wiersza”. range-for sam numerów nie daje, dlatego użyjemy pętli indeksowej.
#include <cstddef>
#include <iostream>
#include <vector>
int main() {
std::vector<int> expenses{120, 350, 80};
std::cout << "Expenses:\n";
for (std::size_t i = 0; i < expenses.size(); ++i) {
std::cout << " #" << i << ": " << expenses[i] << '\n';
}
// Expenses:
// #0: 120
// #1: 350
// #2: 80
}
Na razie prosto: najważniejsze — i < expenses.size().
Znajdź największy wydatek i jego indeks
Częsty wzorzec: potrzebujemy nie tylko maksimum, ale też „gdzie ono było”. To jeden z głównych argumentów za indeksami: dzięki indeksowi łatwo zapamiętać pozycję.
#include <cstddef>
#include <iostream>
#include <vector>
int main() {
std::vector<int> expenses{120, 350, 80};
if (expenses.empty()) {
std::cout << "No expenses\n";
return 0;
}
std::size_t best_i = 0;
for (std::size_t i = 1; i < expenses.size(); ++i) {
if (expenses[i] > expenses[best_i]) {
best_i = i;
}
}
std::cout << "Max expense: #" << best_i
<< " = " << expenses[best_i] << '\n';
// Max expense: #1 = 350
}
Zwróć uwagę na dwa momenty. Sprawdziliśmy empty(), bo inaczej best_i = 0 i expenses[0] byłoby odwołaniem „w próżnię”. I zaczęliśmy pętlę od i = 1, ponieważ element zerowy jest już traktowany jako aktualne maksimum.
Różnica między sąsiednimi wydatkami
Czasem warto zobaczyć „skoki”: na przykład, o ile następny wydatek jest większy/mniejszy od poprzedniego. To zadanie dokładnie „po sąsiadach”, i pętla indeksowa jest tu najprostsza.
#include <cstddef>
#include <iostream>
#include <vector>
int main() {
std::vector<int> expenses{120, 350, 80};
std::cout << "Deltas:\n";
for (std::size_t i = 0; i + 1 < expenses.size(); ++i) {
std::cout << (expenses[i + 1] - expenses[i]) << '\n';
}
// Deltas:
// 230
// -270
}
Warunek i + 1 < expenses.size() — to nie „upiększanie”, lecz ochrona przed wyjściem poza zakres.
Wypisywanie w odwrotnej kolejności
W trackerze wydatków sensowne jest pokazywanie ostatnich wpisów jako pierwszych. Do tego potrzebny jest przebieg odwrotny, i tutaj stosujemy „magiczny” wzór i-- > 0.
#include <cstddef>
#include <iostream>
#include <vector>
int main() {
std::vector<int> expenses{120, 350, 80};
std::cout << "Newest first:\n";
for (std::size_t i = expenses.size(); i-- > 0; ) {
std::cout << expenses[i] << '\n';
}
// Newest first:
// 80
// 350
// 120
}
Jeśli zapamiętasz tylko jeden bezpieczny wzorzec pętli odwrotnej dla size_t, niech to będzie ten.
„Użytkownik podał numer” → sprawdź granice
Indeks często pochodzi z zewnątrz: użytkownik wpisał „usuń wydatek numer 5” (usuwaniem zajmiemy się później), „pokaż wydatek numer 1” lub „zamień wydatek numer 2”. Nawet bez rozbudowanych funkcji i parsowania wejścia warto już przyzwyczaić się do sprawdzania granic.
#include <cstddef>
#include <iostream>
#include <vector>
int main() {
std::vector<int> expenses{120, 350, 80};
int user_index = 1; // załóżmy, że wpisano z std::cin
if (user_index < 0) {
std::cout << "Index must be >= 0\n";
return 0;
}
std::size_t i = static_cast<std::size_t>(user_index);
if (i >= expenses.size()) {
std::cout << "Index out of range\n";
return 0;
}
std::cout << "expenses[" << i << "]=" << expenses[i] << '\n';
// expenses[1]=350
}
Tak, kod jest nieco dłuższy. Za to nie zamienia błędnego wejścia użytkownika w „zgadnij, dlaczego program padł”.
5. ściągawka: bezpieczne wzorce pętli indeksowych
Czasem chce się po prostu „szybko podejrzeć” i nie wymyślać pętli od nowa. Oto kompaktowa ściągawka:
| Zadanie | Wzorzec | Dlaczego tak |
|---|---|---|
| Przejście proste 0..n-1 | |
i < n nigdy nie daje i == n |
| Po sąsiadach i i i+1 | |
gwarantuje, że i+1 istnieje |
| Po sąsiadach i-1 i i | |
gwarantuje, że i-1 istnieje |
| Przejście odwrotne n-1..0 | |
działa również dla n==0, bez użycia i>=0 |
6. Typowe błędy przy pracy z indeksami i std::size_t
Błąd nr 1: warunek i <= v.size() zamiast i < v.size().
Ten błąd wygląda bardzo „po-ludzku”: „dopóki i nie doszedł do rozmiaru, włącznie”. Ale rozmiar to liczba elementów, a nie ostatni indeks. Przy iteracji z i == v.size() dostęp v[i] wychodzi już poza granice.
Błąd nr 2: próba pętli odwrotnej przez i >= 0 przy std::size_t.
Dla typu bez znaku i >= 0 jest zawsze prawdą, ponieważ nie ma wartości ujemnych. W rezultacie pętla staje się albo nieskończona, albo przechodzi do bardzo dużych wartości po dekrementacji. Bezpieczny wzorzec pętli odwrotnej dla size_t to for (std::size_t i = n; i-- > 0; ).
Błąd nr 3: obliczanie v.size()-1 bez sprawdzenia !v.empty().
Jeśli kontener jest pusty, size() równa się zero, a 0 - 1 na typie bez znaku zmienia się w ogromną liczbę. Problem może nie ujawnić się od razu: czasem używasz tej wartości w warunku i otrzymujesz dziwne zachowanie zamiast natychmiastowego błędu.
Błąd nr 4: dostęp do sąsiadów bez odpowiedniej granicy pętli.
Kod v[i + 1] wewnątrz pętli for (i = 0; i < v.size(); ++i) wygląda logicznie, dopóki nie nadejdzie ostatnia iteracja. W niej i + 1 staje się równe v.size(), i to już „po ostatnim”. Dla sąsiadów potrzebna jest pętla z warunkiem i + 1 < v.size() lub przemyślana logika z kontrolami.
Błąd nr 5: mieszanie int i std::size_t bez zrozumienia, co się dzieje.
Gdy porównujesz int i i v.size() (typu size_t), kompilator może wykonywać niejawne konwersje, i liczby ujemne nagle zmieniają się w ogromne dodatnie. To szczególnie nieprzyjemne przy sprawdzaniu granic „dla danych od użytkownika”: wydaje się, że kontrola istnieje, ale działa odwrotnie.
GO TO FULL VERSION