C/Typy złożone: Różnice pomiędzy wersjami

Z Wikibooks, biblioteki wolnych podręczników.
< C
Usunięta treść Dodana treść
→‎Studium przypadku - implementacja listy wskaźnikowej: wywołania konstruktorów i destruktorów
Linia 517: Linia 517:
short i = 2;
short i = 2;
const unsigned long END = 1000;
const unsigned long END = 1000;
first = malloc (sizeof List);
first = create_list();
first->val = &i;
first->val = &i;
first->next = NULL;
first->next = NULL;
Linia 525: Linia 525:
}
}
first.wypisz(stdout);
first.wypisz(stdout);
free_list(first);
return 0;
return 0;
}
}

Wersja z 20:37, 14 lis 2011

typedef

Jest to słowo kluczowe, które służy do definiowania typów pochodnych np.:

 typedef stara_nazwa  nowa_nazwa;
 typedef int mojInt;
 typedef int* WskNaInt;

od tej pory mozna używać typów mojInt i WskNaInt.

Typ wyliczeniowy

Służy do tworzenia zmiennych, które powinny przechowywać tylko pewne z góry ustalone wartości:

 enum Nazwa {WARTOSC_1, WARTOSC_2, WARTOSC_N };

Na przykład można w ten sposób stworzyć zmienną przechowującą kierunek:

 enum Kierunek {W_GORE, W_DOL, W_LEWO, W_PRAWO};
   
 enum Kierunek kierunek = W_GORE;

którą można na przykład wykorzystać w instrukcji switch

 switch(kierunek)
 {
   case W_GORE:
     printf("w górę\n");
     break;
   case W_DOL:
     printf("w dół\n");
     break;
   default:
     printf("gdzieś w bok\n");
 }

Tradycyjnie przechowywane wielkości zapisuje się wielkimi literami (W_GORE, W_DOL).

Tak naprawdę C przechowuje wartości typu wyliczeniowego jako liczby całkowite, o czym można się łatwo przekonać:

 kierunek = W_DOL;
 printf("%i\n", kierunek); /* wypisze 1 */

Kolejne wartości to po prostu liczby naturalne: domyślnie pierwsza to zero, druga jeden itp. Możemy przy deklarowaniu typu wyliczeniowego zmienić domyślne przyporządkowanie:

 enum Kierunek { W_GORE, W_DOL = 8, W_LEWO, W_PRAWO };
 printf("%i %i\n", W_DOL, W_LEWO); /* wypisze 8 9 */

Co więcej liczby mogą się powtarzać i wcale nie muszą być ustawione w kolejności rosnącej:

 enum Kierunek { W_GORE = 5, W_DOL = 5, W_LEWO = 2, W_PRAWO = 1 };
 printf("%i %i\n", W_DOL, W_LEWO); /* wypisze 5 2 */

Traktowanie przez kompilator typu wyliczeniowego jako liczby pozwala na wydajną ich obsługę, ale stwarza niebezpieczeństwa - można przypisywać pod typ wyliczeniowy liczby, nawet nie mające odpowiednika w wartościach, a kompilator może o tym nawet nie ostrzec:

 kierunek = 40;

Struktury

Struktury to specjalny typ danych mogący przechowywać wiele wartości w jednej zmiennej. Od tablic jednakże różni się tym, iż te wartości mogą być różnych typów.

Struktury definiuje się w następujący sposób:

 struct Struktura {
   int pole1;
   int pole2;
   char pole3;
 };

gdzie "Struktura" to nazwa tworzonej struktury.
Nazewnictwo, ilość i typ pól definiuje programista według własnego uznania.

Zmienną posiadającą strukturę tworzy się podając jako jej typ nazwę struktury.

 struct Struktura zmiennaS;


Dostęp do poszczególnych pól uzyskuje się przy pomocy operatora wyboru składnika: kropki ('.').

 zmiennaS.pole1 = 60;   /* przypisanie liczb do pól */
 zmiennaS.pole2 = 2;
 zmiennaS.pole3 = 'a';  /* a teraz znaku */

Unie

Unie to kolejny sposób prezentacji danych w pamięci. Na pierwszy rzut oka wyglądają bardzo podobnie do struktur:

 union Nazwa {
   typ1 nazwa1;
   typ2 nazwa2;
   /* ... */
 };

