Moduł2 - Zajęcia 4 - Funkcje

Section1 Article1: Funkcje

Funkcja to podprogram, niezależny fragment kodu przeznaczony do wielokrotnego wykonywania tego samego zestawu instrukcji dla różnych wartości. Funkcje pomagają w tworzeniu dużych programów, ograniczają powtórzenia i lepiej izolują fragmenty kodu.

Funkcję można traktować jak czarną skrzynkę, która otrzymuje coś na wejściu (dane) i może zwrócić coś na wyjściu (wynik wykonania zawartego w niej kodu).

Section1 Article1: Deklaracja funkcji

            // 1. Deklaracja funkcji multiply
            function multiply() {
                // Ciało funkcji
                console.log("Log podczas wywoływania funkcji multiply");
            }

            // 2. Wywołania funkcji multiply
            multiply();// 'To jest log podczas wywoływania funkcji multiply'
            multiply();// 'To jest log podczas wywoływania funkcji multiply'
            multiply();// 'To jest log podczas wywoływania funkcji multiply'
          

Deklaracja funkcji (function declaration) zaczyna się od słowa kluczowego function, po którym następuje jej nazwa - zwyczajowo czasownik odpowiadający na pytanie «Co zrobić?» oraz para nawiasów okrągłych. Podobnie jak dla zmiennych, korzystamy z konwencji camelCase

Ciało funkcji znajduje się w nawiasach klamrowych {} i zawiera instrukcje, które zostaną wykonane podczas jej wywołania. Sama deklaracja nie wykona instrukcji, do tego niezbędne jest, aby funkcja została wywoływana za pomocą jej nazwy i pary nawiasów okrągłych.

Section1 Article3: Parametry i argumenty

Przy deklaracji, w nawiasach okrągłych po nazwie funkcji znajdują się parametry - "zmienne", dla których wartości będziemy podawać podczas wywołania.

            // Deklaracja parametrów x, y, z
            function multiply(x, y, z) {
                console.log(`Wynikiem mnożenia jest ${x * y * z}`);
            }
          

Parametry to zmienne lokalne dostępne tylko w ciele danej funkcji. Kolejne parametry oddzielone są przecinkami. Możemy zadeklarować zero parametrów, wtedy nawias zarówno przy deklaracji jak i przy wywołaniu będzie pusty.

Parametry będą przyjmowane przy każdym wywołaniu funkcji, ich wartości podczas kilku wywołań nie będą ze sobą powiązane.

Podczas wywoływania funkcji w nawiasach można przekazać argumenty czyli wartości dla zadeklarowanych parametrów funkcji.

            // 1. Deklaracja parametrów x, y, z
            function multiply(x, y, z) {
                console.log(`Wynikiem mnożenia jest ${x * y * z}`);
            }

            // 2. Przekazywanie argumentów
            multiply(2, 3, 5);// Wynikiem mnożenia jest 30
            multiply(4, 8, 12);// Wynikiem mnożenia jest 384
            multiply(17, 6, 25);// Wynikiem mnożenia jest 2550
          

Kolejność przekazywania argumentów musi odpowiadać kolejności zadeklarowanych parametrów: wartość pierwszego argumentu zostanie przypisana do pierwszego parametru, drugiego argumentu do drugiego parametru itd. Jeśli jest więcej parametrów niż argumentów, to parametrom bez odpowiadającego im argumentu zostanie przypisane undefined.

Section1 Article4: Zwracanie wartości

Operator return służy do zwrócenia wartości z ciała funkcji do miejsca gdzie zostaje ona wywołana. Kiedy interpreter napotka return, natychmiast wychodzi z funkcji (kończy jej wykonywanie) i zwraca określoną wartość w miejscu gdzie nastąpiło wywołanie.

            function multiply(x, y, z) {
                console.log("Kod przed return jest wykonywany jak zwykle");
                // Zwracamy wynik wyrażenia mnożenia
                return x * y * z;
                console.log("Ten log nigdy nie zostanie wywołany, jest po return");
            }

            // Wynik funkcji można zapisać do zmiennej
            let result = multiply(2, 3, 5);
            console.log(result);// 30

            result = multiply(4, 8, 12);
            console.log(result);// 384

            result = multiply(17, 6, 25);
            console.log(result);// 2550
          

Instrukcja return bez jawnie określonej wartości zwraca wartość specjalną undefined. Jeśli nie ma return w ciele funkcji, nadal zwróci ona undefined. Z tego powodu, return bez podanej wartości służy tylko do przerwania wykonywania funkcji bez potrzeby użycia instrukcji warunkowych co w niektórych przypadkach poprawia czytelność kodu.

