Wywołanie funkcji. Funkcje rekurencyjne. Zwracanie wartości przez funkcję.

Wprowadzenie

Wiesz już doskonale czym są funkcje oraz w jaki sposób przekazywać do nich argumenty. Czy zastanawiałeś się jednak nad miejscami wywoływania funkcji? W tej lekcji przedstawię Ci informacje na temat wywoływania funkcji, a także przedstawię funkcje, które wywołują same siebie - funkcje rekurencyjne.

Dodatkowo przekażę Ci podstawowe informacje na temat zwracania wartości przez funkcję. Temat ten zostanie poruszony dokładniej w kolejnej lekcji, dlatego już teraz postaraj się opanować podstawowe informacje.

Wywołanie funkcji

Przypomnij sobie wszystkie przykłady, w których występowały funkcje, jakie Ci do tej pory przedstawiłem - gdzie były wywoływane funkcje? Jeśli przyjrzysz się dokładnie, zauważysz, że wszystkie funkcje były wywoływane w funkcji main - głównej części programu.

Czy to oznacza, że wszystkie funkcje muszą być wywoływane właśnie w funkcji main? Jak się okazuje, nie jest to konieczne. Funkcja w języku C++ może być wywołana w dowolnej innej funkcji programu (w tym również oczywiście w funkcji main).

Co dzięki temu zyskujemy? Dzięki takiej konstrukcji języka C++, dowolna funkcja (z wyjątkiem funkcji main) może zostać uruchomiona tak naprawdę w dowolnym miejscu programu i nie musimy się martwić żadnymi ograniczeniami - projektując funkcję zakładamy, że będzie po prostu wykorzystana i przyjmujemy, że może zostać uruchomiona w dowolnym miejscu naszego programu.

Wiedząc, że funkcje mogą być wywoływane w dowolnym miejscu programu, można dzielić program dowolnie na mniejsze fragmenty, czyli funkcje, a same bardziej skomplikowane funkcje dzielić również na jeszcze mniejsze funkcje. Dzięki temu tworząc program w języku C++, można go podzielić na funkcje tak, aby każda z funkcji odpowiadała za wykonanie danej, konkretnej czynności. Dzięki takiemu podejściu, programowanie w języku C++ jest znacznie łatwiejsze. Łatwiejsze jest również testowanie, bo znacznie łatwiej przetestować jest poprawność danej funkcji niż poprawność działania całego programu.

W poniższym programie zademonstrowano właśnie dzielenie kodu programu na części (funkcje) oraz pokazano, że funkcja może być rzeczywiście uruchamiana również z poziomu innych funkcji.

#include <iostream>

using namespace std;

double dodaj(double, double);
double odejmij(double, double);
double pomnoz(double, double);
double podziel(double, double);

void oblicz(double, double);

int main()
{
 double a,b;
 cout <<"Podaj a i b: "<<endl;
 cin >>a>>b;
 cin.ignore();
 oblicz (a,b);
 
 a=15;
 b=77;
 
 cout <<endl<<endl<<"NOWE LICZBY"<<endl<<endl;
 // 2  
 cout <<"Wynik dodawania: "<<dodaj(a,b)<<endl;
 cout <<"Wynik odejmowania: "<<odejmij(a,b)<<endl;
 
 cout <<endl<<"Nacisnij ENTER aby zakonczyc"<<endl;
 getchar();
 return 0;  
 
}

double dodaj(double x, double y) {return x+y;}
double odejmij(double x, double y) {return x-y;}
double pomnoz(double x, double y) {return x*y;}
double podziel(double x, double y) {return x/y;}

void oblicz(double x, double y)
{
  // 1
  cout <<"Wynik dodawania liczb to "<<dodaj(x,y)<<endl;    
  cout <<"Wynik odejmowania liczb to "<<odejmij(x,y)<<endl;
  cout <<"Wynik mnozenia liczb to "<<pomnoz(x,y)<<endl;  
  if (y==0)
     cout <<"Nie mozna obliczyc wyniku dzielenia"<<endl;
  else
     cout <<"Wynik dzielenia liczb to "<<podziel(x,y)<<endl;    
} program nr 36.1

