Функции - втор дел

Функции се повикуваат со наведување на нивните аргументи - вредности кои им се потребни за да ја извршат својата задача. Притоа, во претходното предавање зборувавме за два термина: аргумент и параметар. Аргумент претставува вредност/константа/променлива со која се повикува одредена функција, додека параметар претставува променливата наведена во декларацијата на истата таа функција. Параметрите на одредена функција може да се користат само локално - во блокот со кој е ограничена функцијата.

Предавање на аргументи по вредност и по референца

Стандардно, аргументите во C++ се предаваат по вредност. Ова значи дека, кога се прави повик до одредена функција, во позадина се прави копија од аргументите и таквите копии се користат како параметри на функцијата. Бидејќи се прави копија од аргументите, не постои начин на кој функцијата може да ги промени нивните вредности. Или, кажано поинаку, промените на параметрите немаат никаков ефект врз аргументите.

Програма 16.1

#include <iostream>
using namespace std;

int f1(int a, int b, int c)
{
      a = a+1;
      b = b-1;
      c = c + (a*b);
      
      return c;
}

int main()
{
      int a=3, b=2, c=5;
      
      cout << f1(1, 1, 1) << endl;                     //pechati '1'
      cout << f1(a, 2, 0) << endl;                     //pechati '4'
      
      cout << f1(a, b, c) << endl;                     //pechati '9'
      
      //vrednostite na a, b i c se nepromeneti
      cout << a << " " << b << " " << c << endl;       //pechati '3 2 5'
      
      return 0;
}

На пример, при последниот повик на функцијата f1(int a, int b, int c), се прават копии од вредностите на аргументите (a=3, b=2, c=5). Притоа, промените на вредностите на параметрите немаат никакво влијание на аргументите предадени на функцијата при нејзиниот повик - променливите a, b и c од функцијата main().

Иако функциите може да испратат вредност до оној што ги повикал (на пример, двете функции во програмата дадена погоре, со return наредби, враќаат вредност од тип int), постојат и ситуации кога таквото комуницирање со функциите не е доволно. На пример, понекогаш сакаме функциите да променат повеќе вредности или пак директно да изменат вредност на одредена променлива.

Во C++, најдобриот начин на кој може да им се дозволи на функциите да ги променат вредностите на аргументите е доколку таквите аргументи ги предадеме по референца. За да го овозможиме тоа, единствено нешто што треба да направиме е да означиме дека параметрите претставуваат референци до аргументите, а не нивни копии. Во C++, тоа се прави со запишување на знакот '&' пред името на соодветниот параметар.

Програма 16.2

#include <iostream>
using namespace std;

int f1(int &t, int u, int v)
{
      t = t+1;
      u = u-1;
      v = v + (t*u);
      
      return v;
}

int main()
{
      int a=3, b=2, c=5;
      
      cout << f1(a, b, c) << endl;                     //pechati '9'
      
      //vrednosta na a e promeneta
      //vrednostite na b i c ostanaa nepromeneti
      cout << a << " " << b << " " << c << endl;       //pechati '4 2 5'
      
      return 0;
}

Во програмата дадена погоре, параметарот t во функцијата f1(int &t, int u, int v) претставува референца до променливата a дефинирана во функцијата main(). Останатите два аргументи се пренесуваат по вредност (се прави нивна копија), и промените над соодветните параметри немаат никаков ефект врз аргументите со кои се повикува функцијата.

Практично, референците овозможуваат поврзување на параметрите со аргументите. Имињата на параметрите и аргументите не мора да се совпаѓаат - како што може да се забележи и од програмата дадена претходно. Во позадина, референците најчесто се имплементираат (од страна на компајлерите) со помош на покажувачи. На овој начин, параметрите веќе не претставуваат обични променливи, туку "референци" до истата мемориска локација до која што покажуваат променливите кои се предадени како аргументи на функцијата.

Следната програма прикажува како една функција може да промени вредности на повеќе променливи:

Програма 16.3

#include <iostream>
using namespace std;

void reshi(int x, int y, int &sob, int &odz, int &mnoz)
{
      sob = x+y;
      odz = x-y;
      mnoz = x*y;
}

int main()
{
      int a = 30, b = 4;
      
      int s, o, m;
      reshi(a, b, s, o, m);
      
      cout << s << " " << o << " " << m << endl;       //pechati '34 26 120'
      
      return 0;
}

