Wyrażenia i operacje
Operatory
Operatory są to takie jednostki, które tworzą wyrażenia. Są one najczęściej (ale nie zawsze) określane odpowiednimi symbolami. Każdy z nich ma swoje właściwości. Kompleksowo przedstawię je w następnym rozdziale, gdzie omawiane są funkcje. Konkretne operatory będziemy poznawać w miarę nowych przykładów.
Poznajmy na początek - mam nadzieje większości znane - operatory arytmetyczne +, -, *, / (dodawanie, odejmowanie, mnożenie i dzielenie). Tego objaśniać chyba nie muszę, co najwyżej wymaga tego operator /, poza tym istnieje dodatkowo operator % o identycznym działaniu, lecz innym rodzaju wyniku. Oczywiście nie muszę przypominać, co oznacza operator / dla liczb zmiennoprzecinkowych, ale ma on inne znaczenie dla liczb całkowitych (jeśli ktoś pamięta z pascala taki operator `div' to chyba wie, o co chodzi). Jeśli oba argumenty tego operatora są całkowite, wykonywane jest dzielenie całkowite, czyli bez reszty. Z kolei operator % (`mod'?) oblicza resztę z dzielenia. No to mały przykład:
#include <iostream> using namespace std; int main() { cout << "Wprowadź dwie liczby:\n"; int a, b; cin >> a >> b; cout << "Suma: " << a + b; cout << "\nRóżnica: " << a - b; cout << "\nIloczyn: " << a * b; cout << "\nIloraz: " << a / b; cout << "\nReszta: " << a % b << endl; }
Użyłem tutaj oczywiście znaków \n na początku tekstu, ale proszę zwrócić uwagę, że nie ma ich na końcu linijki. Jeśli ktoś posiada przyzwyczajenia do używania wyrażeń Write - Writeln, czy PRINT ze średnikiem lub bez, to radzę się odzwyczaić (przyp.AP: te polecenia służyły w Pascalu i BASICu do wypisywania tekstu). Tylko jeśli istnieje silny wymóg uwidocznienia, co się pisze w której linijce, można używać odpowiedniej kolejności; jednak podział danych na linijki instrukcji nijak się ma do podziału wypisywanego tekstu na linijki i to sobie proszę zapamiętać.
Zanim jednak przejdę do zbyt wyrafinowanych operatorów, przedstawię jeszcze tzw. operatory relacji (czyli porównujące). Proszę zwrócić szczególną uwagę na ich postać, dość odmienną od innych języków (przynajmniej pierwsze dwa):
- ==
- sprawdza równość argumentów (dwa znaki `=' !)
- !=
- sprawdza nierówność argumentów (tu już tylko jeden)
- <
- sprawdza, czy pierwszy argument jest mniejszy, niż drugi
- >
- sprawdza, czy pierwszy argument jest większy, niż drugi
- <=
- czyli < lub == (mniejsze lub równe)
- >=
- czyli > lub == (większe lub równe)
No i poza tym w wyrażeniach warunkowych występują również operatory logiczne (Ð oznacza wyrażenie warunkowe, czyli np. jakieś porównanie w rodzaju (a!=b) lub funkcję, która zwraca wartość typu bool, oczywiście może to być bardzo skomplikowane [przyp.AP]):
- ! Ð
- `not' (odwraca warunek)
- Ð && Ð2
- `and' (zwraca true, jeśli Ð i Ð2 są true)
- Ð1 || Ð2
- `or' (zwraca true, jeśli jeden z warunków jest prawdziwy)
Istnieją także odpowiadające im operatory słowne, czyli not, and i or.
I tutaj pierwsza BARDZO WAŻNA uwaga! W ramach umożliwienia lepszej optymalizacji programów, standard zakłada pewne dodatkowe zasady co do operatorów && i
Podobnie dzieje się przy operatorze &&, gdy Ð1 jest false. Proszę więc pamiętać, że and i or są w tym języku wyjątkowymi operatorami, które mają zdefiniowaną kolejność wartościowania wyrażeń i nie zawsze drugie wyrażenie musi zostać zwartościowane. Pozostałe operatory niczego takiego bowiem nie zakładają - w wyrażeniu Ð1 + Ð2 np. może być wartościowane najpierw Ð1 a potem Ð2, ale równie dobrze może być odwrotnie (tu kompilator może sobie zdecydować, jak mu bardziej pasuje). Zaleca się zachowanie maksymalnej ostrożności przy konstruowaniu wyrażeń ze szczególną uwagą co do tego właśnie aspektu. NIGDY (z wyjatkiem wspomnianych dwóch operatorów) nie należy budować wyrażeń (często zdarza się to przy przekazywaniu argumentów do funkcji), w których kolejność ich obliczania ma jakieś znaczenie - każda operacja wartościowania wyrażenia MUSI być deterministyczna (tzn. każde jej wykonanie z takimi samymi argumentami musi zwrócic ZAWSZE dokładnie ten sam wynik) i bezstanowa (zależna tylko od podanych argumentów), a i dobrze też, żeby nie była afektywna (tzn. nie zostawiała swoich wyników gdzieś "po kątach"). Przy konstruowaniu wyrażeń miej umiar w używaniu podwyrażeń, które nie spełniają tego warunku i WYSTRZEGAJ się używania kilku podwyrażeń, które mogłyby być od siebie nawzajem zależne.
Wśród operatorów istnieją takie, które odczytują dane i takie, które je zapisują (a niektóre robią jedno i drugie). Proszę dobrze pamiętać o tych właściwościach operatorów, gdyż ich nieznajomość jest często źródłem poważnych błędów, nie wykrywanych przez kompilator w formie ostrzeżeń! Większość operatorów nie modyfikuje danych, jednak modyfikują je np. operator przypisania (tego chyba łatwo się było domyślić), operatory POŁĄCZONE Z PRZYPISANIEM (ang. compound-assignment) oraz operatory ++ i --, któych znaczenie teraz objaśnię.
W językach prymitywnych jeśli chcemy zmodyfikować daną przez np. wykonanie na niej operacji z innym argumentem, używa się wyrażenia typu np. `a=a+5;'. Jest to może proste, ale dopóki `a' jest krótkim i prostym wyrażeniem, a nie np.:
*(*preq)( 12, smart_tab[ NiceLookup( this_id ) ].level )[tm.base].phd
Można to co prawda zapamiętać w zmiennej (tzn. stworzyć wyrażenie pośrednie ze zmienną tymczasową), aczkolwiek doświadczenie wskazuje, że im bardziej skomplikuje się "pośredniość" w prostych wyrażeniach, tym bardziej traci się na czytelności. Język C z założenia miał być językiem spartańskim, o krótkiej i zwięzłej składni. Mamy zatem operator +=. Wskazane wyrażenie możemy zatem zapisać jako `a+=5;', co można odczytać jako "dodaj 5 do a". Podobnie dostępne mamy operatory z wbudowanym przypisaniem dla następujących operatorów: -, *, /, %, &, |, ^, <<, >>.
Kolej teraz na operatory, które operują na liczbach całkowitych jako tablicach bitów. Jak niektórzy znający asembler wiedzą, mamy dostępne operacje logiczno-bitowe AND, OR, EOR (czy XOR) oraz przesunięcia bitów ASL/LSL (SLA/SLL) i ASR/LSR (SRA/SRL). Jeśli ich nie znasz - nie przejmuj się; asembler jest tak naprawdę najprostszym językiem programowania, trudność w posługiwaniu się nim wynika głównie z konieczności zajmowania sie "bebechami" komputera. Nie twierdzę oczywiście, że będziemy się takowymi zajmować, aczkolwiek te operatory są od tego, żeby udostępnić odpowiednie - wygodne bądź co bądź, a co najważniejsze, SZYBKIE - operacje asemblerowe dla języka wyższego poziomu.
Operacje logiczno-bitowe są wprawdzie przemienne, aczkolwiek zawsze jednak (często dość swobodnie) wybiera się wśród argumentów "wartość źródłową" i "maskę bitową". Załóżmy więc dla uproszczenia, że pierwszy argument operatora to wartość źródłowa, a drugi to maska. W takiej sytuacji, nasze operatory mają nastepujące efektywne znaczenie:
- x & m
- AND; w `x' wykasowanie bitów będących zerami w `m'
- x | m
- OR; w `x' ustawienie bitów będących jedynkami w `m'
- x ^ m
- EXOR: w `x' odwrócenie bitów będących jedynkami w `m'
Oczywiście wartość x nie jest w tej operacji zmieniana, podobnie jak w x+y nie zmienia się ani x, ani y; jedynie odpowiednio skonstruowana wartość jest zwracana jako wynik takiego wyrażenia.
Od pewnego czasu istnieją w C++, jak już wspomniałem, dla niektórych operatorów oznaczanych symbolami ich odpowiedniki słowne. Istnieje tutaj drobna niekonsekwencja niestety, ale w sumie nie wyszło źle. Zatem o ile operatory logiczne && i
Podobnie operatory << i >>, które oznaczają przesunięcia bitowe w lewo (czyli mnożenie przez 2) i w prawo (czyli dzielenie przez 2). Jak widać, żeby ustawić w zmiennej `x' bit 3, należy napisać x |= (1 << 3). I tu właśnie zwracam drobną uwagę ludziom znającym asembler, gdyż sam miałem kiedyś z tym problemy.
Jak wiemy, w asemblerach x86, czy m68k istnieją rozkazy arytmetyczne i bitowe, które nie odpowiadają operatorom + czy & lecz właśnie += i &=. I tak samo jest z << i >>. Jeśli więc chcesz:
- wykasować bit 3 w zmiennej a, musisz: a &= ~(1 << 3);
- przesunąć o 3 w lewo bity a, musisz: a <<= 3;
W tym drugim przypadku nie raz zdarzało mi się napisać `a << 3' i dziwiłem się, że operacja nie zostaje wykonana. Kompilator z kolei nie ostrzegł mnie, że "Statement with no effect", bo ten "statement" miał "effect", choć może nie dokładnie taki, o jaki mi chodziło.
Przy przesuwaniu bitów zwracam też SZCZEGÓLNĄ uwagę na `signedness' (znakowość) zmiennej, która jest poddawana operacji. Osoby zaznajomione z asemblerem wiedzą, że innych rozkazów używa się do przesuwania liczb ze znakiem, a innych dla liczb bez znaku (nazywają się odpowiednio przesunięciem arytmetycznym i logicznym). Jeśli więc chodzi Ci o przesunięcie logiczne (czyli bez żadnych sztuczek z najstarszym bitem), musisz użyć zmiennej z modyfikatorem unsigned; rozkaz procesora bowiem nie zależy tu od operatora, lecz od typu danej! Co prawda problem ten istnieje tylko dla operatora >> (przesunięcie arytmetyczne w prawo powoduje, że najstarszy bit - znakowy - jest wypełniany taką wartością, jaka się na jego miejscu znajdowała przed przesunięciem, podczas gdy logiczne wpisuje do niego po prostu zero!), aczkolwiek jednak ten operator jest używany z tych dwóch znacznie częściej.
W grupie operatorów bitowych istnieje jeszcze operator `~', który powoduje odwrócenie wszystkich bitów wartości. Dokładnie to `~x' jest odpowiednikiem wyrażenia `x ^ -1u' (-1u to po prostu maksymalna wartość typu unsigned int).
Wspomnę jeszcze tutaj o dwóch szczególnych operatorach. Wyrażenie `a = a + 1' można zapisać jeszcze krócej, mianowicie `++a' lub `a++' (i podobnie z odejmowaniem); są to operatory inkrementacji (++) i dekrementacji (--). Ich szczególność polega po pierwsze na tym że modyfikują podaną zmienną, z kolei jednak bardzo ważną właściwością jest to, że posiadają dwie wersje: przedrostkową ("prefix", ++x) i przyrostkową ("postfix", x++). Jeśli wyrażenie takie jest pojedynczą instrukcją, wybór wersji nie ma znaczenia. Jeśli jednak jest podwyrażeniem, wówczas decyduje to o tym, co jest wykonywane najpierw: czy odczyt wartości, czy jej modyfikacja. Otóż bowiem wartością wyrażenia `++a' jest `a + 1', podczas gdy wartością `a++' jest `a'. Jak każdy widzi zresztą, wyrażenie zbudowane z tym operatorem jest afektywne (zwłaszcza, że i tak operator ten operuje na L-WARTOŚCI, a nie WARTOŚCI), gdyż poza zwróceniem wartości zostawia jeszcze coś w zmiennej.
Zazwyczaj edukatorzy w tym miejscu zajmują się rozważaniami w stylu "co się stanie jak napiszemy a+++b", ale ja sobie jednak dam spokój. Jak zresztą daję przykład, operatory dodawania i odejmowania są otoczone zawsze spacjami. Wiem, że może się to komuś nie podobać, aczkolwiek choć można się zawsze pokłócić ze mną, to z kompilatorem już raczej nie.
Podstawowe wiadomości o typie string
Jak dotąd, posługiwaliśmy się napisami wyłącznie stałymi, zapisywanymi w cudzysłowiu. Napis taki (co jest jedną z właściwości odziedziczonych z C) jest to tablica znaków, w której koniec oznacza się znakiem specjalnym o kodzie 0. Operowanie takimi napisami jest niezwykle uciążliwe i wymaga trochę więcej wiedzy (podpowiem jedynie, że średnio co dziesiąty nowy list na pl.comp.lang.c to pytanie o błąd w programie, gdzie autor nie umiał poprawnie operować napisami w stylu C). Na szczęście w C++ ta wiedza nie jest nam aż tak na gwałt potrzebna; w C++, jak w każdym porządnym języku, istnieje typ "string", którego zadaniem jest operować napisami bez podobnych problemów.
Oto parę podstawowych wiadomości o typie string. Choć - jak wspomniałem - typ literału napisowego nie jest string, to jednak do zmiennej typu string można go przypisywać:
string s; ... s = "Ala ma kota";
Jeśli jakaś funkcja będzie z kolei wymagała, żeby przekazać jej taki typ, jakiego jest ten literał, należy użyć: s.c_str(). Można też data(), ale ta niestety nie gwarantuje, że łańcuch jest poprawnie zakończony.
Żeby dowiedzieć się, jaką długość ma napis przechowywany w tej zmiennej, należy użyć: s.length() lub s.size() (pierwsze jest historyczne, drugie jest rekomendowane).
Możemy też chcieć znaleźć jakiś fragment w danym napisie, wtedy napiszemy:
Pojedyncze znaki zmiennej:
- s[i]
- zwraca znak z napisu o indeksie i (zaczyna się od zera!)
- s.at(i)
- to samo, tylko sprawdza, czy i nie znajduje się poza zakresem
Sklejanie:
- s1 + s2
- skleja dwa napisy (tzn. zwraca sklejony napis, a s1 i s2 pozostają)
- s1 += s2
- dolepia s2 do s1
Poza tym operatory ==, !=, <, >, <=, >= mają znaczenie chyba dość oczywiste, przy czym oczywiście `<' nie oznacza "krótszy", a "młodszy w kolejności alfabetycznej".
Wyrażenia i wartości
Istnieją różne rodzaje wyrażeń. Wyrażenie proste jest to takie wyrażenie, które nie wykonuje żadnej operacji i symbolizuje tylko własną wartość. Wyrażenie złożone zaś jest to wyrażenie, w którym wartość jest uzyskiwana przez wykonanie jakiejś operacji z użyciem podanych operatorów. Jeśli mamy np. a=abs(t); to `a', `abs' i `t' są wyrażeniami prostymi, natomiast `abs(t)' i pozostałe, które można wyróżnić to już wyrażenia złożone. Od tej pory wyrażenie proste będziemy nazywać WARTOŚCIĄ, natomiast wyrażenie złożone po prostu WYRAŻENIEM.
Skupmy się teraz na wyrażeniach. Składa się ono - jak wspomniałem - z wartości powiązanych odpowiednimi operatorami. W podanym wyrażeniu:
a = abs( t );
`abs' jest również wartością. Dla tej wartości został użyty operator (), który oznacza wywołanie funkcji o podanej przed nim nazwie, podając odpowiednie argumenty, zawarte wewnątrz nawiasów. Tutaj zarówno `abs' jak i `t' są argumentami funkcji `operator()'.
Oczywiście powyższe wyrażenie może być podwyrażeniem innego wyrażenia; zwraca ono bowiem odniesienie (ang. reference, będę też też później używał określenia `referencja') do `a', do którego nastąpiło przypisanie. Całość wyrażenia jednak jest operacją (wyrażenie to - jak widzimy - jest afektywne) i kwalifikuje się do bycia instrukcją.
Ponieważ wyrażenie zawiera jakieś dane źródłowe i wskazuje operacje do wykonania na nich, wykonanie tych operacji wraz z otrzymaniem wyniku (czyli "obliczanie" wyrażenia), zwane jest często "wartościowaniem" wyrażenia (ang. evaluation). Określenie "obliczanie" też się często używa, aczkolwiek niezbyt tu ono pasuje; sugeruje bowiem, że wszelkie wyniki przeprowadzonych operacji są zwracane jako wartość wyrażenia, tzn. operacja obliczania wyrażenia jest nieafektywna (nie zostawia śladów); jak wiemy, tak jest nie zawsze. Przykładem takiego afektywnego wyrażenia jest `c++', gdzie wynik przeprowadzonej operacji znajduje się w zmiennej `c', a wynikiem wyrażenia jest poprzednia wartość zmiennej (a więc, bez wykonania tej operacji i tak otrzymalibyśmy tą samą wartość wyrażenia!). Proszę też pamiętać, co może oznaczać umieszczenie takiego `c++' w wyrażeniu: (is_good() && c++).
Instrukcje, bloki i konteksty
Instrukcja prosta jest to wyrażenie występujące w przeznaczonym dla niego miejscu i zakończone średnikiem. Instrukcja złożona zaś, zwana też blokiem, jest to jedna lub więcej instrukcji prostych, ujętych w { }. Blok taki - proszę pamiętać - posiada już osobny, lokalny zasięg; toteż identyfikatory w nim definiowane mają zasięg tylko wewnątrz tego bloku i mogą "przysłaniać" (ang. hide) identyfikatory znajdujące się w wyższym zasięgu. No to może tak mały przykładzik:
#include <iostream> using namespace std; int x = 5; int main() { // blok funkcji main int x = 0; // zmienna lokalna dla bloku funkcji main { // lokalny blok int x = 2; cout << "Wewnętrzna: " << x << endl; } cout << "Lokalna: " << x << endl; cout << "Globalna: " << ::x << endl; if ( int a = x + 2 > 0 ) cout << a << endl; // instrukcja // podporządkowana if ( ) return 0; }
Zauważ, że zadeklarowałem zmienną w wyrażeniu będącym argumentem instrukcji if. W C++ jest to dopuszczalne, ale proszę się jednak starać tego nie nadużywać. Zmienna `a' jak widać jednak, ma zasięg tylko dla instrukcji podporządkowanej if (jest podana instrukcja prosta, ale można też podać złożoną, tak jak w każdym przypadku). Podobnie jest ze wszystkimi tego typu instrukcjami.
Jak widać, mamy trzy różne zmienne `x' o różnych zasięgach. Ponieważ zdarza się deklarować zmienne lokalne o takich samych nazwach, jak globalne, dlatego istnieje operator ::, zwany operatorem zasięgu. Jego jednoargumentowa (przedrostkowa) postać nakazuje uzyskać identyfikator z najwyższego zasięgu.
Wspomniałem wcześniej, że wyrażenie, które wykonuje jakąś operację, kwalifikuje się do bycia instrukcją. Nie oznacza to co prawda, że wyrażenie, które nie wykonuje operacji nie może być instrukcją (jest to wręcz często niemożliwe do wykrycia przez kompilator!). Nie ma ono jednak jako instrukcja sensu. Najprostszym przykładem takiej instrukcji (zakładając, że `a' jest typu int) jest (o czym już wspominałem) a << 1; lub a + 1;
W C++ w związku z zasięgiem mówi się o czymś takim jak KONTEKST. Kontekst jest to pewien fragment kodu obarczony odpowiednimi regułami. Kontekstem może być pojedyncza instrukcja. Może nim być również blok kodu (ale tylko ten taki bez nagłówka, jaki tutaj pokazałem!). Blok to ma w sumie najłatwiej -- kontekst istnieje tutaj od otwarcia do zamknięcia klamry. W przypadku instrukcji złożonych (konkretnie instrukcji sterujących) zasięg kontekstu jest od nagłówka instrukcji do końca podległej instrukcji (jeśli jest nią blok to do zamknięcia klamry). Jak widać w kodzie powyżej, zmienna 'a' zadeklarowana wewnątrz 'if' jest deklaracją należącą już do kontekstu instrukcji 'if'.
Istnieje też kontekst dla konkretnej funkcji, jak również istnieje kontekst globalny. Wszystkie funkcje są deklarowane w kontekście globalnym, co powoduje, że można przyjąć pewne założenia. Mianowicie istnieją w C++ dwa rodzaje kontekstów: globalny, który jest jednocześnie oddzielony i lokalny, który jest jednocześnie łączny. Kontekst oddzielony ma tę właściwość, że elementy zdefiniowane w takim kontekście nie mają dostępu do kontekstów im podległych - jak wiemy, nikt nie ma dostępu do kontekstu funkcji. Kontekst łączny natomiast jest dziedziczony przez podkonteksty; np. wspomniana 'if' z funkcji main ma dostęp do kontekstu globalnego, kontekstu lokalnego funkcji main oraz posiada jeszcze własny kontekst. Tak naprawdę kontekst jest niejako osobnym obiektem, który jest tworzony w momencie wejścia do kontekstu i niszczony przy wyjściu. Wszelkie zmienne tworzone w tym kontekście są tego obiektu elementami (warto pamiętać o tej regule, bo później będzie przydatna!).
Instrukcje sterujące
Program byłby głupi, gdyby przebiegał krokowo od początku do końca (wiem, że to zdanie brzmi jeszcze głupiej, ale jakoś musiałem zacząć temat ;*). Dlatego też w programie praktycznie zawsze używa się instrukcji sterujących, na które składają się:
- instrukcje odgałęzienia warunkowego: if, else
- pętle: while, do-while, for
- instrukcje przełączające: switch/case
- instrukcje skoku: goto, break, continue
- no i oczywiście poznana już instrukcja powrotu z funkcji, return
Jeśli ktoś oczekiwałby obsługi plików, to zaznaczam, że opisane jest to dopiero w rozdziale o bibliotece standardowej C++ (biblioteka iostream). Jeśli cię to interesuje, możesz tam zajrzeć, korzystanie z niej nie wymaga poznania wszystkich poprzedzających je wiadomości.
Postaram się teraz omówić wszystkie po kolei. Najprostszą instrukcją sterującą jest poznane już odgałęzienie warunkowe, które wygląda w ogólności tak:
if (<warunek>) <instr> else <instr>
Oczywiście `else' z całą resztą jest opcjonalne. Proszę jednak zwrócić szczególną uwagę na nawiasy. Są one konieczne, odmiennie, niż w innych językach (również odmiennie, niż w dyrektywie preprocesora!) i również odmiennie niż w innych językach nie ma słowa w stylu `then'. Wbrew pozorom są to rzeczy ze sobą ściśle powiązane - zamknięcie nawiasu pełni tutaj rolę terminatora wyrażenia warunkowego; w pascalu rolę tą spełnia właśnie `then'.
Element <instr> jest tutaj "instrukcją", albo pojedynczą, albo ujętą w blok. Proszę tu pamiętać (tu przypominam amatorom pascala), że instrukcja zawsze się kończy średnikiem; wystąpi on zatem, jeśli pomiędzy `if' a `else' wystąpi pojedyncza instrukcja! Przykładu chyba podawać nie muszę, poza tym i tak ta instrukcja w wielu z nich wystąpi (nawet już wystąpiła).
Przejdźmy zatem do pętli. Pętla jest to pewna grupa instrukcji, która wykonuje się "w kółko". Jednak ponieważ pętla nie służy do zawieszania programu, kiedyś to trzeba przerwać. Mamy na to kilka metod. Pierwsza z nich polega na sprawdzeniu pewnego warunku na samym początku grupy instrukcji (a więc również przed pierwszym wejściem do pętli) i póki ten warunek jest "spełniony" (tzn. wyrażenie warunkowe zostanie zwartościowane do "true"), zawarte instrukcje są wykonywane. Jeśli nie, przeskakuje się całą tą grupę i program się "kontynuuje dalej":
while ( <warunek> ) <instr>
Jak widać tutaj też są nawiasy i słusznie, jeśli komuś przychodzi na myśl słowo `do', ale nie będę się powtarzać. Ów warunek oczywiście można sprawdzać na końcu pętli, tzn. zakładamy że to co się wykona wewnątrz musi się zakończyć z jakimś skutkiem i ten "skutek" na końcu sobie sprawdzimy. Gdy warunek nie zostanie spełniony, przechodzi się do następnej instrukcji; tak właśnie robi pętla do-while:
do <instr> while ( <warunek> );
W wielu językach programowania znana jest też pętla iteracyjna, zwana zazwyczaj `for'. I takoż zwie się ona tu. Ma jednak dość specyficzną postać i w gruncie rzeczy niewiele różni się od pętli while:
for ( <init>; <warunek>; <next> ) <instr>
Odmiennie, niż w wielu innych językach, wyrażeniami <init>, <warunek> i <next> może być dosłownie wszystko, co jest wyrażeniem. Wyrażenia te jednak mają swoje przeznaczenie i są w odpowiednich do tego momentach wartościowane. Pierwsze wyrażenie, <init> jest wykonywane na początku, jeszcze przed faktycznym rozpoczęciem wykonywania pętli. Drugie, <warunek> ma znaczenie identyczne jak w pętli `while'. Ostatnie wyrażenie, <next>, wykonywane jest każdorazowo po ostatniej instrukcji w pętli. Jednym z typowych uzyć pętli for jest np.:
for ( int i = 0; i < 5; i++ ) <instr>
Takie wyrażenie przebiegnie instrukcję <instr> 5 razy, przekręcając zmienną sterującą `i' w zakresie od 0 do 4 z krokiem 1. Pętla może wydawać się trochę bardziej skomplikowana od jej np. pascalowskiego odpowiednika, ale za to - w odróżnieniu od rzeczonego - umożliwia iterowanie w dowolny sposób. Nie tylko umożliwia podanie dowolnej wartości kroku iteracji (podczas gdy w pascalu mamy do dyspozycji 1 i -1), ale w dodatku zmienna sterująca może być dowolnego typu, a więc nie tylko całkowitego, ale również zmiennoprzecinkowego, czy wskaźnikowego (bardzo często używany wariant!), w dodatku krok iteracji nie musi zawierać w sobie dodawania czy odejmowania.
Wtrącę jeszcze o jednej drobnej sprawie. W przykładzie instrukcji for powyżej zadeklarowałem zmienną sterującą wewnątrz pętli for. Jaki jest zasięg tej zmiennej? Jak wspomniałem, ponieważ kontekst rozpoczyna się wraz z nagłówkiem kontekstu, to owa zmienna będzie ważna tylko wewnątrz tego kontekstu (czyli, jeśli pod tym for-em są klamry, to aż do zamknięcia klamry). Przypominam o tym, bo jest to reguła wprowadzona dopiero w standardzie ANSI C++; wcześniej w C++ taka zmienna miała zasięg na kontekst o jeden wyżej. Do dziś pozostało wiele źródeł, które używają takich deklaracji. Kompilatory z kolei radzą sobie z tym różnie; niechlubnie wyróżnia się tu Microsoft Visual C++. Np. w wersji 5 nie było w ogóle możliwości, żeby to pasowało "pod standard". W wersji 6 dodano opcję kompilatora, ale niestety to powodowało również konieczność zrezygnowania z MFC. W wersji 7 nie sprawdzałem. Najlepiej sobie z tym radzi gcc: honoruje obie reguły jednocześnie dzięki temu, że wykrywa użycie zmiennej, która nie została zadeklarowana. I w przypadku stosowania przestarzałej reguły wystosuje tylko ostrzeżenie.
Zanim jednak przejdę do "sedna sprawy", wspomnę jeszcze o jednej drobnej sprawie, która się często początkującym merda. Otóż niektórzy nagminnie zapominają, że pętla while to nie jest pętla reagująca na zdarzenie. W imperatywizmie niestety instrukcje mają swój ściśle określony moment wykonywania i dlatego np. istnieją dwie osobne postacie, while i do-while (a o trzeciej powiemy za chwilę). Pętla while sprawdza wyrażenie na samym początku i tylko wtedy jest szansa na fakt zmiany jego wartości zareagować. Jeśli zmiana jego wartości nastąpiłaby w wyniku jakiejś tam operacji w środku serii instrukcji wewnątrz pętli, to ów fakt owej pętli najserdeczniej wisi i powiewa. Dopóki nie nastąpi sprawdzenie warunku, to kwestia, czy on się zmienił czy nie, na nic nie wpływa. Jest na to całkiem sporo przykładów, aby daleko nie szukać:
while ( go_on == true ) { GetNext(); go_on = GetOK(); // <-- x = Get(); }
Czy nawet takie:
while ( !f.eof() ) { a = f.get(); b = f.get(); }
W pierwszej pętli intencją użytkownika było kontynuować wykonywanie pętli tak długo, jak długo spełniony jest warunek, więc wydaje mu się, że jak tylko przypisał go_on wartość false, to już wszystko jest dobrze. Niedobrze! Ponieważ nie ma żadnego warunku końca w tym miejscu (oznaczonym <--), więc następna instrukcja wykona się niezależnie od tego, co się znajdzie w go_on. Dalej, w tej następnej pętli również jest pułapka. EOF, czyli koniec pliku podczas jego odczytu może nastąpić w dowolnym momencie, więc również po pierwszym get(). Jeśli się tak stanie, to drugie get() będzie niepoprawne. To są właśnie klasyczne przykłady, w których ani while, ani do-while nie będzie się nigdy sprawdzać (dopóki nie zadbamy w tych miejscach o kontrolę możliwych błędów - przyp.AP.).
Zatem więc może zdajmy sobie sprawę, że te wszystkie pętle to są przypadki szczególne i nie wystarczają dla wszystkich przypadków. Nie zawsze da się warunek sprawdzić konkretnie na początku lub końcu pętli. Tu podpowiem, że wielu wykładowców nie tylko C++, ale takoż (głównie) pascala, kategorycznie wymagają, żeby pętle zawsze wykonywać z użyciem while lub do-while (czy repeat-until w pascalu). Jak zauważyłem, później wszyscy to stosują w praktyce w programowaniu. Co można zrobić, gdy namieszała instrukcja w środku pętli i w tym momencie należy pętlę przerwać? No i wtedy zaczynają się schody - ludzie robią jakieś dziwne akrobacje, często powtarza się (metodą Kopiego-Pejsta) kawałek kodu z pętli przed pętlą lub za pętlą (co ciekawe, spotykam się z tym nad wyraz często!), mnożą się błędy niewłaściwego sprawdzania warunków, przekręca się kolejność instrukcji (często marnując czas) tak, aby tylko pasowało to do jednego z tych dwóch schemacików (praktycznych przykładów na to jest mnóstwo). LUDZIE! Nie dajcie sie zwariować! Sprawdzenie warunku na początku lub końcu pętli to bardzo szczególny przypadek i tak naprawdę nie jest to nawet połowa zastosowań pętli! Nikt Ci nie każe tego używać; możesz użyć pętli nieskończonej, której postać może wyglądać tak
for (;;) { // tu coś robimy... if ( cos sie stalo ) break; // ...i dalej... }
Do przerywania wykonywania pętli służy instrukcja break. Może być ona używana w dowolnej pętli i powoduje wykonanie skoku do pierwszej instrukcji za pętlą. Jeśli zależy nam z kolei na skoku na początek bieżącej pętli, służy do tego celu słowo `continue'. Proszę pamiętać jednak że słowo continue ma zupełnie inne znaczenie dla pętli while i do-while, niż dla pętli for! W przypadku tych pierwszych powoduje normalny skok na początek pętli (dokładnie to skok do miejsca, w którym następuje sprawdzanie warunku), podczas gdy w `for' powoduje przejście do następnej iteracji. W praktyce więc skacze do wyrażenia <next> i dopiero potem jest sprawdzany <warunek>.
Jak by nie były fajne instrukcje break i continue, to jednak w niektórych wypadkach i one mogą nie wystarczać. Dlatego też najbardziej ogólną instrukcją skoku jest - jak niektórzy wiedzą - instrukcja `goto'. Wymaga ona jako argumentu etykiety, do której się skacze.
Zwracam uwagę, że użyteczność tej instrukcji jest dość znikoma. W porównaniu z możliwościami, które nam daje język (czyli - pomijając instrukcje strukturalne - break, continue i return) jest też o wiele mniej czytelna. Potrafi jednak w wielu wypadkach skrócić i uprościć pisanie kodu, proszę więc nie bać się jej używać! Mimo wyklęcia jej przez wielu purystów językowych nie udało się i nie uda się nigdy wyeliminować jej z użycia (tzn. twórcom Javy się to w pewnym sensie udało, mianowicie uzbrojono break i continue w skok do etykiety - właściwie istniała prostsza metoda, należało zastąpić słowo goto słowem np. jump) - środki do jej zlikwidowania nie są na tyle doskonałe, żeby ją całkowicie wyeliminować. Podam parę przykładów:
Mamy taki fragment kodu
while ( (c = geth( st )) != 0 ) { puth( c ); if ( c == '!' ) while ( (p = geth( st )) != 0 ) { a = a + p; if ( p == '*' ) // BREAK! Ale jak? } }
No właśnie. A chcemy opuścić obie pętle while! Instrukcja break nie załatwi nam sprawy, opuści bowiem tylko wewnętrzną pętlę. Język Java (jak również powłoka bash) daje na to prostą radę: break może mieć parametr (w tym wypadku byłoby to "break 2;"). Niestety twórcy (jak wszyscy twórcy, którym się wydaje, że zjedli wszystkie rozumy) zapomnieli o jednym: taka konstrukcja jest absolutnie nieczytelna. Wbrew pozorom właśnie najlepszym w tym wypadku rozwiązaniem jest użycie goto do etykiety zadeklarowanej za zewnętrzną pętlą. Dodatkowo jest rozwiązaniem czytelniejszym, bo łatwiej jest dostrzec wyraźnie wskazane miejsce skoku, niż domyślać się, gdzie jest miejsce kończące dwa konteksty wyżej.
while ( (c = geth( st )) != 0 ) { puth( c ); if ( c == '!' ) while ( (p = geth( st )) != 0 ) { a = a + p; if ( p == '*' ) goto Outside; } } Outside:;
b) operacja wyszukiwania elementu w zbiorze, np. tablicy
for ( i = 0; i < n; i++ ) if ( tab[i] == k ) break;
Tutaj, po opuszczeniu pętli `for' wiemy, że ... że co? Co mogło spowodować opuszczenie pętli `for'? Dwie rzeczy: albo znaleźliśmy element, albo doszliśmy do końca pętli, a elementu nie ma. Jak sprawdzić, co się naprawdę stało? Musimy po prostu sprawdzić jeden z warunków ponownie, albo ten w `if', albo ten w `for'. Możemy jednak zamiast break zastosować goto, które przeskoczy serię instrukcji obsługujących fakt "nie znalezienia elementu".
Innym przypadkiem jest choćby obsługa łączna wyjątkowych (najczęściej błędnych) sytuacji. Na przykład funkcja składa do kupy jakieś dane i podczas każdej z tych czynności może się coś nie powieść. Mamy zaplanowane odpowiednie "recovery" dla przypadku, gdyby się to nie powiodło. Ale gdyby to robić za pomocą if+return, czy nawet if+continue, if+break, cokolwiek takiego - musimy powtórzyć (znów - metodą Kopiego-Pejsta) całość kawałka kodu, który "klei daną zastępczą" w każdym miejscu, w którym może nastąpić wspomniane niepowodzenie. Można to zrobić osobną funkcją, ale nie zawsze się da. Zresztą, nieistotne jest, ile instrukcji musielibyśmy wykonać wewnątrz takiego if - istotne jest to, że trzeba o tym pamiętać; jest to zatem ryzyko popełnienia błędu. Można ewentualnie wspomagać się deklaracjami klas z destruktorami (będzie o tym dalej), ale to jest również spory narzut pracy, nieopłacalny dla prostych przypadków. Efektywnie zatem, każde rozwiązanie będzie gorsze od goto.
Jak zatem widać, istnieje kilka przypadków, w których goto sprawdza się całkiem dobrze, nie powoduje problemów, jest całkiem czytelne, a na dodatek wszelkie inne rozwiązania związane z "porządnym programowaniem" będą tak naprawdę naciągane. Że co, takie przypadki są w mniejszości? Zgadza się, ale po to istnieje instrukcja goto, żeby ją do takich mniejszości stosować (i nie częściej).
Instrukcja `goto' została słusznie wyklęta przez purystów językowych, gdyż w czasie gdy zaczęły zdobywać popularność języki strukturalne (czyli przede wszystkim pascal i C), instrukcja goto była (z przyzwyczajenia) bardzo nadużywana. W pascalu (języku, który jako jeden z pierwszych posiadał instrukcje strukturalne) jednak - prawdę powiedziawszy - bez `goto' obyć się nie można (tzn. można, ale kosztem efektywności i - paradoksalnie - przejrzystości kodu). Instrukcje strukturalne nie eliminują bowiem nawet połowy przypadków użycia goto. O niebo lepiej już w tej kwestii wygląda język C, który poza instrukcjami strukturalnymi posiada również te instrukcje "skoku tam, gdzie trzeba". Instrukcje te dodatkowo zmniejszają potrzeby stosowania rozgałęzień warunkowych, które są - również paradoksalnie - przy zbytniej rozbudowie totalnie nieczytelne. Skąd paradoksy? Twórcy instrukcji strukturalnych zapewniali, że dzięki nim programy są czytelniejsze...
Ostatnią instrukcją sterującą, którą tu opiszę jest instrukcja przełączająca. Tej konstrukcji radziłbym się nawet bardziej wystrzegać, niż goto. Oto składnia:
switch ( <wartość> ) { case <jedna_możliwość>: <instr> <instr> ... case <druga_możliwość>: <instr> <instr> ... ... default: <instr> <instr> ... }
Jak widać, instrukcje te nie muszą być brane w klamry (ale nie ma oczywiście zakazu ;*). Jednak ma ona pewien drażliwy mankament. Otóż pozycje oznaczone jako `case' są tylko miejscami skoku i wcale nie sugerują, że jeśli przejdzie się od jednego do drugiego to sprawa się zakończy. Właśnie dlatego często można zauważyć, że bezpośrednio przed następnym `case' występuje instrukcja `break'. Gdyby jej nie było, wtedy mimo rozpoczęcia się następnej etykiety, instrukcje z tej serii byłyby nadal normalnie wykonywane. Oczywiście nie musi być to break - może być to też np. return, goto lub continue. Jednak wszelkie Coding-Standards kategorycznie zabraniają używania możliwości przechodzenia z jednego case do drugiego bez jawnego opisania tego w komentarzu (w języku C# z kolei używanie "fallthru" jest zabronione - każdy przypadek musi kończyć się przerwaniem wykonywania, jakkolwiek miałby się on odbyć).
Kiedyś pamiętam kolega zagiął mnie pytaniem, co się stanie, kiedy umieszczę `continue' wewnątrz `switch' (pewnie miałaby być to sugestia, że switch rozpocznie sprawdzanie od początku - w końcu wyrażenia w bloku switch też nie muszą być deterministyczne ani bezstanowe). Najlepszą odpowiedź dał mi gcc, informując mnie po prostu, że `continue' może się pojawić tylko wewnątrz pętli ;*). Tzn. po prostu `continue' w ogóle nie ma związku ze `switch'.
Instrukcja przełączająca porównuje po kolei <wartość> z kolejnymi wariantami. Etykieta `default' - jak się zapewne można domyślać - jest miejscem skoku w przypadku gdy <wartość> nie pasuje do żadnego wariantu.
Dlaczego radzę się jej wystrzegać? Dlatego, że stanowi ona niejako pułapkę, w którą wpada każdy, kto jeszcze nie wpadł i nie naprodukował kodu z dużą ilością takich instrukcji. Jest ona bardzo prosta i zachęcająca w stosowaniu do tego stopnia, że bardzo zachęca do tego, żeby za jej pomocą wykonywać różne odgórne mapowania fragmentu kodu do wartości (gdyby można to było robić tylko if-ami, to wielu by to pewno odstraszało, choć ja widziałem mnóstwo kodu, gdzie autorów to nie odstraszało, więc co się może dziać, kiedy mamy do dyspozycji switch!). Po pierwsze, często ludziom się wydaje, że jest tak szybka jak indeksowanie tablicy. Niestety, jest tu problem, mianowicie ponieważ wartości niekoniecznie są rozłożone jedna za drugą, więc kompilator raczej nawet nie zakłada przypadków, w których tak się da. Może najwyżej zrobić wyszukiwanie binarne, więc złożoność tej operacji jest często w najlepszym wypadku logarytmiczna. Ale to nie to akurat jest najgorsze.
Najgorsze jest to, że ta konstrukcja bardzo zachęca do produkowania ogromnych ilości nieczytelnego (i bardzo trudnego w utrzymaniu) kodu. Początkowo tego się nie zauważa, ktoś pomyśli, że co mu się stanie, jak sprawdzi coś względem trzech wartości i wszędzie da po trzy linijki kodu. Potem się okaże, że do każdego przypadku trzeba nie trzy, ale dwadzieścia linijek kodu, potem się ten switch rozbuduje o 50 przypadków i powstają potem potworki... Jedym z częstszych takich przypadków (charakterystycznych dla początkujących) jest tworzenie funkcji, która zwraca pewien łańcuch przypisany do konkretnej wartości, wybieranej przez case. Ale bywa też gorzej. Oglądałem swego czasu kod, w którym instrukcja case stanowiła ponad 90% funkcji, funkcja miała ok. 2000 (!!!) linii, a jeden blok case ok. 50 linii... :)
Pamiętaj zatem, że switch/case można stosować tak mniej więcej do 5-ciu przypadków sprawdzanej wartości (tzn. 5-ciu różnych zestawów instrukcji). Ta wartość i tak jest już zresztą dość przesadzona. Jeśli trafi Ci się obsługa większej ilości wartości, a obsługa każdej z nich jest krótka - spróbuj podzielić je na odpowiednie grupy i kategorie, robiąc drzewo. Złożoność czegoś takiego będzie już lepsza od wyszukiwania połówkowego (ale tylko przy dużej ilości przypadków). Jeśli fragmenty (jak w przytoczonym kodzie) są długie, to oczywiście dziel to na funkcje... :*). Staraj się też korzystać z różnych map, czy tablic asocjacyjnych, bo wtedy kod zawsze jest dużo lepiej uporządkowany i dodanie nowego przypadku będzie zawsze łatwiejsze.
Z kolei w gcc (w razie konieczności) możesz skorzystać z rozszerzenia zwanego "zmiennymi etykietami". Polega to na tym, że instrukcja goto może przyjąć wartość typu void*. Natomiast wartość danej etykiety można pobrać odpowiednim operatorem, mianowicie jednoargumentowym && - patrz info gcc, C Extensions, Labels as Values (niemniej radzę jednak wystrzegać się używania rozszerzeń kompilatorów, chyba że zakładasz kompilowanie programu zawsze w gcc i to w określonej wersji; licz się z tym, że dowolne rozszerzenie może nagle niespodziewanie zniknąć z następnej wersji gcc, tak jak to się stało z dość pokaźną grupą rozszerzeń z gcc sprzed wersji 3.0).
Następny rozdział zagłębi się we wszystkie szczegóły opisujące typy danych, które nie traktują o programowaniu obiektowym. Nie wszystkie trzeba koniecznie znać, toteż możesz przejść do rozdziału o funkcjach, jeśli Cię to znuży ;*).