Prześledźmy zatem powyższy program. W programie zostały zadeklarowane i zdefiniowane funkcje, a dokładniej 5 funkcji. Jak można zaobserwować funkcja oblicz jest funkcją mającą na celu dokonanie podstawowych obliczeń dla podanych liczb oraz wypisanie wyniku poszczególnych działań. Ale jak można zauważyć funkcja ta do wykonania obliczeń wykorzystuje funkcje pomocnicze - są to funkcje dodaj, odejmij, pomnoz i podziel.

Jak widać wszystkie 4 funkcje zostały uruchomione wewnątrz funkcji oblicz - to miejsce oznaczono komentarzem // 1. Ale dodatkowo jeśli przyjrzysz się programowi zauważysz, że funkcje dodaj i odejmij zostały dodatkowo wywołane wewnątrz głównej funkcji programu - funkcji main - to miejsce oznaczono komentarzem // 2.

Jak zatem widzisz funkcja rzeczywiście może zostać wywołana w dowolnym miejscu programu i jak widzisz nie trzeba się tym martwić podczas definiowania funkcji. Funkcje dodaj i odejmij zostały zaprojektowane tak, aby przeprowadzić określone działanie i działają one tak samo dobrze - zarówno gdy są wywoływane przez inną funkcję, jak i przez główną funkcję programu - funkcję main.

Oczywiście dla formalności dodam, że w rzeczywistych programach rzadko kiedy definiuje się funkcje tak proste jak te przedstawione tutaj (dodaj, odejmij, pomnoz, podziel). W naszym programie nie interesuje nas jednak stopień skomplikowania funkcji, a jedynie wywoływanie funkcji, dlatego też przedstawione funkcje nie są skomplikowane i realizują tak proste zadania.

Funkcje rekurencyjne

Wiesz już, że funkcje mogą być wywoływane w dowolnym miejscu programu. W bardziej skomplikowanych programach może nawet zdarzyć się tak, że pod pewnymi warunkami jedna funkcja wywołuje drugą funkcję, a druga funkcja pod pewnymi warunkami wywołuje pierwszą funkcję. Specyficznym rodzajem funkcji są funkcje rekurencyjne, czyli funkcje, które wywołują same siebie.

Rekurencja polega na wywoływaniu funkcji przez samą siebie. Dzięki takiej możliwości w większości języków programowania (w tym również C++), można wykonywać pewne operacje, których wykonanie innymi metodami byłoby bardzo trudne lub wręcz niemożliwe.

Oczywiście nie oznacza to, że funkcja rekurencyjna wywołuje tylko samą siebie - pierwsze wywołanie funkcji musi nastąpić w innej części programu, jednak następnie funkcja może wywoływać samą siebie w celu realizacji powierzonego jej zadania.

Funkcje rekurencyjne są wykorzystywane w wielu zagadnieniach w informatyce, jak chociażby w sortowaniu czy przeprowadzaniu operacji na systemach plików. Trzeba jednak zdawać sobie sprawę, że należy je umiejętnie stosować - nieumiejętne zastosowanie funkcji rekurencyjnych może spowodować bardzo duży spadek wydajności programu.

Klasycznym przykładem funkcji rekurencyjnej jest funkcja silnia, która dla liczb naturalnych większych od zera, jest definiowana następująco: n! = n* (n-1)!, gdzie znak ! (wykrzyknik) jest symbolem silni. Spróbujmy zatem napisać program obliczający silnię z wykorzystaniem funkcji rekurencyjnej.

Spójrz najpierw na poniższy przykładowy program, ale go nie uruchamiaj. Jak Ci się wydaje - czy program jest napisany poprawnie?

#include <iostream>

using namespace std;

unsigned long int silnia(unsigned int);

