Wskaźniki i tablice

Wskaźniki

Dla typu T, typ T* jest typem „wskaźnik do T”. Zmienna typu T* przechowuje adres obiektu typu T.

char c = 'a';
char* p = &c; // p przechowuje adres c

Przykład deklaracji wykorzystujących typy wskaźnikowe:

int* ptrInt;    // wskaźnik do int
char* pc;   // wskaźnik do char
char** ppc; // wskaźnik do wskaźnika do p
int* ap[15]; // tablica 15 wskaźników do int
int (*fp)(char*);  // wskaźnik do funkcji przyjmującej jako argument
              // wskaźnik do char i zwracającej wartość typu int
int* f(char*);     // funkcja przyjmująca jako argument wskaźnik do char i
              // zwracająca wskaźnik do int

Dereferencja wskaźników

Z użyciem wskaźników związane są operatory:

  • Operator & zwraca adres wartości lub wskazanej zmiennej

  • Operator * umożliwia dereferencję wskaźnika – zwraca to, na co wskazuje wskaźnik

  • Operator -> umożliwa dostęp do składowych obiektu wskazywanego przez wskaźnik

char c = 'a';
char* p = &c;
char c2 = *p;

std::string word = "Szkolenie C++";
std::string* ptr_str = &word;
int word_length = ptr_str->length();

Stałe i wskaźniki

Typy wskaźnikowe mogą być dekorowane modyfikatorem const:

void f1(char* p)
{
   char s[] = "Hello";

   const char* pc = s;  // wskaźnik do stałej
   pc[3] = 'g';     // błąd: pc wskazuje na stałą
   pc = p;          // ok.

   char* const cp = s;  // stały wskaźnik
   cp[3] = 'a';     // ok.
   cp = p;          // błąd: cp jest stałą

   const char* const cpc = s;   // stały wskaźnik do stałej
   cpc[3] = 'a';        // błąd
   cpc = p;         // błąd
}

Wskaźniki funkcji

Funkcje mogą być wywołane pośrednio z wykorzystaniem wskaźników do funkcji.

int factorial(int x)  // deklaracja + definicja funkcji
{
   if (x == 0)
      return 1;
   else
      return x * factorial(x-1);
}

int result = factorial(4);  // bezpośrednie wywołanie funkcji

int (*ptrFun)(int);  // ptrFun – wskaźnik na funkcje typu int xxx(int)
ptrFun = &factorial; // przypisanie wskaźnikowi adresu funkcji
result = ptrFun(7);  // pośrednie wywołanie funkcji z wykorzystaniem
                     // wskaźnika

Dla funkcji dozwolone jest:

  • jej wywołanie

  • pobranie jej adresu

#include <iostream>
#include <string>

void error(const std::string& s)
{
   std::cout << "Error: " << s << std::endl;
   exit(1);
}

int div(int a, int b, void (*on_error)(const std::string&))
{
   if (b == 0)      // jeśli dzielnik == 0
      on_error("dzielenie przez 0");  // wywołanie zwrotne
   return a / b;
}

int main()
{
   int x = 10;
   int y = 0;
   std::cout << x << "/" << y << " = " << div(x, y, &error) << std::endl;
}

Puste wskaźniki

Stan wskaźnika niezainicjowanego jest nieokreślony – może on wskazywać na cokolwiek. Tworząc zmienną wskaźnikową zawsze powinniśmy zainicjować jej wartość adresem obiektu lub wartością nullptr (w kodzie legacy - wartością 0 lub NULL).

nullptr - uniwersalny pusty wskaźnik

Nowe słowo kluczowe w C++11 - nullptr.

  • wartość dla wskaźników, które na nic nie wskazują

  • bardziej czytelny i bezpieczniejszy odpowiednik stałej NULL/0

  • posiada zdefiniowany przez standard typ - std::nullptr_t (zdefiniowany w pliku nagłówkowym <cstddef>)

Istnieje niejawna konwersja z wartości nullptr do pustej (zerowej) wartości dowolnego typu wskaźnikowego (lub do wskaźnika do składowej).

int* ptr = nullptr;

namespace std
{
    typedef decltype(nullptr) nullptr_t;
}

int* p = nullptr;
int* p1 = NULL;
int* p2 = 0;

p1 == p; // true
p2 == p; // true

int* p {}; // p is set to nullptr

nullptr rozwiązuje problem z przeciążeniem funkcji przyjmujących jako argument wskaźnik lub typ całkowity:

void foo(int);

foo(0); // wywołuje foo(int)
foo(NULL); // wywołuje foo(int)
foo(nullptr); // błąd kompilacji