Section1 Article5: Kolejność wykonywania kodu

Gdy interpreter napotka wywołanie funkcji (lub metody), wstrzymuje wykonywanie bieżącego kodu i rozpoczyna wykonywanie kodu znajdującego się w ciele funkcji. Po wykonaniu całego kodu funkcji, interpreter opuszcza ciało funkcji, wracając do miejsca w którym nastąpiło wywołanie i kontynuuje wykonywanie dalszego kodu.

            function multiply(x, y, z) {
                console.log(`Wynikiem mnożenia jest ${x * y * z}`);
            }

            console.log("Log przed wywołaniem funkcji multiply");
            multiply(2, 3, 5);// Wynikiem mnożenia jest 30
            console.log("Log po wywołaniu funkcji multiply");

            // Kolejność logów w konsoli
            // "Log przed wywołaniem funkcji multiply"
            // "Wynikiem mnożenia jest 30"
            // "Log po wywołaniu funkcji multiply"
          

Section1 Article6: Domyślne wartości parametrów

Czasami konieczne jest zadeklarowanie funkcji, której parametry będą miały wartości inne niż undefined, nawet jeśli nie przekazano do nich argumentu. W nowoczesnym standardzie JavaScript wspierana jest bardzo prosta składnia, wystarczy podać wartość domyślną bezpośrednio podczas deklarowania parametrów w sygnaturze funkcji poprzedzając ją znakiem =. W przypadku użycia takiej konstrukcji, jeśli dla parametru nie zostanie przekazana wartość argumentu, użyta zostanie wartość podana przez nas jako domyślna.

            function count(countFrom = 0, countTo = 10, step = 1) {
                console.log(`countFrom = ${countFrom}, countTo = ${countTo}, step = ${step}`);

                for (let i = countFrom; i <= countTo; i += step) {
                    console.log(i);
                }
            }

            count(1, 5);// countFrom = 1, countTo = 5, step = 1
            count(2);// countFrom = 2, countTo = 10, step = 1
            count();// countFrom = 0, countTo = 10, step = 1
          

Section1 Article7: Pseudotablica arguments

Dostęp do listy wszystkich argumentów podanych przy wywołaniu można uzyskać za pomocą specjalnej zmiennej arguments, która jest dostępna tylko wewnątrz funkcji i przechowuje wszystkie argumenty jako pseudotablicę.

Pseudotablica to kolekcja z właściwością length i możliwością dostępu do elementu przez indeks, natomiast nie posiada ona większości metod normalnie dostępnych w tablicach.

Zobaczmy przykład użycia arguments w funkcji mnożącej dowolną ilość argumentów. Do iterowania po argumentach użyjemy pętli for...of

            function multiply() {  
              let total = 1;

              for (const argument of arguments) {
                total *= argument;  
              }

              return total;
            }

            console.log(multiply(1, 2, 3));//  6
            console.log(multiply(1, 2, 3, 4));//  24
            console.log(multiply(1, 2, 3, 4, 5));//  120
          

Section1 Article8: Konwersja pseudotablicy

Czasami pseudotablica musi zostać przekonwertowana na normalną tablicę, ponieważ pseudotablica nie ma metod tablicowych, takich jak slice() albo includes(). Zobaczmy dwa przykłady jak możemy to osiągnąć

Użycie metody Array.from(), która zamieni tablicę na pseudotablicę.

            function fn() {
              // Zmienna args będzie zawierać pełnowartościową tablicę
              const args = Array.from(arguments);
            }
          

Użycie operatora ... (spread) dzięki któremu wypełniamy literał tablicy wartościami z pseudotablicy (więcej zastosowań operatora spread poznamy w dalszych partiach materiału)

            function fn() {
              // Zmienna args będzie zawierać pełnowartościową tablicę
              const args = [...arguments]
            }
          

Używając operacji ... (rest), możemy zebrać dowolną liczbę elementów, w naszym przypadku argumentów, do tablicy i przechowywać ją w zmiennej. Wszystkie argumenty zbieramy za pomocą operacji rest bezpośrednio w sygnaturze funkcji bez podania innych argumentów.

            function fn(...args) {
              // Zmienna args będzie zawierać pełnowartościową tablicę
            }
          

Możemy również zadeklarować kilka "stałych" parametrów i zebrać pozostałe do tablicy args

            function fn(a, b, ...args) {
              // zmienna a i b będą zwykłymi parametrami przechowującymi
              // odpowiednio pierwszy i drugi podany argument
              // Zmienna args będzie zawierać pełnowartościową tablicę z trzecim, czwartym
              // i kolejnymi podanymi argumentami
            }
          