Покрај тоа што се овозможува промена на аргументите и враќање на повеќе вредности, една од главните предности на предавањето по референца е тоа што не се прави копија на аргументите. Во одредени ситуации, ова може сериозно да го забрза извршувањето на програмите: на пример, доколку на некоја функција и предаваме огромна структура на податоци, чие копирање би одземало многу време.

Но, што доколку сакаме на функцијата да и предадеме голема структура на податоци, но не сакаме функцијата да може да ги промени вредностите кои се чуваат во структурата. Во тој случај, најдобро решение е аргументите да ги предадеме по т.н. константна референца (референца која не дозволува промена на вредноста на аргументот). Во C++, тоа се прави така што, во декларацијата на функцијата, се додава клучниот збор const - пред името или типот на параметарот. Нормално, бидејќи се работи за референци, задолжително е наведување и на знакот '&'.

Програма 16.4

#include <iostream>
using namespace std;

int f1(int const &x, int const &y)
{
      x = x+1;       //greshka, nedozvoleno
      y = y*3;       //greshka, nedozvoleno
      return x+y;
}

int f2(const int &x, const int &y)
{
      x = x+1;       //greshka, nedozvoleno
      y = y*3;       //greshka, nedozvoleno
      return x*y;
}

int main()
{
      int a = 30, b = 4;
      
      cout << f1(a, b) << endl;
      cout << f2(a, b) << endl;
      
      return 0;
}

Во програмата дадена погоре, промената на кој било од параметрите ќе предизвика грешка - компајлерот нема да дозволи преведување на програмата.

Предавање на аргументи по адреса

Покрај предавањето на аргументи по вредност и референца, во C++ е дозволено и предавање на аргументи по мемориска адреса. Нормално, бидејќи аргументите се предаваат по адреса, параметарот на функцијата треба да е покажувач со соодветен тип - еднаков на типот на аргументот кој и се предава на функцијата.

Бидејќи, во претходниот дел, кажавме дека и референците најчесто се имплементирани преку покажувачи до променливи, сигурно се прашувате што е разликата помеѓу предавањето на аргументи по адреса и предавањето на аргументи по референца. Одговорот е дека разликата е во начинот на нивното користење. Додека кај референците можеме директно да ја прочитаме или промениме вредноста на параметарот, кај покажувачите мора да извршиме дереференцирање на покажувачот за да може да го направиме истото.

Програма 16.5

#include <iostream>
using namespace std;

int f1(int *x, int *y)
{
      (*x) = 5;
      (*y)++;
      
      return (*x) + (*y);
}

int main()
{
      int a = 10, b = 4;
      
      cout << f1(&a, &b) << endl;          //pechati '10'
      cout << a << " " << b << endl;       //pechati '5 5'
      
      cout << f1(&a, &b) << endl;          //pechati '11'
      cout << a << " " << b << endl;       //pechati '5 6'
      
      return 0;
}

Забележете дека читањето и промената на вредностите ги правиме преку дереференцирање на параметрите на функцијата (кои, всушност, претставуваат покажувачи). Нормално, дереференцирањето го правиме со наведување на знакот '*' пред името на променливата. Исто така, забележете и дека аргументите на функцијата всушност претставуваат адреси до соодветните променливи чии вредности сакаме да ги промениме. Добивањето на адреса се прави со наведување на знакот '&' пред името на променливата.

Покажувачите, вообичаено, не се користат како параметри на една функција за предавање на основните типови на податоци. Како што видовме од претходниот дел, за ваквите аргументи користењето на референци има многу поголема смисла - може да се наведе дека променливите кои се референцираат не треба да се менуваат. Исто така, бидејќи не треба да се врши никакво референцирање/дереференцирање на аргументите и параметрите, користењето на референци е многу поедноставно.

Покажувачите се користат при предавање на низи (еднодимензионални или повеќедимензионални) како аргументи на една функција. Всушност, променливите кои ги користиме за пристап до една низа претставуваат покажувачи до првиот елемент од таа низа (ова важи за сите низи). Кога ги користиме заградите '[' и ']' за пристап до одреден елемент од низа, ние всушност вршиме дереференцирање на адресата на тој елемент. На тој начин, можеме да ја прочитаме или промениме неговата вредност.

Разгледајте ја следната програма:

Програма 16.6

#include <iostream>
using namespace std;

