1. Poznanie std::array
Jeśli dopiero co opanowaliście zwykłe tablice int a[5], nasuwa się logiczne pytanie: „Dlaczego biblioteka standardowa zdecydowała się na kolejną tablicę?”. Odpowiedź jest bardzo praktyczna: tablica C to konstrukcja historyczna, mająca wiele zalet i równie wiele niespodzianek. std::array zachowuje wygodę (stały rozmiar i szybkość), ale usuwa część niespodzianek: ma metody, rozmiar nie ginie „po drodze” i dobrze współpracuje z typowymi technikami bibliotecznymi.
std::array<T, N> — to kontener biblioteki standardowej: czyli typ „z pudełka”, który znajduje się w nagłówku <array> i zachowuje się jak „prawdziwy obiekt” z wbudowanymi możliwościami. Jest znormalizowany i rozwija się wraz ze standardem C++, łącznie z narzędziami powiązanymi z tablicą.
Najważniejsze: std::array przechowuje dokładnie N elementów typu T, podobnie jak tablica C. Ale robi to „w stylu nowoczesnego C++”: rozmiar można zapytać, początek i koniec można uzyskać w ten sam sposób, a typ staje się bardziej restrykcyjny.
2. Deklaracja: N — część typu
Gdy po raz pierwszy widzisz std::array<int, 5>, może się wydawać, że to „jakiś skomplikowany szablon”. Tak, to szablon, ale idea jest prosta: T — typ elementu, N — liczba elementów. I ta liczba — to nie zmienna, tylko część typu. Czyli std::array<int, 5> i std::array<int, 6> — to różne typy.
Zacznijmy od deklaracji i odczytu elementów. Niech nasza mini‑aplikacja na poziomie 11 będzie „termometrem na tydzień”: przechowujemy 7 temperatur i liczymy prostą statystykę.
#include <array>
#include <cstddef>
#include <iostream>
int main() {
std::array<int, 7> temps{10, 11, 9, 8, 12, 13, 7};
std::cout << temps[0] << ' ' << temps[6] << '\n'; // 10 7
}
Tutaj temps — to obiekt. Nie „kawałek pamięci bez metadanych”, lecz obiekt, który ma metody i przewidywalne zachowanie.
Dodatkowo miłe jest to, że N jest ustalony bezpośrednio w typie: jeśli spróbujesz przypisać tablicę innego rozmiaru, kompilator zatrzyma cię wcześniej, niż program zacznie robić „ciekawe rzeczy”.
3. Inicjalizacja: pełna, częściowa i {}
Z inicjalizacją początkujący mają zwykle dwa tryby. Pierwszy — „wszystko wypełniłem jawnie, jestem spoko”. Drugi — „ojej, a dlaczego tam śmieci?”. std::array pomaga trzymać się pierwszego trybu, bo inicjalizacja listowa działa bardzo przewidywalnie.
Pełna inicjalizacja wygląda znajomo:
#include <array>
#include <iostream>
int main() {
std::array<int, 3> a{1, 2, 3};
std::cout << a[1] << '\n'; // 2
}
Częściowa inicjalizacja też działa: „ogon” zostanie wypełniony zerami (dla liczb — 0, dla double — 0.0).
#include <array>
#include <iostream>
int main() {
std::array<int, 5> a{10, 20}; // reszta będzie 0
std::cout << a[2] << ' ' << a[4] << '\n'; // 0 0
}
Inicjalizacja zerowa — to kiedy chcesz zagwarantować start „z czystej karty”, bez niespodzianek:
#include <array>
#include <iostream>
int main() {
std::array<int, 4> a{}; // wszystkie 0
std::cout << a[0] << ' ' << a[3] << '\n'; // 0 0
}
Dla naszego „termometru na tydzień” to wygodne: możemy zadeklarować tablicę i potem wypełnić danymi od użytkownika, ale nawet przed wczytaniem wartości elementy będą przewidywalne (a nie „jakie bajty były w pamięci”).
4. size(): mniej magicznych liczb
Gdy pracujesz ze stałą tablicą, najczęstszy błąd to „gdzieś napisałem 7, gdzieś 6, a gdzieś w ogóle zapomniałem zaktualizować”. std::array proponuje zdrowszy nawyk: pytać o rozmiar obiektu.
size() zwraca liczbę elementów (zwykle typem jest std::size_t).
Przepiszmy część naszej mini‑aplikacji: wczytamy temperatury i wypiszemy je z powrotem, ale granicę pętli weźmiemy z size().
#include <array>
#include <cstddef>
#include <iostream>
int main() {
std::array<int, 7> temps{};
for (std::size_t i = 0; i < temps.size(); ++i) {
std::cin >> temps[i];
}
std::cout << "Read " << temps.size() << " values\n"; // np.: Read 7 values
}
Sens tutaj nie polega na tym, że „tak jest krócej”. Sens w tym, że rozmiar żyje w jednym miejscu — w typie std::array<int, 7>, a kod iteracji nie musi pamiętać, że to dokładnie 7. Po prostu pyta tablicę: „ile was tam jest?”.
Tabela: tablica C vs std::array
| Właściwość | int a[7] (tablica C) | std::array<int, 7> |
|---|---|---|
| Można zapytać o rozmiar „oficjalnie” | nie (trzeba sztuczek) | tak, a.size() |
| Rozmiar związany z typem | częściowo (w typie, ale łatwo ginie przy decay) | tak, i niemal nie „znika przypadkowo” |
| Łatwo stracić rozmiar (decay) | bardzo łatwo | znacznie trudniej „przez przypadek” |
| Działa jako obiekt (kopiowanie/przypisanie w całości) | nie (nie przypisuje się bezpośrednio) | tak (kopiowany i przypisywany jak normalny obiekt) |
Ta tabela nie czyni tablicy C „złą”. Jest ona po prostu starsza i mniej restrykcyjna. A my uczymy się teraz pisać tak, by zasady restrykcyjne pomagały, a nie przeszkadzały.
5. begin() i end(): granice zakresu
Dzięki size() już czujemy, że std::array — to nie tylko „pamięć”, ale obiekt. Kolejny krok to zrozumienie, że od kontenera można zażądać „granic”. Do tego służą begin() i end().
Ważne jest, by uchwycić ideę poprawnie: begin() — to „punkt wejścia” do sekwencji, czyli dostęp do pierwszego elementu. end() — to „pozycja tuż za ostatnim elementem”. Nie ostatni element, lecz pozycja „po”. To brzmi dziwnie, ale to niezwykle wygodne ustalenie: zakres elementów to zawsze [begin, end) (zawiera początek i nie zawiera końca).
Wizualnie można myśleć tak:
flowchart LR
B["begin()"] --> E0[0] --> E1[1] --> E2[2] --> D[...]
D --> EN[ostatni] --> X["end()"]
Dlaczego end() to „poza ostatnim”? Bo wtedy pusta tablica też opisuje się poprawnie: jeśli elementów nie ma, begin() == end(). I to bardzo wygodna, wspólna logika.
Dla std::array begin()/end() zwracają „iteratory” — na tym etapie traktujemy je maksymalnie prosto: jako „kursor/wskaznik‑podobną rzecz”, która potrafi poruszać się po elementach i pozwala uzyskać bieżący element.
Pokażmy minimalny przykład:
#include <array>
#include <iostream>
int main() {
std::array<int, 3> a{5, 6, 7};
auto it = a.begin(); // "wskaznik" na pierwszy element
std::cout << *it << '\n'; // 5
}
Tu pojawia się *it. Tak, to operator „pobierz element z iteratora”. Nie wchodzimy w dużą tematykę wskaźników i pamięci, ale trzymamy prosty model: it wskazuje na element, *it — to sam element.
6. Iteracja po iteratorach bez indeksów
Pętle indeksowe są wspaniałe, zwłaszcza gdy się uczysz: i jest czytelne, granica jest jasna, a[i] — też czytelne. Ale czasem chcemy iterować „nie przez liczby”, a przez granice kontenera. begin()/end() dają uniwersalny sposób przejścia od początku do końca, nie znając „ręcznie” rozmiaru.
Tak wygląda wypisywanie elementów przez iteratory:
#include <array>
#include <iostream>
int main() {
std::array<int, 4> a{10, 20, 30, 40};
for (auto it = a.begin(); it != a.end(); ++it) {
std::cout << *it << ' ';
}
std::cout << '\n'; // 10 20 30 40
}
Opiszmy pętlę po ludzku. Bierzemy it na początek tablicy. Dopóki it nie doszedł do „pozycji po ostatnim” (a.end()), wypisujemy bieżący element *it i przesuwamy iterator do przodu ++it.
Zaletą takiej iteracji jest to, że to jednolity nawyk dla kontenerów i zakresów. Minus dla początkującego jest taki, że pojawia się więcej „nowych symboli” (*it, ++it). Dlatego zachowujemy równowagę: indeksy — dla prostoty, begin/end — by rozumieć „granice kontenera” jako odrębną ideę.
7. Przykład: statystyka temperatur
Teraz zbudujemy mały fragment logiki w stylu „jeden plik, jeden main, bez magii”. Zadanie: wczytać 7 temperatur, wypisać je, policzyć sumę i średnią. Część zrobimy indeksami (bo tak prościej), a część — przez begin/end (bo dzisiaj ich uczymy).
Wczytywanie i wypisywanie przez size()
#include <array>
#include <cstddef>
#include <iostream>
int main() {
std::array<int, 7> temps{};
for (std::size_t i = 0; i < temps.size(); ++i) {
std::cin >> temps[i];
}
for (std::size_t i = 0; i < temps.size(); ++i) {
std::cout << temps[i] << ' ';
}
std::cout << '\n'; // np.: 10 11 9 8 12 13 7
}
Tutaj utrwaliliśmy dwie rzeczy: „rozmiar pytamy u tablicy” i „granica pętli to ściśle < size()”.
Suma przez begin()/end()
Teraz liczymy sumę przez iteratory:
#include <array>
#include <iostream>
int main() {
std::array<int, 7> temps{10, 11, 9, 8, 12, 13, 7};
int sum = 0;
for (auto it = temps.begin(); it != temps.end(); ++it) {
sum += *it;
}
std::cout << "sum=" << sum << '\n'; // sum=70
}
Zwróć uwagę: wcale nie używamy indeksów. Po prostu idziemy od begin() do end().
Średnia: liczby całkowite i double
Średnia = suma / liczba. Jeśli wszystko jest int, średnia też będzie int (część ułamkowa odcięta). To w porządku, jeśli o tym pamiętasz. Jeśli chcesz średnią zmiennoprzecinkową, trzeba rzutować na double.
#include <array>
#include <iostream>
int main() {
std::array<int, 7> temps{10, 11, 9, 8, 12, 13, 7};
int sum = 0;
for (auto it = temps.begin(); it != temps.end(); ++it) {
sum += *it;
}
double avg = static_cast<double>(sum) / temps.size();
std::cout << "avg=" << avg << '\n'; // avg=10
}
Tak, w przykładzie avg wyszło dokładnie 10, ale to dlatego, że liczby się tak złożyły. W innych zestawach miałoby się np. 10.4286...
Dlaczego std::array jest często bezpieczniejszy niż tablica C
Czasem początkujący myślą, że „kontener biblioteki standardowej” musi być wolniejszy, bo „w środku pewnie jakaś magia”. W std::array magii prawie nie ma: to cienka powłoka wokół stałego bloku elementów. W odczuciu przypomina tablicę C, ale z normalnym interfejsem.
Kluczowy aspekt bezpieczeństwa to nawet nie to, że std::array „sprawdza granice” (operator [] ich nie sprawdza). Bezpieczeństwo polega na tym, że piszesz mniej ręcznie. Mniej ręcznego kodu — mniej miejsc do pomyłek. size() zmniejsza ryzyko błędu „off-by-one”, begin/end ustanawiają jednolity sposób iteracji, a sam obiekt można przekazywać/kopiować jako całość (w granicach potrzeb), bez tańców z „to tablica, nie da się przypisać”.
Jeszcze jedna praktyczna rzecz: std::array nie zamienia się sam w „tylko adres pierwszego elementu” tak łatwo, jak tablica C. Czyli trudniej jest przypadkowo stracić rozmiar. Jeśli potrzebujesz „surowego dostępu”, też jest on dostępny, ale to będzie świadoma decyzja.
8. Typowe błędy przy pracy ze std::array
Błąd nr 1: dalej wpisywać „magiczne liczby” zamiast size().
Jeśli zadeklarowałeś std::array<int, 7>, a potem piszesz for (int i = 0; i < 7; ++i), to jakbyś kupił inteligentną pralkę, a i tak prał ręcznie, bo „tak wygodniej”. Nawyk używania a.size() szybko się opłaca: zmieniasz rozmiar raz w typie, a nie szukasz wszystkich „7” w pliku.
Błąd nr 2: mylić end() z „ostatnim elementem”.
end() — to pozycja „po ostatnim”. Jeśli spróbujesz zrobić *a.end(), to logicznie to samo, co „wziąć element poza zakresem”. To źle się skończy. Jeśli potrzebujesz ostatniego elementu przez indeks, użyj a[a.size() - 1]. Mówiąc iteratorowo, można dojść do end(), ale nie wolno jej dereferencjonować.
Błąd nr 3: pisać warunek it <= a.end() w pętli iteratorowej.
Prawie zawsze poprawna pętla wygląda jak it != a.end() (lub it < a.end() dla iteratorów z dostępem losowym, ale to osobna historia). Gdy piszesz <= end(), pewnie trafisz na sytuację „a teraz spróbuję przetworzyć end jak element”, i skończy się to źle lub dziwnie.
Błąd nr 4: zapomnieć, że size() nie jest int.
size() zwraca typ rozmiaru (std::size_t). Jeśli przechowujesz indeks w int, a potem porównujesz z size(), kompilator może zacząć ostrzegać, a czasem logika stanie się krucha na granicach. Proste zalecenie: indeksy i rozmiary trzymaj w std::size_t, i życie staje się spokojniejsze.
Błąd nr 5: oczekiwać, że std::array „automatycznie chroni przed wyjściem poza zakres”.
std::array to wygodniejszy i bardziej uporządkowany obiekt, ale a[i] wciąż wymaga, aby i mieściło się w zakresie 0..size()-1. Kontener pomaga, dając size() i sensowne granice, ale myślenie nadal jest potrzebne.
GO TO FULL VERSION