Poznaj nowoczesną tablicę – vector w C++

Czas czytania: 4 minut

W swoich programach z pewnością przechowujesz różne dane. Wykonujesz na nich różne operacje i przekształcenia. Poznałeś do tej pory coś takiego jak „tablica” (np.: int array[n]), która służy do przechowywania określonej ilości elementów danego typu. Tablice były dobre, ale w latach 90. Obecnie posiadają one wiele wad. Jedną z najważniejszych jest stały rozmiar. A przecież zdarzają się sytuacje kiedy nie możemy etapie kodowania przewidzieć ile danych wprowadzi użytkownik. Innym niezaprzeczalnym problemem jest możliwość nieświadomego doprowadzenia do naruszenia ochrony pamięci – zwykła tablica nie pozwala m.in. na sprawdzenie jej rozmiaru. Nie polecam ich używać tym bardziej, że we współczesnym języku C++ są dostępne dużo lepsze i wygodniejsze zamienniki. Jednym z takich następców, którymi zajmiemy się w dzisiejszym wpisie jest kontener vector.

Vector C++ – podstawy

Konstruktor

Najprostszą kolekcję typu vector można utworzyć za pomocą następującej linijki kodu:

1
vector <int> vec;

Na początku podajemy oczywiście typ obiektu. W naszym wypadku jest to vector. Co znajduje się w nawiasach trójkątnych? Typ danych, który będziemy przechowywali. Możemy tam wstawić dowolny typ liczbowy, tekstowy lub klasę. Vector może przechowywać absolutnie wszystko. Oczywiście, vector przechowujący obiekty klasy <MyClass> nie będzie mógł przechowywać liczb całkowitych. Dana kolekcja może przechowywać tylko dane jednego rodzaju.

vec jest nazwą naszej zmiennej.

Kolekcja vector posiada także kilka innych sposobów deklaracji i inicjacji. Niektóre pozostałe często używane metody znajdują się na poniższym listingu.