Operację rest omówiono bardziej szczegółowo w dalszej części kursu, a tutaj pokazano jedno z jej możliwych zastosowań.

Section1 Article9: Wzorzec "Early Return"

Operator if...else jest głównym sposobem tworzenia rozgałęzień warunkowych. Czasami jednak złożone, zagnieżdżone warunki sprawiają, że kod jest trudny do zrozumienia.

Stwórzmy funkcję, która opracowuje wypłaty z osobistego konta bankowego. Otrzymuje jako argumenty kwotę wypłaty oraz saldo rachunku bieżącego, po czym, w zależności od warunku, wykonuje odpowiedni blok instrukcji.

            function withdraw(amount, balance) {
              if (amount === 0) {
                console.log("Wprowadź kwotę większą od zera");
              } else if (amount > balance) {
                console.log("Za mało środków na koncie");
              } else {
                console.log("Operacja wypłaty powiodła się");
              }
            }

            withdraw(0, 300);// "Wprowadź kwotę większą od zera"
            withdraw(500, 300);// "Za mało środków na koncie"
            withdraw(100, 300);// "Operacja wypłaty powiodła się"
          

Nawet w tak prostym przykładzie istnieje kilka zagnieżdżonych operatorów warunkowych, które musimy przeanalizować w całości, aby zrozumieć logikę wykonywania kodu.

W jednej funkcji może być więcej niż jedna instrukcja return. Musimy pamiętać, że wykonywanie funkcji jest przerywane, gdy interpreter napotka zwrot w dowolnym miejscu, a cały kod po nim zostanie zignorowany w bieżącym wywołaniu funkcji.

Wzorzec "Early Return" jest sposobem na wykorzystanie możliwości wcześniejszego wyjścia z funkcji przy użyciu operatora return. Korzystając z tej techniki, otrzymujemy czystszy, bardziej płaski i bardziej zrozumiały kod, który jest łatwiejszy do refaktoryzacji lub rozbudowania.

Dodajmy wszystkie sprawdzenia warunków w oddzielnych instrukcjach if, w każdym bloku instrukcję return, a na końcu ten fragment kodu który wcześniej znajdował się w bloku else. W tym przypadku otrzymamy płaską strukturę warunków, następujących po sobie, a na końcu blok, który zostanie wykonany tylko wtedy, gdy nie zostanie wykonana żadna instrukcja warunkowa if.

            function withdraw(amount, balance) {
              // Jeśli warunek jest wykonany, wywoływany jest console.log
              // i wyjście z funkcji. 
              // Kod następujący po tym bloku if nie zostanie wtedy wykonany.
              if (amount === 0) {
                console.log("Wprowadź kwotę większą od zera");
                return;
              }

              // Jeśli warunek pierwszego if nie jest wykonany, jest on pomijany
              // a interpreter przechodzi do drugiego if.
              // Jeśli warunek jest wykonany, wywoływany jest console.log i wyjście z funkcji.
              // Kod następujący po tym bloku if nie zostanie wtedy wykonany.
              if (amount > balance) {
                console.log("Za mało środków na koncie");
                return;
              }

              // Jeśli żaden z poprzednich if nie został wykonany,
              // interpreter natrafia na ten fragment kodu i wykonuje go.
              // kolejne return nie jest tu koniecznie ponieważ funkcja i tak się kończy
              console.log("Operacja wypłaty zakończona");
            }

            withdraw(0, 300);// "Wprowadź kwotę większą od zera"
            withdraw(500, 300);// "Za mało środków na koncie"
            withdraw(100, 300);// "Operacja wypłaty zakończona"
          

Section1 Article10: Wyrażenie funkcyjne

Wyrażenie funkcyjne (function expression) to deklaracja zmiennej, której wartością będzie funkcja. Jest to alternatywny sposób deklarowania funkcji.

            // Deklaracja funkcji (function declaration)
            function multiply(x, y, z) {
              console.log(`Wynikiem mnożenia jest ${x * y * z}`);
            }

            // Wyrażenie funkcyjne (function expression)
            const multiply = function (x, y, z) {
              console.log(`Wynikiem mnożenia jest ${x * y * z}`);
            };
          

