1. Adresy i wskaźniki
Gdy zaczynasz pisać funkcje, chcemy, by wszystko było proste: podasz liczbę — otrzymasz liczbę. Jednak dość szybko pojawiają się sytuacje, w których „zwykły” parametr nie wyraża sensu. Czasem argument może nie istnieć (np. „jeśli chcesz — podaj, jeśli nie — pomiń”). Czasem funkcja powinna zwrócić wynik nie przez return, lecz „położyć” go w zmiennej, którą jej przekazano. I właśnie tutaj wskaźnik w parametrze to sposób, by uczciwie powiedzieć w interfejsie: „oczekuję adresu, a ten adres może być pusty”.
We współczesnym C++ nullptr jest standardowym i preferowanym sposobem oznaczenia „wskaźnika zerowego” (czyli „nie wskazuje nigdzie”). Nawet w materiałach biblioteki standardowej zobaczysz poprawki typu „use nullptr instead of 0”.
Operator &: przypomnijmy, jak działa
Wyobraź sobie, że zmienna to „pudełko” w pamięci, a wartość — to, co jest w pudełku. Adres to „współrzędne pudełka”: dzięki temu adresowi komputer może znaleźć miejsce, gdzie przechowywana jest wartość. W wcześniejszych tematach o tablicach widzieliśmy, że nazwa tablicy w wielu wyrażeniach zachowuje się jak „adres pierwszego elementu”. To było pierwsze spotkanie z ideą adresu — wtedy nie robiliśmy z tego większej filozofii, a dziś wykorzystamy to jako narzędzie.
Operator & w C++ oznacza „weź adres”. Jeśli mamy zmienną x, to &x to adres tej zmiennej.
#include <iostream>
int main() {
int x = 42;
std::cout << x << '\n'; // 42
std::cout << &x << '\n'; // (jakiś adres, np. 0x7ffd...)
}
Drugiej linii z adresem nie warto próbować zapamiętać ani „zgadywać”: adres zależy od uruchomienia, systemu operacyjnego, kompilatora i fazy księżyca. Ważna jest idea: &x — to nie liczba „42”, to „gdzie leży x”.
Aby utrwalić, warto to sobie wyobrazić na schemacie:
Pamięć (bardzo umownie)
Adres: 0x1000 ┌───────────┐
│ 42 │ <- tu leży int x
└───────────┘
^
|
&x
Wskaźniki mają 2 istotne zalety:
- można przekazać obiekt bez jego kopiowania: bardzo przydatne
- można zmienić obiekt, jeśli istnieje do niego odwołanie
Wskaźniki stały się tak popularne, że dla ich przechowywania powstał osobny typ — wskaźnik.
2. Typ T* i wartość nullptr
Jeśli int to „liczba całkowita”, to int* to „rzecz przechowująca adres int”. Zapis T* czyta się „wskaźnik na T”. Ważne: wskaźnik to nie sam obiekt T, a „odwołanie” do niego.
Druga kluczowa idea — wskaźnik może być pusty. Do tego służy specjalna wartość nullptr. Oznacza: „brak adresu, obiekt nie jest podany, nie ma dokąd spojrzeć”.
Krótka tabela, żeby szybko się zorientować:
| Co to jest | Przykład | Znaczenie |
|---|---|---|
| Obiekt | |
„jest wartość 10” |
| Adres obiektu | |
„gdzie leży x” |
| Wskaźnik | |
„p przechowuje adres x” |
| Pusty wskaźnik | |
„p nie wskazuje nigdzie” |
Spójrzmy na krótki przykład: wskaźnik przechowuje adres, ale sam w sobie „nic nie zmienia”. Zmiany następują po dereferencji.
#include <iostream>
int main() {
int x = 7;
int* p = &x; // p "wskazuje" na x
std::cout << x << '\n'; // 7
std::cout << p << '\n'; // (adres x)
}
Dereferencja *p i sprawdzenie na nullptr
Do tej pory wskaźnik wyglądał jak „dziwna liczba‑adres”. Ale sens wskaźnika polega na dostępie do obiektu przez adres. Do tego służy operator * (gwiazdka) — dereferencja. Oznacza: „weź obiekt, który znajduje się pod tym adresem”.
Mówiąc prostym językiem: p — „gdzie”, *p — „co”.
#include <iostream>
int main() {
int x = 10;
int* p = &x;
*p = 99; // zapisano 99 w x przez wskaźnik
std::cout << x << '\n'; // 99
std::cout << *p << '\n'; // 99
}
Teraz najważniejsza zasada, która chroni przed bólem i problemami: dereferencjonować można tylko niepusty wskaźnik. Jeśli p == nullptr, to *p to próba odczytu/zapisu „w nicość”. Prowadzi to do błędu lub bardzo dziwnego zachowania — dla początkujących wygląda to często jak „magia bugów”.
Dlatego bezpieczny wzorzec jest taki: najpierw sprawdzenie, potem dereferencja.
#include <iostream>
void print_if_present(const int* p) {
if (p == nullptr) {
std::cout << "no value\n"; // no value
return;
}
std::cout << *p << '\n';
}
int main() {
int x = 5;
print_if_present(&x); // 5
print_if_present(nullptr);// no value
}
3. Wskaźniki w parametrach: nullable‑kontrakt i parametry wyjściowe
Gdy widzisz w sygnaturze funkcji int&, możesz założyć, że funkcja otrzymała „drugie imię” istniejącego obiektu i obiekt na pewno istnieje. Referencje nie mogą być „puste”: to część ich sensu.
Natomiast int* w parametrze to inny kontrakt: funkcja otrzymała adres, ale adres może być nullptr. To wygodne, gdy brak argumentu jest normalną sytuacją, a nie błędem.
Krótka tabela porównawcza (bez filozofii, czysto praktycznie):
| Kontrakt | Jak wygląda | Może być „puste”? | Co funkcja musi zrobić |
|---|---|---|---|
| „Obiekt na pewno istnieje” | |
Nie | Można bezpiecznie modyfikować |
| „Obiekt może być nieobecny” | |
Tak () |
Sprawdzić przed |
Tutaj warto przyzwyczaić się do czytania sygnatur jak znaków drogowych. Jeśli wykładowca widzi T*, od razu myśli: „aha, tu możliwy jest nullptr, więc gdzieś powinna być weryfikacja”.
Wyobraźmy sobie, że kontynuujemy nasze konsolowe aplikację „ToDo‑lista”. Przechowuje zadania w std::vector<std::string> i potrafi wypisać listę, dodać zadanie i usunąć po numerze. Numer użytkownik wpisuje jako string (przez getline), i musimy przekształcić go na int.
Na razie nie używamy zaawansowanych mechanizmów błędów (jak optional) i nie chcemy polegać na wyjątkach. Dlatego zrobimy prosty i często używany interfejs: funkcja zwraca bool (sukces/porażka), a wynik umieszcza w parametrze wyjściowym int* out.
Dlaczego int*, a nie int&? Bo w ten sposób możemy wyrazić dodatkowy sens: „wynik można nie odebrać” (np. do sprawdzenia poprawności wejścia bez zapisu).
Parser liczby: bool parse_int(const std::string&, int* out)
Zanim napiszemy kod, ustalmy kontrakt. Jeśli out == nullptr, nie będziemy się dąsać ani kończyć błędem — po prostu uznamy, że wywołujący poprosił „tylko o sprawdzenie stringa”. To powszechny zabieg w projektach: „walidacja bez ekstrakcji wartości”.
#include <string>
bool parse_int(const std::string& s, int* out) {
if (s.empty()) return false;
int sign = 1;
std::size_t i = 0;
if (s[0] == '-') { sign = -1; i = 1; }
if (i == s.size()) return false;
int value = 0;
for (; i < s.size(); ++i) {
char c = s[i];
if (c < '0' || c > '9') return false;
value = value * 10 + (c - '0');
}
if (out != nullptr) {
*out = sign * value;
}
return true;
}
Tak, są tu ograniczenia (np. nie wykrywamy przepełnienia int). Ale na etapie nauki i dla małych danych wejściowych to świetne ćwiczenie: wyraźnie widać sprawdzenie nullptr przed *out.
Jak używać out i jak przekazać adres
Pokażemy teraz, jak to wygląda w main. Najważniejsze — pamiętać: aby przekazać adres zmiennej x do parametru int*, piszemy &x.
#include <iostream>
#include <string>
bool parse_int(const std::string& s, int* out); // prototyp
int main() {
std::string line = "123";
int number = 0;
if (parse_int(line, &number)) {
std::cout << number << '\n'; // 123
}
}
A oto przykład „sprawdzić, ale nie zapisać”:
#include <iostream>
#include <string>
bool parse_int(const std::string& s, int* out); // prototyp
int main() {
std::string line = "12x";
bool ok = parse_int(line, nullptr);
std::cout << ok << '\n'; // 0 (false)
}
Zwróć uwagę na efekt psychologiczny: samo wywołanie parse_int(line, nullptr) czyta się jak „nie potrzebuję wyniku”. To jest właśnie nullable‑kontrakt w praktyce.
Przykład ToDo: usuwanie zadania po numerze
Zbudujmy na tym kawałek logiki aplikacji ToDo. Załóżmy, że mamy tasks i użytkownik wpisał string line, który ma reprezentować numer. Chcemy: jeśli numer jest poprawny i w zakresie, usunąć zadanie. Tutaj wygodnie zrobić funkcję, która zwraca bool i przyjmuje parametr wyjściowy dla indeksu albo od razu wykonuje akcję.
Ponieważ nie wchodzimy teraz w architekturę, zrobimy prosty wariant: funkcja próbuje usunąć, a powodzenie zwraca jako bool.
#include <string>
#include <vector>
bool parse_int(const std::string& s, int* out); // już jest
bool remove_task_by_line(std::vector<std::string>& tasks, const std::string& line) {
int index = -1;
if (!parse_int(line, &index)) return false;
if (index < 1) return false;
int pos = index - 1; // użytkownik wpisuje 1..N, a vector 0..N-1
if (pos >= static_cast<int>(tasks.size())) return false;
tasks.erase(tasks.begin() + pos);
return true;
}
Tu ważna jest nie sama funkcja erase (mogliśmy ją poznać wcześniej), lecz idea: otrzymaliśmy liczbę przez int* out, zatem istnieje miejsce, gdzie funkcja „położyła” wynik.
4. Tablice i wskaźniki: „początek sekwencji”
Wcześniej, gdy omawialiśmy tablice, mogłeś zauważyć osobliwość: nazwa tablicy w wielu miejscach zamienia się w adres pierwszego elementu. To jest właśnie „array-to-pointer conversion (decay)”, ale nie musisz pamiętać tej trudnej nazwy, by korzystać z idei. Dziś ważne jest praktyczne wnioski: jeśli masz tablicę int a[5], możesz ją przekazać tam, gdzie oczekuje się int* lub const int*.
Pokażemy funkcję, która wypisuje pierwsze n liczb. Nie robimy arytmetyki wskaźników, tylko używamy indeksowania p[i], które w C++ działa także dla wskaźników (bo historycznie wskaźniki i tablice są blisko powiązane).
#include <iostream>
void print_numbers(const int* p, int n) {
if (p == nullptr) {
std::cout << "no array\n"; // no array
return;
}
for (int i = 0; i < n; ++i) {
std::cout << p[i] << ' ';
}
std::cout << '\n';
}
int main() {
int a[4] = {10, 20, 30, 40};
print_numbers(a, 4); // 10 20 30 40
print_numbers(nullptr, 3); // no array
}
To świetny most między tematem tablic a wskaźnikami: wskaźnik to rzeczywiście adres, a w przypadku tablic adres pierwszego elementu często „wychodzi na zewnątrz” automatycznie.
Mini‑schemat: jak czytać T* w sygnaturze
Gdy zaczniesz częściej widzieć wskaźniki w kodzie, warto mieć w głowie mały algorytm czytania. To nie jest „formalna reguła”, ale działa w praktyce: najpierw rozumiesz kontrakt, potem sprawdzasz, czy kod mu odpowiada.
flowchart TD
A["Widzę parametr T* p"] --> B["Możliwy nullptr? (zazwyczaj tak)"]
B --> C["Czy jest sprawdzenie p == nullptr przed *p?"]
C -->|tak| D["OK: dereferencja bezpieczniejsza"]
C -->|nie| E["Ryzyko: *p może spowodować crash (albo gorsze)"]
W dobrych przykładach edukacyjnych (i w realnym kodzie) prawie zawsze zobaczysz sprawdzenie albo na początku funkcji, albo tuż przed dereferencją.
10. Typowe błędy przy pracy z T* i nullptr
Błąd nr 1: dereferencja nullptr „bo przecież dobrze podałem”.
Najczęstszy scenariusz: funkcja przyjmuje int* out, wewnątrz od razu pisze *out = ..., a ktoś wywołuje ją jako f(nullptr). Skutek — crash w czasie wykonywania. Psychologicznie to szczególnie wkurzające, bo kompilator nie zasygnalizuje problemu. Leczy się dyscypliną: jeśli wskaźnik może być pusty, w funkcji musi być sprawdzenie if (out == nullptr) ... przed pierwszym użyciem *out.
Błąd nr 2: zapomnieć & przy przekazywaniu adresu.
Jeśli funkcja oczekuje int*, a wy napiszesz parse_int(line, number), kompilator słusznie się zdziwi: „oczekiwałem adresu, a dostałem liczbę”. Prawidłowe wywołanie to parse_int(line, &number). To nie jest „interpunkcja dla samej interpunkcji”, to wyraźne polecenie: prosisz kompilator o adres zmiennej.
Błąd nr 3: mylić p i *p (gdzie vs co).
Początkujący często pisze p = 10 zamiast *p = 10 i potem nie rozumie, dlaczego wszystko się sypie. p — to adres (gdzie), *p — wartość pod adresem (co). Przydatna mantra: „bez gwiazdki — gdzie, z gwiazdką — co”.
Błąd nr 4: używać 0 lub NULL zamiast nullptr.
W starym C++ (i w starszym kodzie) możesz spotkać NULL oraz po prostu 0. To gorzej czytelne i czasem powoduje niejednoznaczności przy przeciążeniach. We współczesnym C++ stosuje się nullptr, ponieważ to odrębna wartość „wskaźnika zerowego”, a nie tylko „jakakolwiek liczba”. W dokumentacji standardu wiele miejsc poprawiono właśnie na nullptr.
Błąd nr 5: myśleć, że wskaźnik „sam tworzy obiekt”.
Wskaźnik to tylko adres. Jeśli masz int* p = nullptr, to nie znaczy „gdzieś jest int, po prostu nie zainicjalizowany”. To znaczy „obiekt nie istnieje, adres jest pusty”. W kontekście tego wykładu nie tworzymy obiektów przez wskaźniki ani nie zarządzamy pamięcią ręcznie — używamy wskaźników tylko jako sposobu przekazania adresu istniejącego obiektu albo przekazania „pustki” jako sygnału.
GO TO FULL VERSION