Funkcje w JavaScript

Funkcja to podstawowy blok kodu w JavaScript, który można zdefiniować raz i wykonywać wielokrotnie. Funkcje pozwalają organizować program w mniejsze, logiczne fragmenty, co ułatwia ponowne użycie kodu oraz jego czytelność. W tej sekcji omówimy sposoby definiowania funkcji, różne rodzaje funkcji (deklaracje, wyrażenia, funkcje strzałkowe), a także pojęcia zasięgu zmiennych i domknięć. Na koniec wyjaśnimy, co oznacza, że funkcje w JavaScript są „obiektami pierwszej klasy”.

Definiowanie funkcji: deklaracje vs wyrażenia

W JavaScript funkcję można zdefiniować na kilka sposobów. Najczęściej używane to deklaracja funkcji oraz wyrażenie funkcyjne.

  • Deklaracja funkcji: Używa słowa kluczowego function na początku. Taka funkcja ma nazwę i można ją wywołać w kodzie. Przykład deklaracji funkcji:
 function greet() {          console.log("Witaj!"); 
 } 
 greet(); // wywołanie funkcji, wypisze: Witaj! 

Funkcję greet zdefiniowaliśmy przez deklarację. Takie funkcje podlegają mechanizmowi hoistingu – co oznacza, że są „wynoszone” na górę zakresu podczas wykonywania skryptu. W praktyce można wywołać funkcję deklarowaną nawet przed jej definicją w kodzie (choć dla czytelności kodu lepiej tego unikać).

  • Wyrażenie funkcyjne: Funkcja może być również utworzona jako wyrażenie przypisane do zmiennej. Nie ma wtedy osobnej nazwy (lub jest to funkcja anonimowa), a dostęp do niej uzyskujemy poprzez zmienną. Przykład wyrażenia funkcyjnego:
 const greet2 = function() {     console.log("Cześć!"); 
 } 
 greet2(); // wypisze: Cześć! 

Tutaj utworzyliśmy funkcję anonimową i przypisaliśmy ją do stałej greet2. Takiej funkcji nie można wywołać przed linią, w której została zdefiniowana, ponieważ nie jest hoistowana w ten sam sposób co deklaracja funkcji. Wyrażenia funkcyjne są użyteczne, gdy chcemy przekazać funkcję jako wartość (np. do innej funkcji) lub definiować je warunkowo.

Oba powyższe sposoby tworzą funkcje o identycznym zachowaniu w czasie wykonywania – różnią się głównie sposobem deklaracji i zachowaniem podczas ładowania skryptu.

Funkcje strzałkowe (arrow functions)

Funkcje strzałkowe zostały wprowadzone w ES6 (ECMAScript 2015) jako krótsza składnia definiowania funkcji. Zamiast słowa kluczowego function, używamy symbolu => (tzw. „strzałki”). Funkcje strzałkowe często upraszczają kod, zwłaszcza gdy definiujemy proste funkcje zwrotne (callbacki).

Przykład zwykłej funkcji vs funkcji strzałkowej robiącej to samo:

 // Zwykła funkcja mnożąca liczbę przez 2
 function multiplyByTwo(x) {
      return x * 2;
 }

 // Funkcja strzałkowa mnożąca liczbę przez 2
 const multiplyByTwoArrow = (x) => {
      return x * 2;
 };

 // Można jeszcze prościej: jeśli ciało funkcji strzałkowej to jedna instrukcja, można pominąć  nawiasy klamrowe i słowo return
 const multiplyByTwoArrowShort = x => x * 2;

 console.log(multiplyByTwo(5));         // 10
 console.log(multiplyByTwoArrow(5));    // 10
 console.log(multiplyByTwoArrowShort(5)); // 10

