Funkcje strzałkowe Arrow Function

Wraz z wprowadzeniem w życie standardu ECMA Script 6 otrzymaliśmy nowy element składni – funkcje strzałkowe (arrow function). Często są one określane jako krótsza forma deklaracji funkcji anonimowych, lecz w rzeczywistości istnieje między nim kilka ważnych różnic.

W czasie omawiania funkcji strzałkowych będę starał się porównywać je do tradycyjnego zapisu funkcji anonimowych aby wyraźnie wskazać różnice, które czasami mogą prowadzić do trudnych do znalezienia błędów w kodzie.

Arrow function jako krótsza forma deklaracji funkcji anonimowej

Nowy sposób deklarowania funkcji pozwala na pewne uproszczenie zapisu. Do tej pory aby przypisać do zmiennej anonimowe wyrażenie funkcyjne musieliśmy zrobić to w następujący sposób:

var fn = function (n) {
    return n * 2;
}

Obecnie możemy ten zapis przekształcić na formę arrow function:

var fn = (n) => n * 2;

Podobnie wygląda kwestia funkcji zwrotnych stosowanych na przykład w metodach operujących na tablicach:

var arr = [1,2,3],
    a, b;

a = arr.map(function(v){
    return v * 10;
});
a; //[10,20,30]

b = arr.map(v => v * 10);
b; //[10,20,30]

Ponownie można zauważyć, że składnia arrow function skróciła zapis. Przeanalizujmy dokładnie sposób deklarowania funkcji strzałkowych.

Forma zapisu funkcji strzałkowych

Zapis arrow function wymaga co najmniej dwóch elementów: argumentów funkcji (na lewo od znaku „=>”) oraz ciała funkcji (na prawo od znaku „=>”).

Jeżeli w funkcji występuje tylko jeden argument to można pominąć otaczające go nawiasy okrągłe i zastosować zapis:

var fn = n => n * 2;

Jest to jednak możliwe jak wspomniałem tylko w jednym wypadku – gdy funkcja pobiera dokładnie jeden argument. Jeśli chcemy przekazać jej inną ilość argumentów (czyli dwa lub więcej albo zero argumnetów) to musimy stosować zapis z nawiasami:

var fn1 = (a,b) => a * b;

var fn2 = () => 'Hello!';

fn1(2,3); //6
fn2();    //'Hello!'

Aby uniknąć nieporozumień i zachować jednolity styl deklarowania funkcji osobiście stosuję zasadę, że zawsze w funkcjach strzałkowych używam nawiasów w celu otoczenia nimi argumentów. Robię tak nawet wtedy, gdy przekazuję do funkcji tylko jeden argument. W Twoich projektach możesz sam zdecydować jaką zasadę przyjmiesz, chyba, że będzie to narzucone przez zespół programistów z którymi pracujesz.

Zauważ jednak, że po prawej stronie „=>” nie używam żadnych nawiasów. Otóż w funkcjach arrow function istnieje założenie, że jeśli nie otoczymy ciała funkcji nawiasami klamrowymi {} to domyślnie wywoływana jest instrukcja return. Jeśli chcemy jawnie wywołać „return” lub przeprowadzić więcej niż jedną operację, albo jedną, która nie jest instrukcją „return” to musimy użyć nawiasów klamrowych.

var fn1 = (a,b) => a * b;

//Inna forma zapisu funkcji fn1:
var fn1 = (a,b) => {return a * b};

//Stosując {} pamiętaj o return! (jeśli ma wystąpić)
var fn2 = (a,b) => {a * b};
fn2(3,4); //undefined   ups...

//W rzeczywistości stworzyliśmy funkcję:
var fn2 = (a,b) => {
    a * b;
    return; //domyślne return undefined
}

