CodeGym /Kursy /C++ SELF /T* i nullptr: wskaźnik jako adres

T* i nullptr: wskaźnik jako adres

C++ SELF
Poziom 15, Lekcja 2
Dostępny

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
int x = 10;
„jest wartość 10”
Adres obiektu
&x
„gdzie leży x”
Wskaźnik
int* p = &x;
„p przechowuje adres x”
Pusty wskaźnik
int* p = nullptr;
„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”
void f(int& x)
Nie Można bezpiecznie modyfikować
x
„Obiekt może być nieobecny”
void f(int* p)
Tak (
nullptr
)
Sprawdzić
p
przed
*p

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.

1
Zadanie
C++ SELF, poziom 15, lekcja 2
Niedostępne
Wartość lub brak
Wartość lub brak
1
Zadanie
C++ SELF, poziom 15, lekcja 2
Niedostępne
Zapisz, jeśli to możliwe
Zapisz, jeśli to możliwe
1
Zadanie
C++ SELF, poziom 15, lekcja 2
Niedostępne
Sprawdzanie liczby
Sprawdzanie liczby
1
Zadanie
C++ SELF, poziom 15, lekcja 2
Niedostępne
Minimum w danych
Minimum w danych
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION