Uvod
Koncept funkcija povratnog poziva podrazumeva predavanje jedne funkcije drugoj u svojstvu argumenta i - iako to što smo prethodno naveli možda ne deluje posebno zanimljivo na prvi pogled (budući da znamo da se u telu jedne funkcije uvek po potrebi može pozvati neka druga funkcija) - u praksi je drugačije i korišćenje funkcija povratnog poziva otvara vrlo zanimljive mogućnosti.
Kada su u pitanju lambda izrazi, * recimo (za početak) da je u pitanju način da se funkcije manjeg obima zapišu "na licu mesta" (često i u samo jednom redu) - tamo gde bismo inače pozivali drugu, imenovanu funkciju (a u nastavku ćemo naravno prodiskutovati o detaljima implementacije).
Za početno upoznavanje sa funkcijama povratnog poziva i sa lambda izrazima koristićemo Javascript, jezik u kome su lambda izrazi (kao i mnogo šta drugo), implementirani na "idiosnikratičan", ** ali istovremeno vrlo pregledan i razumljiv način, a na kraju ćemo se osvrnuti i na primere upotrebe lambda izraza u Python-u, C#-u i Javi.
Callback funkcije
Pretpostavićemo da je čitaocima poznato da se preko funkcije setInterval
mogu periodično pozivati druge funkcije:
function ispis() {
var panel = document.getElementById("info_panel");
var d = new Date();
let sat = d.getHours();
let min = d.getMinutes();
let sek = d.getSeconds();
panel.innerHTML = `${sat}:${min}:${sek}`;
}
setInterval(ispis, 1000);
Praktična svrha konkretnog primera sa gornje slike je prikaz trenutnog vremena preko HTML elementa čiji je id "info_panel" (najprostije rečeno, u pitanju je "digitalni časovnik sa sekundama"), a rezultat se dobija u sprezi dve funkcije:
ispis
- za očitavanje trenutnog vremenasetInterval
- za periodično pozivanje funkcijeispis
U ovom slučaju, funkcija setInterval
je za nas zanimljivija, jer primećujemo da se funkciji, kao jedan od argumenata, predaje naziv druge funkcije.
Kada određenoj funkciji predajemo drugu funkciju kao argument, predata funkcija ima ulogu funkcije povratnog poziva (eng. - callback function).
U gornjem primeru, funkcija setInterval
(čija je svrha periodično pokretanja drugih funkcija), u stanju je da pokrene bilo koju funkciju, koju predamo kao argument.
Da bismo bolje razumeli pravi smisao funkcija povratnog poziva, pogledajmo primer - šematski prikaz funkcije za iscrtavanje grafika matematičkih funkcija (zapisan preko pseudokoda) - pri čemu funkcija (za početak) ne koristi povratne pozive (ali ćemo je na kraju naravno unaprediti):
function generisanjeKoordinataSin() {
// naredbe
}
function generisanjeKoordinataCos() {
// naredbe
}
function generisanjeKoordinataTan() {
// naredbe
}
function generisanjeKoordinataCtg() {
// naredbe
}
function Iscrtavanje(vrsta) {
koordinate = [];
switch (vrsta) {
case "sin": koordinate.push(generisanjeKoordinataSin()); break;
case "cos": koordinate.push(generisanjeKoordinataCos()); break;
case "tan": koordinate.push(generisanjeKoordinataTan()); break;
case "ctg": koordinate.push(generisanjeKoordinataCtg()); break;
default: break;
}
CrtanjeGrafika(koordinate);
}
// Poziv:
Iscrtavanje("sin");
Pretpostavljamo da prikazani (pseudo)kod ne deluje posebno problematično (pogotovo iz perspektive mlađih programera), ali, svakako bi mogao biti i bolji.
Ako kao parametar dodamo callback funkciju, moći ćemo da celo (poveće) switch grananje iz prethodne implementacije praktično svedemo na samo jednu naredbu ....
function Iscrtavanje(callback) {
koordinate = [];
koordinate.push(callback());
CrtanjeGrafika(koordinate);
}
// switch nismo 'svodili', niti prepravljali,
// već (najprostije) izbacili, ali smo posigli
// da funkcionalnost bude očuvana (štaviše,
// funkcionalnost je praktično poboljšana),
// uz pojednostavljivanje koda
.... posle čega ćemo funkciju za iscrtavanje pozivati uz navođenje konkretnih funkcija za generisanje koordinata kao argumenata:
// Iscrtavanje grafika sinusne funkcije:
Iscrtavanje(generisanjeKoordinataSin);
// Iscrtavanje grafika kosinusne funkcije:
Iscrtavanje(generisanjeKoordinataCos);
Boljom organizacijom koda, postigli smo sledeće:
- pojednostavljivanje koda same funkcije
- bolju proširivost (bilo kakva funkcija koja ima odgovarajuću kombinaciju ulaznih i izlaznih vrednosti može se sada proslediti kao callback funkcija)
Druga stavka zaslužuje posebnu pažnju.
Ako nam padne na pamet da proširimo mogućnosti funkcije Iscrtvanje
, tako što ćemo dodati opciju za iscrtavanje (na primer) grafika logaritamske funkcije, dovoljno je da napravimo funkciju koja će generisati koordinate za grafik logaritamske funkcije i da je predamo kao argument:
function generisanjeKoordinataLog() {
// naredbe
}
// Iscrtavanje grafika logaritamske funkcije:
Iscrtavanje(generisanjeKoordinataLog);
Ovo je svakako bolji način (u odnosu na switch grananje), ali, pošto funkcije koje smo prikazali (budući da sadrže samo pseudokod), nisu u stanju da daju pravi rezultat, pogledajmo i jedan jednostavan primer koji možete sami isprobati (pre nego što pređemo na nove teme):
function f1() {
console.log("Poziv funkcije f1()");
}
function f2() {
console.log("Poziv funkcije f2()");
}
function f3(callback1, callback2) {
console.log("Funkcija f3() obavlja sledeće pozive:");
callback1();
callback2();
}
// Poziv:
f3(f1, f2);
f3(f2, f1);
Navedeni kod daje sledeći ispis:
Funkcija f3() obavlja sledeće pozive:
Poziv funkcije f1()
Poziv funkcije f2()
Funkcija f3() obavlja sledeće pozive:
Poziv funkcije f2()
Poziv funkcije f1()
U primeru sa funkcijom za isrtavanje grafika matematičkih funkcija, naslućujemo da bi konkretne funkcije za generisanje koordinata, koje bi se koristile kao callback funkcije, bili blokovi koda većeg obima i značaja.
Međutim, u praksi se vrlo često (nasuprot navedenom) javlja potreba za callback funkcijama sa veoma malim brojem naredbi, često i samo jednom.
U takvim slučajevima, možemo koristiti lambda izraze - male, neimenovane funkcije koje se (u Javascriptu) mogu implementirati na više načina:
- preko neimenovanih funkcija
- preko streličastih (arrow) funkcija
Mapiranje i filtriranje nizova su tipični (reklo bi se zapravo - najtipičniji) primeri metoda u kojima je posebno zgodno koristiti lambda notaciju za definisanje funkcija povratnog poziva.
Preko ulazne vrednosti niz1
....
let niz1 = [ 1, 2, 3, 4, 5, 6, 7 ];
.... možemo kreirati novi niz u koji se kopira kvadrat svakog elementa ulaznog niza:
let niz2 = niz1.map(x => x * x);
// niz2 = [ 1, 4, 9, 16, 25, 36, 49 ];
.... ili možemo u novi niz kopirati samo one elemente koji su veći od 10 (svako x, tako da važi da je x > 10):
let niz3 = niz1.filter(x => x > 10);
// niz3 = [ 16, 25, 36, 49 ];
Ovakav zapis, sa jedne strane deluje vrlo intuitivno, ali istovremeno (sa druge strane), iskustvo je pokazalo da programerima koji se prvi put sreću sa lambda zapisom koji se koristi u ugrađenim funkcijama kao što su map
i filter
, takva sintaksa deluje kao da je svojstvena samo navedenim funkcijama (a ne kao notacija koja se ne može koristiti i u drugim okolnostima).
Pokazaćemo da to nije slučaj, već da je samo u pitanju veoma elegantan zapis callback funkcija, koje bi se inače mogle realizovati i preko 'običnih' neimenovanih funkcija (a svakako i preko imenovanih funkcija) i koristiti i na drugim mestima.
Kao primer ćemo koristiti samostalnu implementaciju funkcije za mapiranje niza, sa povratnim pozivom, koji ćemo realizovati na tri različita načina:
- preko imenovane funkcije
- preko neimenovane funkcije
- preko lambda notacije
Callback funkcija realizovana preko imenovane funkcije
Za početak, kreiraćemo samu funkciju za mapiranje niza, po ugledu na ugrađenu funkciju map
:
function mapiranje(niz, calback) {
let novi_niz = [];
for (let i = 0; i < niz.length; i++) {
novi_niz.push(callback(niz[i]));
}
return novi_niz;
}
Vidimo da se svaki element preslikava preko callback funkcije, koja može biti bilo kakva funkcija koja ispunjava sledeća dva uslova:
- prima jedan celobrojni argument
- vraća jednu celobrojnu vrednost
Sada možemo definisati i funkciju kvadriranje
, koju ćemo proslediti funkciji mapiranje
kao callback funkciju:
function kvadriranje(x) {
return x * x;
}
let niz = [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 ];
let novi_niz = mapiranje(niz, kvadriranje);
Ovo naravno funkcioniše, međutim, budući da je funkcija kvadriranje
mala i uopštena funkcija (praktično - jedna naredba), povratni poziv funkciji mapiranje
možemo uputiti i na jednostavniji način.
Callback funkcija realizovana preko neimenovane funkcije
Prvi stepen optimizacije predstavlja korišćenje neimenovane ("bezimene") funkcije.
Ako smo ranije predavali funkciju kvadriranje
function kvadriranje(x) {
return x * x;
}
.... sada je možemo zapisati na drugi način ....
function (x) {
return x * x;
}
.... pod uslovom da je zapišemo na istom mestu na kom se poziva (ne možemo je u datom obliku pisati "bilo gde", van povratnih poziva, a da to ima ikakvog smisla):
let niz = [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 ];
let novi_niz = mapiranje(niz, function (x) {
return x * x;
});
Dakle, bezimene funkcije mogu se pisati na mestima gde bismo inače pozivali unapred defisnisanu imenovanu funkciju, pri čemu se funkcija praktično definiše na istom mestu na kom se poziva.
Neimenovana funkcija koju smo videli predstavljaju lambda izraz (funkciju manjeg obima implementiranu "na licu mesta"), ali, u najpraktičnijem smislu, lambda izrazi se u Javascript-u (videćemo i kako je u drugim jezicima), najčešće vezuju za arrow funkcije.
Lambda notacija (arrow funkcije)
Strogo formalno, u Javascriptu ne postoji sintaksa koja se zvanično prepoznaje kao lambda notacija, međutim, arrow funkcije (koje po svemu, osim po imenu, odgovaraju onome što se u drugim jezicima smatra i naziva lambda notacijom), predstavljaju de facto standard za implementaciju lambda izraza u JS-u.
Za početak je najlakše da nastavimo tamo gde smo stali i osvrnemo se na funkciju koja vraća kvadrat unetog broja, koju ćemo prebaciti u lambda zapis.
Kreiranje arrow funkcije (lambda izraza) po ugledu na ("običnu") anonimnu funkciju
Ako pogledamo funkciju za računanje kvadrata (koja je zapisana kao obična, "nestreličasta" anonimna funkcija) ....
function (x) {
return x * x;
}
.... zapazićemo da rezultat zavisi od parametra x
i povratne vrednost x * x
(i da u tom smislu pojava rezervisane reči function
ne igra nikakvu ulogu).
Shodno navedenom, Javascript omogućava da se zapis dodatno optimizuje.
Izostavićemo rezervisanu reč function
i između parametara i tela funkcije (vitičastih zagrada) zapisati operator =>
(lambda operator):
(x) => {
return x * x;
}
U slučaju da funkcija sadrži samo jedan parametar, mogu se izostaviti zagrade oko paramet(a)ra ....
x => {
return x * x;
}
Ako funkcija ima samo jednu naredbu, mogu se izostaviti i zagrade oko bloka naredbi funkcije (vitičaste zagrade) ....
x =>
return x * x;
Ako funkcija ima samo jednu naredbu, takođe možemo izostaviti i rezervisanu reč return
:
x =>
x * x;
.... pa je sada sasvim primereno ceo izraz zapisati u jednom redu:
x => x * x;
Na kraju (posle detaljnog prikaza "metamorfoze" neimenovane funkcije u lambda zapis), možemo pozvati i funkciju za mapiranje, kojoj ćemo predati ovakav lambda izraz:
let niz = [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 ];
let novi_niz = mapiranje(niz, x => x * x);
Ovo je, kao što vidimo, vrlo slično pozivu metode map
koju smo ranije pominjali - a sam lambda izraz je identičan.
Funkcije višeg reda
Povratni pozivi nisu jedina prilka za korišćenje lambda izraza.
Bez želje da previše zalazimo u tematiku funkcionalnog programiranja (bar za sada), pokazaćemo kako preko lambda izraza možemo definisati i takozvane funkcije višeg reda (funkcije koje pozivaju druge funkcije).
U dosadašnjem izlaganju, pokazali smo da se anonimne funkcije mogu koristiti kao callback funkcije (unutar pozivajuće funkcije) i spomenuli da ne mogu da stoje same za sebe.
Međutim, ako želimo da arrow funkcije koristimo izvan konteksta povratnih poziva, možemo određenu anonimnu funkciju dodeliti imenovanoj konstanti:
let zbir = (x, y) => x + y;
.... posle čega možemo datu konstantu da koristimo za pozivanje pripisanog lambda izraza.
Na primer, pozivanjem sledećeg koda ....
console.log(zbir(5, 10));
.... u konzoli će biti ispisano 15
.
Ako zamislimo da želimo da implementiramo lambda izraz koji proverava da li dva uneta broja imaju veći zbir ili razliku, mogli bismo to učiniti preko specijalizovanog lambda izraza na sledeći način:
let odabir = (x, y) => (x + y >= x - y)? x + y : x - y;
.... ali, ako želimo malo više fleksibilnosti (možda želimo mogućnost da lambda izraz za odabir časkom prepravimo tako da umesto zbira i razlike, uzima u obzir zbir i razliku kubova), kreiraćemo lambda izraz opštijeg tipa.
Zadržaćemo se na zbiru i razlici i, u tom kontekstu, pridodati funkciju koja računa razliku ....
let razlika = (x, y) => x - y;
.... i na kraju funkciju koja vraća veću od dve unete vrednosti ....
let vecaOdDveVrednosti = (x, y) => (x >= y)? x : y;
.... koja, ako je pozovemo na sledeći način:
let odabir = (x, y, f1, f2) => vecaOdDveVrednosti(f1(x, y), f2(x, y));
console.log(odabir(-10, -15, zbir, razlika));
// Takođe, funkciju možemo pozvati i na sledeći način:
// console.log(odabir(-10, -15, (x, y) => x + y, (x, y) => x - y));
.... praktično postaje ono što smo hteli: funkcija koja proverava da li dve unete vrednosti imaju veći zbir ili razliku - pri čemu se lako može prepraviti predavanjem drugačijih lambda izraza kao argumenata.
Verujemo da će bar neko od čitalaca prepoznati lepotu i potencijal ovakvog pristupa, ali ćemo pravi uvod u funkcionalno programiranje ipak ostaviti za neku drugu priliku, a u nastavku ćemo se osvrnuti na implementaciju lambda izraza u drugim jezicima.
Za početak, u Python-u.
Callback funkcije i lambda izrazi u Python-u
Da bismo se upoznali sa funkcijama povratnog poziva i Lambda izraza u Python-u, ponovo ćemo mapirati niz.
Python takođe (krajnje očekivano), nudi lep i elegantan ugrađeni način za mapiranje:
niz = [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 ]
novi_niz = list(map(lambda x: x * x, niz))
.... ali ćemo (za vežbu) ponovo proći sve korake sopstvene implementacije.
Definisaćemo prvo našu sopstvenu funkciju za mapiranje ....
def mapiranje(niz, callback):
pom = []
for n in niz:
pom.append(callback(n))
return pom
.... koju sada, možemo pozivati na dva načina:
Preko unapred definisane imenovane funkcije:
def kvadriranje(x):
return x * x
niz = [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 ]
novi_niz = mapiranje(niz, kvadriranje)
.... i preko lambda izraza:
niz = [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 ]
novi_niz = mapiranje(niz, lambda x: x * x)
Pre svega, primećujemo da je ovoga puta jedini način za implementaciju anonimne callback funkcije bila lambda notacija (jer Python ne podržava drugi način).
Što se lambda notacije tiče, vidimo da se u Python-u Lambda izrazi ne definišu preko operatora =>
, kao u Javascriptu, već navođenjem rezervisane reči lambda
:
lambda x: x * x
Takođe, napomenimo da i ovde važi pravilo da se zagrade u slučaju izraza sa jednim parametrom mogu izostaviti, dok, ako izraz ima više od jednog parametra, to nije slučaj:
lambda (x, y): 2 * x + y
.... isto kao i u Javascript-u, a napomenimo i to da lambda izrazi bez parametra:
lambda (): print("Dobar dan!")
.... takođe moraju sadržati zagradu.
Kada su u pitanju C# i Java, nećemo se previše udubljivati u primere, zbog izrazite sličnosti sa pristupom koji smo već videli (prosto rečeno - da se ne ponavljamo), ali, ćemo sa svakako osvrnuti na specifičnu implementaciju callback funkcija, da bismo zainteresovanim čitaocima prikazali što više implementacija istog principa.
Callback funkcije i lambda izrazi u C#-u
U slučaju C#-a, takođe ćemo se prvo ukratko osvrnuti na ugrađenu funkciju za mapiranje nizova.
Int32[] niz1 = { 1, 2, 3, 4, 5, 6, 7 };
var niz2 = niz1.Select(x => x * x);
.... i zatim preći na implementaciju iz "domaće radinosti" (po ugledu na one koje smo već videli).
Za implementaciju callback poziva, u C#-u se koriste takozvani delegati:
public delegate Int32 CallbackMapiranje(Int32 v);
Delegat CallbackMapiranje
, označava funkciju (ili, što je za nas u ovom slučaju mnogo zanimljivije - lambda izraz), koja će za jednu unetu vrednost (Int32 v
) vratiti podatak čiji je tip Int32
.
Praktično to znači (kao i do sada), da funkcijama koje pozivaju delegat stavljamo do znanja da će se pri pozivu, na mestu delegata, pojaviti konkretna funkcija koja ima iste ulazne i izlazne parametre kao delegat.
public delegate Int32 CallbackMapiranje(Int32 vrednost);
List<Int32> mapiranje(List<Int32> niz, CallbackMapiranje callback) {
List<Int32> nova_lista = new List<Int32>();
for (Int32 i = 0; i < niz.Count; i++) {
nova_lista.Add(callback(niz[i]));
}
return nova_lista;
}
// poziv:
List<Int32> niz1 = new List<Int32> { 1, 2, 3, 4, 5, 6, 7 };
var niz2 = mapiranje(niz1, x => x * x);
Implementacija u Javi, biće vrlo slična idejno, ali su ostale 'tehnikalije' vidno kompleksnije.
Callback funkcije i lambda izrazi u Javi
Kod implementacije u Javi, vidimo pre svega da inicijalizacija liste nije baš toliko 'komotna' kao do sada, kao i to da se mapiranje niza obavlja na nešto komplikovaniji način:
List<Integer> niz1 = new ArrayList<Integer>() {{
add(1);
add(2);
add(3);
add(4);
add(5);
add(6);
add(7);
}};
var niz2 = niz1.stream()
.map(x -> x * x)
.collect(Collectors.toList());
Što se tiče samih lambda izraza, napomenućemo da lambda operator ima nešto drugačiji oblik: ->
, a pre nego što implementiramo metodu za mapiranje, osvrnućemo se i na interfejse, koji (slično delegatima koje smo upoznali u prethodnom odeljku), predstavljaju način da se pri definisanju određene funkcije, kao parametar preda povratni poziv na funkciju koja će tek biti implementirana.
Na primer ....
interface CallbackMapiranje {
int funkcija(int v);
}
.... interfejs CallbackMapiranje
(kao i delegat istog naziva iz C#-a), na mestu na kom se poziva, određuje pojavu funkcije za koju (još uvek) ne znamo kako će (tačno) biti implementirana, ali, znamo da kao argument prima jednu celobrojnu vrednost i vraća podatak celobrojnog tipa.
Pozivanje metode iz intefejsa je nešto drugačije (kao što ćemo videti), ali i dalje krajnje jasno i intuitivno.
package mapiranjeNizova;
import java.util.ArrayList;
import java.util.List;
public class MapiranjeNizova {
interface CallbackMapiranje {
int funkcija(int v);
}
static List<Integer> mapiranje(List<Integer> lista,
CallbackMapiranje callback) {
List<Integer> nova_lista = new ArrayList<Integer>();
for (int i = 0; i < lista.size(); i++) {
nova_lista.add(callback.funkcija(lista.get(i)));
}
return nova_lista;
}
public static void main(String[] args) {
List<Integer> niz1 = new ArrayList<Integer>() {{
add(1);
add(2);
add(3);
add(4);
add(5);
add(6);
add(7);
}};
var niz2 = mapiranje(niz1, x -> x * x);
System.out.println(niz2.toString());
}
}
Možemo zaključiti da C#-a i Java, kada su u pitanju callback funkcije i lambda izrazi, funkcionišu po istom opštem principu kao i interpretiranii jezici Javascript i Python, ali da su mehanizmi za prosleđivanje povratnih poziva kompleksniji i "zvaničniji" (što je i inače odlika ova dva jezika, u odnosu na Javascript i Python).
Za kraj ....
Na ovom mestu ćemo se 'odjaviti', uz nekoliko opštih opaski ....
Posle nekog vremena provedenog u izučavanju programskih kodova različitog nivoa kompleksnosti, primetićete da, sa jedne strane, postoje jezičke konstrukcije većeg obima (klase, funkcije, moduli, biblioteke), od kojih su neke, bez obzira na veliki obim i nezanemarljivu kompleksnost, "lepe" i jasne, dok neke druge možda i nisu, ali su zato krajnje neophodne.
Sa druge strane, postoje i konstrukcije čiji obim, nivo kompleksnosti i objektvini značaj nisu preveliki, to jest (prosto rečeno) - moglo bi se bez njih, ali, nekako baš(!) imaju smisla i čine proces kodiranja lepšim i prirodnijim.
Po navedenoj kategorizaciji, lambda izrazi, spadaju u tu drugu kategoriju programskih konstrukcija (bez kojih se može), ali, recimo da nam je drago što su nam na raspolaganju i u svemu (uz malo 'pesničke slobode'), nekako podsećaju na ljude koje ne poznajemo lično, već samo iz priče (po dobrim delima), ali sa kojima se povremeno sretnemo u prolazu, razmenimo dobronameran prećutni pozdrav i ostanemo neko vreme pod dobrim utiskom.