W powyższym kodzie multiplyByTwo, multiplyByTwoArrow i multiplyByTwoArrowShort dają ten sam rezultat. Widać, że funkcja strzałkowa pozwala zredukować ilość kodu, zwłaszcza w przypadku funkcji jedno-liniowych. Kilka cech funkcji strzałkowych:

  • Krótka składnia: brak słowa function, a gdy jest tylko jeden parametr można pominąć nawiasy (), zaś gdy funkcja od razu zwraca wynik pojedynczego wyrażenia, można pominąć klamry {} i słowo return.
  • Brak własnego this: Funkcje strzałkowe nie mają własnego kontekstu this ani obiektu arguments. this wewnątrz funkcji strzałkowej odnosi się do this z kontekstu otaczającego (czyli jest leksykalnie wiązane). Dzięki temu funkcje strzałkowe są wygodne np. w metodach tablicowych czy obsłudze zdarzeń, bo nie zmieniają kontekstu. Jednak z tego powodu nie nadają się do definiowania metod obiektu, gdzie zazwyczaj chcemy, by this wskazywało na ten obiekt (o czym więcej później).
  • Nie mogą być konstruktorami: Nie można użyć funkcji strzałkowej z operatorem new do tworzenia obiektów. Są przeznaczone głównie do krótkich funkcji wykonywanych „w locie”.

Zasięg zmiennych a zakres leksykalny

Zasięg (scope) zmiennej to obszar kodu, w którym dana zmienna jest dostępna. JavaScript ma zasięg funkcyjny dla deklaracji var oraz zasięg blokowy dla deklaracji let i const (wprowadzonych w ES6). Oznacza to, że:

  • Zmienna zadeklarowana słowem kluczowym var wewnątrz funkcji jest dostępna tylko w tej funkcji (oraz w ewentualnych funkcjach zagnieżdżonych). Jeśli zadeklarujemy var poza funkcją, będzie globalna (dostępna w całym skrypcie).
  • Zmienne zadeklarowane przez let lub const są dostępne tylko wewnątrz bloku, w którym zostały zadeklarowane (np. między { } pętli, instrukcji warunkowej, funkcji itp.). Nie „wynoszą” się poza ten blok.

Przykładowo:

 function testScope() {
   var a = 1;
   let b = 2;
   if (true) {
     var c = 3;
     let d = 4;
     console.log(a, b); // 1 2 (wewnątrz funkcji widzimy a i b)
   }
   console.log(c); // 3 (c jest dostępne, bo var ma zasięg funkcji)
   console.log(d); // BŁĄD: d is not defined (d ma zasięg bloku if i poza nim nie istnieje)
 }
 testScope();
 console.log(typeof a); // undefined (a nie istnieje poza funkcją)

W powyższym kodzie zmienna a jest lokalna dla funkcji testScope, podobnie b. Zmienna c zadeklarowana przez var wewnątrz bloku if jest wciąż dostępna w całej funkcji (zasięg funkcji), natomiast d zadeklarowane przez let istnieje tylko w bloku if. Poza funkcją testScope żadne z a, b, c, d nie jest widoczne.

W JavaScript obowiązuje zasięg leksykalny (lexical scope), co oznacza, że zasięg zmiennej zależy od tego, gdzie w kodzie została zdefiniowana, a nie od tego, skąd jest wywoływana. Funkcje zagnieżdżone mają dostęp do zmiennych zdefiniowanych w otaczających je funkcjach (czyli w ich zewnętrznym leksykalnym kontekście). Ten mechanizm prowadzi nas do koncepcji domknięć.

Domknięcia (Closures)

Domknięcie to mechanizm, dzięki któremu funkcja wewnętrzna pamięta zmienne ze swojego otaczającego kontekstu, nawet jeśli ten kontekst przestanie istnieć (funkcja zewnętrzna zakończyła wykonanie). Innymi słowy, domknięcie pozwala funkcji zachować dostęp do zasięgu leksykalnego, w którym została utworzona.

Przeanalizujmy przykład domknięcia:

 function utworzLicznik() {
      let licznik = 0;               // zmienna lokalna funkcji utworzLicznik
      return function() {            // zwracamy nową funkcję (anonimową)
        licznik++;                   // korzysta ze zmiennej spoza swojego ciała
        console.log("Stan licznika: " + licznik);
      };
 }

 const licz = utworzLicznik();    // wywołujemy utworzLicznik, zwraca funkcję, którą  przypisujemy do zmiennej
 licz(); // Stan licznika: 1
 licz(); // Stan licznika: 2
 licz(); // Stan licznika: 3