1
2
3
4
5
6
vector<int> v1;
vector<int> v2(50);
vector<int> v3(4,100);
vector<int> v4(v3.begin(),v3.end();
vector<int> v5(v3);
vector<int> v6 {5,10,15}

Na powyższym listingu widzisz najczęściej używane sposoby na utworzenie vectora. W kolejności od pierwszej do ostatniej linijki oznaczają one kontener:

  • pusty
  • o „określonym” początkowym rozmiarze wynoszącym 50 elementów
  • wypełniony określoną ilością kopii danego elementu (4 elementy o wartości 100)
  • który zawiera kopię elementów innego vectora
  • który zawiera kopię elementów innego vectora (sposób drugi)
  • wypełniony określonymi elementami za pomocą listy inicjalizacyjnej (spójrz na wpis o uniform initialization).

Najczęściej będziemy używali sposobu nr 1 i nr 5 i nr 6. Są one najbardziej intuicyjne. Za pomocą sposobu nr 2 możemy powiedzieć vectorowi, ile mniej więcej elementów będzie miała nasza kolekcja. Dzięki temu możemy nieco przyspieszyć program. Poza tym jest to dobry sposób na zagięcie kogoś w testach 🙂 Sposób nr 4 wykorzystuje iteratory, które poznamy za chwilkę. Sposób nr 5 korzysta z listy inicjalizacyjnej. Elementy podane w nawiasach klamrowych będą dodane do vectora w ramach jego inicjacji.

Czym jest iterator?

Iterator jest „wskaźnikiem” na określony element vectora. Iteratory pozwalają łatwo poruszać się po kolekcji. Na początku mogą wydawać się nieco ciężkie do zrozumienia, ale wystarczy nieco wprawy i będziesz wiedział o co w nich chodzi. Każdy vector posiada kilka „domyślnych” iteratorów, które zawsze istnieją i wskazują na pewne charakterystyczne elementy. Nam w tej chwili będą potrzebne dwa, które są widoczne na poniższym listingu, pozostałe poznasz nieco później.

1
2
3
vector<int> vec;
vector<int>::iterator iter1 = vec.begin();
vector<int>::iterator iter2 = vec.end();

Spójrz na linijkę drugą. Utworzyliśmy w niej iterator iter1 przypisaliśmy do niego to, co zwraca metoda begin(). Iterator zwracany przez tę metodę zawsze będzie wskazywał na pierwszy element vectora vec. Innymi słowy na ten, który znajduje się pod indeksem nr 0.

Inaczej zachowywać się będzie iterator iter2. Metoda end() zwróci iterator wskazujący element znajdujący się za ostatnim elementem kolekcji. O ile możemy odczytać element zwracany przez begin() za pomocą wyłuskania, o tyle nie polecam robienia tego samego z iteratorem end() gdyż doprowadzisz do naruszenia ochrony pamięci.

Dodawanie elementów

Vector jest najbardziej uniwersalną strukturą. Pozwala na dodawanie elementów praktycznie w dowolnym miejscu. Oczywiście, ze względu na swoją uniwersalność vector może być nieco wolniejszy od struktur takich jak lista, kolejka czy stos, które w tym względzie nakładają pewne ograniczenia.

1
2
3
4
5
vector<int> v1;
v1.push_back(10);
v1.insert(v1.begin(),30);
v1.push_back(20);
v1.insert(v1.begin()+1,40);

Elementy do vectora najłatwiej dodaje się na jego końcu. Korzystamy wtedy z metody push_back(), podając jako argument element lub obiekt, który chcemy dodać do vectora. Jeśli odczuwasz potrzebę dodania elementu w jakimś innym miejscu, użyj metody insert. Jako pierwszy argument podajemy iterator vectora. Natomiast drugie miejsce zajmuje wartość lub obiekt, który chcemy dodać.

Co oznacza zapis v1.begin()? Metoda begin zwraca iterator wskazujący na pierwszy element. Oznacza to, że jeśli wykonamy metodę insert i podamy jako argument v1.begin(), to nowy element zostanie dodany pod indeksem nr 0. Wszystkie aktualnie istniejące zostaną przesunięte o jeden w przód. Analogicznie, zapis v1.begin()+1 oznacza, że chcemy zapisać element pod indeksem nr 1.

Zagadka: Jaka będzie wartość vectora v1 w powyższym listingu?

Zagadka 1 - odpowiedź
30 40 10

Usuwanie elementów

Aby usunąć element z vectora, będziemy musieli skorzystać z pomocy iteratorów. Spójrz na poniższy listing

1
2
3
4
5
6
vector<int> vec {30, 40, 50, 60, 70, 80};
vec.erase(vec.begin()+1);
// zostanie usunięty element vec[1], czyli liczba 40. Vector po usunięciu będzie zawierał wartości [30,50,60,70,80]
vector<int> vec2(30,40,50,60,70,80);
vec.erase(vec.begin()+2,vec.begin()+4);
// zostaną usunięte elementy vec[2], vec[3]. Vector po usunięciu będzie zawierał wartości [30,40,70,80]

Jak pewnie zauważyłeś, do usuwania elementów z vectora służy metoda erase. Występuje ona w dwóch wariantach.

  • Wariant 1 (linijka 2) – usuwa element wskazywany przez iterator podany jako argument
  • Wariant 2 (linijka 3) – usuwa elementy znajdujące się pomiędzy iteratorami podanymi jako argument pierwszy (włącznie) i drugi (wyłącznie).

O ile wariant pierwszy jest łatwy do zrozumienia, gdyż działa analogicznie do metody insert o tyle wariant drugi może być nieco trudniejszy. Zapis vec.erase(begin()+2, vec.begin()+4) oznacza, że zostaną usunięte elementy znajdujące się pod indeksami 2 i 3. Element wskazywany przez drugi iterator stanowi granicę przedziału ale nie znajduje się w nim, dlatego nie zostanie usunięty.

Zagadka: Spójrz na poniższy listing

1
2
3
4
5
vector<int> vec {1,2,3,4,5,6,7,8,9,10};
vec.erase(vec.begin()+1);
vec.erase(vec.begin()+2,vec.begin()+4);
vec.erase(vec.begin()+1,vec.begin()+2);
vec.erase(vec.begin()+3);

Jaka będzie ostateczna zawartość vectora vec?

Zagadka 2 - odpowiedź
1 6 7 9 10

Wyświetlanie elementów vectora

Elementy znajdujące się w kontenerze vector możemy wyświetlić na kilka sposobów. Pokażę tutaj trzy najpopularniejsze.

Sposób I – zwykły for

1
2
3
4
vector<int> v {1,2,3,4,5,6,7,8,9,10};
for(int i = 0;i<v.size();i++) {
    cout<<v[i]<<endl;
}

Jak zauważamy, metoda opiera się na tradycyjnej pętli for. Po prostu przechodzimy po kolei przez wszystkie elementy kontenera. Widzimy, jak bardzo przydatne są metody wprowadzone przez twórców standardu. W przypadku zwykłej tablicy musielibyśmy pamiętać gdzieś jej rozmiar. Vector robi to za nas. Udostępnia metodę size(). Zwraca ona zwraca ilość elementów znajdujących się w vectorze. Dzięki temu zabezpieczamy się przed tym, aby nie wyjść poza obszar należący do kontenera. Przykład pokazuje także, że dostęp do elementów vectora jest dokładnie taki sam jak do elementów zwykłej tablicy. Tutaj także używamy nawiasów kwadratowych [], aby odczytać konkretny element.

Sposób II – range-based loop

1
2
3
4
vector<int> v {1,2,3,4,5,6,7,8,9,10};
for(auto i : v) {
    cout<<i<<endl;
}

Range-based loop to świetna rzecz wprowadzona w C++11. Dzięki temu nie musimy korzystać z dodatkowych metod! Język zrobi właściwie wszystko za nas! Zmienna i przechowuje aktualnie przetwarzany element kontenera, który wyświetlamy za pomocą strumienia.

Sposób III – iteratory

1
2
3
4
vector<int> v {1,2,3,4,5,6,7,8,9,10};
for(vector<int>::iterator iter = v.begin();iter!=v.end();++iter) {
    cout<<*iter<<endl;
}

Zapis zdecydowanie najtrudniejszy i najdłuższy. W tym wypadku korzystamy z iteratorów. Na początku w ramach inicjacji pętli tworzymy iterator wskazujący na pierwszy element vectora. Warunkiem kończącym jest zrównanie iteratora iter z iteratorem wskazującym za ostatni element tablicy. Po każdym przejściu zwiększamy iterator o 1, co przekłada się na to że wskazujemy tym samym na następny element.

Aby wyświetlić element vectora wskazywany przez iterator, musimy wykonać operację „wyłuskania”. To dlatego w linijce 3 widzisz tą gwiazdkę przy nazwie iteratora. Wyłuskanie pozwala dobrać się do oryginalnego vectora.

Zadanie

Napisz program, który:

  • utworzy vector vec1
  • Doda do vec1 następuje liczby: 3.5, 4.5, 2.25, 3.34
  • Utworzy vector vec2, który będzie zawierał na początku te same wartości co vec1
  • Doda do vectora vec2 po dwa elementy 0 wartości 0 na początku i na końcu vectora.
  • Utworzy vector vec3, który będzie zawierał te same elementy co vec2.
  • Usunie z vectora vec3 element o indeksach: 1, 3, 4, 5, 6
  • Wyświetli zawartość każdego vectora za pomocą trzech różnych sposobów
#include <iostream> #include <vector> using std::vector; using std::cout; using std::endl; int main() { return 0; }

Poprawny wynik działania programu
vec1: 3.5 4.5 2.25 3.34
vec2: 0 0 3.5 4.5 2.25 3.34 0 0
vec3: 0 3.5 0

Vector – zaawansowane

Na koniec omówimy sobie dwie dodatkowe rzeczy, którymi powinieneś się zająć jeśli dobrze rozumiesz materiał z pierwszej części wpisu. Nauczymy się m.in. łatwo konstruować obiekty klas wewnątrz vectora Poznamy także iteratory, które przechodzą vector „w drugą stronę”.

Dodawanie obiektów klas do vectora

Tworząc bardziej zaawansowane projekty z pewnością wiele razy spotkasz się z potrzebą dodania obiektów klas do vectora. Posiadając powyższą wiedzę, mógłbyś to zrobić następująco:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Foo
{
public:
    Foo(int __x,int __y,int __z) : x(__x),y(__y),z(__z) {}
private:
    int x,y,z;
};
int main()
{
    vector <Foo> vec;
    Foo ob1(1,2,3);
    Foo ob2(3,4,5);
    vec.push_back(ob1);
    vec.push_back(ob2);
    vec.push_back(Foo(6,7,8));
}

Powyższa metoda działa, ale nie jest doskonała. Dlaczego? Ponieważ tworzymy obiekty tymczasowe. Służą one jedynie do kreacji obiektu i jego „chwilowego” przytrzymania zanim trafi do vectora. Dzieje się tak przy każdym push_back(). Nawet tym znajdującym się w ostatniej linijce. Tam obiekt tymczasowy tworzony jest jedynie mniej jawnie.

Istnieje rozwiązanie tego problemu. Powyższy kod można zapisać o wiele prościej i wydajniej w następujący sposób.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Foo
{
public:
    Foo(int __x,int __y,int __z) : x(__x),y(__y),z(__z) {}
private:
    int x,y,z;
};
int main()
{
    vector <Foo> vec;
    vec.emplace_back(1,2,3);
    vec.emplace_back(3,4,5);
    vec.emplace_back(6,7,8);
}

Prawda, że teraz jest o wiele lepiej i przyjemniej? Wykorzystaliśmy metodę emplace_back. Działa ona w ten sam sposób co push_back Jako argument tej funkcji podajemy dokładnie to samo, co chcielibyśmy podać w konstruktorze obiektu klasy który chcemy stworzyć. Obiekt tworzony jest „bezpośrednio” w vectorze, dzięki czemu oszczędzamy czas i pamięć.

Iteratory wsteczne

Iteratory wsteczne są bardzo fajną zabawką. Pozwalają przejrzeć tablicę „od tyłu”, co czasem jest przydatne.

1
2
3
4
5
6
7
8
vector <int> vec {1,2,3,4,5,6,7,8,9,10};
for(vector<int>::iterator iter = vec.begin();iter!=vec.end();iter++) {
    cout<<*iter<<endl;
}
cout<<endl;
for(vector<int>::reverse_iterator iter = vec.rbegin();iter!=vec.rend();iter++) {
    cout<<*iter<<endl;
}

Powyższy listing wyświetla zawartość vectora vec najpierw od przodu, a następnie od tyłu. Na pewno zauważyłeś, że iteratory wsteczne praktycznie niczym nie różnią się od zwykłych. Ot – zwykłe nazewnictwo. Zamiast iterator jest reverse_iterator, zamiast begin() jest rbegin(), a zamiast end() rend(). Poza tym nic skomplikowanego, prawda?

Zadanie 2

Napisz program, który:

  • deklaruje klasę Point. Jej atrybutami prywatnymi powinny być zmienne całkowitoliczbowe x, y
    • Klasa Point powinna implementować jeden konstruktor, który jako argumenty przyjmuje dwie zmienne typu int. Prywatne atrybuty powinny zostać zainicjowane za pomocą listy inicjalizacyjnej
  • Tworzy vector przechowujący obiekty klasy Point
  • Wypełnia vector punktami (3,0), (4,5), (2,1), (9,3), (3,2)
  • Wyświetla zawartość vectora w obydwu kierunkach
#include <iostream> #include <vector> using std::vector; using std::cout; using std::endl; int main() { return 0; }

Podobało ci się? Nauczyłeś się czegoś?

Zakończyliśmy omawianie podstawowych cech kontenera vector. Używaj go najczęściej jak tylko się da. Nie męcz się ze zwykłymi tablicami [], w których musisz dbać o stanowczo zbyt wiele rzeczy. Naprawdę szkoda na to czasu i nerwów 🙂 Mam nadzieję, że wpis ci się podobał 🙂

Jeśli jesteś nowym czytelnikiem, nie zapomnij o kliknięciu tego „dzwoneczka” po lewej stronie. Dzięki temu dostaniesz powiadomienie o nowym wpisie :). Nie zapomnij także o polubieniu mojego fanpage’a.

5 komentarzy do “Poznaj nowoczesną tablicę – vector w C++

  1. Mikolaj Odpowiedz

    Czy w zagadce numer 1,nie będzie aby oddpowiedz: 30 40 10 20, a nie tylko 30 40 10?

Skomentuj ~homare Anuluj pisanie odpowiedzi

Twój adres e-mail nie zostanie opublikowany.