Najważniejsza różnica polega na tym, że wyrażenia funkcyjnego nie można wywołać przed jego utworzeniem, lecz dopiero po jego utworzeniu, ponieważ jest to deklaracja zmiennej const. Dla normalnie zdefiniowanych funkcji działa mechanizm hoisting który sprawia, że interpreter "widzi" funkcje w miejscach w kodzie przed ich fizyczna deklaracją. Mechanizm ten nie działa dla zmiennych zadeklarowanych przy pomocy const i let

            // ❌ Błąd! Wywołanie nie działa przed deklaracją
            multiply(1, 2, 3);
            const multiply = function (x, y, z) {
              console.log(`Wynikiem mnożenia jest ${x * y * z}`);
            };
            // ✅ Wywołanie działa po deklaracji
            multiply(4, 5, 6);
          

Zadeklarowaną funkcję można wywołać przed miejscem jej utworzenia w kodzie.

            // ✅ Wywołanie działa przed deklaracją
            multiply(1, 2, 3);

            function multiply(x, y, z) {
              console.log(`Wynikiem mnożenia jest ${x * y * z}`);
            }

            // ✅ Wywołanie działa po deklaracji
            multiply(4, 5, 6);
          

Nie ma znaczenia jakiej składni używasz, najważniejsze jest to, żeby kod w projekcie był przewidywalny. Oznacza to, że musisz starać się nie mieszać deklaracji funkcji z wyrażeniami funkcyjnymi.

Section2 Article1: Zakres

Zakres (scope) to mechanizm określający dostępność zmiennych w wykonywanym kodzie.

Łańcuch zakresu (scope chain) - zakresy tworzą hierarchię, dzięki czemu zakresy podrzędne mogą uzyskiwać dostęp do zmiennych z zakresów nadrzędnych, ale nie odwrotnie.

Zmienna jest widoczna dla kodu wykonywalnego tylko jeśli znajduje się w bieżącym scope lub scope chain

Section2 Article2: Zakres globalny

Zmienne zadeklarowane na najwyższym poziomie, to znaczy poza wszelkimi konstrukcjami, takimi jak if, while, for, oraz funkcje, znajdują się w zakresie globalnym i są dostępne wszędzie po ich zadeklarowaniu, ponieważ każdy kolejny scope jest dla nich podrzędny.

            const globalValue = 10;
            console.log(globalValue);// 10

            function foo() {
              console.log(globalValue);// 10
            }

            for (let i = 0; i < 5; i++) {
              console.log(globalValue);// 10

              if (i === 2) {
                console.log(globalValue);// 10
              }
            }
          

Section2 Article3: Zakres blokowy

Zmienne zadeklarowane wewnątrz instrukcji if, for, funkcji i innych bloków kodu ujęte w nawiasy klamrowe {}, znajdują się w zakresie blokowym i są dostępne tylko w tym bloku kodu lub w blokach w nim zagnieżdżonych.

            function foo() {
              const a = 20; 
              console.log(a);// 20

              for (let i = 0; i < 5; i++) { 
                console.log(a);// 20

                if (i === 2) {
                  console.log(a);// 20
                }
              }
            }

            // ❌ Błąd! Zmienna a nie jest dostępna w zakresie globalnym
            console.log(a);

            for (let i = 0; i < 3; i++) {
              // ❌ Błąd! Zmienna a nie jest dostępna w tym zakresie
              console.log(a);
            }
          

Możemy porównać to do domu z pokojami. Cały dom to nasz zakres globalny. Każda funkcja i blok tworzy nowy pokój zagnieżdżony w domu. Zmienne zadeklarowane w tych pokojach są dostępne tylko wtedy, gdy jesteś w tym pokoju, z innego pokoju te zmienne nie są dostępne. Dla zakresu globalnego (domu) wszystko dostępne w dowolnych pokojach jest widoczne.

            for (let i = 0; i < 5; i++) {
              const a = 20;  
              console.log(a);// 20

              if (i === 2) {
                const b = 30;
                console.log(a);// 20
                console.log(b);// 30  
              }

              if (i === 3) {  
                console.log(a);// 20

                // ❌ Błąd! Zmienna b nie jest dostępna w tym zakresie
                console.log(b);
              }
            }
          

Section2 Article4: Wyszukiwanie za pomocą scope chain

Interpreter najpierw próbuje znaleźć zmienną w zakresie, w którym do niej się zwrócono. Jeśli nie ma takiej zmiennej w zakresie lokalnym, sięgamy o jeden poziom wyżej do momentu aż znajdziemy wartość albo zostanie osiągnięty zakres globalny. Jeżeli w żadnym momencie nie zostanie odnaleziona zmienna lub funkcja o danej nazwie zostanie wyrzucony błąd.

Section3 Article1: Stos wywołań