void f1(int arr[], int n)
{
      for (int i=0; i<n; i++)
            arr[i] = 10+i;                     //avtomatsko dereferenciranje
}

void f2(int *arr, int n)
{
      for (int i=0; i<n; i++)
            arr[i] = 20+i;                     //isto kako kaj f1(int arr[], int n)
}

int main()
{
      int arr[10] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
      
      f1(arr, 10);
      cout << arr[5] << endl;                  //pechati '15'
      
      f2(arr, 10);
      cout << arr[5] << endl;                  //pechati '25'
      
      return 0;
}

И во двете функции, во позадина, предавањето на низата како аргумент се врши преку нејзината адреса. Првиот начин на декларирање (со наведување на името на параметарот и знаците '[' и ']') се користи многу почесто - f1(int arr[], int n), бидејќи од самата декларација на функцијата се гледа дека како аргумент се предава низа од податоци. Од втората функција ова не може директно да се заклучи - може да предаваме низа или едноставен покажувач до променлива од тип int.

Забележете дека низите немаат метод преку кој можеме да ја дознаеме нивната должина. Поради тоа, при предавање на низа како аргумент на одредена функција, потребно е на функцијата да и ја предадеме и должината на соодветната низа (во случајов, тоа е параметарот со име n). Доколку на една функција и предаваме повеќедимензионална низа, во декларацијата на функцијата може да се изостави само големината на првата димензија. Сите останати големини мора да бидат експлицитно наведени. Само на тој начин компјутерот може да ја знае длабочината на секоја димензија и правилно да го изврши дереференцирањето на адресите (инаку, низите претставуваат само блокови податоци наредени последователно во меморијата).

Извадок 16.1

void f2(int m[][2], int n)                //pravilno
void f3(int m[][10][10], int n)           //pravilno
void f4(int m[][2][3][4], int n)          //pravilno

void f5(int m[][][], int n)               //nepravilno
void f6(int m[][][5], int n, int m)       //nepravilno

Кога ги користиме заградите '[' и ']' за пристап до одреден елемент од низа, ние всушност вршиме дереференцирање на адресата на тој елемент. Ако се сеќавате, во делот за покажувачи кажавме дека аритметичките операции врз покажувачите знаат точно колку е големината на податочниот тип на покажувачот. На пример, доколку pokazuvac покажува до првиот елемент на одредена низа, тогаш pokazuvac+1 покажува до вториот елемент, итн. Од тука е јасно како всушност се врши дереференцирањето кај низите: arr[0] претставува (*(arr)) или (*(arr+0)), arr[1] претставува (*(arr+1)), arr[5] претставува (*(arr+5)), итн. Бидејќи компајлерот може самиот да го врши дереференцирањето, ние може да ги користиме знаците '[' и ']' и, на тој начин, да пишуваме код кој е многу поедноставен и почитлив.

Рекурзија

Рекурзивна функција претставува функција која се повикува самата себеси. Кај рекурзивните функции, секогаш (ама секогаш!) мора да наведеме услов за завршување на рекурзијата. Во спротивно, функцијата ќе продолжи да се повикува самата себеси до бесконечност - практично, додека не снема меморија или оперативниот систем не ја сруши нашата програма.

Да разгледаме две функции за пресметување на факториел - производ на сите природни броеви помали или еднакви на N. Факториелот на природниот број N (се означува N!) го дава бројот на пермутации на одредено множество со N елементи (на колку начини може да се наредат N различни елементи во листа). На пример, 3! = 1*2*3 = 6 и постојат 6 различни начини да се наредат 3 елементи во листа: [1, 2, 3], [1, 3, 2], [2, 1, 3], [2, 3, 1], [3, 1, 2], [3, 2, 1].

Програма 16.7

#include <iostream>
using namespace std;

int factorial1(int n)
{
      int r = 1;
      for (int i=2; i<=n; i++)
            r = r*i;
      
      return r;
}

int factorial2(int n)
{
      if (n <= 1)                                //n==0 || n==1
            return 1;                            //(0!)=1, (1!)=1
      
      return n * factorial2(n-1);                //rekurziven povik
}

int main()
{
      cout << factorial1(5) << endl;             //120
      cout << factorial2(5) << endl;             //120
      
      return 0;
}