Na przykład:

 union LiczbaLubZnak {
   int calkowita;
   char znak;
   double rzeczywista;
 };

Pola w unii nakładają się na siebie w ten sposób, że w danej chwili można w niej przechowywać wartość tylko jednego typu. Unia zajmuje w pamięci tyle miejsca, ile zajmuje największa z jej składowych. W powyższym przypadku unia będzie miała prawdopodobnie rozmiar typu double czyli często 64 bity, a całkowita i znak będą wskazywały odpowiednio na pierwsze cztery bajty lub na pierwszy bajt unii (choć nie musi tak być zawsze). Dlaczego tak? Taka forma często przydaje się np. do konwersji pomiędzy różnymi typami danych. Możemy dzięki unii podzielić zmienną 32-bitową na cztery składowe zmienne o długości 8 bitów każda.

Do konkretnych wartości pól unii odwołujemy się, podobnie jak w przypadku struktur, za pomocą kropki:

 union LiczbaLubZnak liczba;
 liczba.calkowita = 10;
 printf("%d\n", liczba.calkowita);

Zazwyczaj użycie unii ma na celu zmniejszenie zapotrzebowania na pamięć, gdy naraz będzie wykorzystywane tylko jedno pole i jest często łączone z użyciem struktur.

Przyjrzyjmy się teraz przykładowi, który powinien dobitnie zademonstrować działanie unii:

 #include <stdio.h>
 
 struct adres_bajtowy {
  __uint8_t a;
  __uint8_t b;
  __uint8_t c;
  __uint8_t d;
  };
 
 union adres {
   __uint32_t ip;
   struct adres_bajtowy badres;
   };
 
 int main ()
 {
    union adres addr;
    addr.badres.a = 192;
    addr.badres.b = 168;
    addr.badres.c = 1;
    addr.badres.d = 1;
    printf ("Adres IP w postaci 32-bitowej zmiennej: %08x\n",addr.ip);
    return 0;
 }

Zauważyłeś pewien ciekawy efekt? Jeśli uruchomiłeś ten program na typowym komputerze domowym (rodzina i386) na ekranie zapewne pojawił Ci się taki oto napis:

Adres IP w postaci 32-bitowej zmiennej: 0101a8c0

Dlaczego jedynki są na początku zmiennej, skoro w programie były to dwa ostatnie bajty (pola c i d struktury)? Jest to problem kolejności bajtów. Aby dowiedzieć się o nim więcej przeczytaj rozdział przenośność programów. Zauważyłeś zatem, że za pomocą tego programu w prosty sposób zamieniliśmy cztery zmienne jednobajtowe w jedną czterobajtową. Jest to tylko jedno z możliwych zastosowań unii.

Inicjalizacja struktur i unii

Jeśli tworzymy nową strukturę lub unię możemy zaraz po jej deklaracji wypełnić ją określonymi danymi. Rozważmy tutaj przykład:

 struct moja_struct {
    int a;
    char b;
    } moja = {1,'c'};

W zasadzie taka deklaracja nie różni się niczym od wypełnienia np. tablicy danymi. Jednak standard C99 wprowadza pewne udogodnienie zarówno przy deklaracji struktur, jak i unii. Polega ono na tym, że w nawiasie klamrowym możemy podać nazwy pól struktury lub unii którym przypisujemy wartość, np.:

 struct moja_struct {
    int a;
    char b;
    } moja = {.b = 'c'}; /* pozostawiamy pole a niewypełnione żadną konkretną wartością */

Wspólne własności typów wyliczeniowych, unii i struktur

Warto zwrócić uwagę, że język C++ przy deklaracji zmiennych typów wyliczeniowych, unii lub struktur nie wymaga przed nazwą typu odpowiedniego słowa kluczowego. Na przykład poniższy kod jest poprawnym programem C++:

 enum   Enum   { A, B, C };
 union  Union  { int a; float b; };
 struct Struct { int a; float b; };
 int main() {
   Enum   e;
   Union  u;
   Struct s;
   e = A;
   u.a = 0;
   s.a = 0;
   return e + u.a + s.a;
 }

Nie jest to jednak poprawny kod C i należy o tym pamiętać szczególnie jeżeli uczysz się języka C korzystając z kompilatora C++.