int main()
{
 unsigned int n;
 
 cout <<"Podaj liczbe, dla ktorej chcesz policzyc wartosc silni: ";
 cin >>n;
 cin.ignore();
 
 cout <<endl<<"Silnia wynosi "<<silnia(n)<<endl;
 
 cout <<endl<<"Nacisnij ENTER aby zakonczyc"<<endl;
 getchar();
 return 0;        
}

unsigned long int silnia(unsigned int i)
{
  return i * silnia (i-1);
}
program nr 36.2

Na pierwszy rzut oka wydaje się, że program jest napisany prawidłowo, jednak gdy spróbujesz go uruchomić to albo program będzie wykonywać się w nieskończoność albo też otrzymasz komunikat systemowy, że program wykonał nieprawidłową operację i nastąpi jego zamknięcie. Przyjrzyjmy się zatem bliżej programowi na obliczanie silni. Czy wiesz już gdzie może być błąd?

Okazuje się, że każdy program powinien mieć tzw. warunek stopu. Warunek stopu oznacza, że program dla dowolnych danych powinien ostatecznie zakończyć działanie. Określenie warunku stopu jest szczególnie ważne w przypadku funkcji rekurencyjnych, bowiem definiując funkcje rekurencyjne nietrudno o warunku stopu zapomnieć.

Czy nasz program posiada zdefiniowany warunek stopu? W funkcji main poza wywołaniem funkcji rekurencyjnej nie ma żadnych pętli lub innych instrukcji mogących powodować wywoływanie programu w nieskończoność. Jedynym podejrzanym elementem może być funkcja silnia, która jest wywoływana właśnie w funkcji main. Funkcja silnia jest funkcją rekurencyjną, czyli wywołuje samą siebie do momentu ... No właśnie, czy jest zdefiniowany moment, w którym funkcja silnia, ma przestać wywoływać samą siebie?

Jeśli przeanalizujesz działanie funkcji silnia np. dla argumentu i=5, okaże się, że silnia(5) wywoła funkcję silnia(4), z kolei funkcja silnia(4) wywoła funkcję silnia(3), funkcja silnia (3) wywoła funkcję silnia(2), a funkcja silnia(2) wywoła funkcję silnia(1), ale to jeszcze nie koniec. Funkcja silnia(1) wywoła funkcję silnia(0), a funkcja silnia(0) wywoła funkcję uwaga z argumentem -1, czyli silnia(-1).

Jak wiadomo z matematycznego punktu widzenia silnia obliczana jest jedynie dla liczb naturalnych, co oznacza, że nie znana jest wartość dla liczb ujemnych. Czy zostało to wzięte pod uwagę w programie? Nie, niestety nie zostało dodane żadne tego typu ograniczenie.

Co gorsza, jeśli przyjrzysz się definicji funkcji silnia, to przyjmuje ona argumenty typu unsigned int, czyli wartość argumentu przekazanego do funkcji nie może być ujemna. Jeśli przekażemy do funkcji jako argument wartość ujemną, to do funkcji zostanie przekazana tak naprawdę największa liczba typu unsigned int, co poskutkuje właśnie tym, że program będzie wywoływał się w nieskończoność.

Co zatem należy zrobić, aby program zaczął działać prawidłowo? W funkcji silnia należy zdefiniować właśnie warunek stopu, który spowoduje, że funkcja zwróci jedynie wartość, a nie wywoła już rekurencyjnie samej siebie dla mniejszego argumentu.

Poniższy program demonstruje poprawną definicję funkcji silnia. Ponieważ wiemy, że silnia(0)=1 i silnia(1)=1, w definicji funkcji silnia wystarczy dodać, żeby wywoływać rekurencyjnie funkcję silnia tylko w przypadku, gdy argument funkcji ma wartość większą niż 1. W przeciwnym przypadku należy zwrócić wartość 1, bo silnia(1)=1. Dzięki temu, program będzie miał poprawnie zdefiniowany warunek stopu - funkcja będzie wywoływana dla coraz mniejszych argumentów (np. 6,5,4,3,2,1), więc gdy wartość argumentu wyniesie 1, dalsze wywołania funkcji nie będą już miały miejsca i nigdy nie zdarzy się, że zostanie wywołana funkcja silnia(0) ani tym bardziej silnia(-1).

