








































Studiuj dzięki licznym zasobom udostępnionym na Docsity
Zdobywaj punkty, pomagając innym studentom lub wykup je w ramach planu Premium
Przygotuj się do egzaminów
Studiuj dzięki licznym zasobom udostępnionym na Docsity
Otrzymaj punkty, aby pobrać
Zdobywaj punkty, pomagając innym studentom lub wykup je w ramach planu Premium
Społeczność
Odkryj najlepsze uniwersytety w twoim kraju, według użytkowników Docsity
Bezpłatne poradniki
Pobierz bezpłatnie nasze przewodniki na temat technik studiowania, metod panowania nad stresem, wskazówki do przygotowania do prac magisterskich opracowane przez wykładowców Docsity
od zera; tutaj są to kolejno 0, 1, 2, 3 i 4. Na postawie tego przykładu możemy więc sformułować bardziej ogólną zasadę: Tablica mieszcząca n elementów jest ...
Typologia: Publikacje
1 / 48
Ta strona nie jest widoczna w podglądzie
Nie przegap ważnych części!
Mylić się jest rzeczą ludzką, ale żeby naprawdę coś spaprać potrzeba komputera. Edward Morgan Forster
Dzisiaj prawie żaden normalny program nie przechowuje swoich danych jedynie w prostych zmiennych - takich, jakimi zajmowaliśmy się do tej pory (tzw. skalarnych ). Istnieje mnóstwo różnych sytuacji, w których są one po prostu niewystarczające, a konieczne stają się bardziej skomplikowane konstrukcje. Wspomnijmy choćby o mapach w grach strategicznych, tabelach w arkuszach kalkulacyjnych czy bazach danych adresowych - wszystkie te informacje mają zbyt złożoną naturę, aby dały się przedstawić przy pomocy pojedynczych zmiennych.
Szanujący się język programowania powinien więc udostępniać odpowiednie konstrukcje, służące do przechowywania takich nieelementarnych typów danych. Naturalnie, C++ posiada takowe mechanizmy - zapoznamy się z nimi w niniejszym rozdziale.
Jeżeli nasz zestaw danych składa się z wielu drobnych elementów tego samego rodzaju , jego najbardziej naturalnym ekwiwalentem w programowaniu będzie tablica.
Tablica (ang. array ) to zespół równorzędnych zmiennych, posiadających wspólną nazwę. Jego poszczególne elementy są rozróżnianie poprzez przypisane im liczby - tak zwane indeksy.
Każdy element tablicy jest więc zmienną należącą do tego samego typu. Nie ma tutaj żadnych ograniczeń: może to być liczba (w matematyce takie tablice nazywamy wektorami), łańcuch znaków (np. lista uczniów lub pracowników), pojedynczy znak, wartość logiczna czy jakikolwiek inny typ danych.
W szczególności, elementem tablicy może być także… inna tablica! Takimi podwójnie złożonymi przypadkami zajmiemy się nieco dalej.
Po tej garści ogólnej wiedzy wstępnej, czas na coś przyjemniejszego - czyli przykłady :)
Zadeklarowanie tablicy przypomina analogiczną operację dla zwykłych (skalarnych) zmiennych. Może zatem wyglądać na przykład tak:
int aKilkaLiczb[5];
Jak zwykle, najpierw piszemy nazwę wybranego typu danych, a później oznaczenie samej zmiennej (w tym przypadku tablicy - to także jest zmienna). Nowością jest tu para nawiasów kwadratowych, umieszczona na końcu deklaracji. Wewnątrz niej wpisujemy rozmiar tablicy, czyli ilość elementów, jaką ma ona zawierać. U nas jest to 5 , a zatem z tylu właśnie liczb (każdej typu int) będzie składała się nasza świeżo zadeklarowana tablica.
Skoro żeśmy już wprowadzili nową zmienną, należałoby coś z nią uczynić - w końcu niewykorzystana zmienna to zmarnowana zmienna :) Nadajmy więc jakieś wartości jej kolejnym elementom:
aKilkaLiczb[0] = 1; aKilkaLiczb[1] = 2; aKilkaLiczb[2] = 3; aKilkaLiczb[3] = 4; aKlikaLiczb[4] = 5;
Tym razem także korzystamy z nawiasów kwadratowych. Teraz jednak używamy ich, aby uzyskać dostęp do konkretnego elementu tablicy, identyfikowanego przez odpowiedni indeks. Niewątpliwie bardzo przypomina to docieranie do określonego znaku w zmiennej tekstowej (typu std::string), aczkolwiek w przypadku tablic możemy mieć do czynienia z dowolnym rodzajem danych. Analogia do łańcuchów znaków przejawia się w jeszcze jednym fakcie - są nim oczywiście indeksy kolejnych elementów tablicy. Identycznie jak przy napisach, liczymy je bowiem od zera ; tutaj są to kolejno 0 , 1 , 2 , 3 i 4. Na postawie tego przykładu możemy więc sformułować bardziej ogólną zasadę:
Tablica mieszcząca n elementów jest indeksowana wartościami 0 , 1 , 2 , … , n - 2 , n - 1.
Z regułą tą wiąże się też bardzo ważne ostrzeżenie:
W tablicy n -elementowej nie istnieje element o indeksie równym n. Próba dostępu do niego jest bardzo częstym błędem, zwanym przekroczeniem indeksów (ang. subscript out of bounds ).
Poniższa linijka kodu spowodowałaby zatem błąd podczas działania programu i jego awaryjne zakończenie:
aKilkaLiczb[5] = 6; // BŁĄD!!!
Pamiętaj więc, byś zwracał baczną uwagę na indeksy tablic, którymi operujesz.
Przekroczenie indeksów to jeden z przedstawicieli licznej rodziny błędów, noszących wspólne miano „pomyłek o jedynkę”. Większość z nich dotyczy właśnie tablic, inne można popełnić choćby przy pracy z liczbami pseudolosowymi: najwredniejszym jest chyba warunek w rodzaju rand() % 10 == 10, który nigdy nie może być spełniony (pomyśl, dlaczego^53 !).
Krytyczne spojrzenie na zaprezentowany kilka akapitów wyżej kawałek kodu może prowadzić do wniosku, że idea tablic nie ma większego sensu. Przecież równie dobrze możnaby zadeklarować 5 zmiennych i zająć się każdą z nich osobno - podobnie jak czynimy to teraz z elementami tablicy:
(^53) Reszta z dzielenia przez 10 może być z nazwy równa jedynie liczbom 0 , 1 , ..., 8 , 9 , zatem nigdy nie zrówna
się z samą dziesiątką. Programista chciał tu zapewne uzyskać wartość z przedziału <1; 10>, ale nie dodał jedynki do wyrażenia - czyli pomylił się o nią :)
Przykład wykorzystania tablicy
Wiemy już, jak teoretycznie wygląda praca z tablicami w języku C++, zatem naturalną koleją rzeczy będzie teraz uważne przyglądnięcie się odpowiedniemu przykładowi. Ten (spory :)) kawałek kodu wygląda następująco:
// Lotto - użycie prostej tablicy liczb
const unsigned ILOSC_LICZB = 6; const int MAKSYMALNA_LICZBA = 49;
void main() { // deklaracja i wyzerowanie tablicy liczb unsigned aLiczby[ILOSC_LICZB]; for (int i = 0; i < ILOSC_LICZB; ++i) aLiczby[i] = 0;
// losowanie liczb srand (static_cast
// sprawdzenie, czy się ona nie powtarza bool bPowtarzaSie = false; for (int j = 0; j < i; ++j) { if (aLiczby[j] == aLiczby[i]) { bPowtarzaSie = true; break; } }
// jeżeli się nie powtarza, przechodzimy do następnej liczby if (!bPowtarzaSie) ++i; }
// wyświetlamy wylosowane liczby std::cout << "Wyniki losowania:" << std::endl; for (int i = 0; i < ILOSC_LICZB; ++i) std::cout << aLiczby[i] << " ";
// czekamy na dowolny klawisz getch(); }
Huh, trzeba przyznać, iż z pewnością nie należy on do elementarnych :) Nie jesteś już jednak zupełnym nowicjuszem w sztuce programowania, więc zrozumienie go nie przysporzy ci wielkich kłopotów. Na początek spróbuj zobaczyć tę przykładową aplikację w działaniu:
Screen 30. Wysyłanie kuponów jest od dzisiaj zbędne ;-)
Nie potrzeba przenikliwości Sherlocka Holmesa, by wydedukować, że program ten dokonuje losowania zestawu liczb według zasad znanej powszechnie gry loteryjnej. Te reguły są determinowane przez dwie stałe, zadeklarowane na samym początku kodu:
const unsigned ILOSC_LICZB = 6; const int MAKSYMALNA_LICZBA = 49;
Ich nazwy są na tyle znaczące, iż dokumentują się same. Wprowadzenie takich stałych ma też inne wyraźne zalety, o których wielokrotnie już wspominaliśmy. Ewentualna zmiana zasad losowania będzie ograniczała się jedynie do modyfikacji tychże dwóch linijek, mimo że te kluczowe wartości są wielokrotnie używane w całym programie.
Najważniejszą zmienną w naszym kodzie jest oczywiście tablica, która przechowuje wylosowane liczby. Deklarujemy i inicjalizujemy ją zaraz na wstępie funkcji main():
unsigned aLiczby[ILOSC_LICZB]; for (int i = 0; i < ILOSC_LICZB; ++i) aLiczby[i] = 0;
Posługując się tutaj pętlą for, ustawiamy wszystkie jej elementy na wartość 0. Zero jest dla nas neutralne, gdyż losowane liczby będą przecież wyłącznie dodatnie.
Identyczny efekt (wyzerowanie tablicy) można uzyskać stosując funkcję memset(), której deklaracja jest zawarta w nagłówku memory.h. Użylibyśmy jej w następujący sposób: memset (aLiczby, 0, sizeof(aLiczby)); Analogiczny skutek spowodowałaby także specjalna funkcja ZeroMemory() z windows.h : ZeroMemory (aLiczby, sizeof(aLiczby)); Nie użyłem tych funkcji w kodzie przykładu, gdyż wyjaśnienie ich działania wymaga wiedzy o wskaźnikach na zmienne, której jeszcze nie posiadasz. Chwilowo jesteśmy więc zdani na swojską pętlę :)
Po wyzerowaniu tablicy przeznaczonej na generowane liczby możemy przystąpić do właściwej czynności programu, czyli ich losowania. Rozpoczynamy je od niezbędnego wywołania funkcji srand():
srand (static_cast
Po dopełnieniu tej drobnej formalności możemy już zająć się po kolei każdą wartością, którą chcemy uzyskać. Znowuż czynimy to poprzez odpowiednią pętlę for:
for (int i = 0; i < ILOSC_LICZB; ) { // ... }
Jak zwykle, przebiega ona po wszystkich elementach tablicy aLiczby. Pewną niespodzianką może być tu nieobecność ostatniej części tej instrukcji, którą jest zazwyczaj inkrementacja licznika. Jej brak spowodowany jest koniecznością sprawdzania, czy wylosowana już liczba nie powtarza się wśród wcześniej wygenerowanych. Z tego też powodu program będzie niekiedy zmuszony do kilkakrotnego „obrotu” pętli przy tej samej wartości licznika i losowania za każdym razem nowej liczby, aż do skutku.
Rzeczone losowane przebiega tradycyjną i znaną nam dobrze drogą:
aLiczby[i] = rand() % MAKSYMALNA_LICZBA + 1;
niepowtarzalności. W innym przypadku licznik zachowuje swą aktualną wartość, więc wówczas będzie przeprowadzona kolejna próba wygenerowania unikalnej liczby. Stanie się to w następnym cyklu pętli. Inaczej mówiąc, jedynie fałszywość zmiennej bPowtarzaSie uprawnia pętlę for do zajęcia się dalszymi elementami tablicy. Inna sytuacja zmuszą ją bowiem do wykonania kolejnego cyklu na tej samej wartości licznika i, a więc także na tym samym elemencie tablicy wynikowej. Czyni to aż do otrzymania pożądanego rezultatu, czyli liczby różnej od wszystkich poprzednich.
Być może nasunęła ci się wątpliwość, czy takie kontrolowanie wylosowanej liczby jest aby na pewno konieczne. Skoro prawidłowo zainicjowaliśmy generator wartości losowych (przy pomocy srand()), to przecież nie powinien on robić nam świństw, którymi z pewnością byłyby powtórzenia wylosowywanych liczb. Jeżeli nawet istnieje jakaś szansa na otrzymanie duplikatu, to jest ona zapewne znikomo mała… Otóż nic bardziej błędnego! Sama potencjalna możliwość wyniknięcia takiej sytuacji jest wystarczającym powodem, żeby dodać do programu zabezpieczający przed nią kod. Przecież nie chcielibyśmy, aby przyszły użytkownik (niekoniecznie tego programu, ale naszych aplikacji w ogóle) otrzymał produkt, który raz działa dobrze, a raz nie! Inna sprawa, że prawdopodobieństwo wylosowania powtarzających się liczb nie jest tu wcale takie małe. Możesz spróbować się o tym przekonać^54 …
Na finiszu całego programu mamy jeszcze wyświetlanie uzyskanego pieczołowicie wyniku. Robimy to naturalnie przy pomocy adekwatnego for’a, który tym razem jest o wiele mniej skomplikowany w porównaniu z poprzednim :) Ostatnia instrukcja, getch();, nie wymaga już nawet żadnego komentarza. Na niej też kończy się wykonywanie naszej aplikacji, a my możemy również zakończyć tutaj jej omawianie. I odetchnąć z ulgą ;)
Uff! To wcale nie było takie łatwe, prawda? Wszystko dlatego, że postawiony problem także nie należał do trywialnych. Analiza algorytmu, służącego do jego rozwiązania, powinna jednak bardziej przybliżyć ci sposób konstruowania kodu, realizującego konkretne zadanie.
Mamy oto przejrzysty i, mam nadzieję, zrozumiały przykład na wykorzystanie tablic w programowaniu. Przyglądając mu się dokładnie, mogłeś dobrze poznać zastosowanie tandemu tablica + pętla for do wykonywania dosyć skomplikowanych czynności na złożonych danych. Jeszcze nie raz użyjemy tego mechanizmu, więc z pewnością będziesz miał szansę na jego doskonałe opanowanie :)
Dotychczasowym przedmiotem naszego zainteresowania były tablice jednowymiarowe , czyli takie, których poszczególne elementy są identyfikowane poprzez jeden indeks. Takie struktury nie zawsze są wystarczające. Pomyślmy na przykład o szachownicy, planszy do gry w statki czy mapach w grach strategicznych. Wszystkie te twory wymagają większej liczby wymiarów i nie dają się przedstawić w postaci zwykłej, ponumerowanej listy.
(^54) Wyliczenie jest bardzo proste. Załóżmy, że losujemy n liczb, z których największa może być równa a. Wtedy pierwsze losowanie nie może rzecz jasna skutkować duplikatem. W drugim jest na to szansa równa 1/ a (gdyż mamy już jedną liczbę), w trzecim - 2/ a (bo mamy już dwie liczby), itd. Dla n liczb całościowe prawdopodobieństwo wynosi zatem (1 + 2 + 3 + ... + n -1)/ a , czyli n ( n - 1)/2 a. U nas n = 6 , zaś a = 49 , więc mamy 6(6 - 1)/(2*49) ≈ 30,6% szansy na otrzymanie zestawu liczb, w którym przynajmniej jedna się powtarza. Gdybyśmy nie umieścili kodu sprawdzającego, wtedy przeciętnie co czwarte uruchomienie programu dawałoby nieprawidłowe wyniki. Byłaby to ewidentna niedoróbka.
Naturalnie, tablice wielowymiarowe mogłyby być z powodzeniem symulowane poprzez ich jednowymiarowe odpowiedniki oraz formuły służące do przeliczania indeksów. Trudno jednak uznać to za wygodne rozwiązanie. Dlatego też C++ radzi sobie z tablicami wielowymiarowymi w znacznie prostszy i bardziej przyjazny sposób. Warto więc przyjrzeć się temu wielkiemu dobrodziejstwu ;)
Deklaracja i inicjalizacja
Domyślasz się może, iż aby zadeklarować tablicę wielowymiarową, należy podać więcej niż jedną liczbę określającą jej rozmiar. Rzeczywiście tak jest:
int aTablica[4][5];
Linijka powyższa tworzy nam dwuwymiarową tablicę o wymiarach 4 na 5 , zawierającą elementy typu int. Możemy ją sobie wyobrazić w sposób podobny do tego:
Schemat 8. Wyobrażenie tablicy dwuwymiarowej 4 × 5
Widać więc, że początkowa analogia do szachownicy była całkiem na miejscu :)
Nasza dziewicza tablica wymaga teraz nadania wstępnych wartości swoim elementom. Jak pamiętamy, przy korzystaniu z jej jednowymiarowych kuzynów intensywnie używaliśmy do tego odpowiednich pętli for. Nic nie stoi na przeszkodzie, aby podobnie postąpić i w tym przypadku:
for (int i = 0; i < 4; ++i) for (int j = 0; j < 5; ++j) aTablica[i][j] = i + j;
Teraz jednak mamy dwa wymiary tablicy, zatem musimy zastosować dwie zagnieżdżone pętle. Ta bardziej zewnętrzna przebiega nam po czterech kolejnych wierszach tablicy, natomiast wewnętrzna zajmuje się każdym z pięciu elementów wybranego wcześniej wiersza. Ostatecznie, przy każdym cyklu zagnieżdżonej pętli liczniki i oraz j mają odpowiednie wartości, abyśmy mogli za ich pomocą uzyskać dostęp do każdego z dwudziestu (4 * 5) elementów tablicy.
Znamy wszakże jeszcze inny środek, służący do wstępnego ustawiania zmiennych - chodzi oczywiście o inicjalizację. Zobaczyliśmy niedawno, że możliwe jest zaprzęgnięcie jej do pracy także przy tablicach jednowymiarowych. Czy będziemy mogli z niej skorzystać również teraz, gdy dodaliśmy do nich następne wymiary?… Jak to zwykle w C++ bywa, odpowiedź jest pozytywna :) Inicjalizacja tablicy dwuwymiarowej wygląda bowiem następująco:
int aTablica[4][5] = { { 0, 1, 2, 3, 4 }, { 1, 2, 3, 4, 5 },
Schemat 10. Przedstawienie tablicy dwuwymiarowej jako tablicy tablic
Uogólniając, możemy stwierdzić, iż:
Każda tablica n -wymiarowa składa się z odpowiedniej liczby tablic ( n -1)-wymiarowych.
Przykładowo, dla trzech wymiarów będziemy mieli tablicę, składającą się z tablic dwuwymiarowych, które z kolei zbudowane są z jednowymiarowych, a te dopiero z pojedynczych skalarów. Nietrudne, prawda? ;)
Zadajesz sobie pewnie pytanie: cóż z tego? Czy ma to jakieś praktyczne znaczenie i zastosowanie w programowaniu?… Pospieszam z odpowiedzią, brzmiącą jak zawsze „ależ oczywiście!” :)) Ujęcie tablic w takim stylu pozwala na ciekawą operację wybrania jednego z wymiarów i przypisania go do innej, pasującej tablicy. Wygląda to mniej więcej tak:
// zadeklarowanie tablicy trój- i dwuwymiarowej int aTablica3D[2][2][2] = { { { 1, 2 }, { 2, 3 } }, { { 3, 4 }, { 4, 5 } } }; int aTablica2D[2][2];
// przypisanie drugiej "płaszczyzny" tablicy aTablica3D do aTablica2D aTablica2D = aTablica3D[1];
// aTablica2D zawiera teraz liczby: { { 3, 4 }, { 4, 5 } }
Przykład ten ma w zasadzie charakter ciekawostki, lecz przyjrzenie mu się z pewnością nikomu nie zaszkodzi :D
Nieco praktyczniejsze byłoby odwołanie do części tablicy - tak, żeby możliwa była jej zmiana niezależnie od całości (np. przekazanie do funkcji). Takie działanie wymaga jednak poznania wskaźników, a to stanie się dopiero w rozdziale 8.
Poznaliśmy właśnie tablice jako sposób na tworzenie złożonych struktur, składających się z wielu elementów. Ułatwiają one (lub wręcz umożliwiają) posługiwanie się złożonymi danymi, jakich nie brak we współczesnych aplikacjach. Znajomość zasad wykorzystywania tablic z pewnością zatem zaprocentuje w przyszłości :)
Także w tym przypadku niezawodnym źródłem uzupełniających informacji jest MSDN.
Wachlarz dostępnych w C++ typów wbudowanych jest, jak wiemy, niezwykle bogaty. W połączeniu z możliwością fuzji wielu pojedynczych zmiennych do postaci wygodnych w użyciu tablic, daje nam to szerokie pole do popisu przy konstruowaniu własnych sposobów na przechowywanie danych.
Nabyte już doświadczenie oraz tytuł niniejszego podrozdziału sugeruje jednak, iż nie jest to wcale kres potencjału używanego przez nas języka. Przeciwnie: C++ oferuje nam możliwość tworzenia swoich własnych typów zmiennych, odpowiadających bardziej konkretnym potrzebom niż zwykłe liczby czy napisy. Nie chodzi tu wcale o znaną i prostą instrukcję typedef, która umie jedynie produkować nowe nazwy dla już istniejących typów. Mam bowiem na myśli znacznie potężniejsze narzędzia, udostępniające dużo większe możliwości w tym zakresie.
Czy znaczy to również, że są one trudne do opanowania? Według mnie siedzący tutaj diabeł wcale nie jest taki straszny, jakim go malują ;D Absolutnie więc nie ma się czego bać!
Pierwszym z owych narzędzi, z którymi się zapoznamy, będą typy wyliczeniowe (ang. enumerated types ). Ujrzymy ich możliwe zastosowania oraz techniki użytkowania, a rozpoczniemy od przykładu z życia wziętego :)
Przydatność praktyczna
W praktyce często zdarza się sytuacja, kiedy chcemy ograniczyć możliwy zbiór wartości zmiennej do kilku(nastu/dziesięciu) ściśle ustalonych elementów. Jeżeli, przykładowo, tworzylibyśmy grę, w której pozwalamy graczowi jedynie na ruch w czterech kierunkach (góra, dół, lewo, prawo), z pewnością musielibyśmy przechowywać w jakiś sposób jego wybór. Służąca do tego zmienna przyjmowałaby więc jedną z czterech określonych wartości. Jak możnaby osiągnąć taki efekt? Jednym z rozwiązań jest zastosowanie stałych, na przykład w taki sposób:
const int KIERUNEK_GORA = 1; const int KIERUNEK_DOL = 2; const int KIERUNEK_LEWO = 3; const int KIERUNEK_PRAWO = 4;
int nKierunek;
Kod ten będzie poprawny oczywiście tylko wtedy, gdy funkcja PobierzWybranyPrzezGraczaKierunek() będzie zwracała wartość będącą także typu DIRECTION.
Wszelkie wątpliwości powinna rozwiać instrukcja switch. Widać wyraźnie, że użyto jej w identyczny sposób jak wtedy, gdy korzystano jeszcze ze zwykłych stałych, deklarowanych oddzielnie.
Na czym więc polega różnica? Otóż tym razem niemożliwe jest przypisanie w rodzaju:
Kierunek = 20;
Kompilator nie pozwoli na nie, gdyż zmienna Kierunek podlega ograniczeniom swego typu DIRECTION. Określając go, ustaliliśmy, że może on reprezentować wyłącznie jedną z czterech podanych wartości, a 20 niewątpliwie nie jest którąś z nich :) Tak więc teraz bezmyślny program kompilujący jest po naszej stronie i pomaga nam jak najwcześniej wyłapywać błędy związane z nieprawidłowymi wartościami niektórych zmiennych.
Definiowanie typu wyliczeniowego
Nie od rzeczy będzie teraz przyjrzenie się kawałkowi kodu, który wprowadza nam nowy typ wyliczeniowy. Oto i jego składnia:
enum nazwa_typu { stała_1 [ = _wartość1 ] , stała_2 [ = _wartość2 ] , stała_3 [ = _wartość3 ] , ... stała_n [ = _wartośćn ] };
Słowo kluczowe enum (ang. enumerate - wyliczać) pełni rolę informującą: mówi, zarówno nam, jak i kompilatorowi, iż mamy tu do czynienia z definicją typu wyliczeniowego. Nazwę, którą chcemy nadać owemu typowi, piszemy zaraz za tym słowem; przyjęło się, aby używać do tego wielkich liter alfabetu.
Potem następuje częsty element w kodzie C++, czyli nawiasy klamrowe. Wewnątrz nich umieszczamy tym razem listę stałych - dozwolonych wartości typu wyliczeniowego. Jedynie one będą dopuszczone przez kompilator do przechowywania przez zmienne należące do definiowanego typu. Tutaj również zaleca się, tak jak w przypadku zwykłych stałych (tworzonych poprzez const), używanie wielkich liter. Dodatkowo, dobrze jest dodać do każdej nazwy odpowiedni przedrostek, powstały z nazwy typu, na przykład:
// przykładowy typ określający poziom trudności jakiejś gry enum DIFFICULTY { DIF_EASY, DIF_MEDIUM, DIF_HARD };
Widać to było także w przykładowym typie DIRECTION.
Nie zapominajmy o średniku na końcu definicji typu wyliczeniowego!
Warto wiedzieć, że stałe, które wprowadzamy w definicji typu wyliczeniowego, reprezentują liczby całkowite i tak też są przez kompilator traktowane. Każdej z nich nadaje on kolejną wartość, poczynając zazwyczaj od zera. Najczęściej nie przejmujemy się, jakie wartości odpowiadają poszczególnym stałym. Czasem jednak należy mieć to na uwadze - na przykład wtedy, gdy planujemy współpracę naszego typu z jakimiś zewnętrznymi bibliotekami. W takiej sytuacji możemy
wyraźnie określić, jakie liczby są reprezentowane przez nasze stałe. Robimy to, wpisując wartość po znaku = i nazwie stałej. Przykładowo, w zaprezentowanym na początku typie DIRECTION moglibyśmy przypisać każdemu wariantowi kod liczbowy odpowiedniego klawisza strzałki:
enum DIRECTION { DIR_UP = 38, DIR_DOWN = 40, DIR_LEFT = 37, DIR_RIGHT = 39 };
Nie trzeba jednak wyraźnie określać wartości dla wszystkich stałych; możliwe jest ich sprecyzowanie tylko dla kilku. Dla pozostałych kompilator dobierze wtedy kolejne liczby, poczynając od tych narzuconych, tzn. zrobi coś takiego:
enum MYENUM { ME_ONE, // 0 ME_TWO = 12, // 12 ME_THREE, // 13 ME_FOUR, // 14 ME_FIVE = 26, // 26 ME_SIX, // 27 ME_SEVEN }; // 28
Zazwyczaj nie trzeba o tym pamiętać, bo lepiej jest albo całkowicie zostawić przydzielanie wartości w gestii kompilatora, albo samemu dobrać je dla wszystkich stałych i nie utrudniać sobie życia ;)
Użycie typu wyliczeniowego
Typy wyliczeniowe zalicza się do typów liczbowych, podobnie jak int czy unsigned. Mimo to nie jest możliwe bezpośrednie przypisanie do zmiennej takiego typu liczby zapisanej wprost. Kompilator nie przepuści więc instrukcji podobnej do tej:
enum DECISION { YES = 1, NO = 0, DONT_KNOW = -1 }; DECISION Decyzja = 0;
Zrobi tak nawet pomimo faktu, iż 0 odpowiada tutaj jednej ze stałych typu DECISION. C++ dba bowiem, aby typów enum używać zgodnie z ich przeznaczeniem, a nie jako zamienników dla zmiennych liczbowych. Powoduje to, że:
Do zmiennych wyliczeniowych możemy przypisywać wyłącznie odpowiadające im stałe. Niemożliwe jest nadanie im „zwykłych” wartości liczbowych.
Jeżeli jednak koniecznie potrzebujemy podobnego przypisania (bo np. odczytaliśmy liczbę z pliku lub uzyskaliśmy ją za pomocą jakiejś zewnętrznej funkcji), możemy salwować się rzutowaniem przy pomocy static_cast:
// zakładamy, że OdczytajWartosc() zwraca liczbę typu int lub podobną Decyzja = static_cast
Pamiętajmy aczkolwiek, żeby w zwykłych sytuacjach używać zdefiniowanych stałych. Inaczej całkowicie wypaczalibyśmy ideę typów wyliczeniowych.
Zastosowania
Ewentualni fani programów przykładowych mogą czuć się zawiedzeni, gdyż nie zaprezentuję żadnego krótkiego, kilkunastolinijkowego, dobitnego kodu obrazującego wykorzystanie typów wyliczeniowych w praktyce. Powód jest dość prosty: taki przykład miałby złożoność i celowość porównywalną do banalnych aplikacji dodających dwie liczby,
Tablice, opisane na początku tego rozdziału, nie są jedynym sposobem na modelowanie złożonych danych. Chociaż przydają się wtedy, gdy informacje mają jednorodną postać zestawu identycznych elementów, istnieje wiele sytuacji, w których potrzebne są inne rozwiązania…
Weźmy chociażby banalny, zdawałoby się, przykład książki adresowej. Na pierwszy rzut oka jest ona idealnym materiałem na prostą tablicę, której elementami byłyby jej kolejne pozycje - adresy. Zauważmy jednak, że sama taka pojedyncza pozycja nie daje się sensownie przedstawić w postaci jednej zmiennej. Dane dotyczące jakiejś osoby obejmują przecież jej imię, nazwisko, ewentualnie pseudonim, adres e-mail, miejsce zamieszkania, telefon… Jest to przynajmniej kilka elementarnych informacji, z których każda wymagałaby oddzielnej zmiennej.
Podobnych przypadków jest w programowaniu mnóstwo i dlatego też dzisiejsze języki posiadają odpowiednie mechanizmy, pozwalające na wygodne przetwarzanie informacji o budowie hierarchicznej. Domyślasz się zapewne, że teraz właśnie rzucimy okiem na ofertę C++ w tym zakresie :)
Typy strukturalne i ich definiowanie
Wróćmy więc do naszego problemu książki adresowej, albo raczej listy kontaktów - najlepiej internetowych. Każda jej pozycja mogłaby się składać z takich oto trzech elementów: ¾ nicka tudzież imienia i nazwiska danej osoby ¾ jej adresu e-mail ¾ numeru identyfikacyjnego w jakimś komunikatorze internetowym
Na przechowywanie tychże informacji potrzebujemy zatem dwóch łańcuchów znaków (po jednym na nick i adres) oraz jednej liczby całkowitej. Znamy oczywiście odpowiadające tym rodzajom danych typy zmiennych w C++: są to rzecz jasna std::string oraz int. Możemy więc użyć ich do utworzenia nowego, złożonego typu, reprezentującego w całości pojedynczy kontakt:
struct CONTACT { std::string strNick; std::string strEmail; int nNumerIM; };
W ten właśnie sposób zdefiniowaliśmy typ strukturalny.
Typy strukturalne (zwane też w skrócie strukturami^56 ) to zestawy kilku zmiennych, należących do innych typów, z których każda posiada swoją własną i unikalną nazwę. Owe „podzmienne” nazywamy polami struktury.
Nasz nowonarodzony typ strukturalny składa się zatem z trzech pól, zaś każde z nich przechowuje jedynie elementarną informację. Zestawione razem reprezentują jednak złożoną daną o jakiejś osobie.
(^56) Zazwyczaj strukturami nazywamy już konkretne zmienne; u nas byłyby to więc rzeczywiste dane kontaktowe jakiejś osoby (czyli zmienne należące do zdefiniowanego właśnie typu CONTACT). Czasem jednak pojęć „typ strukturalny” i „struktura” używa się zamiennie, a ich szczegółowe znaczenie zależy od kontekstu.
Struktury w akcji
Nie zapominajmy, że zdefiniowane przed chwilą „coś” o nazwie CONTACT jest nowym typem, a więc możemy skorzystać z niego tak samo, jak z innych typów w języku C++ (wbudowanych lub poznanych niedawno enum’ów). Zadeklarujmy więc przy jego użyciu jakąś przykładową zmienną:
CONTACT Kontakt;
Logiczne byłoby teraz nadanie jej pewnej wartości… Pamiętamy jednak, że powyższy Kontakt to tak naprawdę trzy zmienne w jednym (coś jak szampon przeciwłupieżowy ;D). Niemożliwe jest zatem przypisanie mu zwykłej, „pojedynczej” wartości, właściwej typom skalarnym. Możemy za to zająć się osobno każdym z jego pól. Są one znanymi nam bardzo dobrze tworami programistycznymi (napisem i liczbą), więc nie będziemy mieli z nimi najmniejszych kłopotów. Cóż zatem zrobić, aby się do nich dobrać?…
Skorzystamy ze specjalnego operatora wyłuskania , będącego zwykłą kropką (.). Pozwala on między innymi na uzyskanie dostępu do określonego pola w strukturze. Użycie go jest bardzo proste i dobrze widoczne na poniższym przykładzie:
// wypełnienie struktury danymi Kontakt.strNick = "Hakier"; Kontakt.strEmail = "[email protected]"; Kontakt.nNumerIM = 192837465;
Postawienie kropki po nazwie struktury umożliwia nam niejako „wejście w jej głąb”. W dobrych środowiskach programistycznych wyświetlana jest nawet lista wszystkich jej pól, jakby na potwierdzenie tego faktu oraz ułatwienie pisania dalszego kodu. Po kropce wprowadzamy więc nazwę pola, do którego chcemy się odwołać. Wykonawszy ten prosty zabieg możemy zrobić ze wskazanym polem wszystko, co się nam żywnie podoba. W przykładzie powyżej czynimy doń zwykłe przypisanie wartości, lecz równie dobrze mogłoby to być jej odczytanie, użycie w wyrażeniu, przekazanie do funkcji, itp. Nie ma bowiem żadnej praktycznej różnicy w korzystaniu z pola struktury i ze zwykłej zmiennej tego samego typu - oczywiście poza faktem, iż to pierwsze jest tylko częścią większej całości. Sądzę, że wszystko to powinno być dla ciebie w miarę jasne :)
Co uważniejsi czytelnicy (czyli pewnie zdecydowana większość ;D) być może zauważyli, iż nie jest to nasze pierwsze spotkanie z kropką w C++. Gdy zajmowaliśmy się dokładniej łańcuchami znaków, używaliśmy formułki napis .length() do pobrania długości tekstu. Czy znaczy to, że typ std::string również należy do strukturalnych?… Cóż, sprawa jest generalnie dosyć złożona, jednak częściowo wyjaśni się już w następnym rozdziale. Na razie wiedz, że cel użycia operatora wyłuskania był tam podobny do aktualnie omawianego (czyli „wejścia w środek” zmiennej), chociaż wtedy nie chodziło nam wcale o odczytanie wartości jakiegoś pola. Sugerują to zresztą nawiasy wieńczące wyrażenie… Pozwól jednak, abym chwilowo z braku czasu i miejsca nie zajmował się bliżej tym zagadnieniem. Jak już nadmieniłem, wrócimy do niego całkiem niedługo, zatem uzbrój się w cierpliwość :)
Spoglądając krytycznym okiem na trzy linijki kodu, które wykonują przypisania wartości do kolejnych pól struktury, możemy nabrać pewnych wątpliwości, czy aby składnia C++ jest rzeczywiście taka oszczędna, jaką się zdaje. Przecież wyraźnie widać, iż musieliśmy tutaj za w każdym wierszu wpisywać nieszczęsną nazwę struktury, czyli Kontakt! Nie dałoby się czegoś z tym zrobić? Kilka języków, w tym np. Delphi i Visual Basic, posiada bloki with , które odciążają nieco
if (aKontakty[i].strNick == strNick) // zwrócenie indeksu pasującej osoby return i;
// ewentualnie, jeśli nic nie znaleziono, zwracamy - return -1; }
Zwróćmy w niej szczególną uwagę na wyrażenie, poprzez które pobieramy pseudonimy kolejnych osób na naszej liście. Jest nim:
aKontakty[i].strNick
W zasadzie nie powinno być ono zaskoczeniem. Jak wiemy doskonale, aKontakty[i]
zwraca nam i-ty element tablicy. U nas jest on strukturą, zatem dostanie się do jej konkretnego pola wymaga też użycia operatora wyłuskania. Czynimy to i uzyskujemy ostatecznie oczekiwany rezultat, który porównujemy z poszukiwanym nickiem. W ten sposób przeglądamy naszą tablicę aż do momentu, gdy faktycznie znajdziemy poszukiwany kontakt. Wtedy też kończymy funkcję i oddajemy indeks znalezionego elementu jako jej wynik. W przypadku niepowodzenia zwracamy natomiast -1, która to liczba nie może być indeksem tablicy w C++. Cała operacja wyszukiwania nie należy więc do szczególnie skomplikowanych :)
Odrobina formalizmu - nie zaszkodzi!
Przyszedł właśnie czas na uporządkowanie i usystematyzowanie posiadanych informacji o strukturach. Największym zainteresowaniem obdarzymy przeto reguły składniowe języka, towarzyszące ich wykorzystaniu. Mimo tak groźnego wstępu nie opuszczaj niniejszego paragrafu, bo taka absencja z pewnością nie wyjdzie ci na dobre :)
Typ strukturalny definiujemy, używając słowa kluczowego struct (ang. structure - struktura). Składnia takiej definicji wygląda następująco:
struct nazwa_typu { typ_pola_1 nazwa_pola_1 ; typ_pola_2 nazwa_pola_2 ; typ_pola_3 nazwa_pola_3 ; ... typ_pola_n nazwa_pola_n ; };
Kolejne wiersze wewnątrz niej łudząco przypominają deklaracje zmiennych i tak też można je traktować. Pola struktury są przecież zawartymi w niej „podzmiennymi”. Całość tej listy pól ujmujemy oczywiście w stosowne do C++ nawiasy klamrowe.
Pamiętajmy, aby za końcowym nawiasem koniecznie umieścić średnik. Pomimo zbliżonego wyglądu definicja typu strukturalnego nie jest przecież funkcją i dlatego nie można zapominać o tym dodatkowym znaku.
Przykład wykorzystania struktury
To prawda, że używanie struktur dotyczy najczęściej dość złożonych zbiorów danych. Tym bardziej wydawałoby się, iż trudno o jakiś nietrywialny przykład zastosowania tegoż mechanizmu językowego w prostym programie. Jest to jednak tylko część prawdy. Struktury występują bowiem bardzo często zarówno w standardowej bibliotece C++, jak i w innych, często używanych kodach - Windows API
czy DirectX. Służą one nierzadko jako sposób na przekazywanie do i z funkcji dużej ilości wymaganych informacji. Zamiast kilkunastu parametrów lepiej przecież użyć jednego, kompleksowego, którym znacznie wygodniej jest operować.
My posłużymy się takim właśnie typem strukturalnym oraz kilkoma funkcjami pomocniczymi, aby zrealizować naszą prostą aplikację. Wszystkie te potrzebne elementy znajdziemy w pliku nagłówkowym c time , gdzie umieszczona jest także definicja typu tm:
struct tm { int tm_sec; // sekundy int tm_min; // minuty int tm_hour; // godziny int tm_mday; // dzień miesiąca int tm_mon; // miesiąc (0..11) int tm_year; // rok (od 1900) int tm_wday; // dzień tygodnia (0..6, gdzie 0 == niedziela) int tm_yday; // dzień roku (0..365, gdzie 0 == 1 stycznia) int tm_isdst; // czy jest aktywny czas letni? };
Patrząc na nazwy jego pól oraz komentarze do nich, nietrudno uznać, iż typ ten ma za zadanie przechowywać datę i czas w formacie przyjaznym dla człowieka. To zaś prowadzi do wniosku, iż nasz program będzie wykonywał czynność związaną w jakiś sposób z upływem czasu. Istotnie tak jest, gdyż jego przeznaczeniem stanie się obliczanie biorytmu.
Biorytm to modny ostatnio zestaw parametrów, które określają aktualne możliwości psychofizyczne każdego człowieka. Według jego zwolenników, nasz potencjał fizyczny, emocjonalny i intelektualny waha się okresowo w cyklach o stałej długości, rozpoczynających się w chwili narodzin.
0
50
100
04-01-0704-01-0804-01-0904-01-1004-01-1104-01-1204-01-1304-01-1404-01-1504-01-1604-01-1704-01-1804-01-1904-01-2004-01-2104-01-2204-01-2304-01-2404-01-2504-01-2604-01-2704-01-2804-01-2904-01-3004-01-3104-02-0104-02-0204-02-0304-02-0404-02-0504-02-
fizyczny emocjonalny intelektualny
Wykres 1. Przykładowy biorytm autora tego tekstu :-)
Możliwe jest przy tym określenie liczbowej wartości każdego z trzech rodzajów biorytmu w danym dniu. Najczęściej przyjmuje się w tym celu przedział „procentowy”, obejmujący liczby od -100 do +100. Same obliczenia nie są szczególnie skomplikowane. Patrząc na wykres biorytmu, widzimy bowiem wyraźnie, iż ma on kształt trzech sinusoid, różniących się jedynie okresami. Wynoszą one tyle, ile długości trwania poszczególnych cykli biorytmu, a przedstawia je poniższa tabelka:
cykl długość fizyczny 23 dni