Należy również pamiętać, że po klamrze zamykającej definicje musi następować średnik. Brak tego średnika jest częstym błędem powodującym czasami niezrozumiałe komunikaty błędów. Jedynym wyjątkiem jest natychmiastowa definicja zmiennych danego typu, na przykład:

 struct Struktura {
   int pole;
 } s1, s2, s3;

Definicja typów wyliczeniowych, unii i struktur jest lokalna do bloku. To znaczy, możemy zdefiniować strukturę wewnątrz jednej z funkcji (czy wręcz wewnątrz jakiegoś bloku funkcji) i tylko tam będzie można używać tego typu.

Częstym idiomem w C jest użycie typedef od razu z definicją typu, by uniknąć pisania enum, union czy struct przy deklaracji zmiennych danego typu.

 typedef struct struktura {
   int pole;
 } Struktura;
 Struktura s1;
 struct struktura s2;

W tym przypadku zmienne s1 i s2 są tego samego typu. Możemy też zrezygnować z nazywania samej struktury:

 typedef struct {
   int pole;
 } Struktura;
 Struktura s1;

Wskaźnik na unię i strukturę

Podobnie, jak na każdą inną zmienna, wskaźnik może wskazywać także na unię lub strukturę. Oto przykład:

 typedef struct {
   int p1, p2;
 } Struktura;

 int main ()
 {
   Struktura s = { 0, 0 };
   Struktura *wsk = &s;
   wsk->p1 = 2;
   wsk->p2 = 3;
   return 0;
 }

Zapis wsk->p1 jest (z definicji) równoważny (*wsk).p1, ale bardziej przejrzysty i powszechnie stosowany. Wyrażenie wsk.p1 spowoduje błąd kompilacji (strukturą jest *wsk a nie wsk).


Zobacz też

Pola bitowe

Struktury mają pewne dodatkowe możliwości w stosunku do zmiennych. Mowa tutaj o rozmiarze elementu struktury. W przeciwieństwie do zmiennej może on mieć nawet 1 bit!. Aby móc zdefiniować taką zmienną musimy użyć tzw. pola bitowego. Wygląda ono tak:

 struct moja {
   unsigned int a1:4, /* 4 bity */
                a2:8, /* 8 bitów (często 1 bajt) */ 
                a3:1, /* 1 bit */
                a4:3; /* 3 bity */
 };

Wszystkie pola tej struktury mają w sumie rozmiar 16 bitów, jednak możemy odwoływać się do nich w taki sam sposób, jak do innych elementów struktury. W ten sposób efektywniej wykorzystujemy pamięć, jednak istnieją pewne zjawiska, których musimy być świadomi przy stosowaniu pól bitowych. Więcej na ten temat w rozdziale przenośność programów.

Pola bitowe znalazły zastosowanie głównie w implementacjach protokołów sieciowych.

Studium przypadku - implementacja listy wskaźnikowej

Rozważmy teraz coś, co każdy z nas może spotkać w codziennym życiu. Każdy z nas widział kiedyś jakiś przykład listy (czy to zakupów, czy też listę wierzycieli). Język C też oferuje listy, jednak w programowaniu listy będą służyły do czegoś innego. Wyobraźmy sobie sytuację, w której jesteśmy autorami genialnego programu, który znajduje kolejne liczby pierwsze. Oczywiście każdą kolejną liczbę pierwszą może wyświetlać na ekran, jednak z matematyki wiemy, że dana liczba jest liczbą pierwszą, jeśli nie dzieli się przez żadną liczbę pierwszą ją poprzedzającą, mniejszą od pierwiastka z badanej liczby. Uff, mniej więcej chodzi o to, że moglibyśmy wykorzystać znalezione wcześniej liczby do przyspieszenia działania naszego programu. Jednak nasze liczby trzeba jakoś mądrze przechować w pamięci. Tablice mają ograniczenie - musimy z góry znać ich rozmiar. Jeśli zapełnilibyśmy tablicę, to przy znalezieniu każdej kolejnej liczby musielibyśmy:

  1. przydzielać nowy obszar pamięci o rozmiarze poprzedniego rozmiaru + rozmiar zmiennej, przechowującej nowo znalezioną liczbę
  2. kopiować zawartość starego obszaru do nowego
  3. zwalniać stary, nieużywany obszar pamięci
  4. w ostatnim elemencie nowej tablicy zapisać znalezioną liczbę.