#include <iostream>

using namespace std;

unsigned long int silnia(unsigned int);

int main()
{
 unsigned int n;
 
 cout <<"Podaj liczbe, dla ktorej chcesz policzyc wartosc silni: ";
 cin >>n;
 cin.ignore();
 
 cout <<endl<<"Silnia wynosi "<<silnia(n)<<endl;
 
 cout <<endl<<"Nacisnij ENTER aby zakonczyc"<<endl;
 getchar();
 return 0;        
}

unsigned long int silnia(unsigned int i)
{
  if (i>1)          
     return i * silnia (i-1);
  else
     return 1;
}
program nr 36.3

Powyższy przypadek pokazuje jak bardzo wywołania funkcji rekurencyjnych są groźne, a raczej jak bardzo trzeba być ostrożnym definiując takie funkcje. W przykładowym programie źródło problemu można było bardzo łatwo znaleźć. Co jednak, jeśli funkcja rekurencyjna oprócz samej siebie wywoływałaby jeszcze inne funkcje (w tym również funkcje rekurencyjne)? Wówczas szukanie problemu mogłoby zająć naprawdę wiele czasu, a co gorsza mogłoby się ujawnić dopiero, kiedy program został już uruchomiony dla pewnych konkretnych liczb.

Funkcje rekurencyjne są powszechnie stosowane w informatyce. Na przykład algorytm sortowania szybkiego wykorzystuje właśnie funkcję rekurencyjną jak podstawę działania. Dzięki temu szybkość wyszukiwania jest zazwyczaj znacznie lepsza niż w przypadku innych algorytmów. W wielu przypadkach zastosowanie funkcji rekurencyjnej okazuje się najszybszym i najłatwiejszym sposobem implementacji danego algorytmu.

Używając funkcji rekurencyjnych trzeba być jednak bardzo ostrożnym. Ponieważ ideą funkcji rekurencyjnych jest wywoływanie samych siebie, trzeba zawsze pamiętać o odpowiedniej definicji warunku stopu, co nie zawsze może być takie łatwe jak w przykładowym programie. Dodatkowo warto wiedzieć, że wywoływanie funkcji w programie zawsze oznacza pewien spadek wydajności. Jeśli są to pojedyncze funkcje wykorzystywane tak jak do tej pory, spadek wydajności jest niemal niezauważalny, a korzystanie z funkcji jest koniecznością, aby móc tworzyć poprawne i przejrzyste programy i funkcji w takich przypadkach należy bezwzględnie używać.

Jednak w przypadku funkcji rekurencyjnych nie mówimy o pojedynczym wywołaniu funkcji. Mówimy tutaj zazwyczaj o dużej liczbie wywołań funkcji, co może znacząco wpłynąć na wydajność programu. W przypadku funkcji silnia ilość wywołań funkcji wyniosłaby niemal tyle ile wartość liczby, dla której funkcję silnia liczymy, co oznacza, że licząc funkcję silnia dla liczby 731, niemal tyle razy funkcja silnia zostałaby właśnie wywołana.

Co zatem zrobić w takiej sytuacji - użyć funkcji rekurencyjnej czy nie? Okazuje się, że w wielu przypadkach lepiej zastąpić funkcję rekurencyjną wywołaniem iteracyjnym (czyli użyć pętli), oczywiście o ile będzie to możliwe. Nie zawsze zastąpienie rekurencji iteracją jest możliwe w prosty sposób. Niekiedy wymaga to pracy znacznie dłuższej niż napisanie całego programu, ale biorąc pod uwagę możliwą poprawę wydajności, taki zabieg jest często stosowany, bowiem wydajność i szybkość działania programu ma często kluczowe znaczenie.