W funkcji utworzLicznik deklarujemy zmienną licznik i zwracamy funkcję wewnętrzną, która inkrementuje licznik i wypisuje jego wartość. Zmienna licz staje się tą wewnętrzną funkcją. Mimo że utworzLicznik() zakończyło działanie, jego zmienna lokalna licznik nie zniknęła – jest przechowywana w domknięciu związanym z funkcją przypisaną do licz. Za każdym wywołaniem licz(), funkcja ma dostęp do swojej zamkniętej zmiennej licznik i może ją modyfikować. To pokazuje istotę domknięć: funkcja zachowuje dostęp do zmiennych obecnych w momencie jej definicji.

Domknięcia są powszechnie wykorzystywane w JavaScript. Przykładowe zastosowania:

  • Funkcje fabrykujące (factory functions): tak jak utworzLicznik powyżej, możemy tworzyć funkcje generujące inne funkcje z pewnym stanem „prywatnym”.
  • Emulacja prywatnych zmiennych: używając funkcji i domknięć można ukryć pewne dane przed zewnętrznym dostępem (moduły, IIFE).
  • Callbacki z dostępem do kontekstu: np. w obsłudze zdarzeń czy timeoutów, często funkcja callback korzysta z zmiennych spoza siebie (z kontekstu, w którym została utworzona).

Zrozumienie domknięć bywa trudne dla początkujących, ale jest kluczowe dla pełnego opanowania JavaScript. W uproszczeniu: domknięcie to „pakiet” zawierający funkcję wraz z jej leksykalnym środowiskiem (zmiennymi), które są zachowane nawet po wykonaniu tej funkcji zewnętrznej.

Funkcje jako obiekty pierwszej klasy

W JavaScript funkcje są obiektami pierwszej klasy (first-class objects/citizens). Oznacza to, że traktujemy je jak każdą inną wartość w języku. Konsekwencje takiego podejścia:

  • Funkcję można przypisać do zmiennej lub stałej (jak pokazano wcześniej).
  • Funkcję można przekazać jako argument do innej funkcji. Np. wiele metod tablicowych czy API przeglądarki oczekuje funkcji-callbacku. Przykład: arr.forEach(function(x) { ... }) przekazuje funkcję anonimową do wykonania na każdym elemencie tablicy.
  • Funkcja może być zwrócona jako wynik z innej funkcji (co widzieliśmy w przykładzie z domknięciem).
  • Funkcje można przechowywać w strukturach danych (np. w tablicy: const operacje = [dodaj, odejmij, pomnóż] gdzie dodaj etc. są funkcjami).
  • Można tworzyć funkcje w locie, np. jako wyrażenia strzałkowe () => ....

Dzięki temu, że funkcje są wartościami, programowanie w JavaScript wspiera paradygmat funkcyjny. Przykładowo, możemy napisać funkcję wyższego rzędu (higher-order function), która przyjmuje inną funkcję jako parametr:

 function wykonajOperacje(a, b, operacja) {
      return operacja(a, b); // wywołujemy przekazaną funkcję operacja z argumentami a i b
 }

 function dodaj(x, y) {
      return x + y;
 }
 console.log(wykonajOperacje(3, 4, dodaj));            // 7, przekazujemy nazwę funkcji
 console.log(wykonajOperacje(3, 4, (x, y) => x * y));  // 12, przekazujemy funkcję strzałkową mnożącą

W powyższym przykładzie wykonajOperacje to funkcja, która deleguje konkretną logikę do przekazanej funkcji operacja. Raz użyliśmy gotowej funkcji dodaj, a raz przekazaliśmy funkcję strzałkową anonimowo przy wywołaniu. Tego typu konstrukcje są możliwe dzięki temu, że funkcje w JavaScript są obiektami pierwszej klasy i mogą być traktowane tak samo jak np. liczby czy stringi.