Cóż, trochę tutaj roboty jest, a kopiowanie całej zawartości jednego obszaru w drugi jest czasochłonne. W takim przypadku możemy użyć listy. Tworząc listę możemy w prosty sposób przechować nowo znalezione liczby. Przy użyciu listy nasze postępowanie ograniczy się do:

  1. przydzielenia obszaru pamięci, aby przechować wartość obliczeń
  2. dodać do listy nowy element

Prawda, że proste? Dodatkowo, lista zajmuje w pamięci tylko tyle pamięci, ile potrzeba na aktualną liczbę elementów. Pusta tablica zajmuje natomiast tyle samo miejsca co pełna tablica.

Implementacja listy

W języku C aby stworzyć listę musimy użyć struktur. Dlaczego? Ponieważ musimy przechować co najmniej trzy wartości:

  1. wskaźnik na pewną zmienną
  2. wskaźnik na kolejny element listy
  3. rozmiar listy

Przyjmijmy, że szukając liczb pierwszych nie przekroczymy możliwości typu unsigned long:

 typedef struct Lista {
   struct element *next; /* wskaźnik na kolejny element listy */
   void *val; /* przechowywana wartość */
   int size; /* ilość elementów */
 } List;

Zacznijmy zatem pisać nasz eksperymentalny program. Pierwszą liczbą pierwszą jest liczba 2 Pierwszym elementem naszej listy będzie zatem struktura, która będzie przechowywała liczbę 2. Na co będzie wskazywało pole next? Ponieważ na początku działania programu będziemy mieć tylko jeden element listy, pole next powinno wskazywać na NULL. Umówmy się zatem, że pole next ostatniego elementu listy będzie wskazywało NULL - po tym poznamy, że lista się skończyła.

 #include <stdio.h>
 #include <stdlib.h>
 typedef struct lista {
   struct element *next;
   unsigned long val;
 } List;
 List lista;
  
 int main ()
 {
   unsigned long i = 3; /* szukamy liczb pierwszych w zakresie od 3 do 1000 */
   const unsigned long END = 1000;
   int val = 2;
   lista.val = &val;
   lista.next = NULL;
   for (;i<=END;++i) {
     /* tutaj powinien znajdować się kod, który sprawdza podzielność sprawdzanej liczby przez
        poprzednio znalezione liczby pierwsze oraz dodaje liczbę do listy w przypadku stwierdzenia,
        że jest ona liczbą pierwszą. */
     }
   lista.wypisz(stdout);
  return 0;
 }

Na początek zajmiemy się wypisywaniem listy. W tym celu będziemy musieli "odwiedzić" każdy element listy. Elementy listy są połączone polem next, aby przeglądnąć listę użyjemy następującego algorytmu:

  1. Ustaw wskaźnik roboczy na pierwszym elemencie listy
  2. Jeśli wskaźnik ma wartość NULL, przerwij
  3. Wypisz element wskazywany przez wskaźnik
  4. Przesuń wskaźnik na element, który jest wskazywany przez pole next
  5. Wróć do punktu 2

Nasza lista ma być dwukierunkowa, więc użyjemy algorytmu odwrotnego:

  1. Sprawdź, czy lista nie jest pusta
  2. Jeśli tak, to wyjdź z funkcji
  3. W przeciwnym wypadku, sprawdź czy istnieje następny element listy
  4. Jeśli tak, to jest uruchamiana rekurencja tak, by pierwszy argument wskazywał na następny element listy
  5. Wypisz element wskazywany przez pierwszy argument
  6. W funkcji nic już więcej nie umieszczamy, nie pobieramy w niej tak jak poprzednio następnego elementu

Nato

 typedef struct lista
 {
     /* ... */
     void (*wypisz)(struct lista*, FILE*); /* deklaracja metody o nazwie 'wypisz' */
     void (*wypisz_odwrotnie)(struct lista*, FILE*);
 } List;
 void wypisz_liste(List *lista, FILE* strumien)
 {
   while( lista )          /* 1 */
     {
     fprintf(strumien, "%p", *lista->val); /* 2 */
     lista = lista->next;            /* 3 */
     }                           /* 4 */
 }
 void wypisz_liste_odwrotnie(List *lista, FILE *strumien)
 {
    if (lista == NULL) return;
    if (lista->next) wypisz_liste_odwrotnie(lista->next, strumien);
    fprintf(strumien, "%p", *lista->val);
 }