Pytanie zatem czy warto stosować funkcje strzałkowe przy większe liczbie instrukcji wewnątrz funkcji? Osobiście uważam, że nie. Deklaracje typu arrow function są dobre przy prostych, krótkich instrukcjach ale dla bardziej rozbudowanych funkcji preferuję zapis „tradycyjny” czyli function(){…}.

Rozmawiamy jednak tutaj o kwestii pewnego uproszczenia kodu… gdy tak na prawdę funkcje strzałkowe powstały z zupełnie innego powodu, a wspomniane skrócenie zapisu jest jedynie pewnego rodzaju efektem ubocznym (choć lepsze słowo to chyba „dodatkowym”). Kwestią najważniejszą w arrow function jest precyzyjne i przewidywalne określenie wskaźnika this.

Wskaźnik this w funkcjach strzałkowych

Nie będziemy tutaj omawiać czym jest wskaźnik this i jakie są zasady jest „tradycyjnego” określania. Zakładam, że czytelnik zna podstawowe reguły dotyczące this.

Funkcje strzałkowe prowadzono w ECMA Script 6 głównie w celu stworzenia struktury, w której jednoznacznie określony jest wskaźnik this. Aby lepiej to zrozumieć przeanalizujmy konkretny przykład obiektu obj, zawierającego dwie metody, których zadaniem jest dodanie stałej wartości baseValue=5 do wartości x, będącej argumentem metod add1 i add2.

var obj = {
    baseValue: 5,
    add1: function(x) {
        var fn = function(v) {return v + this.baseValue};
        return fn(x);
    },
    add2: function(x) {
        var fn = (v) => v + this.baseValue;
        return fn(x);
    }
};

obj.add1(2); //NaN ups...
obj.add2(2); //7 czyli OK

var baseValue = 20; //uwaga - zmienna globalna
obj.add1(2); //22 ups...
obj.add2(2); //7 nadal OK

W obiekcie obj tworzymy właściwość baseValue o wartości 5. Oczekujemy więc, że każde wywołanie zarówno add1 jak i add2 będzie się odnosić do tej wartości. Jest jednak inaczej.

Problem w tym, że w przypadku funkcji add1 this uzależnione jest od tzw. kontekstu wywołania funkcji. Funkcję tę wywołuje metoda add1, której obiektem nadrzędnym jest obiekt globalny, czyli baseValue poszukiwane jest w zakresie globalnym. Nie udaje się takiej zmiennej znaleźć, więc następuje próba dodania v+undefined co zwraca wartość NaN (Not a Number). Gdy stworzymy w global scope zmienną baseValue=20 to metoda add1 odniesie się do niej i zwróci wartość v+20.

A co z metodą add2? W tym wypadku this zawsze odnosi się do zasięgu nadrzędnego dla arrow function czyli u nas do właściwości zapisanych w obj. Znajdujemy tam nasz baseValue=5 więc każde wywołanie add2 w oczekiwany sposób odnosi się do tej wartości zwracając v+5.

Taki sposób jednoznacznego wskazania this pozwala pisać kod bez martwienia się o tradycyjny mechanizm określania wskaźnika this, co sprawia czasami problemy, szczególnie osobom zaczynającym dopiero naukę JS.

Uwaga na this w metodach tablicowych!

Poznaliśmy już sposób działania wskaźnika this w funkcjach strzałkowych. Warto jednak nieco bardziej zgłębić ten temat, gdyż arrow function często są stosowane jako funkcje zwrotne w różnego rodzaju metodach operujących na tablicach.

Najczęściej spotykam się z opinią, że pozwalają one znacznie skrócić zapis kodu i w prosty i czytelny sposób wskazać wartość zwracaną przez callback function. Jest to prawda, ale należy pamiętać o pewnym drobnym szczególe.

Otóż wiele metod Array.prototype przyjmuje jeden (callback function) lub dwa (drugi opcjonalny) argumenty, przy czym drugi argument jest obiektem, na który z założenia ma wskazywać this użyte w funkcji zwrotnej. Sprawdźmy zatem czy tak się właśnie dzieje. Za przykład weźmy funkcję, której zadaniem jest pobranie tablicy liczb i dodanie do każdej z nich stałej wartości zapisanej w baseValue.