void bar(int);
void bar(void*);
void bar(nullptr_t);

bar(0); // wywołuje bar(int)
bar(NULL); // wywołuje bar(int) jeśli NULL jest zdefiniowane przez 0
           // błąd dwuznaczności jeśli NULL jest zdefiniowane przez 0L
bar(nullptr); // wywołuje bar(void*) lub bar (nullptr_t) (jeśli jest dostępne)

Testowanie wskaźników

Stan wskaźnika niezainicjowanego jest nieokreślony – może on wskazywać na cokolwiek. Literał nullptr służy do oznaczenia wskaźnika, który niczego nie wskazuje.

int* px1 = nullptr;

if (px1 != nullptr)
{
    //...
}

int* px2{}; // odpowiednik int* px2 = nullptr;

if (px2)
{
    //...
}

if (!px2)
{
    //...
}

Wskaźniki do void

Wskaźnik do dowolnego typu może zostać przypisany do typu void*. Wskaźnik void* może być przypisany do innego wskaźnika typu void*. Dozwolone są porównania między tymi wskaźnikami (==, !=). Inne operacje są niedozwolone. Wskaźnik do typu void reprezentuje wskaźnik do fragmentu pamięci, ale bez znajomości typu (wskaźnik do „surowej” pamięci).

void f(int* pi)
{
   void* pv = pi;   // niejawna konwersja z int* do void*
   *pv;   // błąd!
   pv++;  // błąd! nieznany rozmiar

   int* ptr2pi = static_cast<int*>(pv);
   (*ptr2pi)++;
}

Tablice

Tablica – sekwencja elementów określonego typu o ustalonej długości i zajmująca sąsiadujące ze sobą komórki pamięci. Liczba elementów musi zostać określona podczas tworzenia tablicy. Dostęp do elementów tablicy odbywa się za pomocą operatora indeksu []. Elementy są indeksowane od 0 do rozmiar-1.

int values[10]; // tablica 10 elementów typu int
values[0] = 77;
values[9] = values[0];

for (int i = 0; i < 10; i++)
{
    values[i] = 42;
}

Inicjalizacja tablic

Tablice mogą być inicjalizowane przy pomocy listy wartości.

int v1[] = {1, 2, 3, 4, 5, 6, 7, 8, 9};
char v2[] = {'a', 'b', 'c', 'd', 0};

Jeśli na liście inicjalizującej znajduje się za mało wartości pozostałe elementy przyjmują wartość zero.

int v1[8] = {1, 2, 3, 4};
int v2[8] = {1, 2, 3, 4, 0, 0, 0, 0};

Taki sposób inicjalizacji jest dozwolony tylko w momencie deklaracji.

Tablice i wskaźniki

Dostęp do elementów tablicy może odbywać się za pomocą wskaźników. Nazwa tablicy może być używana jako wskaźnik do pierwszego elementu tablicy.

int values[10]; // tablica 10 elementów typu int
*values = 88;   // values[0] = 88
int* vp = values; // równoważne vp = &values[0]

Arytmetyka wskaźników

Wskaźniki mogą udostępniać dostęp do dowolnego elementu tablicy.

Na wskaźnikach można wykonywać operacje arytmetyczne.

  • Jeśli do wskaźnika dodamy wartość całkowitą n, przesunie się o n elementów; operator ++ umożliwia przesunięcie wskaźnika o jeden element.

  • Wyrażenie będące sumą wskaźnika i wartości n wskazuje n-ty element za elementem wskazywanym przez wskaźnik.

  • Jeśli odejmiemy od siebie wartości dwóch wskaźników, uzyskamy liczbę całkowitą reprezentującą odległość między elementami wskazywanymi przez te wskaźniki.

int values[10]; // tablica 10 elementów typu int

for (int* p = values; p < values+10; ++p)
{
    std::cout << "index: " << p-values
         << " value: " << *p << std::endl;
}

int x = values[5];
*(values+5) = 10;

Tablice wielowymiarowe

Tablice wielowymiarowe reprezentowane są jako tablice tablic.

int d2[10][20]; // d2 jest tablicą 10 tablic 20 wartości typu int
int value = d2[0][19];

// zerowanie tablicy
for (int i = 0; i < 10; i++)
    for (int j = 0; j < 20; j++)
        d2[i][j] = 0;

Tablice array<T, N>

