1. Trzy sposoby iteracji
Gdy po raz pierwszy widzisz trzy różne sposoby „przejścia po wektorze”, pojawia się uczciwe pytanie: „Dlaczego nie można było zostawić jednej porządnej pętli, i wszyscy byliby spokojni?” To dobre pytanie. Rzeczywistość jest taka: różne zadania wymagają różnych „punktów widzenia” na kolekcję — czasem potrzebujemy numeru elementu, czasem tylko samego elementu, a czasem potrzebna jest „pozycja” w kontenerze (iterator), która potem służy do innych operacji.
Ustalmy od razu, co porównujemy dziś:
- Pętla indeksowa: poruszamy się po i = 0..size()-1 i odwołujemy się do v[i].
- range-for: piszemy for (auto x : v) i otrzymujemy elementy bez ręcznych indeksów.
- Iteratory: piszemy for (auto it = v.begin(); it != v.end(); ++it) i operujemy przez *it.
Wszystkie trzy sposoby — to nie konkurencja „który lepszy”, a raczej różne narzędzia. Jak śrubokręt, klucz i młotek: technicznie młotkiem można wkręcić śrubę… ale zwykle po tym śruba i humor trochę cierpią.
Pętla indeksowa: kiedy potrzebny jest numer elementu
Pętla indeksowa to najbardziej bezpośrednie podejście i zwykle pierwsze, które przychodzi do głowy. Jawnie tworzymy zmienną i, ustalamy granice i przy każdej iteracji pobieramy element v[i]. To przypomina „kartkowanie stron w książce”, gdzie każda strona ma numer — i czasem naprawdę potrzebujesz numeru strony, a nie tylko tekstu na niej.
Najważniejsza myśl tutaj: pętla powinna iterować po size(), a nie po capacity(), i warunek prawie zawsze wygląda jak i < v.size(). Dlaczego nie <=? Bo ostatni dopuszczalny indeks to size() - 1, a size() to już „po ostatnim elemencie”.
Podstawowa pętla indeksowa
#include <cstddef>
#include <iostream>
#include <vector>
int main() {
std::vector<int> scores{10, 20, 30};
for (std::size_t i = 0; i < scores.size(); ++i) {
std::cout << "scores[" << i << "]=" << scores[i] << '\n';
}
}
// scores[0]=10
// scores[1]=20
// scores[2]=30
Tu wszystko jest przejrzyste: jest indeks, jest element. To wygodne, kiedy drukujesz „numer → wartość”, tworzysz tabelkę albo chcesz zmodyfikować element po pozycji.
Kiedy pętla indeksowa jest szczególnie przydatna
Pętla indeksowa wygrywa, gdy potrzebujesz, aby program „pamiętał”, gdzie dokładnie znajduje się element. Na przykład chcesz znaleźć pierwszą wartość mniejszą niż 60 i wypisać jej pozycję. range-for też to potrafi, ale wtedy trzeba pilnować indeksu osobno, a w pętli indeksowej masz go „wbudowanego”.
range-for: czytelność i niuans z kopiami
range-for (range-based for) jest zwykle ceniony za to, że skraca pętlę wizualnie: mówisz „dla każdego elementu wektora zrób …”. To bardziej ludzkie i, szczerze mówiąc, z naszej wewnętrznej tęsknoty: „komputer, zrób ładnie, mam dość pisania i < size()”.
Ale range-for ma jeden niuans, przez który początkujący często obserwują dziwne zachowanie: możesz przypadkowo pracować na kopii elementu, a nie na samym elemencie wektora. Czyli zdaje się, że zmieniasz x, a wektor pozostaje bez zmian — bo modyfikujesz tymczasową kopię.
Krótka historyczna uwaga: nawet semantyka range-for była kiedyś doprecyzowywana i poprawiana w opisach standardu.
range-for tylko do odczytu
#include <iostream>
#include <vector>
int main() {
std::vector<int> scores{10, 20, 30};
for (int x : scores) { // x — kopia każdego elementu
std::cout << x << ' ';
}
std::cout << '\n';
}
// 10 20 30
Tutaj kopia nie przeszkadza, ponieważ tylko odczytujemy wartości.
Dlaczego „zmiana” może nie zadziałać
#include <iostream>
#include <vector>
int main() {
std::vector<int> scores{10, 20, 30};
for (int x : scores) {
x += 5; // zmieniamy kopię, nie wektor
}
for (int x : scores) {
std::cout << x << ' ';
}
std::cout << '\n';
}
// 10 20 30
Wygląda, jakbyśmy „dodali 5”, ale nie: dodaliśmy 5 do kopii. Wektor nie zmienił się.
range-for zmieniać elementy: potrzebne &
#include <iostream>
#include <vector>
int main() {
std::vector<int> scores{10, 20, 30};
for (int& x : scores) { // x — referencja do elementu
x += 5; // zmieniamy sam wektor
}
for (int x : scores) {
std::cout << x << ' ';
}
std::cout << '\n';
}
// 15 25 35
Tutaj zmienia się sam kontener, ponieważ x to „dostęp do oryginału”. Na naszym kursie nie wchodziliśmy jeszcze głęboko w teorię referencji, więc zapamiętaj w skrócie: & w range-for oznacza „pracuję z prawdziwym elementem, a nie z kopią”.
Iteratory begin()/end(): „pozycja” w kontenerze
Słowo „iterator” często przeraża. Brzmi, jakby zaraz zaczęła się lekcja „mechaniki kwantowej kontenerów” i przypadkiem zapisałeś się nie na kurs C++, a na wydział bólu. W praktyce iterator w prostej wersji to po prostu pozycja w kontenerze, bardzo podobna do „inteligentnego wskaźnika” (ale bez nurkowania w wskaźniki).
Główna myśl:
- begin() — pozycja na pierwszym elemencie (jeśli istnieje),
- end() — pozycja po ostatnim elemencie.
I to „po ostatnim” jest kluczowe. end() nie wskazuje na rzeczywisty element, więc nie wolno dereferencjonować *v.end(). To jak „drzwi wyjściowe” z korytarza: służą do stwierdzenia, że korytarz się skończył, ale nie nadają się do mieszkania w nich.
W standardzie C++ temat iteratorów i ich wymagań jest tak istotny, że w raportach redakcyjnych często pojawiają się poprawki i doprecyzowania rozdziałów o iteratorach.
Podstawowa pętla przez iteratory
#include <iostream>
#include <vector>
int main() {
std::vector<int> scores{10, 20, 30};
for (auto it = scores.begin(); it != scores.end(); ++it) {
std::cout << *it << ' '; // *it — bieżący element
}
std::cout << '\n';
}
// 10 20 30
Tutaj auto to nie „magia”, tylko sposób, by nie pisać długiego typu iteratora ręcznie. Na waszym etapie traktujcie to jak „kompilator, proszę, domyśl się”.
Modyfikacja elementów przez iterator
#include <iostream>
#include <vector>
int main() {
std::vector<int> scores{10, 20, 30};
for (auto it = scores.begin(); it != scores.end(); ++it) {
*it += 1; // zmieniamy element "na miejscu"
}
for (auto it = scores.begin(); it != scores.end(); ++it) {
std::cout << *it << ' ';
}
std::cout << '\n';
}
// 11 21 31
Jeśli nie zmieniasz struktury wektora (nie wstawiasz/usuwasz elementów), podejście to działa przewidywalnie. Co się stanie przy usuwaniu — to temat następnego wykładu (pojawi się erase i rozmowa o unieważnianiu iteratorów).
3. Jak wybrać podejście i nie zamienić kodu w rebus
Po trzech sposobach iteracji chcesz prostą odpowiedź: „Powiedz, które jest prawidłowe, i idę dalej”. Ale prawidłowość zależy od zadania. Poniżej — kompaktowa tabela, która pomaga podjąć decyzję bez wróżenia z fusów (i bez mistycznych rytuałów wokół ++it).
| Kryterium | Indeksy (i) | range-for | Iteratory (begin()/end()) |
|---|---|---|---|
| Czytelność „po prostu przejść i wypisać” | Średnia | Świetna | Średnia |
| Potrzebny indeks (pozycja elementu) | Świetnie | Niewygodne (trzeba osobny licznik) | Niewygodne (trzeba licznik) |
| Chcemy zmieniać elementy | Tak | Tak, ale ważne & | Tak |
| Ryzyko błędu granic | Jest (off-by-one) | Minimalne | Średnie (end() nie wolno dereferencjonować) |
| Przygotowanie do operacji typu usuwanie/wstawianie | Słabsze | Słabsze | Mocniejsze (zwykle erase przyjmuje iterator) |
Aby to utrwalić, można wyobrazić sobie prosty „router wyboru” w postaci schematu:
flowchart TD
A[Potrzebujesz przejść po vectorze] --> B{Potrzebny indeks?}
B -->|Tak| C["Pętla indeksowa for (size_t i...)"]
B -->|Nie| D{Trzeba zmieniać elementy?}
D -->|Nie| E["range-for: for (auto x : v)"]
D -->|Tak| F{Później trzeba pracować z pozycją?}
F -->|Nie| G["range-for: for (auto& x : v)"]
F -->|Tak| H["Iteratory: for (auto it = begin; it != end; ++it)"]
Schemat nie jest idealny, ale dobrze oddaje praktykę: do „czytania” zwykle wygrywa range-for, do „czytania + indeksu” — indeksy, a do „pozycji w kontenerze” — iteratory.
4. Praktyczny przykład: dziennik ocen i trzy przejścia
Teraz zbudujemy mały przykład, który będzie wyglądał jak prawdziwa aplikacja, a nie jak „trzy linijki dla trzech linijek”. Niech będzie prosta program: użytkownik wprowadza kilka ocen, zapisujemy je w std::vector<int>, potem je wypisujemy, dodajemy bonusowe punkty i szukamy pierwszej „niedostatecznej” oceny.
Ważne: nie tworzymy osobnych funkcji, bo temat funkcji pojawi się później. Dlatego wszystko zostaje w main(), ale logika jest podzielona na czytelne bloki.
Wczytywanie ocen do wektora
Wstęp: gdy omawiamy iterację, często od razu chcemy „przebiec po elementach”. Ale najpierw elementy muszą się pojawić. W realnym życiu wektor najczęściej jest wypełniany z wejścia, z pliku lub jako wynik obliczeń. Zacznijmy więc od rzetelnego zestawu danych, żeby potem było co iterować.
#include <iostream>
#include <vector>
int main() {
std::vector<int> scores;
int x = 0;
while (std::cin >> x) {
scores.push_back(x);
}
std::cout << "count=" << scores.size() << '\n';
}
// wejście: 10 20 30
// wyjście: count=3
Czytamy liczby, aż wejście się skończy. W Web‑IDE to wygodne: można po prostu zakończyć wprowadzanie i uruchomić program, albo podać stały zestaw (jak to bywa w zadaniach).
Wypisanie z indeksami
Wstęp: gdy pokazujesz użytkownikowi listę wartości, często potrzebujesz nie tylko wartości, ale też jej numeru. To jak lista zadań czy lista zakupów: „0 — mleko, 1 — chleb”. Tutaj pętla indeksowa wydaje się najbardziej naturalna: robi dokładnie to, co chcemy — daje numer i dostęp do elementu.
#include <cstddef>
#include <iostream>
#include <vector>
int main() {
std::vector<int> scores{50, 75, 90};
for (std::size_t i = 0; i < scores.size(); ++i) {
std::cout << i << ": " << scores[i] << '\n';
}
}
// 0: 50
// 1: 75
// 2: 90
Obliczyć sumę
Wstęp: sumowanie to typowy przypadek, gdzie indeks w ogóle nie ma znaczenia. Nie potrzebujemy wiedzieć „który to element”, tylko po prostu dodać każdy do sumy. I właśnie w takich zadaniach range-for daje największą czytelność: mniej szczegółów — mniej miejsca na błędy.
#include <iostream>
#include <vector>
int main() {
std::vector<int> scores{50, 75, 90};
int sum = 0;
for (int x : scores) {
sum += x;
}
std::cout << "sum=" << sum << '\n';
}
// sum=215
Dodanie bonusu do wszystkich
Wstęp: kolejny scenariusz życia — „podkręcić dane” masowo. Np. nauczyciel uznał, że kartkówka była trudna, i dodajemy wszystkim +5. Tutaj range-for nadal jest dobry, ale ważne, by nie zapomnieć o &, inaczej będziesz „podkręcać kopie” i dziwić się, dlaczego sprawiedliwość nie nastąpiła.
#include <iostream>
#include <vector>
int main() {
std::vector<int> scores{50, 75, 90};
for (int& x : scores) {
x += 5;
}
for (int x : scores) {
std::cout << x << ' ';
}
std::cout << '\n';
}
// 55 80 95
Znaleźć pierwszą ocenę poniżej progu
Wstęp: wyszukiwanie pierwszego „niedostatecznego” elementu to dobry przykład, gdzie iteratory wyglądają naturalnie. Idziemy od begin() do end(), sprawdzamy *it, i gdy znajdziemy — przerywamy. To jak przeglądanie listy, aż trafisz na odpowiedni element. Dodatkowo taki styl mentalnie przygotowuje do operacji, które operują na pozycjach.
#include <iostream>
#include <vector>
int main() {
std::vector<int> scores{80, 55, 90, 40};
const int limit = 60;
for (auto it = scores.begin(); it != scores.end(); ++it) {
if (*it < limit) {
std::cout << "first failing score=" << *it << '\n';
break;
}
}
}
// first failing score=55
Gdybyśmy potrzebowali pozycji, moglibyśmy prowadzić osobny licznik pos, ale zasada pozostaje ta sama.
5. Typowe błędy przy iteracji po std::vector
Błąd №1: warunek i <= v.size() zamiast i < v.size().
To klasyczny problem off-by-one: przypadkowo wykonujesz dodatkową iterację, gdzie i == v.size(). To już indeks „po ostatnim elemencie”. Czasem program od razu padnie, czasem zachowa się dziwnie — i to ten typ błędów, które trudno znaleźć, jeśli nie pilnujesz granic.
Błąd №2: używanie int jako indeksu i porównywanie z v.size().
size() zwraca std::size_t (typ bez znaku). Jeśli piszesz int i i porównujesz z v.size(), możesz otrzymać nieprzyjemne, niejawne konwersje, zwłaszcza gdy i może być ujemne albo gdy w kodzie pojawi się więcej arytmetyki wokół indeksu. Na tym etapie kursu przyjmijcie zasadę: indeksy i rozmiary to std::size_t.
Błąd №3: dereferencja end() (czyli próba zrobić *v.end()).
end() to nie ostatni element, tylko pozycja „po ostatnim”. Nie wolno jej dereferencjonować. To nie „prawie działa”, to „prawie działa tylko w filmach o hakerach”, a w realnym świecie to błąd.
Błąd №4: „dlaczego się nie zmieniło?” — range-for po wartości zamiast po referencji.
Gdy piszesz for (int x : v), x to kopia. Zmieniasz x — zmieniasz kopię. Aby zmieniać elementy wektora, użyj for (int& x : v). To jeden z najczęstszych zaskakujących momentów dla początkujących, bo kod wygląda sensownie.
Błąd №5: zmieniać strukturę wektora podczas iteracji i myśleć, że „nic się nie stanie”.
Jeśli wewnątrz pętli wykonujesz push_back, erase i podobne operacje, pozycje i dostęp do elementów mogą się zepsuć. Dziś wspominamy to jedynie jako ostrzeżenie: na początku ucz się iterować — a bezpieczne sposoby usuwania i to, co dokładnie „się psuje”, to osobny temat.
GO TO FULL VERSION