var obj = {baseValue: 10},
    arr = [1,2,3];

arr.map(function(v){return v+this.baseValue},obj);
//[11,12,13]

arr.map(function(v){return v+this.baseValue});
//[NaN,NaN,NaN] brak obiektu wskazującego na this 
//i zmiennej globalnej baseValue

arr.map(v => v+this.baseValue,obj);
//[NaN,NaN,NaN] ups...

arr.map(v => v+this.baseValue);
//[NaN,NaN,NaN] hmmm...

//deklarujemy zmienną globalną baseValue
var baseValue = 100;

arr.map(v => v+this.baseValue);
//[101,102,103] ok, brak obiektu więc odwołanie do zmiennej globalnej

arr.map(v => v+this.baseValue,obj);
//[101,102,103] ups... zignorowanie obj?

Widać zatem wyraźnie, że w przypadku zastosowania zapisu arrow function w wywołaniu metod tablicowych ignorowany jest drugi argument, czyli obiekt, który powinien wskazywać na this!

Warto o tym pamiętać aby zbyt pochopnie nie tworzyć arrow function tam, gdzie rzeczywiście potrzebujemy wskazać dokładnie określone this odnoszące się do innego obiektu.

W przypadku korzystania z transpilatora Babel funkcje również zachowają się tak samo, gdyż przy funkcji strzałkowej i próbie samodzielnego wskazania obiektu dla this zostanie on potraktowany jako undefined:

//Zapis, który zostanie poddany transpilacji:
arr.map(function(v){return v+this.baseValue},obj);
arr.map(v => v+this.baseValue,obj);

//Zapis funkcji po transpilacji Babel:
arr.map(function (v) {
 return v + this.baseValue;
}, obj);

arr.map(function (v) {
 return v + undefined.baseValue;
}, obj);

Wartość undefined nie posiada oczywiście właściwości baseValue więc zapis „undefined.baseValue” zwróci de facto wartość undefined, czyli w tym przypadku oznaczającą „brak wartości”.

Pseudotablica arguments w funkcjach strzałkowych

Z funkcjami strzałkowymi związany jest jeszcze jeden problem. Do tej pory byliśmy przyzwyczajeni do tego, że każda funkcja w JavaScript posiada pseudotablicę arguments, przechowującą wszystkie argumenty z jakimi funkcja została wywołana.

Należy przy tym pamiętać, że nie jest to obiekt dziedziczący po Array.prototype więc nie posiada metod „tablicowych” jak map, forEach, filter, reduce itp. Aby zamienić obiekt arguments w prawdziwą tablicę stosowane są najczęściej dwa rozwiązania:

//Metoda zgodna z ES5
function fn() {
   var a = Array.prototype.slice.call(arguments);
   //a jest teraz tablicą argumentów
}
//Metoda zalecana w standardzie ES6+
function fn(...a) {
   //a jest od razu tablicą argumentów z Array.prototype
}

W pierwszej chwili można by więc sądzić, że funkcje arrow function również posiadają analogiczny obiekt zawierający argumenty, który można przekształcić w zwykłą tablicę. Czy tak jest na prawdę? I tak, i nie… już wyjaśniam dlaczego. Przeanalizujmy poniższy kod:

function fn(x) {
    let sum = () => [...arguments].reduce((a,b) => a+b,0);
    return (x > 5) ? sum(2,3,5) : sum(2,3);
}

fn(6); 
//Spodziewane: x > 5 więc sum(2,3,5)=2+3+5=10
//Otrzymane: 6 ups...

fn(2);
//Spodziewane: x < 5 więc sum(2,3)=2+3=5
//Otrzymane: 2 ups...