Првата функција е стандардна и неа нема посебно да ја разгледуваме. Но, погледнете ја втората функција - factorial2(int n). Таа се повикува самата себеси и го користи резултатот кој истата го враќа за параметар (n-1). Во конкретниот случај (за пресметување на факториел), ова важи бидејќи N! = N * (N-1)!, или 5! = 5 * (4!) = 5 * 4 * 3 * 2 * 1.

Да разгледаме што се случува при повикот на factorial2(5):

  1. се проверува дали (n=5) <= 1. Бидејќи не е, потребно е да се пресмета колку е факториел од 4, за да може да се пресмета вредноста на 5!. Се упатува повик до factorial2(4).
  2. се проверува дали (n=4) <= 1. Бидејќи не е, потребно е да се пресмета колку е факториел од 3, за да може да се пресмета вредноста на 4!. Се упатува повик до factorial2(3).
  3. се проверува дали (n=3) <= 1. Бидејќи не е, потребно е да се пресмета колку е факториел од 2, за да може да се пресмета вредноста на 3!. Се упатува повик до factorial2(2).
  4. се проверува дали (n=2) <= 1. Бидејќи не е, потребно е да се пресмета колку е факториел од 1, за да може да се пресмета вредноста на 2!. Се упатува повик до factorial2(1).
  5. се проверува дали (n=1) <= 1. Бидејќи условот е исполнет, функцијата враќа резултат 1.
  6. Резултатот вратен во чекор 5, при повикот factorial2(1), се користи при пресметување на factorial2(2), така што 2 се множи со добиената вредност од factorial2(1) = 1, во чекор 5. Добиениот резултат 2*factorial2(1) = 2*1 = 2, се враќа како резултат на повикот factorial2(2).
  7. Резултатот вратен во чекор 6, при повикот factorial2(2), се користи при пресметување на factorial2(3), така што 3 се множи со добиената вредност од factorial2(2) = 2, во чекор 6. Добиениот резултат 3*factorial2(2) = 3*2 = 6, се враќа како резултат на повикот factorial2(3).
  8. Резултатот вратен во чекор 7, при повикот factorial2(3), се користи при пресметување на factorial2(4), така што 4 се множи со добиената вредност од factorial2(3) = 6, во чекор 7. Добиениот резултат 4*factorial2(3) = 4*6 = 24, се враќа како резултат на повикот factorial2(4).
  9. Резултатот вратен во чекор 8, при повикот factorial2(4), се користи при пресметување на factorial2(5), така што 5 се множи со добиената вредност од factorial2(4) = 24, во чекор 8. Добиениот резултат 5*factorial2(4) = 5*24 = 120, се враќа како резултат на повикот factorial2(5), што всушност е и првобитниот повик упатен од страна на функцијата main().

Доколку, во која било функција која се повикува самата себеси, не ставиме услов за крај, функцијата ќе продолжи да се повикува себеси и никогаш нема да врати резултат. На пример, во следнава функција е избришан условот за крај:

Извадок 16.2

int factorial(int n)
{
      //fraer sum, i ne stavam uslov za kraj
      //if (n <= 1) return 1;
      
      return n * factorial(n-1);       //rekurziven povik
}

Поради тоа, функцијата ќе продолжи да се повикува самата себеси до бесконечност. Всушност, кога ќе заврши да се повикува за параметри n>=0, таа едноставно ќе продолжи да се повикува за параметри n<0 (негативни броеви). Вака напишано, ние всушност бараме од компјутерот да го пресмета изразот: n*(n-1)*(n-2)* ....... * 2 * 1 * 0 * (-1) * (-2) * ....... (чие пресметување никогаш нема да заврши).

Магацин

Магацин (анг. stack) претставува LIFO ("last in, first out" или "последното внесено, прво се изнесува") структура од податоци врз која може да се вршат следните операции:

  • извади го првиот елемент од магацинот (најгорната кутија од магацинот)
  • стави нов елемент во магацинот (нова кутија врз сите постоечки кутии)

Забележете дека овие две операции ги реализираат LIFO операциите: последниот елемент (last in) кој ќе го ставиме во магацинот (најгорната кутија), ќе биде првиот елемент кој ќе го извадиме (first out). Не е возможно да се извади друга кутија бидејќи има кутии кои зависат од неа (се наоѓаат врз неа и ќе паднат доколку неа ја извадиме - па ќе се искршат компјутери, мобилни телефони, телевизори, чинии, итн).