Kiedy funkcja jest wywoływana, inne funkcje mogą być wywoływane w jej ciele, w tych funkcjach kolejne itd. JavaScript jest językiem jednowątkowym, co oznacza, że tylko jedna instrukcja może być wykonywana na raz. Z uwagi na to, wywołane funkcje, które nie zakończyły swojego wykonywania, muszą zaczekać na wykonanie funkcji wywołanych wewnątrz siebie, aby kontynuować swoją pracę.

            function fnA() {
              console.log("Log wewnątrz funkcji fnA przed wywołaniem fnB");
              fnB();
              console.log("Log wewnątrz funkcji fnA po wywołaniu fnB");
            }

            function fnB() {
              console.log("Log wewnątrz funkcji fnB");
            }

            console.log("Log przed wywołaniem fnA");
            fnA();
            console.log("Log po wywołaniu fnA");

            // "Log przed wywołaniem fnA"
            // "Log wewnątrz funkcji fnA przed wywołaniem fnB"
            // "Log wewnątrz funkcji fnB"
            // "Log wewnątrz funkcji fnA po wywołaniu fnB"
            // "Log po wywołaniu fnA"
          

JavaScript potrzebuje mechanizmu do przechowywania listy funkcji, które zostały wywołane, ale jeszcze nie zakończyły ich wykonywania oraz mechanizmu do zarządzania kolejnością wykonywania instrukcji w tych funkcjach - odpowiada za to stos wywołań. (call stack).

Section3 Article2: Stos

Stos to struktura danych działająca na zasadzie LIFO (Last-In-First-Out), czyli ostatnie weszło, pierwsze wyszło. Ostatnia rzecz dodana do stosu zostanie z niego usunięta jako pierwsza, co oznacza, że możesz dodawać lub usuwać elementy tylko ze szczytu stosu.

Pomyśl o stosie jak o tablicy, która ma tylko metody pop i push, co oznacza, że możesz dodać lub usunąć tylko element na końcu kolekcji, a nie masz dostępu do elementów wcześniejszych. Poniżej możesz zobaczyć zilustrowane działanie stosu.

Section3 Article3: Stos wywołań

Stos wywołań (call stack) to mechanizm śledzenia bieżącej lokalizacji interpretera w kodzie w którym wywoływane jest kilka funkcji. Przechowuje on to

  • która z funkcji jest aktualnie wykonywana,
  • które funkcje są wywoływane z wykonywanej funkcji,
  • która zostanie wywołana w następnej kolejności itd.
  • Kiedy skrypt wywołuje funkcję, interpreter dodaje ją do stosu wywołań i rozpoczyna wykonywanie.
  • Każda funkcja wywoływana przez wykonywaną funkcję jest dodawana do stosu wywołań i jest wykonywana natychmiast po jej wywołaniu.
  • Gdy wykonanie funkcji zostanie zakończone, interpreter zdejmuje ją ze stosu wywołań i wznawia wykonywanie kodu od punktu, w którym zostało ono wcześniej przerwane. Oznacza to, że rozpoczyna się wykonywanie funkcji, której wpis jest następny na stosie.

Stack frame (ramka stosu) - struktura dodawana do stosu podczas wywołania funkcji. Przechowuje informacje takie jak nazwa funkcji i numer linii, w której nastąpiło wywołanie.

            function bar() {
              console.log("bar");
            }

            function baz() {
              console.log("baz");
            }

            function foo() {
              console.log("foo");
              bar();
              baz();
            }

            foo();
          

Podczas wykonywania tego kodu najpierw wywoływane jest foo(), następnie wewnątrz foo() wywołuje się bar(), a następnie baz(). Wywołania console.log() również trafiają do stosu, ponieważ jest to funkcja. Poniższa ilustracja przedstawia stos wywołań krok po kroku dla powyższego przykładu.

Section3 Article4: Przepełnienie stosu wywołań

Stos wywołań nie jest nieograniczony, ma przydzieloną skończoną ilość pamięci. Czasami możesz zobaczyć błąd w konsoli "Uncaught RangeError: Maximum call stack size exceeded" - oznacza on przepełnienie stosu (stack overflow).

Może się to zdarzyć z powodu niewłaściwego użycia rekurencji lub pętli wywołań funkcji, to znaczy, jeśli istnieje nieskończona (lub bardzo wysoka) liczba wywołań funkcji i nie jest zwracany żaden wynik, czyli wywołania nigdy nie kończą się, stos rośnie. Gdy zostanie osiągnięty limit liczby wpisów na stosie, wystąpi taki błąd i skrypt zawiesi się.