Zastanówmy się teraz, jak powinien wyglądać kod, który dodaje do listy następny element. Taka funkcja powinna:

  1. znaleźć ostatni element (tj. element, którego pole next == NULL)
  2. przydzielić odpowiedni obszar pamięci
  3. skopiować w pole val w nowo przydzielonym obszarze znalezioną liczbę pierwszą
  4. nadać polu next ostatniego elementu listy wartość NULL
  5. w pole next ostatniego elementu listy wpisać adres nowo przydzielonego obszaru

Odwrotnie (na potrzeby listy dwukierunkowej):

  1. znaleźć pierwszy element i zapamiętać go w zmiennej
  2. skopiować w pole val w pierwszym elemencie dane
  3. nadać polu next pierwszego elementu listy stary pierwszy element

Napiszmy zatem odpowiednią funkcję:

 typedef struct lista
 {
     /* ... */
     void (*dodaj)(struct lista*, void*);
     void (*dodaj_na_poczatku)(struct lista*, void*);
 } List;
 void dodaj_do_listy (List* lista, void* liczba)
 {
   List *wsk, *nowy;
   wsk = lista;
   while (wsk->next)          /* 1 */
     wsk = wsk->next; /* przesuwamy wsk aż znajdziemy ostatni element */
   nowy = malloc (sizeof *nowy);  /* 2 */
   nowy->val = liczba;                /* adres naszej zmiennej */
   nowy->next = NULL;                 /* adres następnego elementu */
   wsk->next = nowy;                  /* 5 */
 }
 void dodaj_na_poczatek_listy (List *lista, void *liczba)
 {
     List* first = lista;
     lista->val = liczba;
     lista->next = first;
 }

I... to już właściwie koniec naszej funkcji (warto zwrócić uwagę, że funkcja w tej wersji zakłada, że na liście jest już przynajmniej jeden element). Wstaw ją do kodu przed funkcją main. Został nam jeszcze jeden problem: w pętli for musimy dodać kod, który odpowiednio będzie "badał" liczby oraz w przypadku stwierdzenia pierwszeństwa liczby, będzie dodawał ją do listy. Ten kod powinien wyglądać mniej więcej tak:

 typedef struct lista
 {
    /* ... */
    int (*jest_pierwsza)(struct lista*, int);
 } List;
 int jest_pierwsza(List *lista, int liczba)
 {
   el_listy *wsk;
   wsk = lista;
   while (wsk != NULL) {
     if ((liczba % *((int*)wsk->val))==0) return 0;  /* jeśli reszta z dzielenia liczby
                                               przez którąkolwiek z poprzednio
                                               znalezionych liczb pierwszych
                                               jest równa zero, to znaczy, że liczba
                                               ta nie jest liczbą pierwszą. rzutowanie tego wskaźnika na typ int* jest konieczne */
     wsk = wsk->next;
     }
   /* natomiast jeśli sprawdzimy wszystkie poprzednio znalezione liczby
      i żadna z nich nie będzie dzieliła liczby i,
      możemy liczbę i dodać do listy liczb pierwszych */
   return 1;
 }
 ...
 for (;i<=END;++i)
   if (lista.jest_pierwsza(i))
     lista.dodaj(i);

Zostały konstruktory i destruktory. Prosta sprawa:

List create_list(void)
{
   List my_list;
   my_list.wypisz = wypisz_liste;
   my_list.dodaj = dodaj_do_listy;
   my_list.jest_pierwsza = jest_pierwsza;
   my_list.wypisz_odwrotnie = wypisz_liste_odwrotnie;
   my_list.dodaj_na_poczatku = dodaj_na_poczatek_listy;
   my_list.size = 0;
   return my_list;
}
void free_list(List *lista)
{
   if(lista == NULL) return;
   if(lista->next) free_list(lista->next);
   lista->next = lista->next->next;
   free(lista);
}