Funkcja fn() pobiera jeden argument „x” i w zależności od jego wartości zwraca wynik funkcji sum, której zadaniem jest zsumowanie wszystkich argumentów przekazanych w jej wywołaniu (odpowiada za to metoda Array.prototype.reduce). Aby móc skorzystać z metody reduce() przekształciliśmy pseudotablicę arguments funkcji sum() na zwykłą tablicę z Array.prtototype. Jak widać coś jednak poszło nie po naszej myśli, gdyż funkcja fn(x) zawsze zwraca wartość x.

Otóż na wstępie należy zaznaczyć, że metoda reduce działa tutaj jak najbardziej prawidłowo. Problem leży gdzie indziej. W funkcjach strzałkowych obiekt arguments nie odnosi się bowiem do funkcji arrow function lecz do obiektu arguments z funkcji nadrzędnej, w naszym wypadku jest to pseudotablica arguments funkcji fn(), zawierająca de facto zawsze tylko jeden element „x”. Ostatecznie więc metoda sum zawsze zwraca tę jedną wartość.

Czyli nie ma możliwości dostania się do argumentów funkcji strzałkowej?

Otóż jest taka możliwość. Wymaga ona zebrania wszystkich argumentów w wywołaniu funkcji sum w momencie jej deklaracji. Spójrzmy na poniższy, nieco zmodyfikowany przykład tych samych funkcji:

function fn(x) {
    let sum = (...arg) => arg.reduce((a,b) => a+b,0);
    return (x > 5) ? sum(2,3,5) : sum(2,3);
}

fn(6); 
//Spodziewane: x > 5 więc sum(2,3,5)=2+3+5=10
//Otrzymane: 10

fn(2);
//Spodziewane: x < 5 więc sum(2,3)=2+3=5
//Otrzymane: 5.

Teraz wszystko działa prawidłowo. Co się zmieniło? Zauważ, że w tym wypadku argumenty zbieramy w tablicę operatorem „…” w momencie deklaracji funkcji, czyli w nawiasach „()”. Teraz wewnątrz funkcji sum() dysponujemy pełnoprawną tablicą arg, przechowującą jej rzeczywiste argumenty. W razie potrzeby odwołania się do argumentów funkcji nadrzędnej nadal mamy do dyspozycji pseudotablicę arguments, a więc w pewnym sensie mamy dwa w jednym 🙂

Pytanie czy taki sposób działa we współpracy z transpilatorem kodu Babel? Odpowiedź brzmi TAK, ponieważ co prawda Babel będzie operował w tym momencie na pseudotablicy arguments w sposób bezpośredni, lecz całą funkcję strzałkową zastąpi „tradycyjnym” zapisem function sum(){}, więc obiekt arguments faktycznie odwołuje się wtedy do argumentów funkcji sum().

 

Pytanie końcowe, czy warto zatem stosować funkcje strzałkowe jeśli wiąże się z tym tyle dodatkowych komplikacji? Otóż TAK, jak najbardziej i to nie tylko wtedy, gdy jawnie chcemy wskazać this na obiekt nadrzędny, lecz także w pewnych sytuacjach w celu stworzenia bardziej przejrzystego kodu.

Dobrą praktyką według mnie jest używanie arrow function jako funkcji zwrotnej w różnych metodach tablicowych gdy chcemy wywołać tylko jedną instrukcję return, np. w funkcjach map, filter, reduce itp. Uważajmy jednak na przypadek, gdy musimy jawnie wskazać inny obiekt dla this!

Generalnie odradzam stosowanie zapisu arrow function dla bardziej rozbudownaych funkcji, gdyż w tym wypadku niewiele zyskujemy na pominięciu słowa „function”, a możemy czasem nieco utrudnić czytanie kodu, szczególnie gdy funkcja arrow function bezpośrednio zwraca inną funkcję (łatwo wtedy o niezauważenie, że jedyną instrukcją jaką realizuje arrow function jest właśnie „ukryte” return).