Na przykładzie naszej funkcji silnia bardzo łatwo jest zastąpić rekurencję iteracją, rezygnując z wielokrotnego wywoływania funkcji silnia. Czy wiesz jak tego dokonać? Jeśli przeanalizujesz wywołanie funkcji silnia np. dla i=6 otrzymasz silnia(6) = 6 · silnia (5). Z kolei silnia(5) = 5 · silnia(4). Ostatecznie zatem silnia(6) = 6 · 5 · silnia(4). Analizując dalsze wywołania funkcji ostatecznie dojdziesz do wniosku, że silnia(6) = 6 · 5 · 4 · 3 · 2 · 1. Uogólniając dla dowolnej liczby n, silnia(n) = n · (n-1) · (n-2) · ... · 1.

Czy wiesz już zatem jak zdefiniować funkcje silnia iteracyjnie, czyli przy wykorzystaniu pętli zamiast rekurencji? Mam nadzieję, że wiesz już doskonale jak to zrobić. Korzystając z dowolnego typu pętli (choć najwygodniejsza będzie pętla for) dla definicji funkcji silnia otrzymujemy ostatecznie następujący program:

#include <iostream>

using namespace std;

unsigned long int silnia(unsigned int);

int main()
{
 unsigned int n;
 
 cout <<"Podaj liczbe, dla ktorej chcesz policzyc wartosc silni: ";
 cin >>n;
 cin.ignore();
 
 cout <<endl<<"Silnia wynosi "<<silnia(n)<<endl;
 
 cout <<endl<<"Nacisnij ENTER aby zakonczyc"<<endl;
 getchar();
 return 0;        
}

unsigned long int silnia(unsigned int i)
{
  unsigned long int iloczyn=1;
  for (unsigned int j=2;j<=i;++j)
     iloczyn*=j;
     
  return iloczyn;    
}
program nr 36.4

Program dla dużych liczb w wersji iteracyjnej będzie niewątpliwie wykonywał się szybciej niż w wersji rekurencyjnej. Jak widzisz, przekształcenie funkcji rekurencyjnej w funkcję wykorzystującą iterację do realizacji tego samego zadania, było w tym przypadku bardzo łatwe. Pamiętaj jednak, że nie zawsze będzie to możliwe i łatwe do realizacji. Wykorzystuj zatem funkcje rekurencyjne jeśli jest to konieczne, ale rób to zawsze ostrożnie i z rozwagą, pamiętając o konsekwencjach wykorzystania tych funkcji.

Zwracanie wartości przez funkcję

Jak już wiesz, każda funkcja w języku C++ może zwracać jakąś wartość. Wartości zwracane przez funkcję mogą być wykorzystywane w różnych celach - mogą oznaczać rezultat wykonania danej funkcji (np. true - obliczenia zostały wykonane prawidłowo, false - obliczenia nie zostały wykonane prawidłowo) lub zwracać pewną konkretną wartość (np. rezultat obliczeń). W przypadku rekurencyjnej funkcji silnia, zwracanie wartości przez funkcję było niezbędne, aby móc w ogóle skorzystać w ten sposób z funkcji i wyliczyć wartość silni.

Mimo, że zwracanie wartości przez funkcję jest bardzo przydatne i często nie można obyć się bez tego mechanizmu, to jednak zwracanie wartości przez funkcję ma jedną zasadniczą wadę.

Funkcja w języku C++ może zwrócić tylko jedną wartość określonego typu.

Przede wszystkim zauważ, że w języku C++ musimy z góry określić w deklaracji funkcji, jakiego typu wartość będzie zwracana przez funkcję. Nawet gdybyśmy chcieli w pewnych przypadkach zwracać wartość jednego typu, a w innych wartość drugiego typu, nie jest to możliwe - typ wartości jaki zwraca funkcja w języku C++ musi być jasno określony.

Dodatkowo zwróć uwagę, że funkcja w języku C++ może zwrócić tylko jedną wartość określonego typu. Nie jest zatem możliwe zwrócenie przez funkcję dwóch lub trzech wartości - funkcja może zwrócić tylko i wyłącznie jedną wartość.