Веројатно, во моментов, се прашувате "Добро бе, нели предавањето беше за функции? Какви магацини сега, какви глупости?". Значи, време е да објасниме каква врска имаат магацините со повикувањето на функции.

Кај компјутерите, делот од меморијата наречен stack (магацин) служи за чување на сите функциски повици, параметрите на функциите и сите локални променливи кои се користат од страна на функциите. На пример, во следната програма, делот од меморијата кој служи за чување на вредностите на променливите a, b и c се алоцира од stack меморијата:

Програма 16.8

#include <iostream>
using namespace std;

int main()
{
      int a, b, c;
      a = 3; b = 2, c = 5;
      
      cout << (a+b+c) << endl;       //pechati '10'
      return 0;
}

Е сега, зошто оваа меморија се чува во структура на податоци врз која може да се извршуваат LIFO (last in, first out) операции? Одговорот е едноставен - бидејќи за да се заврши една функција и истата да врати резултат, мора претходно да завршат сите функции кои таа ги повикува. Звучи познато? Ова е исто како и кај кутиите - за да извадиме една кутија потребно е прво да ги извадиме сите кутии кои се ставени врз неа.

Како пример, замислете дека функцијата A() ја повикува функцијата B(), функцијата B() ја повикува функцијата C() и функцијата C() ја повикува функцијата D() - или напишано во скратена форма A() -> B() -> C() -> D(). Кога A() ја повикува B(), А мора прво да запише до каде стигнала со извршување (до која наредба), за да може во иднина од таму да продолжи. Овие информации таа ги става во магацин. Слично и за функцијата B() - таа мора да запише до каде стигнала со извршувањето пред да ја повика C(), итн. Целосно исто и за функцијата C() - и таа запишува до каде стигнала пред да испрати повик до D(). Досега елементите во магацинот се наредени вака: [информациите до каде стигнала A се најдолу, па врз нив следат информациите за B, па информациите за C; и, на крај, информациите за D].

Сега, за да заврши функцијата C() мора прво да заврши функцијата D(), за да заврши функцијата B() мора прво да заврши функцијата C() и за да заврши функцијата A() мора прво да заврши функцијата B(). Повторно, исто како и кај магацините - елементите ги вадиме почнувајќи од оној кој стои најгоре: прво информациите за D(), па за C(), па за B(), па за A().

Нормално, stack делот од меморијата има ограничена големина и, во него, не може да запишуваме бесконечно многу податоци. Доколку се обидеме да запишеме повеќе податоци отколку што има простор во меморијата, програмата ќе заврши со порака "Stack overflow". Ова често се случува кај рекурзивните функции без услов за крај - функцијата ќе продолжи да се повикува самата себеси додека има простор да се запишуваат податоци во магацинот. Затоа и ги споменуваме магацините во предавање за рекурзија: следниот пат кога ќе користите рекурзивни функции и ќе добиете порака "Stack overflow", ќе знаете каде сте направиле грешка.

Друга можна причина за преполнување на stack меморијата е креирањето на низи со огромен број на елементи. На пример, следнава програма ќе заврши неславно (интересно, некои компајлери нема ни да дозволат нејзино преведување):

Програма 16.9

#include <iostream>
using namespace std;

int main()
{
      //10000 * 10000 * 10000 * 4 bajti = 4000000000000 bajti
      //4000000000000 bajti ~ 3725 gigabajti memorija
      int arr[10000][10000][10000];
      
      cout << arr[123][1231][2131] << endl;
      return 0;
}

Меморија (код, глобални податоци, куп, магацин)

Кога веќе го споменавме stack делот, потребно е да кажеме каде во меморијата се чуваат и останатите податоци:

  1. код (code area) - тоа е дел од меморијата каде се наоѓаат наредбите од нашата програма
  2. глобален дел (global area) - тоа е дел од меморијата каде се чуваат глобалните податоци (променливите дефинирани надвор од која било функција)
  3. куп (heap) - дел од меморијата од каде се алоцира простор за чување на динамички креираните податоци (arr = new int[10]). Тоа е всушност меморијата побарана за време на извршување на програмата. Глобалниот дел и купот (2 и 3) понекогаш, заедно, се среќаваат под името податочен дел или податочен сегмент (data segment).
  4. stack (магацин) - тоа е дел од меморијата кој служи за чување на сите функциски повици, параметрите на функциите и сите нивни локални променливи

Дозвола за користење: CC BY-NC 2.5 ©