Uvod
Koncept funkcija povratnog poziva podrazumeva predavanje jedne funkcije drugoj - u svojstvu argumenta. Iako ovo naizgled ne deluje previše zanimljivo, budući da znamo da jedna funkcija uvek može pozvati drugu (u samom telu funkcije), u praksi je drugačije i korišćenje funkcija povratnog poziva otvara vrlo zanimljive mogućnosti.
Kada su u pitanju lambda izrazi, recimo da je u pitanju način da se funkcije manjeg obima zapišu u jednom redu, "na licu mesta" (tamo gde bismo inače pozivali drugu, imenovanu funkciju).
Bilo kakva realizovana ideja u nauci i tehnici, koja u svom nazivu sadrži grčka slova, najčešće zvuči misteriozno i komplikovano, kao nekakav tajni projekat iz vremena drugog svetskog rata, ili nešto iz naučne fantastike, ali, ne bojte se, lambda izrazi nisu nešto komplikovano, već naprotiv - uglavnom predstavljaju način da se kod pojednostavi (pri čemu je sam postupak vrlo elegantan).
Za početno upoznavanje sa konceptom funkcija povratnog poziva i lambda izrazima, koristićemo JavaScript, jezik u kome su lambda izrazi (kao i mnogo šta drugo), implementirani na eklektičan, ali istovremeno vrlo pregledan i razumljiv način.
Callback funkcije
Uzmimo za početak jedan primer sa kojim ste se verovatno već sretali:
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 koda sa gornje slike je prikaz trenutnog vremena preko HTML elementa (čiji je id info_panel
), a rezultat dobijamo u sprezi između 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 se naziva funkcija 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 još bolje razumeli prednost ovakvog pristupa, pogledajmo šematski prikaz funkcije za iscrtavanje grafika trigonometrijskih funkcija (zapisan preko pseudokoda) - pri čemu funkcija (za početak) ne koristi povratne pozive:
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 kod (pogotovo iz perspektive mlađih programera), ne deluje posebno problematično, ali, svakako bi mogao biti i bolji.
Dovoljno je da kao parametar dodamo callback funkciju, pa da ceo switch iz prethodne implementacije praktično svedemo na samo jednu naredbu ....
function Iscrtavanje(vrsta) {
koordinate = [];
koordinate.push(callback());
CrtanjeGrafika(koordinate);
}
.... 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 recimo dodati opciju za iscrtavanje 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 petlju), 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) {
callback("Funkcija f3() obavlja sledeće pozive:");
callback1();
callback2();
}
// Poziv:
f3(f1, f2);
Navedeni kod daje sledeći ispis:
Funkcija f3() obavlja sledeće pozive:
Poziv funkcije f1()
Poziv funkcije f2()
U primeru sa funckcijom 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, vrlo često se 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 lambda notacije
Mapiranje i filtriranje nizova su tipični primeri (reklo bi se - najtipičniji), 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
, sve najčešće deluje kao sintaksa koja je "nekako"specijalizovana samo za date funkcije (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]));
}
}
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 najčešće vezuju za lambda notaciju (i preko nje implementiraju).
Lambda notacija
U JavaScript-u, lambda izrazi se najčešće implementiraju kao tzv "streličaste" (arrow) funkcije, čija se sintaksa poklapa sa lambda notacijom kakva se koristi i u drugim jezicima.
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 posmatramo funkciju za računanje kvadrata (koja je zapisana kao obična, "nestreličasta" anoinmna funkcija) ....
function (x) {
return x * x;
}
.... zapazićemo parametar x
i povratnu vrednost x * x
kao informacije od kojih zavisi rezultat (rezervisana reč function
, bez obzira na to što gornji program bez njenog navođenja ne bi proradio, ipak je u tom kontekstu relativno nebitna).
Budući da je tako , JavaScript nam omogućava da zapis dodatno svedemo.
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 ....
x => {
return x * x;
}
.... a, ako funkcija ima samo jednu naredbu, mogu se izostaviti i 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.
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 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.
Ali, ako želimo da ih 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) => vecaOdDveVrednosti(zbir(x, y), razlika(x, y));
console.log(odabir(-10, -15));
.... 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.
Pravi uvod u funkcionalno programiranje ipak ćemo 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 jednog 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 (isto 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 istog 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 skriptni jezici JavaSCript i Python, ali da su mehanizmi za prosleđivanje povratnih poziva kompleksniji i "zvaničniji".
Zaključak
Ovde ć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 konstrukcijama 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 nekako podsećaju na ljude koje poznajemo samo iz priče (po dobrim delima), sa kojima kada se sretnemo u prolazu, razmenimo dobronameran prećutni pozdrav.