O ile z pierwszym ograniczeniem nie da się praktycznie nic zrobić (a tak naprawdę zazwyczaj to ograniczenie nie utrudni Ci zbytnio życia), o tyle drugie ograniczenie w wielu przypadkach nie jest aż tak dotkliwe. Mam nadzieję, że pamiętasz, że w języku C++ oprócz typów prostych istnieją również typy złożone, które można samodzielnie definiować. Zatem jeśli wiesz do jakich celów została stworzona funkcja i jakie wartości chciałbyś za jej pomocą zwracać możesz po prostu zdefiniować własną strukturę, a funkcja będzie zwracała wartość zdefiniowanego przez Ciebie typu strukturalnego.

Wyobraźmy sobie zatem, że chcemy w programie zdefiniować funkcję, która ma za zadanie przeprowadzenie operacji na liczbach naturalnych większych od zera. Tymi operacjami niech będą dodawanie, odejmowanie, mnożenie i dzielenie. Skoro chcemy, żeby funkcja obliczyła rezultaty 4 działań, należy zdefiniować strukturę, składającą się z 4 elementów odpowiedniego typu. Skoro założyliśmy, że działania będziemy przeprowadzać na liczbach naturalnych większych od zera, łatwo dojść do wniosku, że struktura powinna wyglądać następująco:

struct obliczenia
{
  unsigned int suma;
  int roznica;
  long int iloczyn;
  double iloraz;
};

Mając w ten sposób zdefiniowaną strukturę, dosyć łatwo zdefiniować funkcję, która wykorzysta strukturę do zwrócenia większej liczby rezultatów obliczeń. Wyniki obliczeń zostają przypisane do odpowiednich składowych struktury, a następnie wartość typu strukturalnego zostaje zwrócona i można wykorzystać rezultaty obliczeń w innych częściach programu (na przykład w funkcji main). Program, który wykorzystuje w ten sposób strukturę możesz zobaczyć poniżej:

#include <iostream>

using namespace std;

struct obliczenia
{
  unsigned int suma;
  int roznica;
  long int iloczyn;
  double iloraz;
};


struct obliczenia oblicz(unsigned int, unsigned int);

int main()
{
 unsigned int  a,b;
 struct obliczenia wynik;
 cout <<"Podaj a i b (naturalne > 0): "<<endl;
 cin >>a>>b;
 cin.ignore();
 
 wynik =  oblicz (a,b);
 cout <<"Wynik dodawania: "<<wynik.suma<<endl;
 cout <<"Wynik odejmowania: "<<wynik.roznica<<endl;
 cout <<"Wynik mnozenia: "<<wynik.iloczyn<<endl;
 cout <<"Wynik dzielenia: "<<wynik.iloraz<<endl;
 
 cout <<endl<<"Nacisnij ENTER aby zakonczyc"<<endl;
 getchar();
 return 0;    
}

struct obliczenia oblicz(unsigned int x, unsigned int y)
{
  struct obliczenia o;
                   
  o.suma = x+y;                    
  o.roznica = x-y;
  o.iloczyn = x*y;
  o.iloraz = static_cast<double>(x)/y;
 
  return o;  
}
program nr 36.5

Jak widzisz w programie nie ma tak naprawdę żadnych elementów, których byś nie znał. Program prezentuje po prostu możliwość wykorzystania struktury jako typu zwracanego przez funkcję, co niekiedy może być bardzo przydatne, o czym już wkrótce będziesz mógł się przekonać.

Podsumowanie

W tej lekcji przedstawiłem Ci kolejne informacje na temat funkcji. Wiesz już, że funkcje mogą być wywoływane w różnych fragmentach programu i że stosowanie funkcji umożliwia podział programu na mniejsze elementy, nad którymi łatwiej zapanować. Znasz także pojęcie funkcji rekurencyjnych i wiesz, że mimo że mogą być one bardzo przydatne, trzeba być ostrożnym w ich stosowaniu. Wiesz już także, że struktury mogą być przydatnym typem, gdy chcesz aby funkcja udostępniła rezultaty kilku operacji, bowiem funkcje mogą zwracać tylko jedną wartość określonego typu.

powrót