Podsumujmy teraz efekty naszej pracy. Oto cały kod naszego programu:

 #include <stdio.h>
 #include <stdlib.h>
 
 typedef struct lista {
   struct lista *next;
   void* val;
   void (*dodaj)(struct lista*, void*);
   void (*wypisz)(struct lista*, FILE*);
   void (*dodaj_na_poczatku)(struct lista*, void*);
   void (*wypisz_odwrotnie)(struct lista*, FILE*);
   int (*jest_pierwsza)(struct lista*, int);
 } List;
 
 List first;
 List create_list(void)
 {
   List my_list;
   my_list.wypisz = wypisz_liste;
   my_list.dodaj = dodaj_do_listy;
   my_list.jest_pierwsza = jest_pierwsza;
   my_list.wypisz_odwrotnie = wypisz_liste_odwrotnie;
   my_list.dodaj_na_poczatku = dodaj_na_poczatek_listy;
   return my_list;
 }
 
 void dodaj_do_listy (List *lista, void *liczba)
 {
   el_listy *wsk, *nowy;
   wsk = lista;
   while (wsk->next != NULL)
     { 
     wsk = wsk->next; /* przesuwamy wsk aż znajdziemy ostatni element */
     }
   nowy = malloc (sizeof *nowy);
   nowy->val = liczba;
   nowy->next = NULL;
   wsk->next = nowy; /* podczepiamy nowy element do ostatniego z listy */
 }
 void dodaj_na_poczatek_listy (List *lista, void *liczba)
 {
     List* first = lista;
     lista->val = liczba;
     lista->next = first;
 }
 void free_list(List *lista)
 {
     if(lista == NULL) return;
     if(lista->next) free_list(lista->next);
     lista->next = lista->next->next;
     free(lista);
 }
 void wypisz_liste(List *lista, FILE *strum)
 {
   List *wsk=lista;
   while( wsk != NULL )
     {
     fprintf (strum, "%p\n", *wsk->val);
     wsk = wsk->next;
     }
 }
 
 void wypisz_liste_odwrotnie(List *lista, FILE* strum)
 {
    if(lista == NULL) return;
    if(lista->next) wypisz_liste_odwrotnie(lista->next, strum);
    fprintf(strum, "%p\n", *wsk->val);
 }
 
 int jest_pierwsza(List *lista, int liczba)
 {
   List *wsk;
   wsk = lista;
   while (wsk != NULL) {
     if ((liczba%*((int*)wsk->val))==0) return 0;
        wsk = wsk->next;
     }
     return 1;
 }
 
 int main ()
 {
   unsigned long i = 3; /* szukamy liczb pierwszych w zakresie od 3 do 1000 */
   short i = 2;
   const unsigned long END = 1000;
   first = create_list();
   first->val = &i;
   first->next = NULL;
   for (;i!=END;++i) {
     if (first.jest_pierwsza(i))
       first.dodaj(i);
       }
   first.wypisz(stdout);
   free_list(first);
   return 0;
 }

Możemy jeszcze pomyśleć, jak można by wykonać usuwanie elementu z listy. Najprościej byłoby zrobić:

wsk->next = wsk->next->next

ale wtedy element, na który wskazywał wcześniej wsk->next przestaje być dostępny i zaśmieca pamięć. Trzeba go usunąć. Zauważmy, że aby usunąć element potrzebujemy wskaźnika do elementu go poprzedzającego (po to, by nie rozerwać listy). Lista jest dwukierunkowa, więc elementy znają swoje poprzedniki. Popatrzmy na poniższą funkcję:

 typedef struct lista
 {
    /* ... */
    void (*usun)(struct lista*, void*);
 } List;
    
 void usun_z_listy(List *lista, void* element)
 {
   if(lista == NULL) return;
   if(lista->next) usun_z_listy(lista->next, element);
   if(lista->val == element) {
      lista->next = lista->next->next;
      free(lista);
   }
 }

Funkcja ta jest tak napisana, by usuwała z listy wszystkie wystąpienia danego elementu (w naszym programie nie ma to miejsca, ale lista jest zrobiona tak, że może trzymać dowolne liczby). Zauważmy, że wskaźnik wsk jest przesuwany tylko wtedy, gdy nie kasowaliśmy. Gdybyśmy zawsze go przesuwali, przegapilibyśmy element gdyby występował kilka razy pod rząd.

Funkcja ta działa poprawnie tylko wtedy, gdy nie chcemy usuwać pierwszego elementu. Można to poprawić - dodając instrukcję warunkową do funkcji lub dodając do listy "głowę" - pierwszy element nie przechowujący niczego, ale upraszczający operacje na liście. Zostawiamy to do samodzielnej pracy.