Tablica o stałym rozmiarze (fixed size array) specyfikowanym w momencie kompilacji. Pamięć dla tablicy array może być alokowna na stosie, w bloku pamięci statycznej lub wewnątrz obiektu (jako składowa). Spełnia wszystkie wymagania dla kontenera standardowego.

  • Plik nagłówowy: <array>

  • Implementacja typu array nie ma żadnego narzutu w runtimie

  • Typ std::array jest agregatem, więc możliwa jest inicjalizacja agregatowa.

  • Posiada w interfejsie metody zwracające iteratory:

    • T* begin() lub const T* begin() const - zwracają iterator wskazujący na początek tablicy

    • T* end() lub const T* end() const - zwracają iterator wskazujący na następny element za ostatnim elementem tablicy

  • Metoda data() zwraca wskaźnik do typu danych przechowywanych w tablicy.

    std::array<int, 4> arr1 = {1, 2, 3, 4};
    
    for(size_t i = 0; i < arr1.size(); ++i)
        cout << arr1[i] << endl;
    
    int* buffer = arr1.data();
    *(buffer+2) = -1;
    
    for(const auto& item : arr1)
        cout << item << " ";
    cout << endl;
    
  • Metoda swap() umożliwia wymianę danych w tablicach

    std::array<int, 4> arr1 = {1, 2, -1, 4};
    std::array<int, 4> arr2 = {};
    
    print(arr1, "arr1: ");
    print(arr2, "arr2: ");
    
    arr1.swap(arr2);
    cout << "\nSwap:\n";
    print(arr1, "arr1: ");
    print(arr2, "arr2: ");
    
    arr1: [ 1 2 -1 4 ]
    arr2: [ 0 0 0 0 ]
    Swap:
    arr1: [ 0 0 0 0 ]
    arr2: [ 1 2 -1 4 ]
    

vector<T> - tablica dynamiczna

Klasa std::vector<T> stanowi alternatywę dla natywnych tablic C++.

Zalety:

  • Dynamiczny rozmiar możliwy do zmiany w trakcie działania programu

  • Przechowuje informację o swoim rozmiarze – metoda size()

  • Można używać indeksów []

Metoda push_back() dodaje do wektora nowy element. Element jest wstawiany na końcu kolekcji.

#include <vector>

std::vector<int> numbers = { 1, 2, 3 }; // wektor zainicjowany wartościami { 1, 2, 3}
numbers.push_back(4);   // [1, 2, 3, 4]
numbers.push_back(5);   // [1, 2, 3, 4, 5]
numbers.push_back(6);   // [1, 2, 3, 4, 5, 6]

for(size_t i = 0; i < numbers.size(); ++i)
   std::cout << numbers[i] << "\n";

std::vector<std::string> names(5); // utworzenie wektora zawierającego 5
names[0] = "Jan";                  // elementów typu string
names[1] = "Ala";
names[2] = "Aleksandra";
names[3] = "Zyta";
names[4] = "Katarzyna";, 5, 6
names.push_back("Krzysztof");

for(const auto& name : names)
    std::cout << name << "\n";

Pętla for dla zakresów

Range-Based for iteruje po wszystkich elementach zakresu. Jest kompatybilna z:

  • zakresami/kontenerami, które posiadają w interfejsie metody begin() i end()

  • listami inicjalizacyjnymi

  • iteratorami

  • tablicami natywnymi (C-arrays)

std::vector<int> vec;
//... inserting items


for(int item : vec)
    cout << item << endl;

for(int& item : vec) // using ref to modify each element
    item *= 2;

Kopiowanie elementów w trakcie iteracji może obniżyć wydajność (np. dla typów string, shared_ptr) lub może być zabronione np. unique_ptr. Można uniknąć kopiowania elementów w trakcie iteracji dodając referencję. Opcjonalnie można również stosować modyfikator const lub volatile.

std::vector<std::shared_ptr<Gadget>> shared_gadgets;

// ...

for(const auto& ptr : shared_gadgets)
    ptr->do_something();


std::vector<std::unique_ptr<Gadget>> unique_gadgets;

// ...

for(auto ptr : unique_gadgets) // compilation error
    ptr->do_something();

for(const auto& ptr : unique_gadgets) // ok
    ptr->do_something();

Mechanizm pętli Range-Based for

Wyrażenie

for( decl : coll )
{
    statement;
}

jest rozwijane do pętli:

for(auto _pos = coll.begin(), _end = coll.end(); _pos != _end; ++_pos)
{
    decl = *_pos;
    statement;
}

lub z niższym priorytetem do pętli:

for(auto _pos = begin(coll), _end = end(coll); _pos != _end; ++_pos)
{
    decl = *_pos;
    statement;
}