Procedury i funkcje
Gdyby jakiś tyran zabronił stosowania procedur i funkcji, wówczas cała informatyka by padła. Są one tak ważnym narzędziem, że trudno sobie wyobrazić programowanie bez stosowania procedur i funkcji.
Procedury i funkcje stanowią podstawową konstrukcję programistyczną umożliwiającą modularyzację programu. Wiemy, że programy komputerowe mogą mieć dziesiątki czy setki tysięcy wierszy kodu, więc zapanowanie nad tak potężnymi tekstami graniczyłoby z cudem. Konieczne staje się wyodrębnianie fragmentów kodu stanowiących logiczną całość oraz poprzez odpowiednie nazywanie ich - doprowadzenie do tego, że czytając tekst programu nie będziemy wchodzili od razu w szczegóły implementacji, a poprzez nazwę sugerującą rolę ukrytego fragmentu kodu będziemy się mogli zorientować, co spowoduje jego wykonanie.
Poza tą fundamentalną cechą procedur, nie mniej ważną jest możliwość jednokrotnego zapisania ciągu rozkazów, który może być wielokrotnie używany. Korzystaliśmy z tej możliwości np. już przy zadaniach o flagach: polskiej i holenderskiej. Napis Z(i,j) traktowaliśmy jako skrót sekwencji zamieniającej zawartości pól i-tego i j-tego. Zauważmy, że nie tylko nazywamy pewne sekwencje rozkazów, ale umożliwiamy też parametryzację niektórych zmiennych. W tym przypadku kod zamiany dwóch elementów jest bardzo podobny, niezależnie od tego, dla jakich indeksów dokonujemy zamiany.
Dodatkowo jeszcze zyskujemy możliwość rekurencyjnego definiowania kodu, gdy potrafimy wyrazić rozwiązanie w zależności od innych wartości zmiennych, które są parametrami. Przykładowo w zadaniach o największym wspólnym dzielniku mieliśmy możliwość zdefiniowania wartości (a,b) w zależności od mniejszych wartości argumentów.
Procedury zatem są mechanizmem 'abstrakcji', umożliwiającym wyodrębnienie fragmentów kodu i użycie ich na zasadzie 'czarnych skrzynek'. Funkcja to procedura, która poza wykonaniem kodu oblicza pewną wartość - wynik funkcji - nadawany w trakcie wykonania jej identyfikatorowi, tak jak zmiennej.
Najprostsze użycie procedury, to po prostu nazwanie fragmentu kodu i użycie tej nazwy do późniejszego wykonania tego fragmentu. Powiedzmy, że chcemy wypisać rząd gwiazdek. Normalnie robimy to za pomocą pętli
Przykład
for i:=1 to n do Write('*')
Jeśli chcielibyśmy ująć tę instrukcję w procedurę, to możemy zrobić to w następujący sposób. W części deklaracyjnej programu, a więc tak, gdzie definiuje się stałe, typy i zmienne umieszczamy deklarację procedury.
Przykład
procedure Gwiazdki; begin for i:=1 to n do Write('*') end;
Od tej chwili użycie gdziekolwiek identyfikatora Gwiazdki będzie oznaczało wykonanie zaszytego pod tą nazwą kodu powodującego wypisanie n gwiazdek. W momencie deklaracji procedury musi być jasne, o jakie zmienne chodzi. W naszym przypadku mamy do czynienia ze zmiennymi i oraz n. Muszą one być zadeklarowane zanim przystąpimy do pisania kodu procedury lub ich deklaracje powinny wystąpić w jej wnętrzu. Zmienna i jest tutaj zdecydowanie zmienną pomocniczą - jej zadaniem jest jedynie sterowanie liczbą obrotów pętli. Ze względu jednak na to, że w momencie wykonywania procedury jej wartość ulegnie zmianie (zgodnie z naszą semantyką po wykonaniu pętli będzie nieokreślona), nie chcielibyśmy, aby taka ingerencja pozostawała nieodnotowana. Programista widząc taki fragment kodu:
Przykład
i:=5; Gwiazdki; Write(i)
mógłby się spodziewać wypisania wartości 5, a tego nie powinniśmy się spodziewać. Mamy tu do czynienia z tzw. efektem ubocznym , czyli zmianą wartości zmiennej nieuwidocznioną bezpośrednio w kodzie. Występowanie efektów ubocznych jest uważane za wadę i przykład złego stylu programistycznego. Utrudnia zrozumienie tekstu i jego analizę poprawnościową.
Aby temu zapobiec, wszelkie pomocnicze zmienne będziemy deklarowali wewnątrz procedury.
Przykład
procedure Gwiazdki; var i: Integer; {deklaracja lokalnej zmiennej i} begin for i:=1 to n do Write('*') end;
Zmienna i jest teraz zadeklarowana lokalnie i nawet jeśliby występowała inna zmienna i na zewnątrz procedury, to ta deklaracja przesłoni tamtą, wskutek czego użycie procedury Gwiazdki nie będzie ingerować w wartość tej zewnętrznej zmiennej. Za chwilę o mechanizmie przesłaniania opowiemy dokładniej, gdyż jest to sprawa ważniejsza i bardziej skomplikowana, niż mogłoby się to na pozór wydawać.
Wywołanie naszej nowej procedury wygląda tak samo. Na przykład, gdyby nam zależało na wypisaniu najpierw w pierwszym wierszu 5 gwiazdek, a potem w kolejnym wierszu 6 gwiazdek, to moglibyśmy to zrobić następująco:
n:=5; Gwiazdki; Writeln; n:=6; Gwiazdki
Parametry procedur
Zauważmy, że poradziliśmy sobie na razie tylko z jedną z dwóch zmiennych - zmienna n nadal musi być zadeklarowana jakoś inaczej. Dlaczego nie zdecydowaliśmy się na umieszczenie jej również pośród zmiennych lokalnych? Po prostu dlatego, że wartość zmiennej n jest istotna dla wyniku działania procedury (wartość zmiennej i była zupełnie nieistotna; i tak inicjalizowała się ona na początku działania pętli). W tego typu przypadkach, kiedy chcemy wykorzystać wartość pewnej zmiennej, przyjęło się podawać ją, jako parametr procedury . Przy deklaracji procedury, w nagłówku - zaraz po nazwie procedury - podamy listę parametrów wraz z określeniem ich typu. Będzie to równoważne z deklaracją zmiennych występujących jako parametry, których zasięg będzie ograniczony jedynie do treści procedury. Ograniczenie zasięgu takich zmiennych jest niezwykle ważną cechą programowania z użyciem procedur.
Deklaracja naszej procedury będzie wyglądać teraz następująco:
Przykład
procedure Gwiazdki(n: Integer); var i: Integer; {deklaracja lokalnej zmiennej i} begin for i:=1 to n do Write('*') end;
Tym razem wywołanie procedury będzie się nieco różniło. W miejsce parametru można wstawić przy wywołaniu dowolną wartość, również będącą wynikiem wyrażenia. Wyglądać to może przykładowo w taki sposób:
n:=5;
Gwiazdki(n); Writeln; Gwiazdki(n+1)
Przy wywołaniu wylicza się wartość wszystkich parametrów zanim zaczniemy wykonywać jakiekolwiek instrukcje procedury. Efektem tego wywołania będzie zatem wypisanie najpierw 5 gwiazdek, a potem 6. Zauważmy, że zmienna n użyta jako parametr procedury ma inne znaczenie, niż ta, która jest zadeklarowana na zewnątrz i której jest nadana wartość 5. Sposób przekazywania wartości do wnętrza procedury przez parametry jest uznawany za wzorcowy. Czyni on kod czytelniejszym i pozwala na wyraźne zaznaczenie, które wartości są dla procedury ważne.
Wprowadźmy jeszcze nomenklaturę dotyczacą parametrów. Ponieważ parametry występują w dwóch różnych odsłonach: przy deklaracji procedury i przy jej wywołaniu, więc nazwijmy inaczej parametry występujące w tych kontekstach. Parametry przy deklaracji nazywa się formalnymi, a przy wywołaniu aktualnymi.
Czasami zadaniem procedury ma być obliczenie jakiejś złożonej wartości i wtedy też zamiast korzystać z efektów ubocznych lepiej jest wprowadzić do nagłówka informację o tym, które zmienne będą miały nadawane nowe wartości. Twórcy wielu języków programowania uznali tę potrzebę za tak istotną, że wprowadzili specjalne mechanizmy rozróżniające tryby wywoływania parametrów. Do tej pory poznaliśmy wywołanie przez wartość: przekazujemy procedurze pewną wartość, a ona na jej podstawie wykonuje stosowne działania.
Drugim trybem w Pascalu jest tzw. wywołanie parametru przez zmienną. Omówimy te dwie metody nieco dokładniej.
Parametry wołane przez wartość
W przypadku wywołania parametru przez wartość w nagłówku procedury podajemy identyfikator zmiennej i jej typ, a w momencie wywołania przekazujemy procedurze wyrażenie, którego wartość przypisujemy osobnej zmiennej powołanej na użytek tego konkretnego wywołania procedury. Zatem jesli nawet nazwa parametru formalnego i aktualnego są identyczne, to w rzeczywistości przy wykonywaniu kodu procedury działamy na kopii wartości - dokładnie w momencie wywołania procedury rezerwowana jest dodatkowa pamięć rozmiaru odpowiadającego typowi parametru i do tej pamięci kopiowana jest wartość wyrażenia odpowiadającego temu parametrowi, wyliczona w momencie wywołania procedury. Późniejsze zmiany wartości tego parametru aktualnego sa możliwe, ale po zakończeniu wywołania procedury oryginalne wartości pozostają niezmienione.
Parametry wołane przez zmienną
Parametry wołane przez stałą
Funkcje
Zdarza się, że w efekcie wywołania procedury chcemy wyznaczyć jakąś wartość. W takiej sytuacji korzystamy najczęściej z jednego z dwóch mechanizmów: życia funkcji lub specjalnego typu parametrów, którym będziemy nadawali wartość w trakcie wykonania procedury.
Zacznijmy od sposobu pierwszego. Jeżeli wartością procedury jest liczba, wartość logiczna, znak, napis lub wskaźnik, możemy użyć funkcji , czyli procedury z wartością. Deklaracja wygląda podobnie, jak w przypadku procedur, z tym że w nagłówku dodajemy na końcu po dwukropku typ wyniku funkcji. Tak więc funkcja, która podwaja wartość całkowitej liczby mogłaby wyglądać tak:
Przykład
function Dubluj(n: Integer):Integer; begin Dubluj := 2*n end;
Widzimy więc, że w pewnym momencie w tekście funkcji następuje przypisanie wartości jej identyfikatorowi. Późniejsze wywołanie zadeklarowanej funkcji polega na użyciu identyfikatora z odpowiednią liczbą argumentów po prawej stronie wyrażenia. Na przykład wywołanie
n2:=Dubluj(n)
spowoduje nadanie zmiennej n2 podwojonej wartości zmiennej n.
Uważa się, że szczególnie w przypadku funkcji efekty uboczne, choć podobnie jak w przypadku procedur możliwe do wywołania, stanowią poważny problem. Nie chcielibyśmy przecież, aby wykonanie instrukcji y:=sin(x) spowodowało np. zmiane zmiennej x czy n . Byłoby to sprzeczne z przyzwyczajeniami matematycznymi. Gorąco odradzam tego typu styl programowania.
Składnia procedur i funkcji
Składnia procedur i funkcji jest podobna do składni całego programu. Najpierw mamy nagłówek, w którym określamy nazwę procedury oraz jej parametry, a następnie deklaracje i instrukcje. Deklaracje dotyczą zmiennych lokalnych dla procedury, niewidocznych z zewnątrz i zajmujących pamięć tylko w czasie wykonywania procedury. Część deklaracyjna jest identyczna jak w wypadku programu: mogą w niej być zdefiniowane stałe, typy, zmienne, procedury i funkcje. W przypadku procedur lub funkcji występujących w deklaracjach innej procedury lub funkcji mówimy o zagnieżdżeniu . Ponieważ zagnieżdżenia mogą być wielostopniowe (podprocedura podprocedury procedury...), więc ważne jest, aby określić reguły zasłaniania widoczności identyfikatorów. Zajmiemy się tym w następnym rozdziale.
Zobaczmy, jak mechanizm procedur można wykorzystać w prostym przykładzie rysowania ramki złożonej z symboli jednego rodzaju. Załóżmy, że chcemy wydrukować ramkę o bokach wypełnionych znakami ch, o i wierszach, oraz j kolumnach, oddaloną od lewego brzegu ekranu o m pozycji.
Przykład
procedure ramka(i,j,m:Integer; ch:char); {procedura narysuje ramkę ze znaków ch o i wierszach, j kolumnach oddaloną od lewego brzegu ekranu o m pozycji. Zakładamy, że kursor jest na początku wiersza} procedure powiel (n:integer; ch:znak); {to jest wewnętrzna procedura procedury ramka} {Drukujemy znak ch n razy} var k:Integer; begin for k:=1 to n do Write(ch) end; procedure margines(m:Integer); {to jest wewnętrzna procedura procedury ramka} {Drukujemy m spacji} begin powiel(m,' ') end; procedure BokPoziomy (j,m:integer; ch:char); {to jest wewnętrzna procedura procedury ramka} {Drukujemy oddalony o m spacji od lewego brzegu ekranu ciąg j znaków ch} begin margines(m); powiel(j,ch); Writeln {Po wydrukowaniu przechodzimy do nowego wiersza} end; procedure BokiPionowe (i,j,m:integer; ch:char); {to jest wewnętrzna procedura procedury ramka} {Drukujemy dwa boki pionowe wysokości i-2 oddalone od siebie o j-2 spacje Lewy bok jest oddalony od lewego brzegu ekranu o m spacji} var k:Integer; begin for k:=1 to i-2 do begin margines(m); Write(ch); powiel(j-2,' '); Write(ch); end; Writeln end; begin {ramka} {ponieważ często kod procedury jest dość daleko od nagłówka, więc do dobrych obyczajów należy komentowanie, czego dotyczy nadchodzący fragment kodu} BokPoziomy (j,m,ch); BokiPionowe(i,j,m,ch); BokPoziomy (j,m,ch) end
Jeśli teraz np. w tekście programu użyjemy wywołania
ramka(7,6,0,'*'),
to narysuje się ramka siedem na sześć, złożona z gwiazdek i przylegająca do lewego brzegu ekranu.
Parę słów komentarza. Zauważmy, że procedura ramka zawiera 4 podprocedury, przy czym one nawzajem sie wywołują; wręcz podprocedury margines i powiel nie są wykorzystywane bezpośrednio przez ramkę. Procedura margines wygląda dziwnie: mogłoby się wydawać, że jest niepotrzebna, gdyż jej wywołanie sprowadza się do prostego wywołania procedury powiel. Jednak do dobrego stylu programowania należy wyodrębnianie i nazywanie inaczej części kodu, jeśli służą innym celom. Procedura powiel jest niewątpliwie ogólniejsza od procedury margines. Ma też inne parametry - w przypadku marginesu zakładamy, że wypisujemy spacje, a nie dowolne znaki. W takim przypadku asekurujemy się np. przed sytuacją, kiedy zmieni się nam koncepcja marginesu i - powiedzmy - każdy wiersz będzie poprzedzony dwoma wykrzyknikami. Gdybyśmy w kodzie zamiast margines(m) pisali powiel(m,' '), to musielibyśmy odszukać wszystkie wywołania procedury powiel, które dotyczyły marginesu i wstawić przed nimi stosowne instrukcje. A tak wystarczy, że w jednym miejscu - wewnątrz procedury margines - zmienimy fragment kodu na .
procedure margines(m:Integer); begin Write('!!'); powiel(m,' ') end;
i o nic więcej nie musimy się martwić.
Ważne jest, aby procedury były skomentowane. Do standardów należy opisanie, co procedura robi oraz wyjaśnienie roli wszystkich parametrów. Jeśli czynimy jakieś nieoczywiste założenie, to musi ono być umieszczone w komentarzu (np. założenie o pozycji kursora)
Konieczne jest, aby parametry przy wywołaniu były podane w tej samej kolejności, co przy deklaracji i aby typy ich były zgodne. W przypadku niezgodności typów wystąpi błąd kompilacji.